diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index e942f9788a6..4d9c5684305 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -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'; @@ -99,6 +98,12 @@ export function useSubmenuTrigger(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)) { @@ -250,10 +255,6 @@ export function useSubmenuTrigger(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 } }; diff --git a/packages/@react-aria/overlays/src/Overlay.tsx b/packages/@react-aria/overlays/src/Overlay.tsx index 49b0965289a..9eb4274a34a 100644 --- a/packages/@react-aria/overlays/src/Overlay.tsx +++ b/packages/@react-aria/overlays/src/Overlay.tsx @@ -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. @@ -63,7 +67,7 @@ export function Overlay(props: OverlayProps) { let contents = props.children; if (!props.disableFocusManagement) { contents = ( - + {contents} ); diff --git a/packages/@react-aria/overlays/src/usePopover.ts b/packages/@react-aria/overlays/src/usePopover.ts index eeeead7b334..00d1721cb1c 100644 --- a/packages/@react-aria/overlays/src/usePopover.ts +++ b/packages/@react-aria/overlays/src/usePopover.ts @@ -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( { diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index 2bf317064ce..2e6752da3b0 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -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( + + + + + One + Two + Three + + + + ); + 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) { diff --git a/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx b/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx index 0439f285d00..f4dcef74377 100644 --- a/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx +++ b/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx @@ -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();}); @@ -468,30 +468,18 @@ describe('Submenu', function () { } let tree = render(); - 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); }); }); diff --git a/packages/react-aria-components/docs/ComboBox.mdx b/packages/react-aria-components/docs/ComboBox.mdx index 24a05c21e48..bb2fa6cfeca 100644 --- a/packages/react-aria-components/docs/ComboBox.mdx +++ b/packages/react-aria-components/docs/ComboBox.mdx @@ -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; diff --git a/packages/react-aria-components/docs/Menu.mdx b/packages/react-aria-components/docs/Menu.mdx index b13035f189d..2001d15b9fc 100644 --- a/packages/react-aria-components/docs/Menu.mdx +++ b/packages/react-aria-components/docs/Menu.mdx @@ -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 @@ -842,6 +843,52 @@ let items = [ +### 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 ( + + Cut + Copy + Delete + + Add tag... + + + + + News + Travel + Shopping + Business + Entertainment + Food + Technology + Health + Science + + + + + + ); +} +``` + +```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 `` component, or using the [usePress](usePress.html) hook. diff --git a/packages/react-aria-components/docs/Select.mdx b/packages/react-aria-components/docs/Select.mdx index 5f6d15a52b2..aaa08ac0f70 100644 --- a/packages/react-aria-components/docs/Select.mdx +++ b/packages/react-aria-components/docs/Select.mdx @@ -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; diff --git a/packages/react-aria-components/docs/examples/account-menu.mdx b/packages/react-aria-components/docs/examples/account-menu.mdx index 7bb50c7ef1f..e7fdd950ea8 100644 --- a/packages/react-aria-components/docs/examples/account-menu.mdx +++ b/packages/react-aria-components/docs/examples/account-menu.mdx @@ -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 (
- + - - -
- -
-
Marissa Whitaker
-
user@example.com
- Dark Mode -
+ +
+ +
+
Marissa Whitaker
+
user@example.com
+ Dark Mode
- - - Account Settings - Support - - Legal notices - About - - Sign out - -
+
+ + + Account Settings + Support + + Legal notices + About + + Sign out + - + ); } diff --git a/packages/react-aria-components/docs/examples/searchable-select.mdx b/packages/react-aria-components/docs/examples/searchable-select.mdx index 229b623dc1d..6679412ad72 100644 --- a/packages/react-aria-components/docs/examples/searchable-select.mdx +++ b/packages/react-aria-components/docs/examples/searchable-select.mdx @@ -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() { @@ -101,21 +101,19 @@ function SelectExample() { - - - - - - - - - - {item => {item.name}} - - - + + + + + + + + + {item => {item.name}} + + diff --git a/packages/react-aria-components/src/Dialog.tsx b/packages/react-aria-components/src/Dialog.tsx index b45fea8cab3..f8f83e1b732 100644 --- a/packages/react-aria-components/src/Dialog.tsx +++ b/packages/react-aria-components/src/Dialog.tsx @@ -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'] + }] ]}> {props.children} diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 90dc8e133ff..433340b8c7a 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -15,12 +15,12 @@ import {BaseCollection, Collection, CollectionBuilder, createBranchComponent, cr import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; -import {DialogContext, OverlayTriggerStateContext} from './Dialog'; import {filterDOMProps, mergeRefs, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {FocusStrategy, forwardRefType, HoverEvents, Key, LinkDOMProps, MultipleSelection} from '@react-types/shared'; import {HeaderContext} from './Header'; import {KeyboardContext} from './Keyboard'; import {MultipleSelectionState, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; +import {OverlayTriggerStateContext} from './Dialog'; import {PopoverContext} from './Popover'; import {PressResponder, useHover} from '@react-aria/interactions'; import React, { @@ -83,7 +83,8 @@ export function MenuTrigger(props: MenuTriggerProps) { triggerRef: ref, scrollRef, placement: 'bottom start', - style: {'--trigger-width': buttonWidth} as React.CSSProperties + style: {'--trigger-width': buttonWidth} as React.CSSProperties, + 'aria-labelledby': menuProps['aria-labelledby'] }] ]}> @@ -138,62 +139,7 @@ export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigg trigger: 'SubmenuTrigger', triggerRef: itemRef, placement: 'end top', - ...popoverProps - }] - ]}> - - {props.children[1]} - - ); -}, props => props.children[0]); - -// TODO: make SubdialogTrigger unstable -export interface SubDialogTriggerProps { - /** - * The contents of the SubDialogTrigger. The first child should be an Item (the trigger) and the second child should be the Popover (for the subdialog). - */ - children: ReactElement[], - /** - * The delay time in milliseconds for the subdialog to appear after hovering over the trigger. - * @default 200 - */ - delay?: number -} - -/** - * A subdialog trigger is used to wrap a subdialog's trigger item and the subdialog itself. - * - * @version alpha - */ -export const SubDialogTrigger = /*#__PURE__*/ createBranchComponent('subdialogtrigger', (props: SubDialogTriggerProps, ref: ForwardedRef, item) => { - let {CollectionBranch} = useContext(CollectionRendererContext); - let state = useContext(MenuStateContext)!; - let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; - let submenuTriggerState = useSubmenuTriggerState({triggerKey: item.key}, rootMenuTriggerState); - let subdialogRef = useRef(null); - let itemRef = useObjectRef(ref); - let {parentMenuRef, shouldUseVirtualFocus} = useContext(SubmenuTriggerContext)!; - let {submenuTriggerProps, submenuProps, popoverProps} = useSubmenuTrigger({ - parentMenuRef, - submenuRef: subdialogRef, - type: 'dialog', - delay: props.delay, - shouldUseVirtualFocus - // TODO: might need to have something like isUnavailable like we do for ContextualHelpTrigger - }, submenuTriggerState, itemRef); - - return ( - diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index d4f39bba74a..d5f10fdf8b4 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -10,17 +10,18 @@ * governing permissions and limitations under the License. */ +import {AriaLabelingProps, forwardRefType, RefObject} from '@react-types/shared'; import {AriaPopoverProps, DismissButton, Overlay, PlacementAxis, PositionProps, useLocale, usePopover} from 'react-aria'; import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; -import {forwardRefType, RefObject} from '@react-types/shared'; +import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately'; import {OverlayTriggerStateContext} from './Dialog'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useRef, useState} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, useContext, useEffect, useRef, useState} from 'react'; import {useIsHidden} from '@react-aria/collections'; -export interface PopoverProps extends Omit, Omit, OverlayTriggerProps, RenderProps, SlotProps { +export interface PopoverProps extends Omit, Omit, OverlayTriggerProps, RenderProps, SlotProps, AriaLabelingProps { /** * The name of the component that triggered the popover. This is reflected on the element * as the `data-trigger` attribute, and can be used to provide specific @@ -142,7 +143,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: Po let [arrowWidth, setArrowWidth] = useState(0); let containerRef = useRef(null); let groupCtx = useContext(PopoverGroupContext); - let isSubPopover = groupCtx && (props.trigger === 'SubmenuTrigger' || props.trigger === 'SubDialogTrigger'); + let isSubPopover = groupCtx && props.trigger === 'SubmenuTrigger'; useLayoutEffect(() => { if (arrowRef.current && state.isOpen) { setArrowWidth(arrowRef.current.getBoundingClientRect().width); @@ -171,11 +172,32 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: Po } }); + // Automatically render Popover with role=dialog except when isNonModal is true, + // or a dialog is already nested inside the popover. + let shouldBeDialog = !props.isNonModal || props.trigger === 'SubmenuTrigger'; + let [isDialog, setDialog] = useState(false); + useLayoutEffect(() => { + if (ref.current) { + setDialog(shouldBeDialog && !ref.current.querySelector('[role=dialog]')); + } + }, [ref, shouldBeDialog]); + + // Focus the popover itself on mount, unless a child element is already focused. + useEffect(() => { + if (isDialog && ref.current && !ref.current.contains(document.activeElement)) { + focusSafely(ref.current); + } + }, [isDialog, ref]); + let style = {...popoverProps.style, ...renderProps.style}; let overlay = (
+ {!props.isNonModal && state.isOpen &&
}
@@ -208,7 +230,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: Po // Submenus/subdialogs are mounted into the root popover's container. return ( - + {overlay} ); diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index befbe1af75a..35613d14b5e 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -179,7 +179,8 @@ function SelectInner({props, selectRef: ref, collection}: Sele triggerRef: buttonRef, scrollRef, placement: 'bottom start', - style: {'--trigger-width': buttonWidth} as React.CSSProperties + style: {'--trigger-width': buttonWidth} as React.CSSProperties, + 'aria-labelledby': menuProps['aria-labelledby'] }], [ListBoxContext, {...menuProps, ref: scrollRef}], [ListStateContext, state], diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 18a2da0b87d..bb3e70f7375 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -50,7 +50,7 @@ export {Keyboard, KeyboardContext} from './Keyboard'; export {Label, LabelContext} from './Label'; export {Link, LinkContext} from './Link'; export {ListBox, ListBoxItem, ListBoxSection, ListBoxContext, ListStateContext} from './ListBox'; -export {Menu, MenuItem, MenuTrigger, MenuSection, MenuContext, MenuStateContext, RootMenuTriggerStateContext, SubmenuTrigger, SubDialogTrigger as UNSTABLE_SubDialogTrigger} from './Menu'; +export {Menu, MenuItem, MenuTrigger, MenuSection, MenuContext, MenuStateContext, RootMenuTriggerStateContext, SubmenuTrigger} from './Menu'; export {Meter, MeterContext} from './Meter'; export {Modal, ModalOverlay, ModalContext} from './Modal'; export {NumberField, NumberFieldContext, NumberFieldStateContext} from './NumberField'; diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index d6aa9612b27..7489a98db93 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Collection, Dialog, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, Popover, SearchField, Select, SelectValue, Separator, UNSTABLE_SubDialogTrigger as SubDialogTrigger, SubmenuTrigger, Text, TextField, Virtualizer} from 'react-aria-components'; +import {Autocomplete, Button, Collection, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, Popover, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Text, TextField, Virtualizer} from 'react-aria-components'; import {MyListBoxItem, MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; @@ -50,7 +50,7 @@ let StaticMenu = (props) => { Bar Baz Google - + With subdialog { border: '1px solid gray', padding: 5 }}> - - - - - - Please select an option below. - - - Subdialog Foo - Subdialog Bar - Subdialog Baz - - - + + + + + Please select an option below. + + + Subdialog Foo + Subdialog Bar + Subdialog Baz + + - + Option Option with a space @@ -230,7 +228,7 @@ let dynamicRenderTrigger = (item: ItemNode) => { ); } else { return ( - + {item.name} @@ -241,20 +239,18 @@ let dynamicRenderTrigger = (item: ItemNode) => { border: '1px solid gray', padding: 5 }}> - - - - - - Please select an option below. - - - {(item) => dynamicRenderFuncSections(item)} - - - + + + + + Please select an option below. + + + {(item) => dynamicRenderFuncSections(item)} + + - + ); } }; @@ -602,18 +598,16 @@ export const AutocompleteInPopover = { padding: 20, height: 250 }}> - - -
- - - - Please select an option below. - - -
-
-
+ +
+ + + + Please select an option below. + + +
+
); @@ -649,20 +643,18 @@ export const AutocompleteInPopoverDialogTrigger = { padding: 20, height: 250 }}> - - {() => ( - -
- - - - Please select an option below. - - -
-
- )} -
+ {() => ( + +
+ + + + Please select an option below. + + +
+
+ )} ); @@ -686,33 +678,31 @@ const MyMenu = () => { let {contains} = useFilter({sensitivity: 'base'}); return ( - + - - - - - - - - - console.log('open')}>Open - console.log('rename')}> - Rename… - - console.log('duplicate')}> - Duplicate - - console.log('share')}>Share… - console.log('delete')}> - Delete… - - - - + + + + + + + + console.log('open')}>Open + console.log('rename')}> + Rename… + + console.log('duplicate')}> + Duplicate + + console.log('share')}>Share… + console.log('delete')}> + Delete… + + + - + ); }; @@ -720,33 +710,31 @@ const MyMenu2 = () => { let {contains} = useFilter({sensitivity: 'base'}); return ( - + - - - - - - - console.log('open')}>Open - console.log('rename')}> - Rename… - - console.log('duplicate')}> - Duplicate - - console.log('share')}>Share… - console.log('delete')}> - Delete… - - - - - - + + + + + + console.log('open')}>Open + console.log('rename')}> + Rename… + + console.log('duplicate')}> + Duplicate + + console.log('share')}>Share… + console.log('delete')}> + Delete… + + + + + - + ); }; @@ -780,22 +768,18 @@ export const AutocompleteMenuInPopoverDialogTrigger = { padding: 20, height: 250 }}> - - {() => ( - -
- - - - Please select an option below. - - - {item => dynamicRenderFuncSections(item)} - -
-
- )} -
+ +
+ + + + Please select an option below. + + + {item => dynamicRenderFuncSections(item)} + +
+
); @@ -820,16 +804,14 @@ export const AutocompleteSelect = () => ( - - - - - - - {item => {item.name}} - - - + + + + + + {item => {item.name}} + + ); diff --git a/packages/react-aria-components/stories/Menu.stories.tsx b/packages/react-aria-components/stories/Menu.stories.tsx index 3614f05596c..c16209a35c7 100644 --- a/packages/react-aria-components/stories/Menu.stories.tsx +++ b/packages/react-aria-components/stories/Menu.stories.tsx @@ -11,11 +11,10 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Dialog, Header, Heading, Input, Keyboard, Label, Menu, MenuSection, MenuTrigger, Popover, Separator, SubmenuTrigger, Text, TextField} from 'react-aria-components'; +import {Button, Header, Heading, Input, Keyboard, Label, Menu, MenuSection, MenuTrigger, Popover, Separator, SubmenuTrigger, Text, TextField} from 'react-aria-components'; import {MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; -import {SubDialogTrigger} from '../src/Menu'; export default { title: 'React Aria Components' @@ -287,7 +286,7 @@ export const SubdialogExample = (args) => ( Foo - + Bar ( border: '1px solid gray', padding: 5 }}> - - {({close}) => ( -
- Sign up - - - - - - - - - - - SubMenu - - - 1 - 2 - 3 - - - - - SubDialog - - - {({close}) => ( - - Contact - - - - - - - - - - - )} - - - - C -
- - - )} -
+
+ Sign up + + + + + + + + + + + SubMenu + + + 1 + 2 + 3 + + + + + SubDialog + + + Contact + + + + + + + + + + + + + C +
+ +
-
+ Baz Google
diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index ebe042293e4..64e01e83cc7 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -877,7 +877,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' let {getByRole, getAllByRole} = (renderers.subdialogs!)(); let menu = getByRole('menu'); let options = within(menu).getAllByRole('menuitem'); - expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); await user.click(options[1]); act(() => {jest.runAllTimers();}); @@ -897,7 +897,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' let {getByRole, getAllByRole} = (renderers.subdialogs!)(); let menu = getByRole('menu'); let options = within(menu).getAllByRole('menuitem'); - expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); await user.click(options[1]); act(() => { jest.runAllTimers(); @@ -927,7 +927,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' let input = getByRole('searchbox'); let menu = getByRole('menu'); let options = within(menu).getAllByRole('menuitem'); - expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); await user.tab(); expect(document.activeElement).toBe(input); await user.keyboard('{ArrowDown}'); @@ -970,7 +970,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' // Open the nested submenu await user.keyboard('{ArrowDown}'); - await user.keyboard('{ArrowDown}'); await user.keyboard('{ArrowRight}'); act(() => jest.runAllTimers()); dialogs = getAllByRole('dialog'); @@ -985,7 +984,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' expect(dialogs).toHaveLength(1); expect(document.activeElement).toBe(subDialogInput); let subDialogMenuItems = within(dialogs[0]).getAllByRole('menuitem'); - expect(subDialogMenuItems[1]).toHaveAttribute('aria-haspopup', 'dialog'); + expect(subDialogMenuItems[1]).toHaveAttribute('aria-haspopup', 'menu'); expect(subDialogInput).toHaveAttribute('aria-activedescendant', subDialogMenuItems[1].id); await user.keyboard('{Escape}'); @@ -1005,7 +1004,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' fireEvent.click(input, {pointerType: 'mouse', width: 1, height: 1, detail: 0}); expect(document.activeElement).toBe(input); let options = within(menu).getAllByRole('menuitem'); - expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); act(() => options[1].focus()); fireEvent.click(options[1], {pointerType: 'mouse', width: 1, height: 1, detail: 0}); @@ -1042,7 +1041,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' await user.keyboard('{ArrowDown}'); let options = within(menus[0]).getAllByRole('menuitem'); expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); // Open subdialog await user.keyboard('{ArrowRight}'); @@ -1057,7 +1056,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' // Open submenu await user.keyboard('{ArrowDown}'); - await user.keyboard('{ArrowDown}'); await user.keyboard('{ArrowRight}'); act(() => {jest.runAllTimers();}); menus = getAllByRole('menu'); @@ -1065,22 +1063,22 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' expect(menus).toHaveLength(3); expect(menus[2]).toContainElement(document.activeElement as HTMLElement); let submenuItems = within(menus[2]).getAllByRole('menuitem'); - expect(submenuItems[1]).toHaveAttribute('aria-haspopup', 'dialog'); + expect(submenuItems[1]).toHaveAttribute('aria-haspopup', 'menu'); // Open last subdialog await user.keyboard('{ArrowDown}'); await user.keyboard('{ArrowRight}'); act(() => {jest.runAllTimers();}); dialogs = getAllByRole('dialog'); - expect(dialogs).toHaveLength(2); - let subDialogInput2 = within(dialogs[1]).getByRole('searchbox'); + expect(dialogs).toHaveLength(3); + let subDialogInput2 = within(dialogs[2]).getByRole('searchbox'); expect(document.activeElement).toBe(subDialogInput2); // Check focus is restored to the expected places when closing dialogs/menus await user.keyboard('{Escape}'); act(() => jest.runAllTimers()); dialogs = getAllByRole('dialog'); - expect(dialogs).toHaveLength(1); + expect(dialogs).toHaveLength(2); expect(document.activeElement).toBe(submenuItems[1]); await user.keyboard('{ArrowLeft}'); diff --git a/packages/react-aria-components/test/AriaMenu.test-util.tsx b/packages/react-aria-components/test/AriaMenu.test-util.tsx index 17255bf59f0..0c22b6a9ebf 100644 --- a/packages/react-aria-components/test/AriaMenu.test-util.tsx +++ b/packages/react-aria-components/test/AriaMenu.test-util.tsx @@ -291,23 +291,6 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) => expect(menu).not.toBeInTheDocument(); }); - it('closes if menu is tabbed away from', async function () { - let tree = renderers.standard(); - 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); - }); - it('has hidden dismiss buttons for screen readers', async function () { let tree = renderers.standard(); let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container}); diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 79a7aa5cebe..7dc1f5476dc 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -12,7 +12,7 @@ import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaAutocompleteTests} from './AriaAutocomplete.test-util'; -import {Autocomplete, Button, Dialog, DialogTrigger, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, SearchField, Select, SelectValue, Separator, UNSTABLE_SubDialogTrigger as SubDialogTrigger, SubmenuTrigger, Text, TextField} from '..'; +import {Autocomplete, Button, Dialog, DialogTrigger, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Text, TextField} from '..'; import React, {ReactNode} from 'react'; import {useAsyncList} from 'react-stately'; import {useFilter} from '@react-aria/i18n'; @@ -97,33 +97,29 @@ let SubMenus = (props) => ( let SubDialogs = (props) => ( Foo - + Bar - - - - Lvl 1 Bar 1 - - Lvl 1 Bar 2 - - - - - Lvl 2 Bar 1 - Lvl 2 Bar 2 - Lvl 2 Bar 3 - - - - - - Lvl 1 Bar 3 - - - + + + Lvl 1 Bar 1 + + Lvl 1 Bar 2 + + + + Lvl 2 Bar 1 + Lvl 2 Bar 2 + Lvl 2 Bar 3 + + + + + Lvl 1 Bar 3 + + - + Baz ); @@ -131,42 +127,38 @@ let SubDialogs = (props) => ( let SubDialogAndMenu = (props) => ( Foo - + Bar - - - - Lvl 1 Bar 1 - - Lvl 1 Bar 2 - - - Lvl 2 Bar 1 - - Lvl 2 Bar 2 - - - - - Lvl 3 Bar 1 - Lvl 3 Bar 2 - Lvl 3 Bar 3 - - - - - - Lvl 2 Bar 3 - - - - Lvl 1 Bar 3 - - - + + + Lvl 1 Bar 1 + + Lvl 1 Bar 2 + + + Lvl 2 Bar 1 + + Lvl 2 Bar 2 + + + + Lvl 3 Bar 1 + Lvl 3 Bar 2 + Lvl 3 Bar 3 + + + + + Lvl 2 Bar 3 + + + + Lvl 1 Bar 3 + + - + Baz ); @@ -443,11 +435,9 @@ describe('Autocomplete', () => { - - - - - + + + ); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index 5d69ebb1594..79b84f8f525 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -12,10 +12,9 @@ import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaMenuTests} from './AriaMenu.test-util'; -import {Button, Collection, Dialog, Header, Heading, Input, Keyboard, Label, Menu, MenuContext, MenuItem, MenuSection, MenuTrigger, Popover, Pressable, Separator, SubmenuTrigger, Text, TextField} from '..'; +import {Button, Collection, Header, Heading, Input, Keyboard, Label, Menu, MenuContext, MenuItem, MenuSection, MenuTrigger, Popover, Pressable, Separator, SubmenuTrigger, Text, TextField} from '..'; import React, {useState} from 'react'; import {Selection, SelectionMode} from '@react-types/shared'; -import {SubDialogTrigger} from '../src/Menu'; import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -1120,29 +1119,22 @@ describe('Menu', () => { Open Rename… Duplicate - + Share… - - {({close}) => ( -
- Sign up - - - - - - - - - -
- )} -
+
+ Sign up + + + + + + + + +
-
+ Delete…
@@ -1162,9 +1154,8 @@ describe('Menu', () => { let triggerItem = menuTester.submenuTriggers[0]; expect(triggerItem).toHaveTextContent('Share…'); - expect(triggerItem).toHaveAttribute('aria-haspopup', 'dialog'); + expect(triggerItem).toHaveAttribute('aria-haspopup', 'menu'); expect(triggerItem).toHaveAttribute('aria-expanded', 'false'); - // TODO: should this have a different data attribute aka has-subdialog? expect(triggerItem).toHaveAttribute('data-has-submenu', 'true'); expect(triggerItem).not.toHaveAttribute('data-open'); @@ -1174,22 +1165,19 @@ describe('Menu', () => { expect(triggerItem).toHaveAttribute('data-hovered', 'true'); expect(triggerItem).toHaveAttribute('aria-expanded', 'true'); expect(triggerItem).toHaveAttribute('data-open', 'true'); - let subdialog = getAllByRole('dialog')[0]; + let subdialog = getAllByRole('dialog')[1]; expect(subdialog).toBeInTheDocument(); let subdialogPopover = subdialog.closest('.react-aria-Popover') as HTMLElement; expect(subdialogPopover).toBeInTheDocument(); - expect(subdialogPopover).toHaveAttribute('data-trigger', 'SubDialogTrigger'); + expect(subdialogPopover).toHaveAttribute('data-trigger', 'SubmenuTrigger'); let inputs = within(subdialogPopover).getAllByRole('textbox'); - let buttons = within(subdialogPopover).getAllByRole('button'); await user.click(inputs[0]); expect(document.activeElement).toBe(inputs[0]); await user.tab(); expect(document.activeElement).toBe(inputs[1]); await user.tab(); - expect(document.activeElement).toBe(buttons[0]); - await user.tab(); expect(document.activeElement).toBe(inputs[0]); }); @@ -1203,47 +1191,35 @@ describe('Menu', () => { Open Rename… Duplicate - + Share… - - {({close}) => ( - <> - - - Nested Subdialog - - - {({close}) => ( -
- Contact - - - - - - - - - -
- )} -
-
-
- B - C -
- - - )} -
+ + + Nested Subdialog + +
+ Contact + + + + + + + + + +
+
+
+ B + C +
+
-
+ Delete… @@ -1255,7 +1231,7 @@ describe('Menu', () => { let triggerItem = menuTester.submenuTriggers[0]; expect(triggerItem).toHaveTextContent('Share…'); - expect(triggerItem).toHaveAttribute('aria-haspopup', 'dialog'); + expect(triggerItem).toHaveAttribute('aria-haspopup', 'menu'); // Open the subdialog let subDialogTester = await menuTester.openSubmenu({submenuTrigger: triggerItem}); @@ -1264,24 +1240,24 @@ describe('Menu', () => { let subDialogTriggerItem = subDialogTester?.submenuTriggers[0]; expect(subDialogTriggerItem).toHaveTextContent('Nested Subdialog'); - expect(subDialogTriggerItem).toHaveAttribute('aria-haspopup', 'dialog'); + expect(subDialogTriggerItem).toHaveAttribute('aria-haspopup', 'menu'); // Open the nested subdialog await subDialogTester?.openSubmenu({submenuTrigger: subDialogTriggerItem!}); act(() => {jest.runAllTimers();}); let subdialogs = getAllByRole('dialog'); - expect(subdialogs).toHaveLength(2); + expect(subdialogs).toHaveLength(3); await user.keyboard('{Escape}'); act(() => {jest.runAllTimers();}); subdialogs = getAllByRole('dialog'); - expect(subdialogs).toHaveLength(1); + expect(subdialogs).toHaveLength(2); expect(document.activeElement).toBe(subDialogTriggerItem); await user.keyboard('{Escape}'); act(() => {jest.runAllTimers();}); subdialogs = queryAllByRole('dialog'); - expect(subdialogs).toHaveLength(0); + expect(subdialogs).toHaveLength(1); expect(document.activeElement).toBe(triggerItem); }); @@ -1295,47 +1271,35 @@ describe('Menu', () => { Open Rename… Duplicate - + Share… - - {({close}) => ( - <> - - - Nested Subdialog - - - {({close}) => ( -
- Contact - - - - - - - - - -
- )} -
-
-
- B - C -
- - - )} -
+ + + Nested Subdialog + +
+ Contact + + + + + + + + + +
+
+
+ B + C +
+
-
+ Delete… @@ -1357,7 +1321,7 @@ describe('Menu', () => { await subDialogTester?.openSubmenu({submenuTrigger: subDialogTriggerItem!}); act(() => {jest.runAllTimers();}); let subdialogs = getAllByRole('dialog'); - expect(subdialogs).toHaveLength(2); + expect(subdialogs).toHaveLength(3); await user.click(document.body); act(() => {jest.runAllTimers();}); @@ -1432,6 +1396,38 @@ describe('Menu', () => { let menu = getByRole('menu'); expect(menu).toBeInTheDocument(); }); + + it('contains focus within the menu', async function () { + let tree = render( + + + + + +
Heading 1
+ Foo + Bar + Baz +
+
+
+
+ ); + let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container}); + menuTester.setInteractionType('keyboard'); + + await menuTester.open(); + act(() => {jest.runAllTimers();}); + + let menu = menuTester.menu; + let activeElement = document.activeElement; + + await user.tab(); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + expect(menu).toBeInTheDocument(); + expect(document.activeElement).toBe(activeElement); + }); }); // better to accept items from the test? or just have the test have a requirement that you render a certain-ish structure? @@ -1537,25 +1533,6 @@ AriaMenuTests({ multipleSelection: () => render( ), - siblingFocusableElement: () => render( - <> - - - - - - -
Heading 1
- Foo - Bar - Baz -
-
-
-
- - - ), multipleMenus: () => render( <>