Skip to content

Commit 405a660

Browse files
authored
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
1 parent 028017e commit 405a660

File tree

3 files changed

+172
-28
lines changed

3 files changed

+172
-28
lines changed

packages/@react-spectrum/s2/chromatic/Tabs.stories.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
*/
1212

1313
import Bell from '../s2wf-icons/S2_Icon_Bell_20_N.svg';
14+
import {Button, Tab, TabList, TabPanel, Tabs} from '../src';
15+
import {Collection, Text} from '@react-spectrum/s2';
1416
import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg';
1517
import Heart from '../s2wf-icons/S2_Icon_Heart_20_N.svg';
1618
import type {Meta, StoryObj} from '@storybook/react';
1719
import {style} from '../style/spectrum-theme' with { type: 'macro' };
18-
import {Tab, TabList, TabPanel, Tabs} from '../src/Tabs';
19-
import {Text} from '@react-spectrum/s2';
2020
import {userEvent} from '@storybook/test';
21+
import {useState} from 'react';
2122

2223
const meta: Meta<typeof Tabs> = {
2324
component: Tabs,
@@ -186,3 +187,62 @@ export const Collasped = {
186187
await userEvent.keyboard('{Enter}');
187188
}
188189
};
190+
191+
function AddRemoveExample(props) {
192+
let [tabs, setTabs] = useState([
193+
{id: 1, title: 'Tab 1', content: 'Tab body 1'},
194+
{id: 2, title: 'Tab 2', content: 'Tab body 2'},
195+
{id: 3, title: 'Tab 3', content: 'Tab body 3'},
196+
{id: 4, title: 'Tab 4', content: 'Tab body 4'},
197+
{id: 5, title: 'Tab 5', content: 'Tab body 5'},
198+
{id: 6, title: 'Tab 6', content: 'Tab body 6'},
199+
{id: 7, title: 'Tab 7', content: 'Tab body 7'},
200+
{id: 8, title: 'Tab 8', content: 'Tab body 8'},
201+
{id: 9, title: 'Tab 9', content: 'Tab body 9'}
202+
]);
203+
204+
let addTab = () => {
205+
setTabs(tabs => [
206+
...tabs,
207+
{
208+
id: tabs.length + 1,
209+
title: `Tab ${tabs.length + 1}`,
210+
content: `Tab body ${tabs.length + 1}`
211+
}
212+
]);
213+
};
214+
215+
let removeTab = () => {
216+
if (tabs.length > 1) {
217+
setTabs(tabs => tabs.slice(0, -1));
218+
}
219+
};
220+
221+
return (
222+
<div className={style({width: 600})}>
223+
<Tabs {...props} aria-label="Tabs">
224+
<div className={style({display: 'flex', alginSelf: 'stretch'})}>
225+
<TabList items={tabs} styles={style({flexShrink: 1, flexGrow: 1, flexBasis: 'auto'})}>
226+
{tab => <Tab id={tab.id}>{tab.title}</Tab>}
227+
</TabList>
228+
<div className={style({display: 'flex', alignItems: 'center', flexShrink: 0, flexGrow: 0, flexBasis: 'auto'})}>
229+
<Button onPress={addTab}>Add tab</Button>
230+
<Button onPress={removeTab}>Remove tab</Button>
231+
</div>
232+
</div>
233+
<Collection items={tabs}>
234+
{tab => (
235+
<TabPanel id={tab.id}>
236+
{tab.content}
237+
</TabPanel>
238+
)}
239+
</Collection>
240+
</Tabs>
241+
</div>
242+
);
243+
}
244+
245+
export const CustomizedLayout = {
246+
render: (args: any) => (<AddRemoveExample {...args} />
247+
)
248+
};

