diff --git a/CHANGELOG.md b/CHANGELOG.md index ce88bbd3d3..a58d32b13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `@lumx/react`: + - `Tooltip` & `Popover`: migrate/update positioning lib from popperjs to floating-ui + - `Toolip`: render with native HTML popover without react portal when browser supports it + ## [4.4.0][] - 2026-02-19 ### Added diff --git a/packages/lumx-core/src/scss/components/tooltip/_index.scss b/packages/lumx-core/src/scss/components/tooltip/_index.scss index 43d6530b90..ea9e442488 100644 --- a/packages/lumx-core/src/scss/components/tooltip/_index.scss +++ b/packages/lumx-core/src/scss/components/tooltip/_index.scss @@ -8,6 +8,11 @@ .#{$lumx-base-prefix}-tooltip { $self: &; + &[popover] { + margin: 0; + border: none; + } + position: absolute; top: 0; left: 0; @@ -16,6 +21,7 @@ background-color: lumx-color-variant("dark", "N"); border-radius: var(--lumx-border-radius); will-change: transform; + overflow: visible; &--is-initializing { opacity: 0; diff --git a/packages/lumx-react/package.json b/packages/lumx-react/package.json index 5bdb6e0f40..65713ff83c 100644 --- a/packages/lumx-react/package.json +++ b/packages/lumx-react/package.json @@ -6,11 +6,10 @@ "url": "https://github.com/lumapps/design-system/issues" }, "dependencies": { + "@floating-ui/react-dom": "^2.1.7", "@lumx/core": "^4.4.0", "@lumx/icons": "^4.4.0", - "@popperjs/core": "^2.5.4", - "body-scroll-lock": "^3.1.5", - "react-popper": "^2.2.4" + "body-scroll-lock": "^3.1.5" }, "devDependencies": { "@babel/core": "^7.26.10", diff --git a/packages/lumx-react/src/components/dropdown/Dropdown.stories.tsx b/packages/lumx-react/src/components/dropdown/Dropdown.stories.tsx index 5f9d7fbd5c..37b459c9b9 100644 --- a/packages/lumx-react/src/components/dropdown/Dropdown.stories.tsx +++ b/packages/lumx-react/src/components/dropdown/Dropdown.stories.tsx @@ -1,5 +1,5 @@ import range from 'lodash/range'; -import { useRef, useState } from 'react'; +import { useRef, useState, useCallback } from 'react'; import { Button, Dropdown, List, ListItem } from '@lumx/react'; @@ -8,9 +8,9 @@ export default { title: 'LumX components/dropdown/Dropdown' }; export const InfiniteScroll = () => { const buttonRef = useRef(null); const [items, setItems] = useState(range(10)); - const onInfiniteScroll = () => { - setItems([...items, ...range(items.length, items.length + 10)]); - }; + const onInfiniteScroll = useCallback(() => { + setItems((prevItems) => [...prevItems, ...range(prevItems.length, prevItems.length + 10)]); + }, []); return ( <> diff --git a/packages/lumx-react/src/components/popover-dialog/PopoverDialog.test.stories.tsx b/packages/lumx-react/src/components/popover-dialog/PopoverDialog.test.stories.tsx new file mode 100644 index 0000000000..6f2a5f2f6d --- /dev/null +++ b/packages/lumx-react/src/components/popover-dialog/PopoverDialog.test.stories.tsx @@ -0,0 +1,148 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { useRef } from 'react'; + +import { useBooleanState } from '@lumx/react/hooks/useBooleanState'; +import { mdiClose, mdiMenuDown } from '@lumx/icons'; +import { Heading, HeadingLevelProvider } from '@lumx/react'; +import { expect, screen, within } from 'storybook/test'; +import type { GenericStory } from '@lumx/react/stories/utils/types'; + +import { PopoverDialog } from '.'; +import { Button, IconButton } from '../button'; + +const IconButtonTrigger = ({ children, ...props }: any) => { + const anchorRef = useRef(null); + const [isOpen, close, open] = useBooleanState(false); + + return ( + <> + + + + + {children} + + + ); +}; + +export default { + title: 'LumX components/popover-dialog/PopoverDialog/Tests', + component: PopoverDialog, + tags: ['!snapshot'], + parameters: { chromatic: { disable: true } }, + render: IconButtonTrigger, +}; + +/** Test: popover dialog opens and focuses the first button */ +export const TestOpenAndInitFocus = { + args: { label: 'Test Label' }, + async play({ userEvent }) { + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await userEvent.click(trigger); + + const dialog = await screen.findByRole('dialog', { name: 'Test Label' }); + expect(within(dialog).getAllByRole('button')[0]).toHaveFocus(); + }, +} satisfies GenericStory; + +/** Test: popover dialog works with aria-label */ +export const TestAriaLabel = { + args: { 'aria-label': 'Custom Label' }, + async play({ userEvent }) { + await userEvent.click(screen.getByRole('button', { name: 'Open popover' })); + expect(await screen.findByRole('dialog', { name: 'Custom Label' })).toBeInTheDocument(); + }, +} satisfies GenericStory; + +/** Test: focus is trapped within the popover dialog */ +export const TestTrapFocus = { + args: { label: 'Test Label' }, + async play({ userEvent }) { + await userEvent.click(screen.getByRole('button', { name: 'Open popover' })); + const dialog = await screen.findByRole('dialog', { name: 'Test Label' }); + const dialogButtons = within(dialog).getAllByRole('button'); + + // First button should have focus + expect(dialogButtons[0]).toHaveFocus(); + + // Tab to next button + await userEvent.tab(); + expect(dialogButtons[1]).toHaveFocus(); + + // Tab again: focus should loop back to first button + await userEvent.tab(); + expect(dialogButtons[0]).toHaveFocus(); + }, +} satisfies GenericStory; + +/** Test: escape closes the dialog and restores focus to trigger */ +export const TestCloseOnEscape = { + args: { label: 'Test Label' }, + async play({ userEvent }) { + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await userEvent.click(trigger); + + const dialog = await screen.findByRole('dialog', { name: 'Test Label' }); + + await userEvent.keyboard('{Escape}'); + expect(dialog).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }, +} satisfies GenericStory; + +/** Test: closing via the Close button restores focus to trigger */ +export const TestCloseExternally = { + args: { label: 'Test Label' }, + async play({ userEvent }) { + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await userEvent.click(trigger); + + await screen.findByRole('dialog', { name: 'Test Label' }); + + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }, +} satisfies GenericStory; + +/** Test: escape closes dialog with icon button trigger and restores focus */ +export const TestCloseEscapeWithTooltip = { + args: { label: 'Test Label' }, + async play({ userEvent }) { + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await userEvent.click(trigger); + + const dialog = await screen.findByRole('dialog', { name: 'Test Label' }); + + await userEvent.keyboard('{Escape}'); + expect(dialog).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }, +} satisfies GenericStory; + +/** Test: heading level context is reset inside the popover dialog */ +export const TestHeadingLevelReset = { + render({ children, ...props }: any) { + return ( + + {children} + + ); + }, + args: { + children: Title, + }, + async play({ userEvent }) { + await userEvent.click(screen.getByRole('button', { name: 'Open popover' })); + // Heading inside the popover dialog should use level 2 (reset from context level 3) + expect(screen.getByRole('heading', { name: 'Title', level: 2 })).toBeInTheDocument(); + }, +} satisfies GenericStory; diff --git a/packages/lumx-react/src/components/popover-dialog/PopoverDialog.test.tsx b/packages/lumx-react/src/components/popover-dialog/PopoverDialog.test.tsx index 2cb1fd4381..5ff442e767 100644 --- a/packages/lumx-react/src/components/popover-dialog/PopoverDialog.test.tsx +++ b/packages/lumx-react/src/components/popover-dialog/PopoverDialog.test.tsx @@ -1,16 +1,11 @@ -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; import { commonTestsSuiteRTL, SetupRenderOptions } from '@lumx/react/testing/utils'; import { getByClassName } from '@lumx/react/testing/utils/queries'; -import { Heading, HeadingLevelProvider } from '@lumx/react'; -import { WithButtonTrigger, WithIconButtonTrigger } from './PopoverDialog.stories'; import { PopoverDialog, PopoverDialogProps } from './PopoverDialog'; const CLASSNAME = PopoverDialog.className as string; -vi.mock('@lumx/react/utils/browser/isFocusVisible'); - const setup = (propsOverride: Partial = {}, { wrapper }: SetupRenderOptions = {}) => { const props = { children:
, ...propsOverride }; const { container } = render( @@ -35,133 +30,4 @@ describe(`<${PopoverDialog.displayName}>`, () => { forwardAttributes: 'element', forwardRef: 'element', }); - - it('should open and init focus', async () => { - const label = 'Test Label'; - render(); - - // Open popover - const triggerElement = screen.getByRole('button', { name: 'Open popover' }); - await userEvent.click(triggerElement); - - const dialog = await screen.findByRole('dialog', { name: label }); - - // Focused the first button - expect(within(dialog).getAllByRole('button')[0]).toHaveFocus(); - }); - - it('should work with aria-label', async () => { - const label = 'Test Label'; - render(); - - // Open popover - const triggerElement = screen.getByRole('button', { name: 'Open popover' }); - await userEvent.click(triggerElement); - - expect(await screen.findByRole('dialog', { name: label })).toBeInTheDocument(); - }); - - it('should trap focus', async () => { - const label = 'Test Label'; - render(); - - // Open popover - const triggerElement = screen.getByRole('button', { name: 'Open popover' }); - await userEvent.click(triggerElement); - - const dialog = await screen.findByRole('dialog', { name: label }); - - const dialogButtons = within(dialog).getAllByRole('button'); - - // First button should have focus by default on opening - expect(dialogButtons[0]).toHaveFocus(); - - // Tab to next button - await userEvent.tab(); - - // Second button should have focus - expect(dialogButtons[1]).toHaveFocus(); - - // Tab to next button - await userEvent.tab(); - - // As there is no more button, focus should loop back to first button. - expect(dialogButtons[0]).toHaveFocus(); - }); - - it('should close on escape and restore focus to trigger', async () => { - const label = 'Test Label'; - render(); - - // Open popover - const triggerElement = screen.getByRole('button', { name: 'Open popover' }); - await userEvent.click(triggerElement); - - const dialog = await screen.findByRole('dialog', { name: label }); - - // Close the popover - await userEvent.keyboard('{escape}'); - - expect(dialog).not.toBeInTheDocument(); - - // Focus restored to the trigger element - expect(triggerElement).toHaveFocus(); - }); - - it('should close externally and restore focus to trigger', async () => { - const label = 'Test Label'; - render(); - - // Open popover - const triggerElement = screen.getByRole('button', { name: 'Open popover' }); - await userEvent.click(triggerElement); - - const dialog = await screen.findByRole('dialog', { name: label }); - - // Close the popover - await userEvent.click(screen.getByRole('button', { name: 'Close' })); - - expect(dialog).not.toBeInTheDocument(); - - // Focus restored to the trigger element - expect(triggerElement).toHaveFocus(); - }); - - it('should close on escape and restore focus to trigger having a tooltip', async () => { - const label = 'Test Label'; - render(); - - // Open popover - const triggerElement = screen.getByRole('button', { name: 'Open popover' }); - await userEvent.click(triggerElement); - - const dialog = await screen.findByRole('dialog', { name: label }); - - // Close the popover - await userEvent.keyboard('{escape}'); - - expect(dialog).not.toBeInTheDocument(); - - // Focus restored to the trigger element - expect(triggerElement).toHaveFocus(); - }); - - it('should have reset the heading level context', async () => { - render( - // This level context should not affect headings inside the popover dialog - - - - {/* Heading inside the popover dialog */} - Title - - , - ); - - // Open popover - await userEvent.click(screen.getByRole('button', { name: 'Open popover' })); - - // Heading inside should use the popover dialog heading level 2 - expect(screen.getByRole('heading', { name: 'Title', level: 2 })).toBeInTheDocument(); - }); }); diff --git a/packages/lumx-react/src/components/popover/Popover.tsx b/packages/lumx-react/src/components/popover/Popover.tsx index 38adcfcd71..c9315eaa46 100644 --- a/packages/lumx-react/src/components/popover/Popover.tsx +++ b/packages/lumx-react/src/components/popover/Popover.tsx @@ -124,18 +124,17 @@ const _InnerPopover = forwardRef((props, ref) => { } = props; const popoverRef = useRef(null); - const { styles, attributes, isPositioned, position, setArrowElement, setPopperElement, popperElement } = - usePopoverStyle({ - offset, - hasArrow, - fitToAnchorWidth, - fitWithinViewportHeight, - boundaryRef, - anchorRef, - placement, - style, - zIndex, - }); + const { styles, isPositioned, position, setArrowElement, setPopperElement, popperElement } = usePopoverStyle({ + offset, + hasArrow, + fitToAnchorWidth, + fitWithinViewportHeight, + boundaryRef, + anchorRef, + placement, + style, + zIndex, + }); const unmountSentinel = useRestoreFocusOnClose({ focusAnchorOnClose, anchorRef, parentElement }, popperElement); const focusZoneElement = focusTrapZoneElement?.current || popoverRef?.current; @@ -161,11 +160,10 @@ const _InnerPopover = forwardRef((props, ref) => { [`theme-${theme}`]: Boolean(theme), [`elevation-${adjustedElevation}`]: Boolean(adjustedElevation), [`position-${position}`]: Boolean(position), - 'is-initializing': !styles.popover?.transform, }), )} style={styles.popover} - {...attributes.popper} + data-popper-placement={position} > {unmountSentinel} diff --git a/packages/lumx-react/src/components/popover/usePopoverStyle.tsx b/packages/lumx-react/src/components/popover/usePopoverStyle.tsx index 9f91b1752c..a8a2122f18 100644 --- a/packages/lumx-react/src/components/popover/usePopoverStyle.tsx +++ b/packages/lumx-react/src/components/popover/usePopoverStyle.tsx @@ -1,70 +1,33 @@ -import { useEffect, useMemo, useState } from 'react'; -import memoize from 'lodash/memoize'; -import { detectOverflow } from '@popperjs/core'; +import { useMemo, useState } from 'react'; + +import { + useFloating, + offset as offsetMiddleware, + flip, + shift, + size, + arrow as arrowMiddleware, + autoPlacement, + autoUpdate, + type Placement as FloatingPlacement, + type Middleware, +} from '@floating-ui/react-dom'; -import { DOCUMENT, WINDOW } from '@lumx/react/constants'; import { PopoverProps } from '@lumx/react/components/popover/Popover'; -import { usePopper } from '@lumx/react/hooks/usePopper'; import { ARROW_SIZE, FitAnchorWidth, Placement } from './constants'; -/** - * Popper js modifier to fit popover min width to the anchor width. - */ -const sameWidth = memoize((anchorWidthOption: FitAnchorWidth) => ({ - name: 'sameWidth', - enabled: true, - phase: 'beforeWrite', - requires: ['computeStyles'], - fn({ state }: any) { - // eslint-disable-next-line no-param-reassign - state.styles.popper[anchorWidthOption] = `${state.rects.reference.width}px`; - }, - effect({ state }: any) { - // eslint-disable-next-line no-param-reassign - state.elements.popper.style[anchorWidthOption] = `${state.elements.reference.offsetWidth}px`; - }, -})); - -/** - * Popper js modifier to compute max size of the popover. - */ -const maxSize = { - name: 'maxSize', - enabled: true, - phase: 'main', - requiresIfExists: ['offset', 'preventOverflow', 'flip'], - fn({ state, name, options }: any) { - const overflow = detectOverflow(state, options); - - const { y = 0, x = 0 } = state.modifiersData.preventOverflow; - const { width, height } = state.rects.popper; - - const [basePlacement] = state.placement.split('-'); - - const widthProp = basePlacement === 'left' ? 'left' : 'right'; - const heightProp = basePlacement === 'top' ? 'top' : 'bottom'; - // eslint-disable-next-line no-param-reassign - state.modifiersData[name] = { - width: width - overflow[widthProp] - x, - height: height - overflow[heightProp] - y, - }; - }, -}; - -/** - * Popper js modifier to apply the max height. - */ -const applyMaxHeight = { - name: 'applyMaxHeight', - enabled: true, - phase: 'beforeWrite', - requires: ['maxSize'], - fn({ state }: any) { - const { height } = state.modifiersData.maxSize; - // eslint-disable-next-line no-param-reassign - state.elements.popper.style.maxHeight = `${height - ARROW_SIZE}px`; - }, -}; +function parseAutoPlacement(placement?: Placement) { + if (placement === 'auto') return { isAuto: true }; + if (placement === 'auto-start') return { isAuto: true, autoAlignment: 'start' as const }; + if (placement === 'auto-end') return { isAuto: true, autoAlignment: 'end' as const }; + return { floatingPlacement: placement as FloatingPlacement, isAuto: false }; +} + +function parseFitWidth(fitToAnchorWidth?: PopoverProps['fitToAnchorWidth']) { + if (!fitToAnchorWidth) return undefined; + if (typeof fitToAnchorWidth === 'string') return fitToAnchorWidth; + return FitAnchorWidth.MIN_WIDTH; +} type Options = Pick< PopoverProps, @@ -81,7 +44,6 @@ type Options = Pick< interface Output { styles: { arrow?: React.CSSProperties; popover?: React.CSSProperties }; - attributes: any; isPositioned: boolean; position?: Placement; setArrowElement?: React.Ref; @@ -103,80 +65,102 @@ export function usePopoverStyle({ const [popperElement, setPopperElement] = useState(null); const [arrowElement, setArrowElement] = useState(null); - const actualOffset: [number, number] = [offset?.along ?? 0, (offset?.away ?? 0) + (hasArrow ? ARROW_SIZE : 0)]; - const modifiers: any = [ - { - name: 'offset', - options: { offset: actualOffset }, - }, - ]; - if (hasArrow && arrowElement) { - modifiers.push({ - name: 'arrow', - options: { - element: arrowElement, - padding: ARROW_SIZE / 2, - }, - }); - } - if (fitToAnchorWidth) { - const fitWidth = typeof fitToAnchorWidth === 'string' ? fitToAnchorWidth : FitAnchorWidth.MIN_WIDTH; - modifiers.push(sameWidth(fitWidth)); - } - if (fitWithinViewportHeight) { - modifiers.push({ ...maxSize, options: { boundary: boundaryRef?.current } }, applyMaxHeight); - } - if (boundaryRef) { - modifiers.push( - { name: 'flip', options: { boundary: boundaryRef.current } }, - { name: 'preventOverflow', options: { boundary: boundaryRef.current } }, - ); - } - - const { styles, attributes, state, update } = usePopper(anchorRef.current, popperElement, { placement, modifiers }); - - // Auto update popover - useEffect(() => { - const { current: anchorElement } = anchorRef; - if (!update || !popperElement || !anchorElement || !WINDOW?.ResizeObserver) { - return undefined; + const { floatingPlacement, isAuto, autoAlignment } = parseAutoPlacement(placement); + const fitWidth = parseFitWidth(fitToAnchorWidth); + + const boundary = boundaryRef?.current ?? undefined; + + // Build middleware array (order matters: offset → flip/autoPlacement → shift → size → arrow) + const middleware = useMemo(() => { + const mw: Middleware[] = []; + + // Offset middleware + const awayOffset = (offset?.away ?? 0) + (hasArrow ? ARROW_SIZE : 0); + const alongOffset = offset?.along ?? 0; + mw.push(offsetMiddleware({ mainAxis: awayOffset, crossAxis: alongOffset })); + + // Positioning middlewares + if (isAuto) { + mw.push(autoPlacement({ ...(boundary ? { boundary } : {}), alignment: autoAlignment })); + } else { + mw.push(flip(boundary ? { boundary } : {})); + mw.push(shift(boundary ? { boundary } : {})); } - let running: ReturnType | undefined; - function updateRateLimited() { - if (running) return; - update?.(); - running = setTimeout(() => { - running = undefined; - }, 100); + + // Size middleware + if (fitWidth || fitWithinViewportHeight) { + mw.push( + size({ + ...(boundary ? { boundary } : {}), + apply({ availableHeight, rects, elements }) { + if (fitWidth) { + Object.assign(elements.floating.style, { [fitWidth]: `${rects.reference.width}px` }); + } + if (fitWithinViewportHeight) { + // eslint-disable-next-line no-param-reassign + elements.floating.style.maxHeight = `${Math.max(0, availableHeight - ARROW_SIZE)}px`; + } + }, + }), + ); + } + + // Arrow middleware + if (hasArrow && arrowElement) { + mw.push(arrowMiddleware({ element: arrowElement, padding: ARROW_SIZE / 2 })); } - updateRateLimited(); - - // On anchor or popover resize - const resizeObserver = new ResizeObserver(updateRateLimited); - resizeObserver.observe(anchorElement); - resizeObserver.observe(popperElement); - return () => { - resizeObserver.disconnect(); - }; - }, [anchorRef, popperElement, update]); - const position = state?.placement ?? placement; + return mw; + }, [ + offset?.away, + offset?.along, + hasArrow, + isAuto, + autoAlignment, + boundary, + fitWidth, + fitWithinViewportHeight, + arrowElement, + ]); + + const anchorElement = anchorRef.current; + + const { + floatingStyles, + placement: resolvedPlacement, + isPositioned, + middlewareData, + } = useFloating({ + placement: floatingPlacement, + whileElementsMounted: autoUpdate, + middleware, + elements: { + reference: anchorElement, + floating: popperElement, + }, + }); - const popoverStyle = useMemo(() => { - const newStyles = { ...style, ...styles.popper, zIndex }; + const position = resolvedPlacement ?? placement; - if (fitWithinViewportHeight && !newStyles.maxHeight) { - newStyles.maxHeight = WINDOW?.innerHeight || DOCUMENT?.documentElement.clientHeight; - } + // Compute arrow styles from middleware data + const arrowStyles = useMemo((): React.CSSProperties | undefined => { + const arrowData = middlewareData.arrow; + if (!arrowData) return undefined; + return { + left: arrowData.x != null ? `${arrowData.x}px` : '', + top: arrowData.y != null ? `${arrowData.y}px` : '', + }; + }, [middlewareData.arrow]); - return newStyles; - }, [style, styles.popper, zIndex, fitWithinViewportHeight]); + // Merge floating styles with user-provided styles and zIndex + const popoverStyle = useMemo((): React.CSSProperties => { + return { ...style, ...floatingStyles, zIndex }; + }, [style, floatingStyles, zIndex]); return { - styles: { arrow: styles.arrow, popover: popoverStyle }, - attributes, - isPositioned: (state?.rects?.popper?.y ?? -1) >= 0, - position, + styles: { arrow: arrowStyles, popover: popoverStyle }, + isPositioned, + position: position as Placement, setArrowElement, setPopperElement, popperElement, diff --git a/packages/lumx-react/src/components/tooltip/Tooltip.stories.tsx b/packages/lumx-react/src/components/tooltip/Tooltip.stories.tsx index a0082ad34d..1168f04a0f 100644 --- a/packages/lumx-react/src/components/tooltip/Tooltip.stories.tsx +++ b/packages/lumx-react/src/components/tooltip/Tooltip.stories.tsx @@ -1,6 +1,4 @@ -import React, { useState } from 'react'; - -import { Button, Dialog, Dropdown, Placement, Tooltip } from '@lumx/react'; +import { Button, Placement, Tooltip } from '@lumx/react'; import { getSelectArgType } from '@lumx/core/stories/controls/selectArgType'; import { withChromaticForceScreenSize } from '@lumx/react/stories/decorators/withChromaticForceScreenSize'; import { ARIA_LINK_MODES } from '@lumx/react/components/tooltip/constants'; @@ -63,58 +61,3 @@ export const MultilineTooltip = { label: 'First sentence.\nSecond sentence.\nThird sentence.\n', }, }; - -/** Tooltip should hide when a dropdown opens */ -export const TooltipWithDropdown = (props: any) => { - const [button, setButton] = useState(null); - const [isOpen, setOpen] = useState(false); - return ( - <> -
- - - - setOpen(false)}> - Dropdown - - - ); -}; - -/** Tooltip should hide when the anchor is hidden */ -export const HideTooltipOnHiddenAnchor = () => { - const [isOpen, setOpen] = useState(false); - return ( - <> - The tooltip should show when the button is hovered but it should disappear when the dialog get in-between - the mouse and the button -
- - - - setOpen(false)}> - Dialog - - - ); -}; -HideTooltipOnHiddenAnchor.parameters = { chromatic: { disableSnapshot: true } }; -HideTooltipOnHiddenAnchor.tags = ['!snapshot']; - -/** Test focusing a tooltip anchor programmatically */ -export const TestProgrammaticFocus = () => { - const anchorRef = React.useRef(null); - return ( - <> -

The tooltip should open on keyboard focus but not on programmatic focus (ex: after a click)

- - - - - - ); -}; -TestProgrammaticFocus.parameters = { chromatic: { disableSnapshot: true } }; -TestProgrammaticFocus.tags = ['!snapshot']; diff --git a/packages/lumx-react/src/components/tooltip/Tooltip.test.stories.tsx b/packages/lumx-react/src/components/tooltip/Tooltip.test.stories.tsx new file mode 100644 index 0000000000..599512fd15 --- /dev/null +++ b/packages/lumx-react/src/components/tooltip/Tooltip.test.stories.tsx @@ -0,0 +1,267 @@ +import { useState, useRef } from 'react'; +import range from 'lodash/range'; +import { waitFor, expect, screen } from 'storybook/test'; +import { Button, Dialog, Dropdown, FlexBox } from '@lumx/react'; +import type { GenericStory } from '@lumx/react/stories/utils/types'; + +import { Tooltip } from '.'; + +export default { + title: 'LumX components/tooltip/Tooltip/Tests', + component: Tooltip, + tags: ['!snapshot'], + parameters: { chromatic: { disableSnapshot: true } }, +}; + +/** Tooltip should hide when a dropdown opens and should work in a scroll context */ +export const TestTooltipWithDropdownAndScroll = (props: any) => { + const anchorRef = useRef(null); + const [isOpen, setOpen] = useState(false); + return ( + <> + {range(200).map((i) => ( +
+ ))} + + + + + + Tooltips should close when attached popover/dropdown opens.
+ It should also keep displaying while scrolling (here on a position: fixed button) +
+
+ setOpen(false)}> + Dropdown + + + ); +}; + +/** Tooltip should hide when the anchor is hidden */ +export const HideTooltipOnHiddenAnchor = () => { + const [isOpen, setOpen] = useState(false); + return ( + <> + The tooltip should show when the button is hovered but it should disappear when the dialog get in-between + the mouse and the button +
+ + + + setOpen(false)}> + Dialog + + + ); +}; + +/** Test focusing a tooltip anchor programmatically */ +export const TestProgrammaticFocus = () => { + const anchorRef = useRef(null); + return ( + <> +

The tooltip should open on keyboard focus but not on programmatic focus (ex: after a click)

+ + + + + + ); +}; + +/** Test: tooltip activates on anchor hover and closes on unhover */ +export const TestActivateOnHover = { + args: { + label: 'Tooltip label', + children: , + }, + async play({ canvas, userEvent }) { + // Initially no tooltip + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + const button = canvas.getByRole('button', { name: 'Anchor' }); + + // Hover anchor button + await userEvent.hover(button); + const tooltip = await screen.findByRole('tooltip', { name: 'Tooltip label' }); + expect(tooltip).toBeInTheDocument(); + + // Unhover anchor button + await userEvent.unhover(button); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }, +} satisfies GenericStory; + +/** Test: tooltip stays open when hovering from anchor to tooltip */ +export const TestHoverAnchorThenTooltip = { + args: { + label: 'Tooltip label', + children: , + }, + async play({ canvas, userEvent }) { + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + const button = canvas.getByRole('button', { name: 'Anchor' }); + + // Hover anchor button + await userEvent.hover(button); + const tooltip = await screen.findByRole('tooltip', { name: 'Tooltip label' }); + expect(tooltip).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-describedby', tooltip.id); + + // Hover tooltip (should stay open) + await userEvent.hover(tooltip); + expect(tooltip).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-describedby', tooltip.id); + + // Unhover tooltip + await userEvent.unhover(tooltip); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }, +} satisfies GenericStory; + +/** Test: tooltip activates on keyboard focus-visible and closes on escape */ +export const TestFocusVisibleAndEscape = { + args: { + label: 'Tooltip label', + children: , + }, + async play({ canvas, userEvent }) { + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + // Tab to focus the button (keyboard focus => focus-visible) + await userEvent.tab(); + const button = canvas.getByRole('button', { name: 'Anchor' }); + expect(button).toHaveFocus(); + + // Tooltip should open + const tooltip = await screen.findByRole('tooltip', { name: 'Tooltip label' }); + expect(tooltip).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-describedby', tooltip.id); + + // Tab away => tooltip closes + await userEvent.tab(); + expect(button).not.toHaveFocus(); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + + // Shift+tab back => tooltip opens again + await userEvent.tab({ shift: true }); + await screen.findByRole('tooltip', { name: 'Tooltip label' }); + + // Escape => tooltip closes + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }, +} satisfies GenericStory; + +/** Test: click-focus (not focus-visible) should NOT activate tooltip */ +export const TestNoActivateOnClickFocus = { + args: { + label: 'Tooltip label', + children: , + }, + async play({ canvas, userEvent }) { + const button = canvas.getByRole('button', { name: 'Anchor' }); + + // Click focuses the button (mouse focus, not :focus-visible) + await userEvent.click(button); + + // Tooltip should NOT open (click-focus is not focus-visible) + await expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }, +} satisfies GenericStory; + +/** Test: closeMode="hide" keeps the tooltip in the DOM but visually hidden */ +export const TestCloseModeHide = { + args: { + label: 'Tooltip label', + children: , + closeMode: 'hide', + }, + async play({ canvas, userEvent }) { + // Tooltip should be in DOM but not visible + const tooltip = screen.getByRole('tooltip', { hidden: true }); + expect(tooltip).toBeInTheDocument(); + + // Hover to open + const button = canvas.getByRole('button', { name: 'Anchor' }); + await userEvent.hover(button); + await waitFor(() => expect(tooltip).toBeVisible()); + }, +} satisfies GenericStory; + +/** Test: aria-describedby is set on button anchor when tooltip opens via hover */ +export const TestAriaDescribedByOnHover = { + args: { + label: 'Tooltip label', + children: , + }, + async play({ canvas, userEvent }) { + const anchor = canvas.getByRole('button', { name: 'Anchor' }); + expect(anchor).toHaveAttribute('aria-describedby', ':description1:'); + + await userEvent.hover(anchor); + const tooltip = await screen.findByRole('tooltip'); + expect(anchor).toHaveAttribute('aria-describedby', `:description1: ${tooltip.id}`); + }, +} satisfies GenericStory; + +/** Test: aria-describedby is set on wrapper anchor when tooltip opens via hover */ +export const TestAriaDescribedByOnWrapperHover = { + render(props: any) { + return ( + + Anchor + + ); + }, + async play({ canvasElement, userEvent }) { + const anchorWrapper = canvasElement.querySelector('.lumx-tooltip-anchor-wrapper'); + expect(anchorWrapper).toBeInTheDocument(); + expect(anchorWrapper).not.toHaveAttribute('aria-describedby'); + + await userEvent.hover(anchorWrapper!); + const tooltip = await screen.findByRole('tooltip'); + expect(anchorWrapper).toHaveAttribute('aria-describedby', tooltip.id); + }, +} satisfies GenericStory; + +/** Test: aria-labelledby is set on button anchor when tooltip opens via hover */ +export const TestAriaLabelledByOnHover = { + args: { + label: 'Tooltip label', + children: , + ariaLinkMode: 'aria-labelledby', + }, + async play({ canvas, userEvent }) { + const anchor = canvas.getByRole('button', { name: 'Anchor' }); + expect(anchor).toHaveAttribute('aria-labelledby', ':label1:'); + + await userEvent.hover(anchor); + const tooltip = await screen.findByRole('tooltip'); + expect(anchor).toHaveAttribute('aria-labelledby', `:label1: ${tooltip.id}`); + }, +} satisfies GenericStory; + +/** Test: aria-labelledby is set on wrapper anchor when tooltip opens via hover */ +export const TestAriaLabelledByOnWrapperHover = { + render(props: any) { + return ( + + Anchor + + ); + }, + async play({ canvasElement, userEvent }) { + const anchorWrapper = canvasElement.querySelector('.lumx-tooltip-anchor-wrapper'); + expect(anchorWrapper).toBeInTheDocument(); + expect(anchorWrapper).not.toHaveAttribute('aria-labelledby'); + + await userEvent.hover(anchorWrapper!); + const tooltip = await screen.findByRole('tooltip'); + expect(anchorWrapper).toHaveAttribute('aria-labelledby', tooltip.id); + }, +} satisfies GenericStory; diff --git a/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx b/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx index 57b48e73cc..b53490ba1c 100644 --- a/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx +++ b/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx @@ -1,38 +1,34 @@ import React from 'react'; -import { MockInstance } from 'vitest'; import { Button } from '@lumx/react'; -import { screen, render } from '@testing-library/react'; +import { act, screen, render } from '@testing-library/react'; import { queryAllByTagName, queryByClassName } from '@lumx/react/testing/utils/queries'; import { commonTestsSuiteRTL } from '@lumx/react/testing/utils'; import userEvent from '@testing-library/user-event'; -import { isFocusVisible } from '@lumx/react/utils/browser/isFocusVisible'; import { classNames } from '@lumx/core/js/utils'; import { Tooltip, TooltipProps } from './Tooltip'; const CLASSNAME = Tooltip.className as string; -vi.mock('@lumx/react/utils/browser/isFocusVisible'); +vi.mock('@lumx/react/utils/browser/isPopoverSupported', () => ({ + isPopoverSupported: vi.fn(() => false), +})); vi.mock('@lumx/react/hooks/useId', () => ({ useId: () => ':r1:' })); -// Skip delays -vi.mock('@lumx/react/constants', async (importActual: any) => { - const actual = (await importActual()) as Record; - return { - ...actual, - TOOLTIP_HOVER_DELAY: { open: 0, close: 0 }, - }; -}); /** * Mounts the component and returns common DOM elements / data needed in multiple tests further down. */ const setup = async (propsOverride: Partial = {}) => { const props: any = { forceOpen: true, label: 'Tooltip label', children: 'Anchor', ...propsOverride }; - const result = render(); + let result: ReturnType; + // Wrap in act to flush async useFloating position updates. + await act(async () => { + result = render(); + }); const tooltip = screen.queryByRole('tooltip'); const anchorWrapper = queryByClassName(document.body, 'lumx-tooltip-anchor-wrapper'); - return { props, tooltip, anchorWrapper, result }; + return { props, tooltip, anchorWrapper, result: result! }; }; describe(`<${Tooltip.displayName}>`, () => { @@ -148,7 +144,7 @@ describe(`<${Tooltip.displayName}>`, () => { }); expect(tooltip).toBeInTheDocument(); expect(tooltip).toHaveClass(classNames.visuallyHidden()); - // Popper styles should not be applied when closed. + // Floating styles should not be applied when closed. expect(tooltip?.style?.transform).toBe(''); const anchor = screen.getByRole('button', { name: 'Anchor' }); @@ -158,20 +154,6 @@ describe(`<${Tooltip.displayName}>`, () => { }); describe('ariaLinkMode="aria-describedby"', () => { - it('should add aria-describedby on anchor on open', async () => { - await setup({ - label: 'Tooltip label', - forceOpen: false, - children: , - }); - const anchor = screen.getByRole('button', { name: 'Anchor' }); - expect(anchor).toHaveAttribute('aria-describedby', ':description1:'); - - await userEvent.hover(anchor); - const tooltip = screen.queryByRole('tooltip'); - expect(anchor).toHaveAttribute('aria-describedby', `:description1: ${tooltip?.id}`); - }); - it('should always add aria-describedby on anchor with closeMode="hide"', async () => { const { tooltip } = await setup({ label: 'Tooltip label', @@ -197,19 +179,6 @@ describe(`<${Tooltip.displayName}>`, () => { expect(screen.getByRole('button')).toHaveAttribute('aria-describedby', `:description1:`); }); - it('should add aria-describedby on anchor wrapper on open', async () => { - const { anchorWrapper } = await setup({ - label: 'Tooltip label', - forceOpen: false, - children: 'Anchor', - }); - expect(anchorWrapper).not.toHaveAttribute('aria-describedby'); - - await userEvent.hover(anchorWrapper as any); - const tooltip = screen.queryByRole('tooltip'); - expect(anchorWrapper).toHaveAttribute('aria-describedby', tooltip?.id); - }); - it('should always add aria-describedby on anchor wrapper with closeMode="hide"', async () => { const { tooltip, anchorWrapper } = await setup({ label: 'Tooltip label', @@ -222,21 +191,6 @@ describe(`<${Tooltip.displayName}>`, () => { }); describe('ariaLinkMode="aria-labelledby"', () => { - it('should add aria-labelledby on anchor on open', async () => { - await setup({ - label: 'Tooltip label', - forceOpen: false, - children: , - ariaLinkMode: 'aria-labelledby', - }); - const anchor = screen.getByRole('button', { name: 'Anchor' }); - expect(anchor).toHaveAttribute('aria-labelledby', ':label1:'); - - await userEvent.hover(anchor); - const tooltip = screen.queryByRole('tooltip'); - expect(anchor).toHaveAttribute('aria-labelledby', `:label1: ${tooltip?.id}`); - }); - it('should always add aria-labelledby on anchor with closeMode="hide"', async () => { const label = 'Tooltip label'; const { tooltip } = await setup({ @@ -266,20 +220,6 @@ describe(`<${Tooltip.displayName}>`, () => { expect(screen.getByRole('button')).toHaveAttribute('aria-labelledby', `:label1:`); }); - it('should add aria-labelledby on anchor wrapper on open', async () => { - const { anchorWrapper } = await setup({ - label: 'Tooltip label', - forceOpen: false, - children: 'Anchor', - ariaLinkMode: 'aria-labelledby', - }); - expect(anchorWrapper).not.toHaveAttribute('aria-labelledby'); - - await userEvent.hover(anchorWrapper as any); - const tooltip = screen.queryByRole('tooltip'); - expect(anchorWrapper).toHaveAttribute('aria-labelledby', tooltip?.id); - }); - it('should always add aria-labelledby on anchor wrapper with closeMode="hide"', async () => { const { tooltip, anchorWrapper } = await setup({ label: 'Tooltip label', @@ -293,118 +233,6 @@ describe(`<${Tooltip.displayName}>`, () => { }); }); - describe('activation', () => { - it('should activate on anchor hover', async () => { - let { tooltip } = await setup({ - label: 'Tooltip label', - children: , - forceOpen: false, - }); - - expect(tooltip).not.toBeInTheDocument(); - - // Hover anchor button - const button = screen.getByRole('button', { name: 'Anchor' }); - await userEvent.hover(button); - - // Tooltip opened - tooltip = await screen.findByRole('tooltip', { name: 'Tooltip label' }); - expect(tooltip).toBeInTheDocument(); - - // Un-hover anchor button - await userEvent.unhover(button); - - expect(button).not.toHaveFocus(); - // Tooltip closed - expect(tooltip).not.toBeInTheDocument(); - }); - - it('should activate on hover anchor and then tooltip', async () => { - let { tooltip } = await setup({ - label: 'Tooltip label', - children: , - forceOpen: false, - }); - - expect(tooltip).not.toBeInTheDocument(); - - // Hover anchor button - const button = screen.getByRole('button', { name: 'Anchor' }); - await userEvent.hover(button); - - // Tooltip opened - tooltip = await screen.findByRole('tooltip', { name: 'Tooltip label' }); - expect(tooltip).toBeInTheDocument(); - expect(button).toHaveAttribute('aria-describedby', tooltip?.id); - - // Hover tooltip - await userEvent.hover(tooltip); - expect(tooltip).toBeInTheDocument(); - expect(button).toHaveAttribute('aria-describedby', tooltip?.id); - - // Un-hover tooltip - await userEvent.unhover(tooltip); - expect(button).not.toHaveFocus(); - // Tooltip closed - expect(tooltip).not.toBeInTheDocument(); - }); - - it('should activate on anchor focus visible and close on escape', async () => { - (isFocusVisible as unknown as MockInstance).mockReturnValue(true); - let { tooltip } = await setup({ - label: 'Tooltip label', - children: , - forceOpen: false, - }); - - expect(tooltip).not.toBeInTheDocument(); - - // Focus anchor button - await userEvent.tab(); - const button = screen.getByRole('button', { name: 'Anchor' }); - expect(button).toHaveFocus(); - - // Tooltip opened - tooltip = await screen.findByRole('tooltip', { name: 'Tooltip label' }); - expect(tooltip).toBeInTheDocument(); - expect(button).toHaveAttribute('aria-describedby', tooltip?.id); - - // Focus next element => close tooltip - await userEvent.tab(); - expect(button).not.toHaveFocus(); - expect(tooltip).not.toBeInTheDocument(); - - // Focus again - await userEvent.tab({ shift: true }); - tooltip = await screen.findByRole('tooltip', { name: 'Tooltip label' }); - expect(tooltip).toBeInTheDocument(); - - // Escape pressed => close tooltip - await userEvent.keyboard('{Escape}'); - expect(tooltip).not.toBeInTheDocument(); - }); - - it('should not activate on anchor focus if not visible', async () => { - (isFocusVisible as unknown as MockInstance).mockReturnValue(false); - let { tooltip } = await setup({ - label: 'Tooltip label', - children: , - forceOpen: false, - }); - - expect(tooltip).not.toBeInTheDocument(); - - // Focus anchor button - await userEvent.tab(); - const button = screen.getByRole('button', { name: 'Anchor' }); - expect(button).toHaveFocus(); - - // Tooltip not opening - tooltip = screen.queryByRole('tooltip', { name: 'Tooltip label' }); - expect(tooltip).not.toBeInTheDocument(); - }); - }); - // Common tests suite. commonTestsSuiteRTL(setup, { baseClassName: CLASSNAME, diff --git a/packages/lumx-react/src/components/tooltip/Tooltip.tsx b/packages/lumx-react/src/components/tooltip/Tooltip.tsx index a7d9955cb9..f8790fe558 100644 --- a/packages/lumx-react/src/components/tooltip/Tooltip.tsx +++ b/packages/lumx-react/src/components/tooltip/Tooltip.tsx @@ -1,6 +1,8 @@ /* eslint-disable react-hooks/rules-of-hooks */ import React, { ReactNode, useState } from 'react'; +import { useFloating, offset, autoUpdate, type Placement as FloatingPlacement } from '@floating-ui/react-dom'; + import { DOCUMENT } from '@lumx/react/constants'; import { GenericProps, HasCloseMode } from '@lumx/react/utils/type'; import type { LumxClassName } from '@lumx/core/js/types'; @@ -9,10 +11,10 @@ import { useMergeRefs } from '@lumx/react/utils/react/mergeRefs'; import { Placement } from '@lumx/react/components/popover'; import { TooltipContextProvider } from '@lumx/react/components/tooltip/context'; import { useId } from '@lumx/react/hooks/useId'; -import { usePopper } from '@lumx/react/hooks/usePopper'; import { forwardRef } from '@lumx/react/utils/react/forwardRef'; import { ARIA_LINK_MODES, TOOLTIP_ZINDEX } from '@lumx/react/components/tooltip/constants'; +import { isPopoverSupported } from '@lumx/react/utils/browser/isPopoverSupported'; import { Portal } from '@lumx/react/utils'; import { useInjectTooltipRef } from './useInjectTooltipRef'; import { useTooltipOpen } from './useTooltipOpen'; @@ -36,6 +38,11 @@ export interface TooltipProps extends GenericProps, HasCloseMode { placement?: TooltipPlacement; /** Choose how the tooltip text should link to the anchor */ ariaLinkMode?: (typeof ARIA_LINK_MODES)[number]; + /** + * z-index positioning + * @deprecated Never really needed. Ignored on browsers supporting the HTML popover API + */ + zIndex?: number; } /** @@ -93,17 +100,14 @@ export const Tooltip = forwardRef((props, ref) => const [popperElement, setPopperElement] = useState(null); const [anchorElement, setAnchorElement] = useState(null); - const { styles, attributes, update } = usePopper(anchorElement, popperElement, { - placement, - modifiers: [ - { - name: 'offset', - options: { offset: [0, ARROW_SIZE] }, - }, - ], + const { floatingStyles, placement: resolvedPlacement } = useFloating({ + placement: placement as FloatingPlacement, + whileElementsMounted: autoUpdate, + middleware: [offset(ARROW_SIZE)], + elements: { reference: anchorElement, floating: popperElement }, }); - const position = attributes?.popper?.['data-popper-placement'] ?? placement; + const position = resolvedPlacement ?? placement; const { isOpen: isActivated, onPopperMount } = useTooltipOpen(delay, anchorElement); const isOpen = (isActivated || forceOpen) && !!label; const isMounted = !!label && (isOpen || closeMode === 'hide'); @@ -117,35 +121,51 @@ export const Tooltip = forwardRef((props, ref) => ariaLinkMode: ariaLinkMode as any, }); - // Update on open + // Update popover visibility on open/close. React.useEffect(() => { - if (isOpen || popperElement) update?.(); - }, [isOpen, update, popperElement]); + if (!popperElement?.popover) return; + try { + if (isOpen) popperElement.showPopover(); + else popperElement.hidePopover(); + } catch { + /* already open/closed */ + } + }, [isOpen, popperElement]); const labelLines = label ? label.split('\n') : []; const tooltipRef = useMergeRefs(ref, setPopperElement, onPopperMount); + let popover: React.HTMLAttributes['popover']; + let Wrapper = Portal; + + if (isPopoverSupported()) { + // Use native HTML popover API + popover = forceOpen ? 'manual' : 'hint'; + // No need for Portal (originally used to escape potential parent hidden/cliped overflow) + Wrapper = React.Fragment; + } + return ( <> {wrappedChildren} {isMounted && ( - +