Skip to content
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
6 changes: 6 additions & 0 deletions packages/@internationalized/number/src/NumberParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/@internationalized/number/test/NumberParser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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 @@ -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';
Expand Down Expand Up @@ -223,11 +223,13 @@ export function useSubmenuTrigger<T>(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) {
Expand All @@ -249,7 +251,6 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
onPress,
onHoverChange,
onKeyDown: submenuTriggerKeyDown,
onBlur,
isOpen: state.isOpen
},
submenuProps,
Expand Down
64 changes: 62 additions & 2 deletions packages/@react-spectrum/s2/chromatic/Tabs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Tabs> = {
component: Tabs,
Expand Down Expand Up @@ -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 (
<div className={style({width: 600})}>
<Tabs {...props} aria-label="Tabs">
<div className={style({display: 'flex', alginSelf: 'stretch'})}>
<TabList items={tabs} styles={style({flexShrink: 1, flexGrow: 1, flexBasis: 'auto'})}>
{tab => <Tab id={tab.id}>{tab.title}</Tab>}
</TabList>
<div className={style({display: 'flex', alignItems: 'center', flexShrink: 0, flexGrow: 0, flexBasis: 'auto'})}>
<Button onPress={addTab}>Add tab</Button>
<Button onPress={removeTab}>Remove tab</Button>
</div>
</div>
<Collection items={tabs}>
{tab => (
<TabPanel id={tab.id}>
{tab.content}
</TabPanel>
)}
</Collection>
</Tabs>
</div>
);
}