packages/@react-spectrum/s2/src/Tabs.tsx

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,23 @@ const InternalTabsContext = createContext<Partial<TabsProps> & {
8282
prevRef?: RefObject<DOMRect | null>,
8383
selectedKey?: Key | null
8484
}>({});
85-
const CollapseContext = createContext({
85+
86+
interface CollapseContextType {
87+
showTabs: boolean,
88+
menuId: string,
89+
valueId: string,
90+
ariaLabel?: string | undefined,
91+
ariaDescribedBy?: string | undefined,
92+
tabs: Array<Node<any>>,
93+
listRef?: RefObject<HTMLDivElement | null>,
94+
onSelectionChange?: (key: Key) => void
95+
}
96+
97+
const CollapseContext = createContext<CollapseContextType>({
8698
showTabs: true,
8799
menuId: '',
88-
valueId: ''
100+
valueId: '',
101+
tabs: []
89102
});
90103

91104
const tabs = style({
@@ -198,35 +211,57 @@ const tablist = style({
198211
minWidth: 'min'
199212
});
200213

214+
const tablistWrapper = style({
215+
position: 'relative',
216+
minWidth: 'min',
217+
flexShrink: 0,
218+
flexGrow: 0
219+
}, getAllowedOverrides());
220+
201221
export function TabList<T extends object>(props: TabListProps<T>): ReactNode | null {
202-
let {showTabs} = useContext(CollapseContext) ?? {};
222+
let {showTabs, menuId, valueId, tabs, listRef, onSelectionChange, ariaLabel, ariaDescribedBy} = useContext(CollapseContext) ?? {};
223+
let {density, orientation, labelBehavior} = useContext(InternalTabsContext);
203224

204225
if (showTabs) {
205226
return <TabListInner {...props} />;
206227
}
207-
return null;
228+
229+
return (
230+
<div className={tablistWrapper(null, props.styles)}>
231+
{listRef && <div className={tablist({orientation, labelBehavior, density})}>
232+
<HiddenTabs items={tabs} density={density} listRef={listRef} />
233+
</div>}
234+
<TabsMenu
235+
id={menuId}
236+
valueId={valueId}
237+
items={tabs}
238+
onSelectionChange={onSelectionChange}
239+
aria-label={ariaLabel}
240+
aria-describedby={ariaDescribedBy} />
241+
</div>
242+
);
208243
}
209244

210245
function TabListInner<T extends object>(props: TabListProps<T>) {
211246
let {
212247
tablistRef,
248+
orientation,
213249
density,
214250
labelBehavior,
215251
'aria-label': ariaLabel,
216252
'aria-labelledby': ariaLabelledBy
217253
} = useContext(InternalTabsContext) ?? {};
254+
let {tabs, listRef} = useContext(CollapseContext) ?? {};
218255

219256
return (
220257
<div
221258
style={props.UNSAFE_style}
222259
className={
223260
(props.UNSAFE_className || '') +
224-
style({
225-
position: 'relative',
226-
flexGrow: 0,
227-
flexShrink: 0,
228-
minWidth: 'min'
229-
}, getAllowedOverrides())(null, props.styles)}>
261+
tablistWrapper(null, props.styles)}>
262+
{listRef && <div className={tablist({orientation, labelBehavior, density})}>
263+
<HiddenTabs items={tabs} density={density} listRef={listRef} />
264+
</div>}
230265
<RACTabList
231266
{...props}
232267
aria-label={ariaLabel}
@@ -519,7 +554,7 @@ let HiddenTabs = function (props: {
519554
size?: string,
520555
density?: 'compact' | 'regular'
521556
}) {
522-
let {listRef, items, size, density} = props;
557+
let {listRef, items = [], size, density} = props;
523558

524559
return (
525560
<div
@@ -612,7 +647,7 @@ let TabsMenu = (props: {valueId: string, items: Array<Node<any>>, onSelectionCha
612647
};
613648

614649
let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collection<Node<unknown>>, containerRef: any} & TabsProps) => {
615-
let {density = 'regular', orientation = 'horizontal', labelBehavior = 'show', onSelectionChange} = props;
650+
let {orientation = 'horizontal', onSelectionChange} = props;
616651
let [showItems, _setShowItems] = useState(true);
617652
showItems = orientation === 'vertical' ? true : showItems;
618653
let setShowItems = useCallback((value: boolean) => {
@@ -683,14 +718,7 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect
683718
} else {
684719
contents = (
685720
<>
686-
<TabsMenu
687-
id={menuId}
688-
valueId={valueId}
689-
items={children}
690-
onSelectionChange={onSelectionChange}
691-
aria-label={props['aria-label']}
692-
aria-describedby={props['aria-labelledby']} />
693-
<CollapseContext.Provider value={{showTabs: false, menuId, valueId}}>
721+
<CollapseContext.Provider value={{showTabs: false, tabs: children, menuId, valueId, listRef: listRef, onSelectionChange, ariaLabel: props['aria-label'], ariaDescribedBy: props['aria-labelledby']}}>
694722
{props.children}
695723
</CollapseContext.Provider>
696724
</>
@@ -699,10 +727,7 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect
699727

700728
return (
701729
<div style={props.UNSAFE_style} className={(props.UNSAFE_className || '') + tabs({orientation}, props.styles)} ref={containerRef}>
702-
<div className={tablist({orientation, labelBehavior, density})}>
703-
<HiddenTabs items={children} density={density} listRef={listRef} />
704-
</div>
705-
<CollapseContext.Provider value={{showTabs: true, menuId, valueId}}>
730+
<CollapseContext.Provider value={{showTabs: true, menuId, valueId, tabs: children, listRef: listRef}}>
706731
{contents}
707732
</CollapseContext.Provider>
708733
</div>

packages/@react-spectrum/s2/stories/Tabs.stories.tsx

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111
*/
1212

1313
import Bell from '../s2wf-icons/S2_Icon_Bell_20_N.svg';
14+
import {Button, Tab, TabList, TabPanel, Tabs, TabsProps} from '../src';
1415
import {Collection, Text} from '@react-spectrum/s2';
1516
import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg';
1617
import {fn} from '@storybook/test';
1718
import Heart from '../s2wf-icons/S2_Icon_Heart_20_N.svg';
1819
import type {Meta, StoryObj} from '@storybook/react';
19-
import {ReactElement} from 'react';
20+
import React, {ReactElement} from 'react';
2021
import {style} from '../style' with { type: 'macro' };
21-
import {Tab, TabList, TabPanel, Tabs, TabsProps} from '../src';
2222

2323
const meta: Meta<typeof Tabs> = {
2424
component: Tabs,
@@ -148,3 +148,62 @@ export const Dynamic: Story = {
148148
</div>
149149
)
150150
};
151+
152+
function AddRemoveTabsExample(props) {
153+
let [tabs, setTabs] = React.useState([
154+
{id: 1, title: 'Tab 1', content: 'Tab body 1'},
155+
{id: 2, title: 'Tab 2', content: 'Tab body 2'},
156+
{id: 3, title: 'Tab 3', content: 'Tab body 3'},
157+
{id: 4, title: 'Tab 4', content: 'Tab body 4'},
158+
{id: 5, title: 'Tab 5', content: 'Tab body 5'},
159+
{id: 6, title: 'Tab 6', content: 'Tab body 6'},
160+
{id: 7, title: 'Tab 7', content: 'Tab body 7'},
161+
{id: 8, title: 'Tab 8', content: 'Tab body 8'},
162+
{id: 9, title: 'Tab 9', content: 'Tab body 9'}
163+
]);
164+
165+
let addTab = () => {
166+
setTabs(tabs => [
167+
...tabs,
168+
{
169+
id: tabs.length + 1,
170+
title: `Tab ${tabs.length + 1}`,
171+
content: `Tab body ${tabs.length + 1}`
172+
}
173+
]);
174+
};
175+
176+
let removeTab = () => {
177+
if (tabs.length > 1) {
178+
setTabs(tabs => tabs.slice(0, -1));
179+
}
180+
};
181+
182+
return (
183+
<div className={style({width: 600})}>
184+
<Tabs {...props} aria-label="Tabs">
185+
<div className={style({display: 'flex', alginSelf: 'stretch'})}>
186+
<TabList items={tabs} styles={style({flexShrink: 1, flexGrow: 1, flexBasis: 'auto'})}>
187+
{tab => <Tab id={tab.id}>{tab.title}</Tab>}
188+
</TabList>
189+
<div className={style({display: 'flex', alignItems: 'center', flexShrink: 0, flexGrow: 0, flexBasis: 'auto'})}>
190+
<Button onPress={addTab}>Add tab</Button>
191+
<Button onPress={removeTab}>Remove tab</Button>
192+
</div>
193+
</div>
194+
<Collection items={tabs}>
195+
{tab => (
196+
<TabPanel id={tab.id}>
197+
{tab.content}
198+
</TabPanel>
199+
)}
200+
</Collection>
201+
</Tabs>
202+
</div>
203+
);
204+
}
205+
206+
export const CustomizedLayout: Story = {
207+
render: (args) => <AddRemoveTabsExample {...args} />,
208+
tags: ['!autodocs']
209+
};

0 commit comments

Comments
 (0)