From 1a0d9fe8ed881afbc2bdd60d2b56d3ad2b9dc730 Mon Sep 17 00:00:00 2001 From: lumbot Date: Thu, 19 Feb 2026 13:18:57 +0000 Subject: [PATCH 1/2] chore(react): migrate from popperjs to floating-ui --- CHANGELOG.md | 5 + packages/lumx-react/package.json | 5 +- .../components/dropdown/Dropdown.stories.tsx | 8 +- .../PopoverDialog.test.stories.tsx | 148 ++++++++++ .../popover-dialog/PopoverDialog.test.tsx | 136 +-------- .../src/components/popover/Popover.tsx | 26 +- .../components/popover/usePopoverStyle.tsx | 244 ++++++++-------- .../components/tooltip/Tooltip.stories.tsx | 59 +--- .../tooltip/Tooltip.test.stories.tsx | 267 ++++++++++++++++++ .../src/components/tooltip/Tooltip.test.tsx | 183 +----------- .../src/components/tooltip/Tooltip.tsx | 30 +- .../lumx-react/src/hooks/usePopper.test.tsx | 24 -- packages/lumx-react/src/hooks/usePopper.ts | 12 - .../lumx-react/src/stories/utils/types.ts | 4 + yarn.lock | 77 ++--- 15 files changed, 616 insertions(+), 612 deletions(-) create mode 100644 packages/lumx-react/src/components/popover-dialog/PopoverDialog.test.stories.tsx create mode 100644 packages/lumx-react/src/components/tooltip/Tooltip.test.stories.tsx delete mode 100644 packages/lumx-react/src/hooks/usePopper.test.tsx delete mode 100644 packages/lumx-react/src/hooks/usePopper.ts create mode 100644 packages/lumx-react/src/stories/utils/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce88bbd3d3..a18f60a47a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ 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 + ## [4.4.0][] - 2026-02-19 ### Added 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..a57d0b20ac 100644 --- a/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx +++ b/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx @@ -1,19 +1,16 @@ 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/hooks/useId', () => ({ useId: () => ':r1:' })); // Skip delays vi.mock('@lumx/react/constants', async (importActual: any) => { @@ -29,10 +26,14 @@ vi.mock('@lumx/react/constants', async (importActual: any) => { */ 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 +149,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 +159,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 +184,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 +196,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 +225,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 +238,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..210ed2e897 100644 --- a/packages/lumx-react/src/components/tooltip/Tooltip.tsx +++ b/packages/lumx-react/src/components/tooltip/Tooltip.tsx @@ -1,5 +1,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import React, { ReactNode, useState } from 'react'; +import { 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'; @@ -9,7 +11,6 @@ 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'; @@ -93,17 +94,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,11 +115,6 @@ export const Tooltip = forwardRef((props, ref) => ariaLinkMode: ariaLinkMode as any, }); - // Update on open - React.useEffect(() => { - if (isOpen || popperElement) update?.(); - }, [isOpen, update, popperElement]); - const labelLines = label ? label.split('\n') : []; const tooltipRef = useMergeRefs(ref, setPopperElement, onPopperMount); @@ -140,12 +133,11 @@ export const Tooltip = forwardRef((props, ref) => className, block({ [`position-${position}`]: Boolean(position), - 'is-initializing': !styles.popper?.transform, }), isHidden && classNames.visuallyHidden(), )} - style={{ ...(isHidden ? undefined : styles.popper), zIndex }} - {...attributes.popper} + style={{ ...(isHidden ? undefined : floatingStyles), zIndex }} + data-popper-placement={position} >
diff --git a/packages/lumx-react/src/hooks/usePopper.test.tsx b/packages/lumx-react/src/hooks/usePopper.test.tsx deleted file mode 100644 index 8c79711f52..0000000000 --- a/packages/lumx-react/src/hooks/usePopper.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { render } from '@testing-library/react'; -import { IS_BROWSER } from '@lumx/react/constants'; -import { usePopper } from './usePopper'; - -describe('usePopper', () => { - it('should return popper attributes and styles', () => { - const anchorEl = document.createElement('div'); - const popperEl = document.createElement('div'); - - let result: any; - const TestComponent = ({ anchor, popper, opts }: any) => { - result = usePopper(anchor, popper, opts); - return null; - }; - render(); - - expect(result.attributes.popper).toBeDefined(); - expect(result.styles.popper).toBeDefined(); - - if (!IS_BROWSER) { - expect(result.attributes.popper?.['data-popper-placement']).toBe('bottom'); - } - }); -}); diff --git a/packages/lumx-react/src/hooks/usePopper.ts b/packages/lumx-react/src/hooks/usePopper.ts deleted file mode 100644 index 3deb6036aa..0000000000 --- a/packages/lumx-react/src/hooks/usePopper.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { usePopper as usePopperHook } from 'react-popper'; -import { IS_BROWSER } from '@lumx/react/constants'; - -/** Stub usePopper for use outside of browsers */ -const useStubPopper: typeof usePopperHook = (_a, _p, { placement }: any) => - ({ - attributes: { popper: { 'data-popper-placement': placement } }, - styles: { popper: { transform: 'translate(1, 1)' } }, - }) as any; - -/** Switch hook implementation between environment */ -export const usePopper: typeof usePopperHook = IS_BROWSER ? usePopperHook : useStubPopper; diff --git a/packages/lumx-react/src/stories/utils/types.ts b/packages/lumx-react/src/stories/utils/types.ts new file mode 100644 index 0000000000..79308d767f --- /dev/null +++ b/packages/lumx-react/src/stories/utils/types.ts @@ -0,0 +1,4 @@ +import type { StoryObj } from '@storybook/react-vite'; + +/** Generic story type for test stories without strict component typing. */ +export type GenericStory = StoryObj<{ component(props: any): any }>; diff --git a/yarn.lock b/yarn.lock index cac5ba442d..45e2fd1992 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2288,6 +2288,44 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.7.4": + version: 1.7.4 + resolution: "@floating-ui/core@npm:1.7.4" + dependencies: + "@floating-ui/utils": "npm:^0.2.10" + checksum: 10/b750f306a99be879f0bce879108c440d5b0a67303d3d8318e153687f6ed1af27908428e27cc955475253bd902b95452a3434bd4f0cf96e66e5b5d0db1aa8ea3c + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.7.5": + version: 1.7.5 + resolution: "@floating-ui/dom@npm:1.7.5" + dependencies: + "@floating-ui/core": "npm:^1.7.4" + "@floating-ui/utils": "npm:^0.2.10" + checksum: 10/2764990da82bd5cfe942211480aa82352926326008de93f5f3f19749cc8b171fe05b77526a2652605eadcbeab902c6506f18d60a4c43281f2651802047de100b + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.1.7": + version: 2.1.7 + resolution: "@floating-ui/react-dom@npm:2.1.7" + dependencies: + "@floating-ui/dom": "npm:^1.7.5" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10/870eb2109af3ab09ea0076eb8e0ad307da274978c3dfe28e83422136a7f85cac700f62c37663c75bc25b174d33457c0224d1944e80b9a7ca5ff7b28b8f77b7ab + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.10": + version: 0.2.10 + resolution: "@floating-ui/utils@npm:0.2.10" + checksum: 10/b635ea865a8be2484b608b7157f5abf9ed439f351011a74b7e988439e2898199a9a8b790f52291e05bdcf119088160dc782d98cff45cc98c5a271bc6f51327ae + languageName: node + linkType: hard + "@gar/promisify@npm:^1.0.1, @gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -2939,9 +2977,9 @@ __metadata: "@babel/preset-react": "npm:^7.26.3" "@babel/preset-typescript": "npm:^7.26.0" "@chromatic-com/storybook": "npm:^5.0.0" + "@floating-ui/react-dom": "npm:^2.1.7" "@lumx/core": "npm:^4.4.0" "@lumx/icons": "npm:^4.4.0" - "@popperjs/core": "npm:^2.5.4" "@rollup/plugin-babel": "npm:^6.1.0" "@rollup/plugin-commonjs": "npm:^29.0.0" "@rollup/plugin-node-resolve": "npm:^16.0.3" @@ -2969,7 +3007,6 @@ __metadata: playwright: "npm:^1.58.2" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" - react-popper: "npm:^2.2.4" rollup: "npm:^4.56.0" rollup-plugin-analyzer: "npm:^4.0.0" rollup-plugin-cleaner: "npm:^1.0.0" @@ -3991,13 +4028,6 @@ __metadata: languageName: node linkType: hard -"@popperjs/core@npm:^2.5.4": - version: 2.5.4 - resolution: "@popperjs/core@npm:2.5.4" - checksum: 10/897004d75c5bbb128284c5334e6c05396b7ae0a3f1bc0fe89cb58a911fb95f48a7a024d6d41ca6f6d2083e37a8cdee7467f7433c8f7ad5cbbb7632254675e4b4 - languageName: node - linkType: hard - "@react-hook/intersection-observer@npm:^3.1.1": version: 3.1.2 resolution: "@react-hook/intersection-observer@npm:3.1.2" @@ -19581,13 +19611,6 @@ __metadata: languageName: node linkType: hard -"react-fast-compare@npm:^3.0.1": - version: 3.2.0 - resolution: "react-fast-compare@npm:3.2.0" - checksum: 10/26ed35d425f197f04c85d572eac943d901a2713335b79483d4f3f94ee5caf97f20678f89bedd385ace9b1637890c88fc5442d732bad0871135643d9703312cd7 - languageName: node - linkType: hard - "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -19602,19 +19625,6 @@ __metadata: languageName: node linkType: hard -"react-popper@npm:^2.2.4": - version: 2.2.4 - resolution: "react-popper@npm:2.2.4" - dependencies: - react-fast-compare: "npm:^3.0.1" - warning: "npm:^4.0.2" - peerDependencies: - "@popperjs/core": ^2.0.0 - react: ^16.8.0 || ^17 - checksum: 10/0c4290f51f123859321b1020b7a0d95affe65d71b823e75a29c5caf5e66a90c14eaa758306979fd65dde1dae693b475fbf25677028e7580509b8f759f3268a03 - languageName: node - linkType: hard - "react-refresh@npm:^0.14.0": version: 0.14.2 resolution: "react-refresh@npm:0.14.2" @@ -24145,15 +24155,6 @@ __metadata: languageName: node linkType: hard -"warning@npm:^4.0.2": - version: 4.0.3 - resolution: "warning@npm:4.0.3" - dependencies: - loose-envify: "npm:^1.0.0" - checksum: 10/e7842aff036e2e07ce7a6cc3225e707775b969fe3d0577ad64bd24660e3a9ce3017f0b8c22a136566dcd3a151f37b8ed1ccee103b3bd82bd8a571bf80b247bc4 - languageName: node - linkType: hard - "watchpack@npm:^2.4.1": version: 2.5.1 resolution: "watchpack@npm:2.5.1" From bdbbdecf1f5de352aa104cbe88f9157b84d485cb Mon Sep 17 00:00:00 2001 From: Guillaume Cornut Date: Fri, 20 Feb 2026 13:16:13 +0100 Subject: [PATCH 2/2] feat(tooltip): use HTML popover API --- CHANGELOG.md | 1 + .../src/scss/components/tooltip/_index.scss | 6 +++ .../src/components/tooltip/Tooltip.test.tsx | 11 ++--- .../src/components/tooltip/Tooltip.tsx | 40 ++++++++++++++++--- packages/lumx-react/src/untypped-modules.d.ts | 7 ++++ .../src/utils/browser/isPopoverSupported.ts | 6 +++ 6 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 packages/lumx-react/src/utils/browser/isPopoverSupported.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a18f60a47a..a58d32b13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@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 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/src/components/tooltip/Tooltip.test.tsx b/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx index a57d0b20ac..b53490ba1c 100644 --- a/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx +++ b/packages/lumx-react/src/components/tooltip/Tooltip.test.tsx @@ -11,15 +11,10 @@ import { Tooltip, TooltipProps } from './Tooltip'; const CLASSNAME = Tooltip.className as string; +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. diff --git a/packages/lumx-react/src/components/tooltip/Tooltip.tsx b/packages/lumx-react/src/components/tooltip/Tooltip.tsx index 210ed2e897..f8790fe558 100644 --- a/packages/lumx-react/src/components/tooltip/Tooltip.tsx +++ b/packages/lumx-react/src/components/tooltip/Tooltip.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { ReactNode, useState } from 'react'; +import React, { ReactNode, useState } from 'react'; import { useFloating, offset, autoUpdate, type Placement as FloatingPlacement } from '@floating-ui/react-dom'; @@ -14,6 +14,7 @@ import { useId } from '@lumx/react/hooks/useId'; 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'; @@ -37,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; } /** @@ -115,28 +121,50 @@ export const Tooltip = forwardRef((props, ref) => ariaLinkMode: ariaLinkMode as any, }); + // Update popover visibility on open/close. + React.useEffect(() => { + 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 && ( - + - + )} ); diff --git a/packages/lumx-react/src/untypped-modules.d.ts b/packages/lumx-react/src/untypped-modules.d.ts index 97a2f1680e..719526b08c 100644 --- a/packages/lumx-react/src/untypped-modules.d.ts +++ b/packages/lumx-react/src/untypped-modules.d.ts @@ -2,6 +2,13 @@ interface CSSStyleDeclaration { viewTransitionName: string | null; } + +// Extend React HTMLAttributes to support the HTML popover API (not yet in stable @types/react) +declare namespace React { + interface HTMLAttributes { + popover?: '' | 'auto' | 'manual' | 'hint' | undefined; + } +} /** * List untypped modules here to declare them as explicit any. */ diff --git a/packages/lumx-react/src/utils/browser/isPopoverSupported.ts b/packages/lumx-react/src/utils/browser/isPopoverSupported.ts new file mode 100644 index 0000000000..baba6bf412 --- /dev/null +++ b/packages/lumx-react/src/utils/browser/isPopoverSupported.ts @@ -0,0 +1,6 @@ +import { WINDOW } from '@lumx/react/constants'; + +/** Check if browser supports the HTML Popover API */ +export function isPopoverSupported() { + return WINDOW != null && 'popover' in HTMLElement.prototype; +}