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
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 (
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at some point, i know we refactored the collapse behavior of Tabs due to an AXE accessibility error due to where we were rendering the menu but i've run AXE on this and nothing has come up

the reason as to why i moved it is because before, if a user chose to add a custom wrapper like this:

<div>
  <TabList />
  <div>
    <Button />
  </div>
</div>

what would happen is when the tabs collapsed, the wrapper would no longer wrap around both the menu + (hidden tabs) + buttons. so in order to ensure the wrapper actually wraps the expected item, i've moved the menu + hidden tabs into here

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