From 8b0e7d72b4175c43f936794fad882d052ca769fc Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 6 Aug 2025 09:37:01 -0700 Subject: [PATCH 1/4] fix: Make submenu hover focus remain on the trigger and collapse submenus when hovering root menu (#8666) * fix: hovering submenutrigger should keep focus on the trigger also makes it so ArrowRight still moves to the submenu after hovering, not sure if this breaks VO moving focus to the dismiss button though... * make submenu close if any other item in the same menu is focused this fixes the case where the user hovers a root menu level item when there are multiple submenu levels open but only the last one closes --- .../@react-aria/menu/src/useSubmenuTrigger.ts | 11 +++--- packages/react-aria-components/src/Menu.tsx | 6 ++- .../react-aria-components/src/Popover.tsx | 5 ++- .../test/AriaMenu.test-util.tsx | 37 +++++++++++++++++++ 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 4d9c5684305..590ce43a213 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; 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 {focusWithoutScrolling, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -223,11 +223,13 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm } }; - let onBlur = (e) => { - if (state.isOpen && (parentMenuRef.current?.contains(e.relatedTarget))) { + useEvent(parentMenuRef, 'focusin', (e) => { + // If we detect focus moved to a different item in the same menu that the currently open submenu trigger is in + // then close the submenu. This is for a case where the user hovers a root menu item when multiple submenus are open + if (state.isOpen && (parentMenuRef.current?.contains(e.target as HTMLElement) && e.target !== ref.current)) { onSubmenuClose(); } - }; + }); let shouldCloseOnInteractOutside = (target) => { if (target !== ref.current) { @@ -249,7 +251,6 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm onPress, onHoverChange, onKeyDown: submenuTriggerKeyDown, - onBlur, isOpen: state.isOpen }, submenuProps, diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index cda7ba63bd1..d180bb19d51 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -131,10 +131,12 @@ export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigg { - if (isDialog && ref.current && !ref.current.contains(document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !ref.current.contains(document.activeElement)) { focusSafely(ref.current); } - }, [isDialog, ref]); + }, [isDialog, ref, props.trigger]); let children = useMemo(() => { let children = renderProps.children; diff --git a/packages/react-aria-components/test/AriaMenu.test-util.tsx b/packages/react-aria-components/test/AriaMenu.test-util.tsx index d3b70bc9de9..087821d8941 100644 --- a/packages/react-aria-components/test/AriaMenu.test-util.tsx +++ b/packages/react-aria-components/test/AriaMenu.test-util.tsx @@ -693,6 +693,43 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps): vo expect(nestedSubmenu).not.toBeInTheDocument(); expect(document.activeElement).toBe(nestedSubmenuTrigger); }); + + it('should close the submenu if another item in the same menu is focused', async () => { + let tree = (renderers.submenus!)(); + let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container}); + await menuTester.open(); + let menu = menuTester.menu; + let submenuTrigger = menuTester.submenuTriggers[0]; + let submenuUtil = (await menuTester.openSubmenu({submenuTrigger}))!; + act(() => {jest.runAllTimers();}); + let submenu = submenuUtil.menu; + expect(submenu).toBeInTheDocument(); + let nestedSubmenuTrigger = submenuUtil.submenuTriggers[0]; + let nestedSubmenuUtil = (await submenuUtil.openSubmenu({submenuTrigger: nestedSubmenuTrigger}))!; + act(() => {jest.runAllTimers();}); + let nestedSubmenu = nestedSubmenuUtil.menu; + expect(submenu).toBeInTheDocument(); + await user.hover(menuTester.options()[0]); + act(() => {jest.runAllTimers();}); + expect(nestedSubmenu).not.toBeInTheDocument(); + expect(submenu).not.toBeInTheDocument(); + expect(menu).toBeInTheDocument(); + }); + + it('should retain focus on the submenu trigger when hovering it', async () => { + let tree = (renderers.submenus!)(); + let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container}); + await menuTester.open(); + await user.hover(menuTester.submenuTriggers[0]); + act(() => {jest.runAllTimers();}); + expect(menuTester.submenuTriggers[0]).toHaveAttribute('aria-expanded', 'true'); + expect(document.activeElement).toBe(menuTester.submenuTriggers[0]); + + // It should also allow the user to move focus into the submenu via ArrowRight + await user.keyboard('{ArrowRight}'); + let submenu = tree.getAllByRole('menu')[1]; + expect(document.activeElement).toBe(within(submenu).getAllByRole('menuitem')[0]); + }); }); } From 028017e1d28646c1b199031313b8fb970b4d3f2f Mon Sep 17 00:00:00 2001 From: "yugo.innami" <58389827+nami8824@users.noreply.github.com> Date: Thu, 7 Aug 2025 02:24:04 +0900 Subject: [PATCH 2/4] fix: reexport visually hidden (#8676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 印南雄悟 <> --- packages/react-aria-components/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index e0384baca75..56eeeee7bb4 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -79,7 +79,7 @@ export {TreeLoadMoreItem, Tree, TreeItem, TreeContext, TreeItemContent, TreeStat export {useDragAndDrop} from './useDragAndDrop'; export {DropIndicator, DropIndicatorContext, DragAndDropContext} from './DragAndDrop'; export {Virtualizer} from './Virtualizer'; -export {DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem, SSRProvider, RouterProvider, I18nProvider, useLocale, useFilter, Pressable, Focusable} from 'react-aria'; +export {DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem, SSRProvider, RouterProvider, I18nProvider, useLocale, useFilter, Pressable, Focusable, VisuallyHidden} from 'react-aria'; export {FormValidationContext, parseColor, getColorChannels, ToastQueue as UNSTABLE_ToastQueue} from 'react-stately'; export {ListLayout, GridLayout, WaterfallLayout} from '@react-stately/layout'; export {Layout, LayoutInfo, Size, Rect, Point} from '@react-stately/virtualizer'; From 405a66091b09076ac8e311016850ac4ca0959720 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:25:17 -0700 Subject: [PATCH 3/4] fix: support collapse behavior when customizing S2 tab layout (#8665) * fix: support collapse behavior when customizing S2 tab layout * fix bugs * cleanup * update types, add chromatic --- .../s2/chromatic/Tabs.stories.tsx | 64 +++++++++++++++- packages/@react-spectrum/s2/src/Tabs.tsx | 73 +++++++++++++------ .../s2/stories/Tabs.stories.tsx | 63 +++++++++++++++- 3 files changed, 172 insertions(+), 28 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/Tabs.stories.tsx b/packages/@react-spectrum/s2/chromatic/Tabs.stories.tsx index 4850028a80c..163ae081cc3 100644 --- a/packages/@react-spectrum/s2/chromatic/Tabs.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Tabs.stories.tsx @@ -11,13 +11,14 @@ */ import Bell from '../s2wf-icons/S2_Icon_Bell_20_N.svg'; +import {Button, Tab, TabList, TabPanel, Tabs} from '../src'; +import {Collection, Text} from '@react-spectrum/s2'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Heart from '../s2wf-icons/S2_Icon_Heart_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '../style/spectrum-theme' with { type: 'macro' }; -import {Tab, TabList, TabPanel, Tabs} from '../src/Tabs'; -import {Text} from '@react-spectrum/s2'; import {userEvent} from '@storybook/test'; +import {useState} from 'react'; const meta: Meta = { component: Tabs, @@ -186,3 +187,62 @@ export const Collasped = { await userEvent.keyboard('{Enter}'); } }; + +function AddRemoveExample(props) { + let [tabs, setTabs] = useState([ + {id: 1, title: 'Tab 1', content: 'Tab body 1'}, + {id: 2, title: 'Tab 2', content: 'Tab body 2'}, + {id: 3, title: 'Tab 3', content: 'Tab body 3'}, + {id: 4, title: 'Tab 4', content: 'Tab body 4'}, + {id: 5, title: 'Tab 5', content: 'Tab body 5'}, + {id: 6, title: 'Tab 6', content: 'Tab body 6'}, + {id: 7, title: 'Tab 7', content: 'Tab body 7'}, + {id: 8, title: 'Tab 8', content: 'Tab body 8'}, + {id: 9, title: 'Tab 9', content: 'Tab body 9'} + ]); + + let addTab = () => { + setTabs(tabs => [ + ...tabs, + { + id: tabs.length + 1, + title: `Tab ${tabs.length + 1}`, + content: `Tab body ${tabs.length + 1}` + } + ]); + }; + + let removeTab = () => { + if (tabs.length > 1) { + setTabs(tabs => tabs.slice(0, -1)); + } + }; + + return ( +
+ +
+ + {tab => {tab.title}} + +
+ + +
+
+ + {tab => ( + + {tab.content} + + )} + +
+
+ ); +} + +export const CustomizedLayout = { + render: (args: any) => ( + ) +}; diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 733013b49a3..992bd15cac4 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -82,10 +82,23 @@ const InternalTabsContext = createContext & { prevRef?: RefObject, selectedKey?: Key | null }>({}); -const CollapseContext = createContext({ + +interface CollapseContextType { + showTabs: boolean, + menuId: string, + valueId: string, + ariaLabel?: string | undefined, + ariaDescribedBy?: string | undefined, + tabs: Array>, + listRef?: RefObject, + onSelectionChange?: (key: Key) => void +} + +const CollapseContext = createContext({ showTabs: true, menuId: '', - valueId: '' + valueId: '', + tabs: [] }); const tabs = style({ @@ -198,35 +211,57 @@ const tablist = style({ minWidth: 'min' }); +const tablistWrapper = style({ + position: 'relative', + minWidth: 'min', + flexShrink: 0, + flexGrow: 0 +}, getAllowedOverrides()); + export function TabList(props: TabListProps): ReactNode | null { - let {showTabs} = useContext(CollapseContext) ?? {}; + let {showTabs, menuId, valueId, tabs, listRef, onSelectionChange, ariaLabel, ariaDescribedBy} = useContext(CollapseContext) ?? {}; + let {density, orientation, labelBehavior} = useContext(InternalTabsContext); if (showTabs) { return ; } - return null; + + return ( +
+ {listRef &&
+ +
} + +
+ ); } function TabListInner(props: TabListProps) { let { tablistRef, + orientation, density, labelBehavior, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy } = useContext(InternalTabsContext) ?? {}; + let {tabs, listRef} = useContext(CollapseContext) ?? {}; return (
+ tablistWrapper(null, props.styles)}> + {listRef &&
+ +
} >, onSelectionCha }; let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collection>, containerRef: any} & TabsProps) => { - let {density = 'regular', orientation = 'horizontal', labelBehavior = 'show', onSelectionChange} = props; + let {orientation = 'horizontal', onSelectionChange} = props; let [showItems, _setShowItems] = useState(true); showItems = orientation === 'vertical' ? true : showItems; let setShowItems = useCallback((value: boolean) => { @@ -683,14 +718,7 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect } else { contents = ( <> - - + {props.children} @@ -699,10 +727,7 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect return (
-
- -
- + {contents}
diff --git a/packages/@react-spectrum/s2/stories/Tabs.stories.tsx b/packages/@react-spectrum/s2/stories/Tabs.stories.tsx index 2e68ba5d3e7..da54f4b77b2 100644 --- a/packages/@react-spectrum/s2/stories/Tabs.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Tabs.stories.tsx @@ -11,14 +11,14 @@ */ import Bell from '../s2wf-icons/S2_Icon_Bell_20_N.svg'; +import {Button, Tab, TabList, TabPanel, Tabs, TabsProps} from '../src'; import {Collection, Text} from '@react-spectrum/s2'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import {fn} from '@storybook/test'; import Heart from '../s2wf-icons/S2_Icon_Heart_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement} from 'react'; +import React, {ReactElement} from 'react'; import {style} from '../style' with { type: 'macro' }; -import {Tab, TabList, TabPanel, Tabs, TabsProps} from '../src'; const meta: Meta = { component: Tabs, @@ -148,3 +148,62 @@ export const Dynamic: Story = {
) }; + +function AddRemoveTabsExample(props) { + let [tabs, setTabs] = React.useState([ + {id: 1, title: 'Tab 1', content: 'Tab body 1'}, + {id: 2, title: 'Tab 2', content: 'Tab body 2'}, + {id: 3, title: 'Tab 3', content: 'Tab body 3'}, + {id: 4, title: 'Tab 4', content: 'Tab body 4'}, + {id: 5, title: 'Tab 5', content: 'Tab body 5'}, + {id: 6, title: 'Tab 6', content: 'Tab body 6'}, + {id: 7, title: 'Tab 7', content: 'Tab body 7'}, + {id: 8, title: 'Tab 8', content: 'Tab body 8'}, + {id: 9, title: 'Tab 9', content: 'Tab body 9'} + ]); + + let addTab = () => { + setTabs(tabs => [ + ...tabs, + { + id: tabs.length + 1, + title: `Tab ${tabs.length + 1}`, + content: `Tab body ${tabs.length + 1}` + } + ]); + }; + + let removeTab = () => { + if (tabs.length > 1) { + setTabs(tabs => tabs.slice(0, -1)); + } + }; + + return ( +
+ +
+ + {tab => {tab.title}} + +
+ + +
+
+ + {tab => ( + + {tab.content} + + )} + +
+
+ ); +} + +export const CustomizedLayout: Story = { + render: (args) => , + tags: ['!autodocs'] +}; From 22e397e821fa202b14cb5f172a15044c12e1c0ad Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 7 Aug 2025 03:25:40 +1000 Subject: [PATCH 4/4] fix: Swiss currency parsing (#8546) * fix: Swiss currency parsing * remove only * Apply suggestion from @snowystinger * add comment --- packages/@internationalized/number/src/NumberParser.ts | 6 ++++++ .../@internationalized/number/test/NumberParser.test.js | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index cc0ab0b43b6..ae6448f027d 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -215,6 +215,12 @@ class NumberParserImpl { } } + // In some locale styles, such as swiss currency, the group character can be a special single quote + // that keyboards don't typically have. This expands the character to include the easier to type single quote. + if (this.symbols.group === '’' && value.includes("'")) { + value = replaceAll(value, "'", this.symbols.group); + } + // fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard, // so allow space and non-breaking space as a group char as well if (this.options.locale === 'fr-FR' && this.symbols.group) { diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index 4c9ef627de0..a9266d997cf 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -188,6 +188,12 @@ describe('NumberParser', function () { }); }); + it('should parse a swiss currency number', () => { + expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse('CHF 1’000.00')).toBe(1000); + expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse("CHF 1'000.00")).toBe(1000); + expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse("CHF 1'000.00")).toBe(1000); + }); + describe('round trips', function () { fc.configureGlobal({numRuns: 200}); // Locales have to include: 'de-DE', 'ar-EG', 'fr-FR' and possibly others