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 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-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'] +}; 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/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'; 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]); + }); }); }