diff --git a/packages/ui/src/elements/FileManager/index.tsx b/packages/ui/src/elements/FileManager/index.tsx index 5eba78f18e1..09427bd1419 100644 --- a/packages/ui/src/elements/FileManager/index.tsx +++ b/packages/ui/src/elements/FileManager/index.tsx @@ -370,7 +370,7 @@ export const FileManager: React.FC = ({ return (
- +
{data?.filename && !removedFile && ( = ({ className, user }) => { - const anchorRef = useRef(null) const [hovered, setHovered] = useState(false) - const [anchorRect, setAnchorRect] = useState(null) const { t } = useTranslation() const userToUse = isClientUserObject(user) ? (user.email ?? user.id) : t('general:anotherUser') const tooltipLabel = `${userToUse} ${t('general:isEditing')}` - const updateAnchorRect = useCallback(() => { - const node = anchorRef.current - - if (!node) { - return - } - - setAnchorRect(node.getBoundingClientRect()) - }, []) - - useEffect(() => { - if (!hovered) { - return - } - - // Keep the portaled tooltip aligned to the icon as the page or an overflow - // container scrolls. - window.addEventListener('scroll', updateAnchorRect, true) - window.addEventListener('resize', updateAnchorRect) - - return () => { - window.removeEventListener('scroll', updateAnchorRect, true) - window.removeEventListener('resize', updateAnchorRect) - } - }, [hovered, updateAnchorRect]) - return (
setHovered(false)} - onFocus={() => { - updateAnchorRect() - setHovered(true) - }} + onFocus={() => setHovered(true)} onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault() // prevent page scroll when focused } }} - onMouseEnter={() => { - updateAnchorRect() - setHovered(true) - }} + onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} - ref={anchorRef} role="button" tabIndex={0} > - {hovered && - anchorRect && - createPortal( -
- - {tooltipLabel} - -
, - document.body, - )} + + {tooltipLabel} +
) } diff --git a/packages/ui/src/elements/PreviewButton/index.tsx b/packages/ui/src/elements/PreviewButton/index.tsx index 16a9742a8be..7812c1e34fa 100644 --- a/packages/ui/src/elements/PreviewButton/index.tsx +++ b/packages/ui/src/elements/PreviewButton/index.tsx @@ -54,7 +54,7 @@ export function PreviewButton() { id="preview-button" onClick={handleClick} /> - + {label} diff --git a/packages/ui/src/elements/SidebarToggle/index.css b/packages/ui/src/elements/SidebarToggle/index.css index c90975bc18a..6dd92c9e239 100644 --- a/packages/ui/src/elements/SidebarToggle/index.css +++ b/packages/ui/src/elements/SidebarToggle/index.css @@ -26,20 +26,4 @@ outline-offset: -1px; } } - - .sidebar-toggle .tooltip { - left: 0; - top: 100%; - bottom: auto; - transform: translate3d(0, var(--spacer-1), 0); - } - - .sidebar-toggle .tooltip::after { - top: 0; - left: var(--spacer-3); - bottom: auto; - transform: translate3d(-50%, -100%, 0); - border-bottom: var(--caret-size) solid var(--color-bg-inverse); - border-top: none; - } } diff --git a/packages/ui/src/elements/Tooltip/index.css b/packages/ui/src/elements/Tooltip/index.css index 52eb11b53f1..9cf0bfdcfdc 100644 --- a/packages/ui/src/elements/Tooltip/index.css +++ b/packages/ui/src/elements/Tooltip/index.css @@ -4,15 +4,18 @@ --caret-height: 7px; --caret-offset: 4px; --caret-border-color: var(--color-border); - --tooltip-x: -50%; --tooltip-caret-x: 0px; + --tooltip-caret-y: 0px; --tooltip-max-width: none; + position: fixed; + top: 0; + left: 0; + z-index: var(--z-portal-element); + pointer-events: none; + opacity: 0; background-color: var(--color-bg-tooltip); - position: absolute; - z-index: 3; - left: 50%; max-width: var(--tooltip-max-width); padding: var(--spacer-1); color: var(--color-text-ontooltip); @@ -26,6 +29,11 @@ box-shadow: var(--elevation-300-tooltip); visibility: hidden; + &[data-wrap] { + white-space: normal; + overflow-wrap: break-word; + } + &.field-error { background-color: var(--color-bg-danger); color: var(--color-text-ondanger); @@ -51,11 +59,6 @@ } } - .tooltip--wrap { - white-space: normal; - overflow-wrap: break-word; - } - .tooltip__caret { position: absolute; pointer-events: none; @@ -94,13 +97,6 @@ } .tooltip--position-top { - top: 0; - transform: translate3d( - var(--tooltip-x), - calc(-100% - var(--caret-height) - var(--caret-offset) + 1px), - 0 - ); - .tooltip__caret { --color-border: var(--ramp-grey-700); top: calc(100% - 0.5px); @@ -112,13 +108,6 @@ } .tooltip--position-bottom { - top: 100%; - transform: translate3d( - var(--tooltip-x), - calc(var(--caret-height) + var(--caret-offset) - 1px), - 0 - ); - .tooltip__caret { bottom: calc(100% - 0.5px); transform: rotate(180deg); @@ -134,28 +123,21 @@ } .tooltip--position-left { - top: 50%; - left: 0; - transform: translate3d(calc(-100% - var(--caret-height) - var(--caret-offset)), -50%, 0); - .tooltip__caret { top: calc(50% + 0.5px); left: calc(100% - 0.5px); - transform: translateY(calc(1 * var(--caret-width) / 2)) rotate(-90deg); + transform: translateY(calc(1 * var(--caret-width) / 2 + var(--tooltip-caret-y))) + rotate(-90deg); transform-origin: top left; } } .tooltip--position-right { - top: 50%; - left: 100%; - transform: translate3d(calc(var(--caret-height) + var(--caret-offset)), -50%, 0); - .tooltip__caret { top: calc(50% + 0.5px); left: unset; right: calc(100% - 0.5px); - transform: translateY(calc(1 * var(--caret-width) / 2)) rotate(90deg); + transform: translateY(calc(1 * var(--caret-width) / 2 + var(--tooltip-caret-y))) rotate(90deg); transform-origin: top right; } } diff --git a/packages/ui/src/elements/Tooltip/index.tsx b/packages/ui/src/elements/Tooltip/index.tsx index 8ef33dfb4ad..88f306f4ccf 100644 --- a/packages/ui/src/elements/Tooltip/index.tsx +++ b/packages/ui/src/elements/Tooltip/index.tsx @@ -1,41 +1,33 @@ 'use client' -import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react' +import { autoUpdate, computePosition, flip, hide, offset, shift, size } from '@floating-ui/dom' +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' -import { useIntersect } from '../../hooks/useIntersect.js' import './index.css' -// Breathing room (px) between a shifted tooltip and its clipping edge. Maps to `--spacer-1`. const EDGE_GUTTER = 4 -const getClipBoundary = (el: HTMLElement | null): DOMRect | null => { - let current = el?.parentElement || null - - while (current) { - const { overflowX } = window.getComputedStyle(current) - if ( - overflowX === 'auto' || - overflowX === 'clip' || - overflowX === 'hidden' || - overflowX === 'scroll' - ) { - return current.getBoundingClientRect() - } - current = current.parentElement - } +const TOP_BOTTOM_OFFSET = 10 +const LEFT_RIGHT_OFFSET = 11 - return null -} +type Side = 'bottom' | 'left' | 'right' | 'top' export type Props = { alignCaret?: 'center' | 'left' | 'right' - boundingRef?: React.RefObject children: React.ReactNode className?: string delay?: number - position?: 'bottom' | 'left' | 'right' | 'top' + /** + * Applied to the portaled tooltip element, so it can be located independent of its + * position in the DOM (e.g. by tests). + */ + id?: string + position?: Side show?: boolean /** - * If the tooltip position should not change depending on if the toolbar is outside the boundingRef. @default false + * Disables adaptive placement: the tooltip always renders on `position` (or its + * default) instead of flipping to whichever side has room. It does not affect + * portaling - every tooltip is portaled to `document.body` regardless. @default false */ staticPositioning?: boolean } @@ -60,10 +52,28 @@ const TooltipCaret: React.FC = () => { ) } +const isTriggerHidden = (element: Element): boolean => { + if (typeof element.checkVisibility === 'function') { + return !element.checkVisibility() + } + + // Fallback for browsers without `checkVisibility`: an element collapsed by a + // `display: none` ancestor has no client rects. + return element.getClientRects().length === 0 +} + +const toPlacement = (side: Side, alignCaret: 'center' | 'left' | 'right') => { + if (side === 'left' || side === 'right' || alignCaret === 'center') { + return side + } + + return `${side}-${alignCaret === 'left' ? 'start' : 'end'}` as const +} + export const Tooltip: React.FC = (props) => { const { + id, alignCaret = 'center', - boundingRef, children, className, delay = 500, @@ -72,19 +82,19 @@ export const Tooltip: React.FC = (props) => { staticPositioning = false, } = props - const [show, setShow] = React.useState(showFromProps) - const [position, setPosition] = React.useState<'bottom' | 'left' | 'right' | 'top'>('top') - const [shiftX, setShiftX] = useState(0) - const [maxWidth, setMaxWidth] = useState(null) + const generatedID = React.useId() + const tooltipID = id || generatedID - const [ref, intersectionEntry, node] = useIntersect( - { - root: boundingRef?.current || null, - rootMargin: '-145px 0px 0px 100px', - threshold: 0, - }, - staticPositioning, - ) + const [show, setShow] = useState(showFromProps) + const [isMounted, setIsMounted] = useState(false) + const [placement, setPlacement] = useState(positionFromProps || 'top') + + // A hidden marker rendered where the tooltip used to live in the DOM, so its + // parent element can be used as the floating-ui reference for the portaled tooltip. + const triggerMarkerRef = useRef(null) + const floatingRef = useRef(null) + + const updateTokenRef = useRef(0) useEffect(() => { let timerID: NodeJS.Timeout @@ -105,109 +115,192 @@ export const Tooltip: React.FC = (props) => { } }, [showFromProps, delay]) + // Keep the tooltip mounted for the duration of its fade-out transition so the + // portal doesn't disappear abruptly when `show` flips back to false. useEffect(() => { - if (staticPositioning) { + if (show) { + setIsMounted(true) return } - setPosition(intersectionEntry?.isIntersecting ? 'top' : 'bottom') - }, [intersectionEntry, staticPositioning]) - - // Keep a horizontally-centered tooltip (top/bottom) inside its boundary by - // shifting it inward by exactly the amount it would overflow. The hidden - // measuring aside (`node`) provides the natural, unshifted geometry. - const computeShift = useCallback(() => { - const usesHorizontalCenter = - (positionFromProps || position) === 'top' || (positionFromProps || position) === 'bottom' - - if (staticPositioning || !node || !usesHorizontalCenter) { - setShiftX(0) - setMaxWidth(null) - return + + // Matches the `.tooltip--show` opacity transition duration. + const timeoutID = setTimeout(() => setIsMounted(false), 200) + + return () => { + clearTimeout(timeoutID) } + }, [show]) - const rect = node.getBoundingClientRect() - const boundary = - boundingRef?.current?.getBoundingClientRect() ?? getClipBoundary(node) ?? undefined - const leftEdge = (boundary?.left ?? 0) + EDGE_GUTTER - const rightEdge = (boundary?.right ?? window.innerWidth) - EDGE_GUTTER - const available = rightEdge - leftEdge - - // When the tooltip can't fit even at full boundary width, cap it so it wraps. - const nextMaxWidth = rect.width > available ? available : null - setMaxWidth(nextMaxWidth) - - // Compute the shift against the clamped box, kept centered on the anchor. - const center = rect.left + rect.width / 2 - const effectiveWidth = nextMaxWidth ?? rect.width - const effectiveLeft = center - effectiveWidth / 2 - const effectiveRight = center + effectiveWidth / 2 - - let shift = 0 - if (effectiveRight > rightEdge) { - shift = rightEdge - effectiveRight + const updatePosition = useCallback(async () => { + const trigger = triggerMarkerRef.current?.parentElement + const floatingElement = floatingRef.current + + if (!trigger || !floatingElement) { + return } - if (effectiveLeft + shift < leftEdge) { - shift = leftEdge - effectiveLeft + + const token = ++updateTokenRef.current + const boundary = 'clippingAncestors' + const canFlip = !staticPositioning && !positionFromProps + + const { + middlewareData, + placement: resolvedPlacement, + x, + y, + } = await computePosition(trigger, floatingElement, { + middleware: [ + offset(({ placement: currentPlacement }) => + currentPlacement.startsWith('left') || currentPlacement.startsWith('right') + ? LEFT_RIGHT_OFFSET + : TOP_BOTTOM_OFFSET, + ), + ...(canFlip ? [flip({ boundary, padding: EDGE_GUTTER })] : []), + ...(staticPositioning + ? [] + : [ + shift({ boundary, padding: EDGE_GUTTER }), + size({ + apply({ availableWidth, elements }) { + const shouldWrap = elements.floating.scrollWidth > availableWidth + + elements.floating.toggleAttribute('data-wrap', shouldWrap) + elements.floating.style.setProperty( + '--tooltip-max-width', + shouldWrap ? `${Math.floor(availableWidth)}px` : '', + ) + }, + boundary, + padding: EDGE_GUTTER, + }), + ]), + hide({ boundary: 'clippingAncestors' }), + ], + placement: toPlacement(positionFromProps || 'top', alignCaret), + strategy: 'fixed', + }) + + // A newer call already applied a fresher position - discard this stale result. + if (token !== updateTokenRef.current) { + return } - setShiftX(shift) - }, [boundingRef, node, position, positionFromProps, staticPositioning]) + const hidden = isTriggerHidden(trigger) || Boolean(middlewareData.hide?.referenceHidden) + + Object.assign(floatingElement.style, { + left: `${x}px`, + top: `${y}px`, + visibility: hidden ? 'hidden' : '', + }) + + const resolvedSide = resolvedPlacement.split('-')[0] as Side + const isHorizontalSide = resolvedSide === 'left' || resolvedSide === 'right' + + const shiftX = !isHorizontalSide && alignCaret === 'center' ? middlewareData.shift?.x || 0 : 0 + const shiftY = isHorizontalSide ? middlewareData.shift?.y || 0 : 0 + + floatingElement.style.setProperty('--tooltip-caret-x', shiftX ? `${-shiftX}px` : '0px') + floatingElement.style.setProperty('--tooltip-caret-y', shiftY ? `${-shiftY}px` : '0px') + + setPlacement(resolvedSide) + }, [alignCaret, positionFromProps, staticPositioning]) + + const setFloatingElement = useCallback( + (element: HTMLDivElement | null) => { + floatingRef.current = element + void updatePosition() + }, + [updatePosition], + ) + // While mounted, let floating-ui keep the portaled tooltip aligned to its trigger + // as the page scrolls, the trigger resizes or moves, or a hidden ancestor becomes + // visible again - instead of manually re-measuring on window scroll/resize alone. useLayoutEffect(() => { - if (!show) { + const trigger = triggerMarkerRef.current?.parentElement + const floatingElement = floatingRef.current + + if (!isMounted || !trigger || !floatingElement) { return } - computeShift() - window.addEventListener('resize', computeShift) + const cleanupAutoUpdate = autoUpdate(trigger, floatingElement, updatePosition, { + elementResize: 'ResizeObserver' in window, + }) + + // autoUpdate's own layout-shift tracking gives up once the trigger's rect + // collapses to zero (e.g. behind a `display: none` ancestor), so it can't + // tell when the trigger becomes visible again. Watch for that separately. + const visibilityObserver = + typeof IntersectionObserver === 'function' + ? new IntersectionObserver(() => void updatePosition(), { threshold: 0 }) + : null + + visibilityObserver?.observe(trigger) + + const visualViewport = window.visualViewport + + visualViewport?.addEventListener('resize', updatePosition) + visualViewport?.addEventListener('scroll', updatePosition) return () => { - window.removeEventListener('resize', computeShift) + cleanupAutoUpdate() + visibilityObserver?.disconnect() + visualViewport?.removeEventListener('resize', updatePosition) + visualViewport?.removeEventListener('scroll', updatePosition) + } + }, [isMounted, updatePosition]) + + useEffect(() => { + const trigger = triggerMarkerRef.current?.parentElement + + if (!show || !trigger) { + return + } + + const describedByIDs = trigger.getAttribute('aria-describedby')?.split(' ') || [] + + if (!describedByIDs.includes(tooltipID)) { + trigger.setAttribute('aria-describedby', [...describedByIDs, tooltipID].join(' ')) + } + + return () => { + const remainingIDs = (trigger.getAttribute('aria-describedby')?.split(' ') || []).filter( + (describedByID) => describedByID !== tooltipID, + ) + + if (remainingIDs.length) { + trigger.setAttribute('aria-describedby', remainingIDs.join(' ')) + } else { + trigger.removeAttribute('aria-describedby') + } } - }, [show, computeShift]) + }, [show, tooltipID]) - // The first aside is always on top. The purpose of that is that it can reliably be used for the interaction observer (as it's not moving around), to calculate the position of the actual tooltip. return ( - {!staticPositioning && ( - - )} - + ) } diff --git a/packages/ui/src/elements/Upload/index.css b/packages/ui/src/elements/Upload/index.css index 93e8127524d..f41ab80526b 100644 --- a/packages/ui/src/elements/Upload/index.css +++ b/packages/ui/src/elements/Upload/index.css @@ -11,11 +11,6 @@ position: relative; } - .file-field .tooltip.error-message { - z-index: 3; - bottom: calc(100% - var(--spacer-2)); - } - .file-field__file-selected { display: flex; } diff --git a/packages/ui/src/fields/Checkbox/index.css b/packages/ui/src/fields/Checkbox/index.css index 92e1eeecde3..5a7016eaa18 100644 --- a/packages/ui/src/fields/Checkbox/index.css +++ b/packages/ui/src/fields/Checkbox/index.css @@ -1,15 +1,6 @@ @layer payload-default { .checkbox { position: relative; - - .tooltip:not([aria-hidden='true']) { - left: auto; - right: calc(-1 * var(--spacer-2)); - position: absolute; - transform: translateY(calc(var(--caret-size) * -1)); - margin-bottom: 0.2em; - max-width: fit-content; - } } .checkbox-input { @@ -159,7 +150,6 @@ /* Allow Wrap */ .checkbox-input__tooltip { - --tooltip-x: 0; left: 0; width: max-content; max-width: 280px; @@ -178,83 +168,57 @@ transform: translateX(-50%) rotate(180deg); } - .checkbox-input--has-tooltip { - position: relative; - width: max-content; - max-width: 100%; - } - - /* Allow Wrap */ - .checkbox-input__tooltip { - --tooltip-x: 0; - left: 0; - width: max-content; - max-width: 280px; - white-space: normal; - text-align: start; - } + /* Muted variant - used in tables with lighter appearance */ + .checkbox-input--muted { + .checkbox-input__input { + background: var(--color-bg-secondary); + border-color: var(--color-border); + } - /* Center the caret on the checkbox box itself (0.5rem from the row's - left edge = center of the 1rem-wide checkbox input). */ - .checkbox-input__tooltip.tooltip--caret-left .tooltip__caret { - left: 0.5rem; - transform: translateX(-50%); - } + &:not(.checkbox-input--read-only):not(.checkbox-input--checked) { + &:hover { + .checkbox-input__input { + border-color: var(--field-color-toggle-border-hover); + } + } + } - .checkbox-input__tooltip.tooltip--caret-left.tooltip--position-bottom .tooltip__caret { - transform: translateX(-50%) rotate(180deg); - /* Muted variant - used in tables with lighter appearance */ - .checkbox-input--muted { + /* Ensure checked state overrides muted base styles */ + &.checkbox-input--checked { .checkbox-input__input { - background: var(--color-bg-secondary); - border-color: var(--color-border); + background-color: var(--field-color-toggle-bg-checked); + border-color: var(--field-color-toggle-border-checked); } + } - &:not(.checkbox-input--read-only):not(.checkbox-input--checked) { - &:hover { - .checkbox-input__input { - border-color: var(--field-color-toggle-border-hover); - } - } + /* Ensure partial state overrides muted base styles */ + &:has(.checkbox-input__icon.partial) { + .checkbox-input__input { + background-color: var(--field-color-toggle-bg-checked); + border-color: var(--field-color-toggle-border-checked); } + } + } - /* Ensure checked state overrides muted base styles */ - &.checkbox-input--checked { - .checkbox-input__input { - background-color: var(--field-color-toggle-bg-checked); - border-color: var(--field-color-toggle-border-checked); - } + .checkbox.error { + .checkbox-input--checked { + .checkbox-input__input { + background-color: var(--field-color-error-bg); + border-color: var(--field-color-border-error); } - /* Ensure partial state overrides muted base styles */ - &:has(.checkbox-input__icon.partial) { - .checkbox-input__input { - background-color: var(--field-color-toggle-bg-checked); - border-color: var(--field-color-toggle-border-checked); - } + &:hover .checkbox-input__input { + border-color: var(--field-color-border-error); } } - .checkbox.error { - .checkbox-input--checked { - .checkbox-input__input { - background-color: var(--field-color-error-bg); - border-color: var(--field-color-border-error); - } - - &:hover .checkbox-input__input { - border-color: var(--field-color-border-error); - } + .checkbox-input:not(.checkbox-input--checked) { + .checkbox-input__input { + border-color: var(--field-color-border-error); } - .checkbox-input:not(.checkbox-input--checked) { - .checkbox-input__input { - border-color: var(--field-color-border-error); - } - - &:hover .checkbox-input__input { - border-color: var(--field-color-border-error); - } + &:hover .checkbox-input__input { + border-color: var(--field-color-border-error); } } } diff --git a/packages/ui/src/fields/FieldError/index.css b/packages/ui/src/fields/FieldError/index.css index a589fd88608..5e05e3d8766 100644 --- a/packages/ui/src/fields/FieldError/index.css +++ b/packages/ui/src/fields/FieldError/index.css @@ -1,15 +1,6 @@ @layer payload-default { .field-error.tooltip { - --tooltip-x: 0%; - left: auto; - max-width: 75%; - right: 0; color: var(--field-color-error-text); background-color: var(--field-color-error-bg); - - &::after { - border-top-color: var(--field-color-error-bg); - border-bottom-color: var(--field-color-error-bg); - } } } diff --git a/packages/ui/src/fields/FieldError/index.tsx b/packages/ui/src/fields/FieldError/index.tsx index c5425670727..f510b2cd5c7 100644 --- a/packages/ui/src/fields/FieldError/index.tsx +++ b/packages/ui/src/fields/FieldError/index.tsx @@ -5,7 +5,9 @@ import type { GenericErrorProps } from 'payload' import React from 'react' import { Tooltip } from '../../elements/Tooltip/index.js' -import { useFormFields, useFormSubmitted } from '../../forms/Form/context.js' +import { useForm, useFormFields, useFormSubmitted } from '../../forms/Form/context.js' +import { useEditDepth } from '../../providers/EditDepth/index.js' +import { generateFieldID } from '../../utilities/generateFieldID.js' import './index.css' const baseClass = 'field-error' @@ -19,6 +21,8 @@ export const FieldError: React.FC = (props) => { } = props const hasSubmitted = useFormSubmitted() + const { uuid } = useForm() + const editDepth = useEditDepth() const field = useFormFields(([fields]) => (fields && fields?.[path]) || null) const { errorMessage, valid } = field || {} @@ -28,7 +32,13 @@ export const FieldError: React.FC = (props) => { if (showMessage && message?.length) { return ( - + {message} ) diff --git a/packages/ui/src/fields/RadioGroup/index.css b/packages/ui/src/fields/RadioGroup/index.css index 290f1981dbf..151f5a8bc55 100644 --- a/packages/ui/src/fields/RadioGroup/index.css +++ b/packages/ui/src/fields/RadioGroup/index.css @@ -10,13 +10,6 @@ gap: var(--spacer-2); } - .tooltip:not([aria-hidden='true']) { - right: auto; - position: static; - margin-bottom: 0.2em; - max-width: fit-content; - } - ul { list-style: none; padding: 0; diff --git a/packages/ui/src/utilities/generateFieldID.ts b/packages/ui/src/utilities/generateFieldID.ts index e429b804e3d..d4ae0962c19 100644 --- a/packages/ui/src/utilities/generateFieldID.ts +++ b/packages/ui/src/utilities/generateFieldID.ts @@ -1,6 +1,11 @@ -export const generateFieldID = (path: string, editDepth: number, uuid: string) => { +export const generateFieldID = ( + path: string, + editDepth: number, + uuid: string, + prefix: string = 'field', +) => { if (!path) { return undefined } - return `field-${path.replace(/\./g, '__')}${editDepth > 1 ? `-${editDepth}` : ''}${uuid ? `-${uuid}` : ''}` + return `${prefix}-${path.replace(/\./g, '__')}${editDepth > 1 ? `-${editDepth}` : ''}${uuid ? `-${uuid}` : ''}` } diff --git a/test/admin/components/views/TooltipShowcase/index.tsx b/test/admin/components/views/TooltipShowcase/index.tsx new file mode 100644 index 00000000000..73c49e11f48 --- /dev/null +++ b/test/admin/components/views/TooltipShowcase/index.tsx @@ -0,0 +1,118 @@ +'use client' + +import { Tooltip } from '@payloadcms/ui' +import React, { useState } from 'react' + +const triggerStyle: React.CSSProperties = { + border: '1px solid var(--theme-elevation-150)', + borderRadius: 'var(--style-radius-s)', + display: 'inline-block', + padding: '0.5rem 1rem', + position: 'relative', +} + +const sectionStyle: React.CSSProperties = { + marginBottom: '3rem', +} + +const Section: React.FC<{ children: React.ReactNode; title: string }> = ({ children, title }) => ( +
+

{title}

+ {children} +
+) + +export const TooltipShowcase: React.FC = () => { + const [isTriggerShifted, setIsTriggerShifted] = useState(false) + const [isHiddenTriggerVisible, setIsHiddenTriggerVisible] = useState(false) + + return ( +
+

Tooltip Showcase

+ +
+
+
+ Hover me + Escapes overflow +
+
+
+ +
+
+ +
+ Hover me + + Follows trigger + +
+
+
+ +
+
+ +
+
+ Hover me + + Appears after unhide + +
+
+
+
+ +
+
+
+ Hover me + + Static tooltip + +
+
+
+
+ ) +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 209b45fd0dd..22f92a3cf55 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -140,6 +140,10 @@ export default buildConfigWithDefaults({ Component: '/components/views/ButtonStyles/index.js#ButtonStyles', path: '/button-styles', }, + TooltipShowcase: { + Component: '/components/views/TooltipShowcase/index.js#TooltipShowcase', + path: '/tooltip-showcase', + }, }, sidebar: { tabs: [ diff --git a/test/admin/e2e/tooltip/e2e.spec.ts b/test/admin/e2e/tooltip/e2e.spec.ts new file mode 100644 index 00000000000..e41a3c1860a --- /dev/null +++ b/test/admin/e2e/tooltip/e2e.spec.ts @@ -0,0 +1,113 @@ +import type { BrowserContext, Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' + +import type { Config } from '../../payload-types.js' + +import { + ensureCompilationIsDone, + initPageConsoleErrorCatch, +} from '../../../__helpers/e2e/helpers.js' +import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +test.describe('Tooltip', () => { + let page: Page + let context: BrowserContext + let url: AdminUrlUtil + let serverURL: string + + test.beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + ;({ serverURL } = await initPayloadE2ENoConfig({ dirname })) + url = new AdminUrlUtil(serverURL, 'posts') + + context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ page, serverURL }) + }) + + test.afterAll(async () => { + await context.close() + }) + + test.beforeEach(async () => { + await page.goto(`${url.admin}/tooltip-showcase`) + await expect(page.locator('h1')).toHaveText('Tooltip Showcase') + }) + + test('escapes an overflow: hidden container', async () => { + const tooltip = page.locator('#tooltip-overflow-demo') + await expect(tooltip).toBeVisible() + await expect(tooltip).toHaveClass(/tooltip--show/) + + // The portaled tooltip is a direct child of , so it cannot be + // clipped by the trigger's overflow: hidden ancestor. + const isInsideOverflowContainer = await tooltip.evaluate( + (el) => !!el.closest('.tooltip-showcase__overflow-container'), + ) + expect(isInsideOverflowContainer).toBe(false) + + const tooltipBox = await tooltip.boundingBox() + expect(tooltipBox?.height).toBeGreaterThan(0) + }) + + test('updates its position when the trigger moves or resizes', async () => { + const trigger = page.locator('.tooltip-showcase__move-resize-trigger') + const tooltip = page.locator('#tooltip-move-resize-demo') + + // The trigger is below the fold - the `hide` middleware correctly keeps the + // tooltip hidden until it's scrolled into view. + await trigger.scrollIntoViewIfNeeded() + + await expect(tooltip).toBeVisible() + const initialBox = await tooltip.boundingBox() + + await page.locator('.tooltip-showcase__move-resize-toggle').click() + + await expect(async () => { + const nextBox = await tooltip.boundingBox() + expect(nextBox?.x).not.toBe(initialBox?.x) + }).toPass() + }) + + test('appears once a trigger that was initially hidden becomes visible', async () => { + const tooltip = page.locator('#tooltip-hidden-demo') + + // The trigger's ancestor is `display: none`, so the tooltip must not be + // visibly rendered yet even though `show` is permanently true. + await expect(tooltip).toBeHidden() + + await page.locator('.tooltip-showcase__hidden-toggle').click() + + await expect(tooltip).toBeVisible() + const tooltipBox = await tooltip.boundingBox() + const triggerBox = await page.locator('.tooltip-showcase__hidden-trigger').boundingBox() + + expect(tooltipBox?.height).toBeGreaterThan(0) + // The tooltip should be re-measured against the now-visible trigger, + // not left pinned to a stale (0, 0) position. + expect(Math.abs((tooltipBox?.x ?? 0) - (triggerBox?.x ?? 0))).toBeLessThan(200) + }) + + test('staticPositioning tooltips still portal to the body without adaptive placement', async () => { + const tooltip = page.locator('#tooltip-static-demo') + await expect(tooltip).toBeVisible() + + const isPortaled = await tooltip.evaluate((el) => el.parentElement === document.body) + expect(isPortaled).toBe(true) + + // Even though there isn't enough room below the trigger, staticPositioning + // must keep the requested placement instead of flipping to the top. + await expect(tooltip).toHaveClass(/tooltip--position-bottom/) + }) +}) diff --git a/test/auth-basic/e2e.spec.ts b/test/auth-basic/e2e.spec.ts index f502ee286a0..b65fc122565 100644 --- a/test/auth-basic/e2e.spec.ts +++ b/test/auth-basic/e2e.spec.ts @@ -50,16 +50,14 @@ const createFirstUser = async ({ await page.locator('#field-email').fill(devUser.email) await page.locator('#field-password').fill(devUser.password) await page.locator('.form-submit > button').click() - await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText( - 'This field is required.', - ) + await expect(page.locator('#field-error-confirm-password')).toHaveText('This field is required.') // make them match, but does not pass password validation await page.locator('#field-email').fill(devUser.email) await page.locator('#field-password').fill('12') await page.locator('#field-confirm-password').fill('12') await page.locator('.form-submit > button').click() - await expect(page.locator('.field-type.password .field-error')).toHaveText( + await expect(page.locator('#field-error-password')).toHaveText( 'This value must be longer than the minimum length of 3 characters.', ) diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index cde7d117a31..0edadd55765 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -13,7 +13,6 @@ import { login } from '../__helpers/e2e/auth/login.js' import { logout } from '../__helpers/e2e/auth/logout.js' import { ensureCompilationIsDone, - exactText, getRoutes, initPageConsoleErrorCatch, saveDocAndAssert, @@ -112,7 +111,7 @@ describe('Auth', () => { await page.locator('#field-password').fill(devUser.password) await page.locator('.form-submit > button').click() - await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText( + await expect(page.locator('#field-error-confirm-password')).toHaveText( 'This field is required.', ) @@ -122,7 +121,7 @@ describe('Auth', () => { await page.locator('#field-confirm-password').fill('12') await page.locator('.form-submit > button').click() - await expect(page.locator('.field-type.password .field-error')).toHaveText( + await expect(page.locator('#field-error-password')).toHaveText( 'This value must be longer than the minimum length of 3 characters.', ) @@ -252,20 +251,16 @@ describe('Auth', () => { await expect(page.locator('#cancel-change-password')).toBeVisible() // should fail to save without confirm password await page.locator('#action-save').click() - await expect( - page.locator('.field-type.confirm-password .tooltip--show', { - hasText: exactText('This field is required.'), - }), - ).toBeVisible() + await expect(page.locator('#field-error-confirm-password')).toHaveText( + 'This field is required.', + ) // should fail to save with incorrect confirm password await page.locator('#field-confirm-password').fill('wrong password') await page.locator('#action-save').click() - await expect( - page.locator('.field-type.confirm-password .tooltip--show', { - hasText: exactText('Passwords do not match.'), - }), - ).toBeVisible() + await expect(page.locator('#field-error-confirm-password')).toHaveText( + 'Passwords do not match.', + ) // should succeed with matching confirm password await page.locator('#field-confirm-password').fill('password') @@ -282,11 +277,9 @@ describe('Auth', () => { await page.locator('#field-password').fill('password') // should fail to save without confirm password await page.locator('#action-save').click({ delay: 100 }) - await expect( - page.locator('.field-type.confirm-password .tooltip--show', { - hasText: exactText('This field is required.'), - }), - ).toBeVisible() + await expect(page.locator('#field-error-confirm-password')).toHaveText( + 'This field is required.', + ) // should succeed with matching confirm password await page.locator('#field-confirm-password').fill('password') diff --git a/test/dashboard/e2e.spec.ts b/test/dashboard/e2e.spec.ts index 5a17da45dd9..4e7cc9d8747 100644 --- a/test/dashboard/e2e.spec.ts +++ b/test/dashboard/e2e.spec.ts @@ -553,7 +553,7 @@ describe('Dashboard', () => { await page.waitForTimeout(500) await drawer.getByRole('button', { name: 'Save Changes' }).click() - await expect(drawer.locator('.field-error')).toBeVisible() + await expect(page.locator('[id^="field-error-title"]')).toBeVisible() await expect(drawer).toBeVisible() await titleInput.fill('Valid Title') @@ -604,7 +604,7 @@ describe('Dashboard', () => { await page.waitForTimeout(500) await drawer.getByRole('button', { name: 'Save Changes' }).click() - await expect(drawer.locator('.field-error .tooltip-content')).toContainText( + await expect(page.locator('[id^="field-error-description"]')).toContainText( 'Description must be at least 10 characters', ) await expect(drawer).toBeVisible() @@ -741,7 +741,10 @@ describe('Dashboard', () => { test('widget re-renders when query params change (= modular dashboard RSC rerenders)', async ({ page, }) => { - test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.') + test.skip( + process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', + 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.', + ) const d = new DashboardHelper(page) await d.setEditing() await d.addWidget('page query') diff --git a/test/field-error-states/e2e.spec.ts b/test/field-error-states/e2e.spec.ts index 88241bf7948..9ce4f0cbe52 100644 --- a/test/field-error-states/e2e.spec.ts +++ b/test/field-error-states/e2e.spec.ts @@ -335,7 +335,7 @@ describe('Field Error States', () => { await expect( page.locator('#field-arrayWithMinRows .banner.banner--type-danger'), ).toBeVisible() - await expect(page.locator('#field-arrayWithMinRows .field-error')).toHaveCount(0) + await expect(page.locator('.field-error')).toHaveCount(0) }) test('blocks error pill should show for minRows errors before child edits', async ({ @@ -360,7 +360,7 @@ describe('Field Error States', () => { await expect( page.locator('#field-blocksWithMinRows .banner.banner--type-danger'), ).toBeVisible() - await expect(page.locator('#field-blocksWithMinRows .field-error')).toHaveCount(0) + await expect(page.locator('.field-error')).toHaveCount(0) }) }) diff --git a/test/fields/collections/Checkbox/e2e.spec.ts b/test/fields/collections/Checkbox/e2e.spec.ts index e297dc7db88..03413a1fd78 100644 --- a/test/fields/collections/Checkbox/e2e.spec.ts +++ b/test/fields/collections/Checkbox/e2e.spec.ts @@ -74,6 +74,22 @@ describe('Checkboxes', () => { await expect(page.locator('table > tbody > tr')).toHaveCount(1) }) + test('should portal the field-error tooltip next to the checkbox when invalid', async () => { + await page.goto(url.create) + await page.locator('#field-checkboxRequiresTrue').click() + await page.locator('#action-save').click({ delay: 100 }) + + const tooltip = page.locator('.tooltip--show', { hasText: 'This field is required.' }) + await expect(tooltip).toBeVisible() + + const isPortaledToBody = await tooltip.evaluate((el) => el.parentElement === document.body) + expect(isPortaledToBody).toBe(true) + + const tooltipBox = await tooltip.boundingBox() + const checkboxBox = await page.locator('#field-checkboxRequiresTrue').boundingBox() + expect(Math.abs((tooltipBox?.x ?? 0) - (checkboxBox?.x ?? 0))).toBeLessThan(200) + }) + describe.skip('A11y', () => { test('Edit view should have no accessibility violations', async ({}, testInfo) => { await page.goto(url.create) diff --git a/test/fields/collections/Checkbox/index.ts b/test/fields/collections/Checkbox/index.ts index 9838330b4de..db2d6c732af 100644 --- a/test/fields/collections/Checkbox/index.ts +++ b/test/fields/collections/Checkbox/index.ts @@ -14,6 +14,13 @@ const CheckboxFields: CollectionConfig = { name: 'checkboxNotRequired', type: 'checkbox', }, + { + name: 'checkboxRequiresTrue', + type: 'checkbox', + defaultValue: true, + // `required` alone won't fail on an unchecked (`false`) checkbox. + validate: (value) => (value ? true : 'This field is required.'), + }, ], versions: false, } diff --git a/test/fields/collections/Radio/e2e.spec.ts b/test/fields/collections/Radio/e2e.spec.ts index bbc837350ec..3f05132c9b7 100644 --- a/test/fields/collections/Radio/e2e.spec.ts +++ b/test/fields/collections/Radio/e2e.spec.ts @@ -71,7 +71,7 @@ describe('Radio', () => { test('should show i18n label while editing', async () => { await page.goto(url.create) - await expect(page.locator('label[for="field-radio"]')).toHaveText('Radio en') + await expect(page.locator('label[for="field-radio"]')).toHaveText('Radio en*') }) test('should show i18n radio labels', async () => { @@ -93,6 +93,21 @@ describe('Radio', () => { ).toBeVisible() }) + test('should portal the field-error tooltip next to the radio group when invalid', async () => { + await page.goto(url.create) + await page.locator('#action-save').click({ delay: 100 }) + + const tooltip = page.locator('.tooltip--show', { hasText: 'This field is required.' }) + await expect(tooltip).toBeVisible() + + const isPortaledToBody = await tooltip.evaluate((el) => el.parentElement === document.body) + expect(isPortaledToBody).toBe(true) + + const tooltipBox = await tooltip.boundingBox() + const radioGroupBox = await page.locator('#field-radio').boundingBox() + expect(Math.abs((tooltipBox?.x ?? 0) - (radioGroupBox?.x ?? 0))).toBeLessThan(200) + }) + describe.skip('A11y', () => { test('Edit view should have no accessibility violations', async ({}, testInfo) => { await page.goto(url.create) diff --git a/test/fields/collections/Radio/index.ts b/test/fields/collections/Radio/index.ts index 1f515e8fd64..74a6451638a 100644 --- a/test/fields/collections/Radio/index.ts +++ b/test/fields/collections/Radio/index.ts @@ -27,6 +27,7 @@ const RadioFields: CollectionConfig = { value: 'three', }, ], + required: true, }, { name: 'radioWithJsxLabelOption', diff --git a/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts b/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts index e86b0dbbbe3..bddc85188d0 100644 --- a/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts +++ b/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts @@ -1271,7 +1271,7 @@ describe('lexicalBlocks', () => { ) await wait(300) - const requiredTooltip = conditionalArrayBlock + const requiredTooltip = page .locator('.tooltip-content:has-text("This field is required.")') .first() await wait(300) diff --git a/test/live-preview/e2e.spec.ts b/test/live-preview/e2e.spec.ts index 38da931669a..463af3724ae 100644 --- a/test/live-preview/e2e.spec.ts +++ b/test/live-preview/e2e.spec.ts @@ -332,10 +332,10 @@ describe('Live Preview', () => { await expect(previewButton).toBeVisible() await previewButton.hover() - await expect(page.locator('#preview-button ~ .tooltip--show')).toHaveText('Copy') + await expect(page.locator('#preview-button-tooltip')).toHaveText('Copy') await previewButton.click() - await expect(page.locator('#preview-button ~ .tooltip--show')).toHaveText('Copied') + await expect(page.locator('#preview-button-tooltip')).toHaveText('Copied') await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) .toMatch(/\/live-preview/) diff --git a/test/locked-documents/e2e.spec.ts b/test/locked-documents/e2e.spec.ts index 6dabc55e9ec..4cc0f883ab5 100644 --- a/test/locked-documents/e2e.spec.ts +++ b/test/locked-documents/e2e.spec.ts @@ -173,6 +173,17 @@ describe('Locked Documents', () => { await expect(page.locator('.table .row-2 .locked svg.icon--lock')).toBeVisible() }) + test('should show tooltip with editing user when hovering the lock icon on a document row', async () => { + await page.goto(postsUrl.list) + + const lockIcon = page.locator('.table .row-2 .locked') + await lockIcon.hover() + + await expect( + page.locator('.tooltip--show', { hasText: exactText(`${user2.email} is editing`) }), + ).toBeVisible() + }) + test('should not show lock icon on document row if unlocked', async () => { await page.goto(postsUrl.list) diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 7e75467032d..692c1245ad0 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -519,6 +519,17 @@ describe('Uploads', () => { await saveDocAndAssert(page) }) + test('should show a tooltip for the remove button on an unsaved selected file', async () => { + await gotoAndWaitForForm(page, mediaURL.create) + await page.setInputFiles('input[type="file"]', path.resolve(dirname, './image.png')) + + const removeButton = page.locator('.file-manager__remove') + await expect(removeButton).toBeVisible() + await removeButton.hover() + + await expect(page.locator('.tooltip--show', { hasText: exactText('Cancel') })).toBeVisible() + }) + test('should remove remote URL button if pasteURL is false', async () => { // pasteURL option is set to false in the media collection await page.goto(mediaURL.create) @@ -1749,7 +1760,9 @@ describe('Uploads', () => { // It should already be active (no need to navigate). // Should show "A file name is required" error message - await expect(bulkUploadModal.locator('.field-error')).toContainText('A file name is required') + await expect(page.locator('[id^="field-error-filename"]')).toContainText( + 'A file name is required', + ) // Filename field should be empty (as we cleared it) await expect(bulkUploadModal.locator('#field-filemanager-filename')).toHaveValue('') diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index d3ba742f3cd..d1f14b29e25 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -1914,7 +1914,8 @@ describe('Versions', () => { await saveDocAndAssert(page, '#action-save-draft', 'error') const parentFieldType = page.locator('.field-type:has(#field-title)') - await expect(parentFieldType.locator('.tooltip--show')).toBeVisible() + + await expect(page.locator('.tooltip--show')).toBeVisible() await expect(parentFieldType).toHaveClass(/error/) await titleField.fill('New title')