Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/lumx-core/src/scss/components/tooltip/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
.#{$lumx-base-prefix}-tooltip {
$self: &;

&[popover] {
margin: 0;
border: none;
}

position: absolute;
top: 0;
left: 0;
Expand All @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions packages/lumx-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<>
<IconButton label="Open popover" ref={anchorRef} onClick={open} icon={mdiMenuDown} />
<PopoverDialog
anchorRef={anchorRef}
isOpen={isOpen}
onClose={close}
placement="bottom"
className="lumx-spacing-padding-huge"
{...props}
>
<IconButton emphasis="low" onClick={close} icon={mdiClose} label="Close" />
<Button emphasis="medium">Other button</Button>
{children}
</PopoverDialog>
</>
);
};

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 (
<HeadingLevelProvider level={3}>
<IconButtonTrigger {...props}>{children}</IconButtonTrigger>
</HeadingLevelProvider>
);
},
args: {
children: <Heading>Title</Heading>,
},
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;
Original file line number Diff line number Diff line change
@@ -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<PopoverDialogProps> = {}, { wrapper }: SetupRenderOptions = {}) => {
const props = { children: <div />, ...propsOverride };
const { container } = render(
Expand All @@ -35,133 +30,4 @@ describe(`<${PopoverDialog.displayName}>`, () => {
forwardAttributes: 'element',
forwardRef: 'element',
});

it('should open and init focus', async () => {
const label = 'Test Label';
render(<WithButtonTrigger label={label} />);

// 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(<WithButtonTrigger aria-label={label} />);

// 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(<WithButtonTrigger label={label} />);

// 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(<WithButtonTrigger label={label} />);

// 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(<WithButtonTrigger label={label} />);

// 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(<WithIconButtonTrigger label={label} />);

// 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

<HeadingLevelProvider level={3}>
<WithIconButtonTrigger>
{/* Heading inside the popover dialog */}
<Heading>Title</Heading>
</WithIconButtonTrigger>
</HeadingLevelProvider>,
);

// 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();
});
});
Loading