export const CustomizedLayout = {
render: (args: any) => (<AddRemoveExample {...args} />
)
};
73 changes: 49 additions & 24 deletions packages/@react-spectrum/s2/src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,23 @@ const InternalTabsContext = createContext<Partial<TabsProps> & {
prevRef?: RefObject<DOMRect | null>,
selectedKey?: Key | null
}>({});
const CollapseContext = createContext({

interface CollapseContextType {
showTabs: boolean,
menuId: string,
valueId: string,
ariaLabel?: string | undefined,
ariaDescribedBy?: string | undefined,
tabs: Array<Node<any>>,
listRef?: RefObject<HTMLDivElement | null>,
onSelectionChange?: (key: Key) => void
}

const CollapseContext = createContext<CollapseContextType>({
showTabs: true,
menuId: '',
valueId: ''
valueId: '',
tabs: []
});

const tabs = style({
Expand Down Expand Up @@ -198,35 +211,57 @@ const tablist = style({
minWidth: 'min'
});

const tablistWrapper = style({
position: 'relative',
minWidth: 'min',
flexShrink: 0,
flexGrow: 0
}, getAllowedOverrides());

export function TabList<T extends object>(props: TabListProps<T>): 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 <TabListInner {...props} />;
}
return null;

return (
<div className={tablistWrapper(null, props.styles)}>
{listRef && <div className={tablist({orientation, labelBehavior, density})}>
<HiddenTabs items={tabs} density={density} listRef={listRef} />
</div>}
<TabsMenu
id={menuId}
valueId={valueId}
items={tabs}
onSelectionChange={onSelectionChange}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy} />
</div>
);
}

function TabListInner<T extends object>(props: TabListProps<T>) {
let {
tablistRef,
orientation,
density,
labelBehavior,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy
} = useContext(InternalTabsContext) ?? {};
let {tabs, listRef} = useContext(CollapseContext) ?? {};

return (
<div
style={props.UNSAFE_style}
className={
(props.UNSAFE_className || '') +
style({
position: 'relative',
flexGrow: 0,
flexShrink: 0,
minWidth: 'min'
}, getAllowedOverrides())(null, props.styles)}>
tablistWrapper(null, props.styles)}>
{listRef && <div className={tablist({orientation, labelBehavior, density})}>
<HiddenTabs items={tabs} density={density} listRef={listRef} />
</div>}
<RACTabList
{...props}
aria-label={ariaLabel}
Expand Down Expand Up @@ -519,7 +554,7 @@ let HiddenTabs = function (props: {
size?: string,
density?: 'compact' | 'regular'
}) {
let {listRef, items, size, density} = props;
let {listRef, items = [], size, density} = props;

return (
<div
Expand Down Expand Up @@ -612,7 +647,7 @@ let TabsMenu = (props: {valueId: string, items: Array<Node<any>>, onSelectionCha
};

let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collection<Node<unknown>>, 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) => {
Expand Down Expand Up @@ -683,14 +718,7 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect
} else {
contents = (
<>
<TabsMenu
id={menuId}
valueId={valueId}
items={children}
onSelectionChange={onSelectionChange}
aria-label={props['aria-label']}
aria-describedby={props['aria-labelledby']} />
<CollapseContext.Provider value={{showTabs: false, menuId, valueId}}>
<CollapseContext.Provider value={{showTabs: false, tabs: children, menuId, valueId, listRef: listRef, onSelectionChange, ariaLabel: props['aria-label'], ariaDescribedBy: props['aria-labelledby']}}>
{props.children}
</CollapseContext.Provider>
</>
Expand All @@ -699,10 +727,7 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect

return (
<div style={props.UNSAFE_style} className={(props.UNSAFE_className || '') + tabs({orientation}, props.styles)} ref={containerRef}>
<div className={tablist({orientation, labelBehavior, density})}>
<HiddenTabs items={children} density={density} listRef={listRef} />
</div>
<CollapseContext.Provider value={{showTabs: true, menuId, valueId}}>
<CollapseContext.Provider value={{showTabs: true, menuId, valueId, tabs: children, listRef: listRef}}>
{contents}
</CollapseContext.Provider>
</div>
Expand Down
63 changes: 61 additions & 2 deletions packages/@react-spectrum/s2/stories/Tabs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Tabs> = {
component: Tabs,
Expand Down Expand Up @@ -148,3 +148,62 @@ export const Dynamic: Story = {
</div>
)
};

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 (
<div className={style({width: 600})}>
<Tabs {...props} aria-label="Tabs">
<div className={style({display: 'flex', alginSelf: 'stretch'})}>
<TabList items={tabs} styles={style({flexShrink: 1, flexGrow: 1, flexBasis: 'auto'})}>
{tab => <Tab id={tab.id}>{tab.title}</Tab>}
</TabList>
<div className={style({display: 'flex', alignItems: 'center', flexShrink: 0, flexGrow: 0, flexBasis: 'auto'})}>
<Button onPress={addTab}>Add tab</Button>
<Button onPress={removeTab}>Remove tab</Button>
</div>
</div>
<Collection items={tabs}>
{tab => (
<TabPanel id={tab.id}>
{tab.content}
</TabPanel>
)}
</Collection>
</Tabs>
</div>
);
}

export const CustomizedLayout: Story = {
render: (args) => <AddRemoveTabsExample {...args} />,
tags: ['!autodocs']
};
6 changes: 4 additions & 2 deletions packages/react-aria-components/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,12 @@ export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigg
<Provider
values={[
[MenuItemContext, {...submenuTriggerProps, onAction: undefined, ref: itemRef}],
[MenuContext, submenuProps],
[MenuContext, {
ref: submenuRef,
...submenuProps
}],
[OverlayTriggerStateContext, submenuTriggerState],
[PopoverContext, {
ref: submenuRef,
trigger: 'SubmenuTrigger',
triggerRef: itemRef,
placement: 'end top',
Expand Down
5 changes: 3 additions & 2 deletions packages/react-aria-components/src/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,12 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts
}, [ref, shouldBeDialog]);

// Focus the popover itself on mount, unless a child element is already focused.
// Skip this for submenus since hovering a submenutrigger should keep focus on the trigger
useEffect(() => {
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;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-aria-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading