diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 0929bd00c9d..f6c6c9ba03c 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, useEvent, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {dispatchVirtualFocus} from '@react-aria/focus'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -220,6 +220,8 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta [privateValidationStateProp]: state }, inputRef); + useFormReset(inputRef, state.defaultSelectedKey, state.setSelectedKey); + // Press handlers for the ComboBox button let onPress = (e: PressEvent) => { if (e.pointerType === 'touch') { diff --git a/packages/@react-aria/listbox/src/useListBoxSection.ts b/packages/@react-aria/listbox/src/useListBoxSection.ts index 82952c43bcc..1b0a045cf4c 100644 --- a/packages/@react-aria/listbox/src/useListBoxSection.ts +++ b/packages/@react-aria/listbox/src/useListBoxSection.ts @@ -46,11 +46,15 @@ export function useListBoxSection(props: AriaListBoxSectionProps): ListBoxSectio role: 'presentation' }, headingProps: heading ? { - // Techincally, listbox cannot contain headings according to ARIA. + // Technically, listbox cannot contain headings according to ARIA. // We hide the heading from assistive technology, using role="presentation", // and only use it as a visual label for the nested group. id: headingId, - role: 'presentation' + role: 'presentation', + onMouseDown: (e) => { + // Prevent DOM focus from moving on mouse down when using virtual focus + e.preventDefault(); + } } : {}, groupProps: { role: 'group', diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 30440abd5d7..58881c84064 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -72,10 +72,13 @@ export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, K /** * Whether the menu should close when the menu item is selected. - * @default true + * @deprecated - use shouldCloseOnSelect instead. */ closeOnSelect?: boolean, + /** Whether the menu should close when the menu item is selected. */ + shouldCloseOnSelect?: boolean, + /** Whether the menu item is contained in a virtual scrolling menu. */ isVirtualized?: boolean, @@ -109,6 +112,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re id, key, closeOnSelect, + shouldCloseOnSelect, isVirtualized, 'aria-haspopup': hasPopup, onPressStart, @@ -221,8 +225,10 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re ? interaction.current?.key === 'Enter' || selectionManager.selectionMode === 'none' || selectionManager.isLink(key) // Close except if multi-select is enabled. : selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key); - - shouldClose = closeOnSelect ?? shouldClose; + + + shouldClose = shouldCloseOnSelect ?? closeOnSelect ?? shouldClose; + if (onClose && !isTrigger && shouldClose) { onClose(); } @@ -312,8 +318,8 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re ...mergeProps( domProps, linkProps, - isTrigger - ? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']} + isTrigger + ? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']} : itemProps, pressProps, hoverProps, diff --git a/packages/@react-aria/utils/src/useEffectEvent.ts b/packages/@react-aria/utils/src/useEffectEvent.ts index 20b89d03038..f5046d525d3 100644 --- a/packages/@react-aria/utils/src/useEffectEvent.ts +++ b/packages/@react-aria/utils/src/useEffectEvent.ts @@ -17,7 +17,10 @@ import {useLayoutEffect} from './useLayoutEffect'; // before all layout effects, but is available only in React 18 and later. const useEarlyEffect = React['useInsertionEffect'] ?? useLayoutEffect; -export function useEffectEvent(fn?: T): T { +// Starting with React 19.2, this hook has been internalized. +const useModernEffectEvent = React['useEffectEvent'] ?? useLegacyEffectEvent; + +function useLegacyEffectEvent(fn?: T): T { const ref = useRef(null); useEarlyEffect(() => { ref.current = fn; @@ -28,3 +31,7 @@ export function useEffectEvent(fn?: T): T { return f?.(...args); }, []); } + +export function useEffectEvent(fn: T): T { + return useModernEffectEvent(fn); +} diff --git a/packages/@react-aria/utils/src/useEvent.ts b/packages/@react-aria/utils/src/useEvent.ts index 1dd35499847..c83b479e614 100644 --- a/packages/@react-aria/utils/src/useEvent.ts +++ b/packages/@react-aria/utils/src/useEvent.ts @@ -11,7 +11,7 @@ */ import {RefObject} from '@react-types/shared'; -import {useEffect} from 'react'; +import {useCallback, useEffect} from 'react'; import {useEffectEvent} from './useEffectEvent'; export function useEvent( @@ -20,7 +20,8 @@ export function useEvent( handler?: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any, options?: boolean | AddEventListenerOptions ): void { - let handleEvent = useEffectEvent(handler); + let noop = useCallback(() => {}, []); + let handleEvent = useEffectEvent(handler ?? noop); let isDisabled = handler == null; useEffect(() => { diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 8a6b7ee6e54..167e59e665f 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -5268,26 +5268,30 @@ describe('ComboBox', function () { if (parseInt(React.version, 10) >= 19) { it('resets to defaultSelectedKey when submitting form action', async () => { - function Test() { + function Test(props) { const [value, formAction] = React.useActionState(() => '2', '1'); return (
- +
); } - let {getByTestId, getByRole} = render(); + let {getByTestId, getByRole, rerender} = render(); let input = getByRole('combobox'); expect(input).toHaveValue('One'); let button = getByTestId('submit'); await act(async () => await user.click(button)); expect(input).toHaveValue('Two'); + + rerender(); + await act(async () => await user.click(button)); + expect(document.querySelector('input[name=combobox]')).toHaveValue('2'); }); } @@ -5597,26 +5601,30 @@ describe('ComboBox', function () { if (parseInt(React.version, 10) >= 19) { it('resets to defaultSelectedKey when submitting form action', async () => { - function Test() { + function Test(props) { const [value, formAction] = React.useActionState(() => '2', '1'); return (
- +
); } - let {getByTestId} = render(); + let {getByTestId, rerender} = render(); let input = document.querySelector('input[name=combobox]'); expect(input).toHaveValue('One'); let button = getByTestId('submit'); await act(async () => await user.click(button)); expect(input).toHaveValue('Two'); + + rerender(); + await act(async () => await user.click(button)); + expect(input).toHaveValue('2'); }); } diff --git a/packages/@react-types/radio/src/index.d.ts b/packages/@react-types/radio/src/index.d.ts index 5646a4ed95e..383832e8e73 100644 --- a/packages/@react-types/radio/src/index.d.ts +++ b/packages/@react-types/radio/src/index.d.ts @@ -30,7 +30,7 @@ import { } from '@react-types/shared'; import {ReactElement, ReactNode} from 'react'; -export interface RadioGroupProps extends ValueBase, InputBase, Pick, Validation, LabelableProps, HelpTextProps, FocusEvents { +export interface RadioGroupProps extends ValueBase, InputBase, Pick, Validation, LabelableProps, HelpTextProps, FocusEvents { /** * The axis the Radio Button(s) should align with. * @default 'vertical' diff --git a/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx b/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx index 47d0a9a1a83..93afe017da9 100644 --- a/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx +++ b/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx @@ -20,7 +20,7 @@ export const description = 'Combines a text input with a listbox, allowing users {docs.exports.ComboBox.description} - ```tsx render docs={vanillaDocs.exports.ComboBox} links={vanillaDocs.links} props={['label', 'isDisabled']} initialProps={{label: 'Favorite Animal', placeholder: 'Select a flavor'}} type="vanilla" files={["starters/docs/src/ComboBox.tsx", "starters/docs/src/ComboBox.css"]} + ```tsx render docs={vanillaDocs.exports.ComboBox} links={vanillaDocs.links} props={['label', 'isDisabled']} initialProps={{label: 'Favorite Animal', placeholder: 'Select an animal'}} type="vanilla" files={["starters/docs/src/ComboBox.tsx", "starters/docs/src/ComboBox.css"]} "use client"; import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; @@ -34,7 +34,7 @@ export const description = 'Combines a text input with a listbox, allowing users ``` - ```tsx render docs={vanillaDocs.exports.ComboBox} links={vanillaDocs.links} props={['label', 'isDisabled']} initialProps={{label: 'Favorite Animal', placeholder: 'Select a flavor'}} type="tailwind" files={["starters/tailwind/src/ComboBox.tsx"]} + ```tsx render docs={vanillaDocs.exports.ComboBox} links={vanillaDocs.links} props={['label', 'isDisabled']} initialProps={{label: 'Favorite Animal', placeholder: 'Select an animal'}} type="tailwind" files={["starters/tailwind/src/ComboBox.tsx"]} "use client"; import {ComboBox, ComboBoxItem} from 'tailwind-starter/ComboBox'; diff --git a/packages/dev/s2-docs/pages/react-aria/Toast.mdx b/packages/dev/s2-docs/pages/react-aria/Toast.mdx index 3c56fe8cebf..7ed9fa9c048 100644 --- a/packages/dev/s2-docs/pages/react-aria/Toast.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Toast.mdx @@ -122,7 +122,7 @@ Include a ` + + + Open + Rename… + Duplicate + Share… + Delete… + + + + ); + + expect(queryByRole('menu')).not.toBeInTheDocument(); + + let button = getByRole('button'); + await user.click(button); + + let menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + + let items = getAllByRole('menuitem'); + expect(items).toHaveLength(5); + + let item = items[0]; + expect(item).toHaveTextContent('Open'); + await user.click(item); + expect(menu).toBeInTheDocument(); + }); + + it('should not close individual menu item when shouldCloseOnSelect=false', async () => { + let {queryByRole, getByRole, getAllByRole} = render( + + + + + Open + Rename… + Duplicate + Share… + Delete… + + + + ); + + expect(queryByRole('menu')).not.toBeInTheDocument(); + + let button = getByRole('button'); + await user.click(button); + + let menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + + let items = getAllByRole('menuitem'); + expect(items).toHaveLength(5); + + let item = items[0]; + expect(item).toHaveTextContent('Open'); + await user.click(item); + expect(menu).toBeInTheDocument(); + + item = items[1]; + expect(item).toHaveTextContent('Rename'); + await user.click(item); + expect(menu).not.toBeInTheDocument(); + }); + + it('should not close menu items within a section when shouldCloseOnSelect=false', async () => { + let {queryByRole, getByRole, getAllByRole} = render( + + + + + + Open + Rename… + Duplicate + + + Share… + Delete… + + + + + ); + + expect(queryByRole('menu')).not.toBeInTheDocument(); + + let button = getByRole('button'); + await user.click(button); + + let menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + + let items = getAllByRole('menuitem'); + expect(items).toHaveLength(5); + + let item = items[0]; + expect(item).toHaveTextContent('Open'); + await user.click(item); + expect(menu).toBeInTheDocument(); + + item = items[3]; + expect(item).toHaveTextContent('Share'); + await user.click(item); + expect(menu).not.toBeInTheDocument(); + }); + describe('supports links', function () { describe.each(['mouse', 'keyboard'])('%s', (type) => { it.each(['none', 'single', 'multiple'] as unknown as SelectionMode[])('with selectionMode = %s', async function (selectionMode) {