From 82a7368e9e6501791d82c7d3a8c7555e1d409f0d Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 31 Jul 2025 14:28:40 -0700 Subject: [PATCH 1/4] fix: support collapse behavior when customizing S2 tab layout --- packages/@react-spectrum/s2/src/Tabs.tsx | 57 ++++++++++++++----- .../s2/stories/Tabs.stories.tsx | 56 +++++++++++++++++- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 733013b49a3..1f5f7d1baf0 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -82,7 +82,16 @@ const InternalTabsContext = createContext & { prevRef?: RefObject, selectedKey?: Key | null }>({}); -const CollapseContext = createContext({ + +interface CollapseContextType { + showTabs: boolean; + menuId: string; + valueId: string; + tabs?: Array> | undefined; + listRef?: RefObject; +} + +const CollapseContext = createContext({ showTabs: true, menuId: '', valueId: '' @@ -199,22 +208,42 @@ const tablist = style({ }); export function TabList(props: TabListProps): ReactNode | null { - let {showTabs} = useContext(CollapseContext) ?? {}; + let {showTabs, tabs, listRef} = useContext(CollapseContext) ?? {}; + let {density, orientation, labelBehavior} = useContext(InternalTabsContext); if (showTabs) { - return ; + return ( + <> + + ); } - return null; + + return ( +
+
+ +
+ +
+ ); } function TabListInner(props: TabListProps) { let { tablistRef, + orientation, density, labelBehavior, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy } = useContext(InternalTabsContext) ?? {}; + let {showTabs, tabs, listRef} = useContext(CollapseContext) ?? {}; return (
(props: TabListProps) { flexShrink: 0, minWidth: 'min' }, getAllowedOverrides())(null, props.styles)}> +
+ +
- - + aria-describedby={props['aria-labelledby']} /> */} + {props.children} ); } + // console.log(children) return (
-
- -
- + {contents}
); -}; +}; \ No newline at end of file diff --git a/packages/@react-spectrum/s2/stories/Tabs.stories.tsx b/packages/@react-spectrum/s2/stories/Tabs.stories.tsx index 2e68ba5d3e7..1d4b15e6907 100644 --- a/packages/@react-spectrum/s2/stories/Tabs.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Tabs.stories.tsx @@ -18,7 +18,9 @@ import Heart from '../s2wf-icons/S2_Icon_Heart_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; import {ReactElement} from 'react'; import {style} from '../style' with { type: 'macro' }; -import {Tab, TabList, TabPanel, Tabs, TabsProps} from '../src'; +import {Button, Tab, TabList, TabPanel, Tabs, TabsProps} from '../src'; +import React from 'react'; +import { tab } from '@testing-library/user-event/dist/cjs/setup/directApi.js'; const meta: Meta = { component: Tabs, @@ -148,3 +150,55 @@ export const Dynamic: Story = {
) }; + +function ExampleTest(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'} + ]); + + 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 AddRemoveTabs: Story = { + render: (args) => +} From 417954c4e2f1108d8d19b3c9aaf7f87b61ad94f6 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 1 Aug 2025 15:16:35 -0700 Subject: [PATCH 2/4] fix bugs --- packages/@react-spectrum/s2/src/Tabs.tsx | 59 ++++++++++--------- .../s2/stories/Tabs.stories.tsx | 20 +++---- 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 1f5f7d1baf0..8c83f9aaad5 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -84,17 +84,22 @@ const InternalTabsContext = createContext & { }>({}); interface CollapseContextType { - showTabs: boolean; - menuId: string; - valueId: string; - tabs?: Array> | undefined; - listRef?: RefObject; + 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: [], + listRef: {current: null} }); const tabs = style({ @@ -207,29 +212,33 @@ 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, tabs, listRef} = useContext(CollapseContext) ?? {}; + let {showTabs, menuId, valueId, tabs, listRef, onSelectionChange, ariaLabel, ariaDescribedBy} = useContext(CollapseContext) ?? {}; let {density, orientation, labelBehavior} = useContext(InternalTabsContext); if (showTabs) { - return ( - <> - - ); + return ; } return ( -
+
+ onSelectionChange={onSelectionChange} + aria-label={ariaLabel} + aria-describedby={ariaDescribedBy} />
); } @@ -243,19 +252,14 @@ function TabListInner(props: TabListProps) { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy } = useContext(InternalTabsContext) ?? {}; - let {showTabs, tabs, listRef} = useContext(CollapseContext) ?? {}; + let {tabs, listRef} = useContext(CollapseContext) ?? {}; return (
+ tablistWrapper(null, props.styles)}>
@@ -644,7 +648,7 @@ let TabsMenu = (props: {valueId: string, items: Array>, 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) => { @@ -723,14 +727,13 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect onSelectionChange={onSelectionChange} aria-label={props['aria-label']} aria-describedby={props['aria-labelledby']} /> */} - + {props.children} ); } - // console.log(children) return (
@@ -738,4 +741,4 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect
); -}; \ No newline at end of file +}; diff --git a/packages/@react-spectrum/s2/stories/Tabs.stories.tsx b/packages/@react-spectrum/s2/stories/Tabs.stories.tsx index 1d4b15e6907..f151c4067b9 100644 --- a/packages/@react-spectrum/s2/stories/Tabs.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Tabs.stories.tsx @@ -11,16 +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 {Button, Tab, TabList, TabPanel, Tabs, TabsProps} from '../src'; -import React from 'react'; -import { tab } from '@testing-library/user-event/dist/cjs/setup/directApi.js'; const meta: Meta = { component: Tabs, @@ -188,17 +186,17 @@ function ExampleTest(props) {
- {tab => ( - - {tab.content} - + {tab => ( + + {tab.content} + )} - + - ) + ); } export const AddRemoveTabs: Story = { render: (args) => -} +}; From c01f829683c99ed2a4bb40ba3fc8710a184b7304 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:58:42 -0700 Subject: [PATCH 3/4] cleanup --- packages/@react-spectrum/s2/src/Tabs.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 8c83f9aaad5..b63f5c519d9 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -708,7 +708,6 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect let valueId = useId(); let contents: ReactNode; - if (showItems) { contents = ( - {/* */} {props.children} From a28fcd65032267df9db1c0d5bc4aad72a79f4dcb Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 5 Aug 2025 16:14:04 -0700 Subject: [PATCH 4/4] update types, add chromatic --- .../s2/chromatic/Tabs.stories.tsx | 64 ++++++++++++++++++- packages/@react-spectrum/s2/src/Tabs.tsx | 13 ++-- .../s2/stories/Tabs.stories.tsx | 15 +++-- 3 files changed, 79 insertions(+), 13 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 b63f5c519d9..992bd15cac4 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -90,7 +90,7 @@ interface CollapseContextType { ariaLabel?: string | undefined, ariaDescribedBy?: string | undefined, tabs: Array>, - listRef: RefObject, + listRef?: RefObject, onSelectionChange?: (key: Key) => void } @@ -98,8 +98,7 @@ const CollapseContext = createContext({ showTabs: true, menuId: '', valueId: '', - tabs: [], - listRef: {current: null} + tabs: [] }); const tabs = style({ @@ -229,9 +228,9 @@ export function TabList(props: TabListProps): ReactNode | n return (
-
+ {listRef &&
-
+
} (props: TabListProps) { className={ (props.UNSAFE_className || '') + tablistWrapper(null, props.styles)}> -
+ {listRef &&
-
+
} { @@ -197,6 +203,7 @@ function ExampleTest(props) { ); } -export const AddRemoveTabs: Story = { - render: (args) => +export const CustomizedLayout: Story = { + render: (args) => , + tags: ['!autodocs'] };