Skip to content

feat: Automatically render popovers as dialogs #7813

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 28, 2025
Merged
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
11 changes: 6 additions & 5 deletions packages/@react-aria/menu/src/useSubmenuTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {AriaMenuOptions} from './useMenu';
import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays';
import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared';
import {focusWithoutScrolling, useEffectEvent, useId, useLayoutEffect} from '@react-aria/utils';
import {getInteractionModality} from '@react-aria/interactions';
import type {SubmenuTriggerState} from '@react-stately/menu';
import {useCallback, useRef} from 'react';
import {useLocale} from '@react-aria/i18n';
Expand Down Expand Up @@ -99,6 +98,12 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
}, [cancelOpenTimeout]);

let submenuKeyDown = (e: KeyboardEvent) => {
// If focus is not within the menu, assume virtual focus is being used.
// This means some other input element is also within the popover, so we shouldn't close the menu.
if (!e.currentTarget.contains(document.activeElement)) {
return;
}

switch (e.key) {
case 'ArrowLeft':
if (direction === 'ltr' && e.currentTarget.contains(e.target as Element)) {
Expand Down Expand Up @@ -250,10 +255,6 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
submenuProps,
popoverProps: {
isNonModal: true,
// We will manually coerce focus back to the triggers for mobile screen readers and non virtual focus use cases (aka submenus outside of autocomplete) so turn off
// FocusScope then. For virtual focus use cases (Autocomplete subdialogs/menu) and subdialogs we want to keep FocusScope restoreFocus to automatically
// send focus to parent subdialog input fields and/or tab containment
disableFocusManagement: !shouldUseVirtualFocus && (getInteractionModality() === 'virtual' || type === 'menu'),
shouldCloseOnInteractOutside
}
};
Expand Down
6 changes: 5 additions & 1 deletion packages/@react-aria/overlays/src/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export interface OverlayProps {
* implement focus containment and restoration to ensure the overlay is keyboard accessible.
*/
disableFocusManagement?: boolean,
/**
* Whether to contain focus within the overlay.
*/
shouldContainFocus?: boolean,
/**
* Whether the overlay is currently performing an exit animation. When true,
* focus is allowed to move outside.
Expand Down Expand Up @@ -63,7 +67,7 @@ export function Overlay(props: OverlayProps) {
let contents = props.children;
if (!props.disableFocusManagement) {
contents = (
<FocusScope restoreFocus contain={contain && !isExiting}>
<FocusScope restoreFocus contain={(props.shouldContainFocus || contain) && !isExiting}>
{contents}
</FocusScope>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/overlays/src/usePopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
...otherProps
} = props;

let isSubmenu = otherProps['trigger'] === 'SubmenuTrigger' || otherProps['trigger'] === 'SubDialogTrigger';
let isSubmenu = otherProps['trigger'] === 'SubmenuTrigger';

let {overlayProps, underlayProps} = useOverlay(
{
Expand Down
30 changes: 30 additions & 0 deletions packages/@react-spectrum/menu/test/MenuTrigger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,36 @@ describe('MenuTrigger', function () {
expect(tree.getByRole('menu').closest('[data-testid="custom-container"]')).toBe(tree.getByTestId('custom-container'));
});
});

it('closes if menu is tabbed away from', async function () {
let tree = render(
<Provider theme={theme}>
<MenuTrigger>
<Button variant="primary">
{triggerText}
</Button>
<Menu>
<Item id="1">One</Item>
<Item id="2">Two</Item>
<Item id="3">Three</Item>
</Menu>
</MenuTrigger>
</Provider>
);
let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');

await menuTester.open();
act(() => {jest.runAllTimers();});

let menu = menuTester.menu;

await user.tab();
act(() => {jest.runAllTimers();});
act(() => {jest.runAllTimers();});
expect(menu).not.toBeInTheDocument();
expect(document.activeElement).toBe(menuTester.trigger);
});
});

function SelectionStatic(props) {
Expand Down
24 changes: 6 additions & 18 deletions packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ describe('Submenu', function () {
expect(document.activeElement).toBe(submenuTrigger2);
});

it('should shift focus to the prev/next element adjacent to the menu trigger when pressing Tab', async function () {
it('should contain focus when pressing Tab', async function () {
async function openSubMenus() {
await user.keyboard('[ArrowDown]');
act(() => {jest.runAllTimers();});
Expand All @@ -468,30 +468,18 @@ describe('Submenu', function () {
}

let tree = render(<TabBehaviorStory />);
let inputleft = tree.getByTestId('inputleft');
let inputright = tree.getByTestId('inputright');
let triggerButton = tree.getByRole('button');
await user.tab();
await user.tab();
await openSubMenus();

// Tab should close all menus and move focus to the next input relative to the original menu trigger

// Tab do nothing.
await user.tab();
let activeElement = document.activeElement;
act(() => {jest.runAllTimers();});
let menus = tree.queryAllByRole('menu', {hidden: true});
expect(menus).toHaveLength(0);
expect(document.activeElement).toBe(inputright);

await user.tab({shift: true});
expect(document.activeElement).toBe(triggerButton);
await openSubMenus();

// Shift + Tab should close all menus and move focus to the prev input relative to the original menu trigger
await user.tab({shift: true});
act(() => {jest.runAllTimers();});
menus = tree.queryAllByRole('menu', {hidden: true});
expect(menus).toHaveLength(0);
expect(document.activeElement).toBe(inputleft);
expect(menus).toHaveLength(3);
expect(document.activeElement).toBe(activeElement);
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/react-aria-components/docs/ComboBox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ import {ComboBox, Label, Input, Button, Popover, ListBox, ListBoxItem} from 'rea
}

.react-aria-ListBoxItem {
padding: 0.286rem 0.571rem 0.286rem 1.571rem;
padding: 0 0.571rem 0 1.571rem;

&[data-focus-visible] {
outline: none;
Expand Down
47 changes: 47 additions & 0 deletions packages/react-aria-components/docs/Menu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {MenuTrigger, Button, Popover, Menu, MenuItem} from 'react-aria-component
```css hidden
@import './Button.mdx' layer(button);
@import './Popover.mdx' layer(popover);
@import './SearchField.mdx' layer(searchfield);
```

```css
Expand Down Expand Up @@ -842,6 +843,52 @@ let items = [

</details>

### Complex content

Submenu popovers can also include components other than menus. This example uses an [Autocomplete](Autocomplete.html) to make the submenu searchable.

```tsx example
import {Menu, Popover, SubmenuTrigger, Autocomplete, useFilter} from 'react-aria-components';
import {MySearchField} from './SearchField';

function Example() {
let {contains} = useFilter({sensitivity: 'base'});

return (
<MyMenuButton label="Actions">
<MyItem>Cut</MyItem>
<MyItem>Copy</MyItem>
<MyItem>Delete</MyItem>
<SubmenuTrigger>
<MyItem>Add tag...</MyItem>
<Popover>
<Autocomplete filter={contains}>
<MySearchField label="Search tags" autoFocus />
<Menu>
<MyItem>News</MyItem>
<MyItem>Travel</MyItem>
<MyItem>Shopping</MyItem>
<MyItem>Business</MyItem>
<MyItem>Entertainment</MyItem>
<MyItem>Food</MyItem>
<MyItem>Technology</MyItem>
<MyItem>Health</MyItem>
<MyItem>Science</MyItem>
</Menu>
</Autocomplete>
</Popover>
</SubmenuTrigger>
</MyMenuButton>
);
}
```

```css hidden
.react-aria-Popover[data-trigger=SubmenuTrigger] .react-aria-SearchField {
margin: 4px 8px;
}
```

## Custom trigger

`MenuTrigger` works out of the box with any pressable React Aria component (e.g. [Button](Button.html), [Link](Link.html), etc.). Custom trigger elements such as third party components and other DOM elements are also supported by wrapping them with the `<Pressable>` component, or using the [usePress](usePress.html) hook.
Expand Down
2 changes: 1 addition & 1 deletion packages/react-aria-components/docs/Select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ import {Select, SelectValue, Label, Button, Popover, ListBox, ListBoxItem} from
}

.react-aria-ListBoxItem {
padding: 0.286rem 0.571rem 0.286rem 1.571rem;
padding: 0 0.571rem 0 1.571rem;

&[data-focus-visible] {
outline: none;
Expand Down
44 changes: 21 additions & 23 deletions packages/react-aria-components/docs/examples/account-menu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,39 +39,37 @@ import './tailwind.global.css';
```

```tsx example standalone
import {DialogTrigger, Button, Popover, Dialog, Menu, MenuItem, Separator, Switch, composeRenderProps} from 'react-aria-components';
import {MenuTrigger, Button, Popover, Menu, MenuItem, Separator, Switch, composeRenderProps} from 'react-aria-components';
import type {MenuItemProps, SwitchProps} from 'react-aria-components';

function AccountMenuExample() {
return (
<div className="p-8 bg-gray-50 dark:bg-zinc-900 rounded-lg flex items-start justify-center">
<DialogTrigger>
<MenuTrigger>
<Button aria-label="Account" className="inline-flex items-center justify-center rounded-md p-1.5 text-white bg-transparent border-none hover:bg-gray-200 pressed:bg-gray-300 dark:hover:bg-zinc-800 dark:pressed:bg-zinc-700 transition-colors cursor-default outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600">
<img alt="" src="https://i.imgur.com/xIe7Wlb.png" className="w-7 h-7 rounded-full" />
</Button>
<Popover placement="bottom end" className="p-2 overflow-auto rounded-lg bg-white dark:bg-zinc-950 shadow-lg ring-1 ring-black/10 dark:ring-white/15 entering:animate-in entering:fade-in entering:placement-bottom:slide-in-from-top-1 entering:placement-top:slide-in-from-bottom-1 exiting:animate-out exiting:fade-out exiting:placement-bottom:slide-out-to-top-1 exiting:placement-top:slide-out-to-bottom-1 fill-mode-forwards origin-top-left">
<Dialog className="outline-hidden">
<div className="flex gap-2 items-center mx-3 mt-2">
<img alt="" src="https://i.imgur.com/xIe7Wlb.png" className="w-16 h-16 rounded-full" />
<div className="flex flex-col gap-1">
<div className="text-[15px] font-bold text-gray-900 dark:text-gray-100 leading-none">Marissa Whitaker</div>
<div className="text-base text-gray-900 dark:text-gray-100 leading-none mb-1">[email protected]</div>
<MySwitch>Dark Mode</MySwitch>
</div>
<Popover className="p-2 overflow-auto outline-hidden rounded-lg bg-white dark:bg-zinc-950 shadow-lg ring-1 ring-black/10 dark:ring-white/15 entering:animate-in entering:fade-in entering:placement-bottom:slide-in-from-top-1 entering:placement-top:slide-in-from-bottom-1 exiting:animate-out exiting:fade-out exiting:placement-bottom:slide-out-to-top-1 exiting:placement-top:slide-out-to-bottom-1 fill-mode-forwards origin-top-left">
<div className="flex gap-2 items-center mx-3 mt-2">
<img alt="" src="https://i.imgur.com/xIe7Wlb.png" className="w-16 h-16 rounded-full" />
<div className="flex flex-col gap-1">
<div className="text-[15px] font-bold text-gray-900 dark:text-gray-100 leading-none">Marissa Whitaker</div>
<div className="text-base text-gray-900 dark:text-gray-100 leading-none mb-1">[email protected]</div>
<MySwitch>Dark Mode</MySwitch>
</div>
<Separator className="border-none bg-gray-300 dark:bg-zinc-600 h-[1px] mx-3 mt-4 mb-2" />
<Menu className="outline-hidden">
<MyMenuItem id="new">Account Settings</MyMenuItem>
<MyMenuItem id="open">Support</MyMenuItem>
<Separator className="bg-gray-300 dark:bg-zinc-600 h-[1px] mx-3 my-2" />
<MyMenuItem id="save">Legal notices</MyMenuItem>
<MyMenuItem id="save-as">About</MyMenuItem>
<Separator className="bg-gray-300 dark:bg-zinc-600 h-[1px] mx-3 my-2" />
<MyMenuItem id="print">Sign out</MyMenuItem>
</Menu>
</Dialog>
</div>
<Separator className="border-none bg-gray-300 dark:bg-zinc-600 h-[1px] mx-3 mt-4 mb-2" />
<Menu className="outline-hidden">
<MyMenuItem id="new">Account Settings</MyMenuItem>
<MyMenuItem id="open">Support</MyMenuItem>
<Separator className="bg-gray-300 dark:bg-zinc-600 h-[1px] mx-3 my-2" />
<MyMenuItem id="save">Legal notices</MyMenuItem>
<MyMenuItem id="save-as">About</MyMenuItem>
<Separator className="bg-gray-300 dark:bg-zinc-600 h-[1px] mx-3 my-2" />
<MyMenuItem id="print">Sign out</MyMenuItem>
</Menu>
</Popover>
</DialogTrigger>
</MenuTrigger>
</div>
);
}
Expand Down
30 changes: 14 additions & 16 deletions packages/react-aria-components/docs/examples/searchable-select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const languages = [

```tsx example standalone
import type {ListBoxItemProps} from 'react-aria-components';
import {Autocomplete, Select, Label, Button, SelectValue, Popover, Dialog, ListBox, ListBoxItem, SearchField, Input, useFilter} from 'react-aria-components';
import {Autocomplete, Select, Label, Button, SelectValue, Popover, ListBox, ListBoxItem, SearchField, Input, useFilter} from 'react-aria-components';
import {ChevronsUpDownIcon, CheckIcon, CheckIcon, SearchIcon, XIcon} from 'lucide-react';

function SelectExample() {
Expand All @@ -101,21 +101,19 @@ function SelectExample() {
<SelectValue className="flex-1 truncate" />
<ChevronsUpDownIcon className="w-4 h-4" />
</Button>
<Popover className="!max-h-80 w-(--trigger-width) rounded-md bg-white text-base shadow-lg ring-1 ring-black/5 entering:animate-in entering:fade-in exiting:animate-out exiting:fade-out">
<Dialog className="flex flex-col max-h-[inherit]">
<Autocomplete filter={contains}>
<SearchField aria-label="Search" autoFocus className="group flex items-center bg-white forced-colors:bg-[Field] border-2 border-gray-300 has-focus:border-sky-600 rounded-full m-1">
<SearchIcon aria-hidden className="w-4 h-4 ml-2 text-gray-600 forced-colors:text-[ButtonText]" />
<Input placeholder="Search languages" className="px-2 py-1 flex-1 min-w-0 border-none outline outline-0 bg-white text-base text-gray-800 placeholder-gray-500 font-[inherit] [&::-webkit-search-cancel-button]:hidden" />
<Button className="text-sm text-center transition rounded-full border-0 p-1 flex items-center justify-center text-gray-600 bg-transparent hover:bg-black/[5%] pressed:bg-black/10 mr-1 w-6 group-empty:invisible">
<XIcon aria-hidden className="w-4 h-4" />
</Button>
</SearchField>
<ListBox items={languages} className="outline-hidden p-1 overflow-auto flex-1 scroll-pb-1">
{item => <SelectItem>{item.name}</SelectItem>}
</ListBox>
</Autocomplete>
</Dialog>
<Popover className="!max-h-80 w-(--trigger-width) flex flex-col rounded-md bg-white text-base shadow-lg ring-1 ring-black/5 entering:animate-in entering:fade-in exiting:animate-out exiting:fade-out">
<Autocomplete filter={contains}>
<SearchField aria-label="Search" autoFocus className="group flex items-center bg-white forced-colors:bg-[Field] border-2 border-gray-300 has-focus:border-sky-600 rounded-full m-1">
<SearchIcon aria-hidden className="w-4 h-4 ml-2 text-gray-600 forced-colors:text-[ButtonText]" />
<Input placeholder="Search languages" className="px-2 py-1 flex-1 min-w-0 border-none outline outline-0 bg-white text-base text-gray-800 placeholder-gray-500 font-[inherit] [&::-webkit-search-cancel-button]:hidden" />
<Button className="text-sm text-center transition rounded-full border-0 p-1 flex items-center justify-center text-gray-600 bg-transparent hover:bg-black/[5%] pressed:bg-black/10 mr-1 w-6 group-empty:invisible">
<XIcon aria-hidden className="w-4 h-4" />
</Button>
</SearchField>
<ListBox items={languages} className="outline-hidden p-1 overflow-auto flex-1 scroll-pb-1">
{item => <SelectItem>{item.name}</SelectItem>}
</ListBox>
</Autocomplete>
</Popover>
</Select>
</div>
Expand Down
6 changes: 5 additions & 1 deletion packages/react-aria-components/src/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ export function DialogTrigger(props: DialogTriggerProps) {
[OverlayTriggerStateContext, state],
[RootMenuTriggerStateContext, state],
[DialogContext, overlayProps],
[PopoverContext, {trigger: 'DialogTrigger', triggerRef: buttonRef}]
[PopoverContext, {
trigger: 'DialogTrigger',
triggerRef: buttonRef,
'aria-labelledby': overlayProps['aria-labelledby']
}]
]}>
<PressResponder {...triggerProps} ref={buttonRef} isPressed={state.isOpen}>
{props.children}
Expand Down
Loading