From a0d3da9e0d48ef205bf0f6321428229c2ccf8398 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 12 Nov 2025 13:04:24 +0100 Subject: [PATCH 01/46] UI: Fix regression on addon panel empty content fontsize --- .../core/src/components/components/Tabs/EmptyTabContent.tsx | 6 +++++- .../src/components/components/addon-panel/addon-panel.tsx | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/code/core/src/components/components/Tabs/EmptyTabContent.tsx b/code/core/src/components/components/Tabs/EmptyTabContent.tsx index 8b8b88b53e37..505709b2740c 100644 --- a/code/core/src/components/components/Tabs/EmptyTabContent.tsx +++ b/code/core/src/components/components/Tabs/EmptyTabContent.tsx @@ -27,6 +27,10 @@ const Title = styled.div(({ theme }) => ({ color: theme.color.defaultText, })); +const Footer = styled.div(({ theme }) => ({ + fontSize: theme.typography.size.s2 - 1, +})); + const Description = styled.div(({ theme }) => ({ fontWeight: theme.typography.weight.regular, fontSize: theme.typography.size.s2 - 1, @@ -47,7 +51,7 @@ export const EmptyTabContent = ({ title, description, footer }: Props) => { {title} {description && {description}} - {footer} + ); }; diff --git a/code/core/src/components/components/addon-panel/addon-panel.tsx b/code/core/src/components/components/addon-panel/addon-panel.tsx index 97003ff63822..9304980f4ff8 100644 --- a/code/core/src/components/components/addon-panel/addon-panel.tsx +++ b/code/core/src/components/components/addon-panel/addon-panel.tsx @@ -28,9 +28,10 @@ export interface AddonPanelProps { hasScrollbar?: boolean; } -const Div = styled.div({ +const Div = styled.div(({ theme }) => ({ + fontSize: theme.typography.size.s2 - 1, height: '100%', -}); +})); export const AddonPanel = ({ active, children, hasScrollbar = true }: AddonPanelProps) => { return ( From 860e8e8ec3a780176843cf6f7ff2c67d14f7c354 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 12 Nov 2025 17:24:10 +0100 Subject: [PATCH 02/46] UI: Avoid injecting an empty div --- code/core/src/components/components/Tabs/EmptyTabContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/components/components/Tabs/EmptyTabContent.tsx b/code/core/src/components/components/Tabs/EmptyTabContent.tsx index 505709b2740c..8cdc5c0100ce 100644 --- a/code/core/src/components/components/Tabs/EmptyTabContent.tsx +++ b/code/core/src/components/components/Tabs/EmptyTabContent.tsx @@ -51,7 +51,7 @@ export const EmptyTabContent = ({ title, description, footer }: Props) => { {title} {description && {description}} -
{footer}
+ {footer &&
{footer}
} ); }; From f2ae071bcee3537b0c57e3e36302da1050496aae Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 17 Nov 2025 16:35:37 +0100 Subject: [PATCH 03/46] Docs: Separate supported from community frameworks --- docs/index.mdx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/index.mdx b/docs/index.mdx index dbf4b9eff947..8a6ca852c3c6 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -15,9 +15,17 @@ Storybook is a standalone tool that runs alongside your app. It's a zero-config +Want to know more about installing Storybook? Check out the [installation guide](./get-started/install.mdx). + +## Supported frameworks + -Want to know more about installing Storybook? Check out the [installation guide](./get-started/install.mdx). +## Community-maintained frameworks + +External contributors maintain Storybook for additional frameworks. Storybook does not directly provide support for these. + + ## Main concepts From d1e7bab548ebc178efa1fc8478b8e13a4d2d32bc Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 19 Nov 2025 10:01:57 +0100 Subject: [PATCH 04/46] Update docs/index.mdx Co-authored-by: jonniebigodes --- docs/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.mdx b/docs/index.mdx index 8a6ca852c3c6..574da0d6086b 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -23,7 +23,7 @@ Want to know more about installing Storybook? Check out the [installation guide] ## Community-maintained frameworks -External contributors maintain Storybook for additional frameworks. Storybook does not directly provide support for these. +Storybook includes an active community that supports additional frameworks and libraries. These community-maintained frameworks are actively developed and maintained by community contributors. From 94f87695fbf6243b550a4b8567bf9dc39b30dd5a Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 11:09:05 +0100 Subject: [PATCH 05/46] Fix ListboxButton focus outline visibility --- .../src/components/components/Listbox/Listbox.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/code/core/src/components/components/Listbox/Listbox.tsx b/code/core/src/components/components/Listbox/Listbox.tsx index 0259b924ccb5..152ffe4656b2 100644 --- a/code/core/src/components/components/Listbox/Listbox.tsx +++ b/code/core/src/components/components/Listbox/Listbox.tsx @@ -65,7 +65,8 @@ const ListboxHoverItem = styled(ListboxItem)<{ targetId: string }>(({ targetId } opacity: 1, '@supports (interpolate-size: allow-keywords)': { interpolateSize: 'allow-keywords', - transition: 'all var(--transition-duration, 0.2s)', + transitionProperty: 'inline-size, margin-left, opacity, padding-inline', + transitionDuration: 'var(--transition-duration, 0.2s)', }, }, [`&:not(:hover, :has(:focus-visible)) [data-target-id="${targetId}"]`]: { @@ -76,9 +77,16 @@ const ListboxHoverItem = styled(ListboxItem)<{ targetId: string }>(({ targetId } }, })); +const StyledButton = styled(Button)({ + '&:focus-visible': { + // Prevent focus outline from being cut off by overflow: hidden + outlineOffset: -2, + }, +}); + const ListboxButton = forwardRef>( function ListboxButton({ padding = 'small', size = 'medium', variant = 'ghost', ...props }, ref) { - return - + {isExcluded ? 'Include' : 'Exclude'} + + ), }; }; @@ -213,49 +159,51 @@ export const TagsFilterPanel = ({ return ( {Object.keys(filtersById).length > 0 && ( - - {isNothingSelectedYet ? ( - - ) : ( - - )} - {hasDefaultSelection && ( - - )} - + + + {isNothingSelectedYet ? ( + setAllFilters(true)} + > + + {filtersLabel} + + ) : ( + setAllFilters(false)} + > + + {filtersLabel} + + )} + {hasDefaultSelection && ( + + + + )} + + )} - + {links.map((group) => ( + link.id).join('_')}> + {group.map((link) => ( + {link.content} + ))} + + ))} ); }; From 1b989be778b9d3ddb25bf02997c567c5f11171b0 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 16:16:19 +0100 Subject: [PATCH 08/46] Fix TagsFilterPanel scrolling and fix story data --- .../components/components/ActionsList/ActionsList.stories.tsx | 2 +- code/core/src/manager/components/sidebar/TagsFilterPanel.tsx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/code/core/src/components/components/ActionsList/ActionsList.stories.tsx b/code/core/src/components/components/ActionsList/ActionsList.stories.tsx index d924a89d4e1f..c14f9976e04c 100644 --- a/code/core/src/components/components/ActionsList/ActionsList.stories.tsx +++ b/code/core/src/components/components/ActionsList/ActionsList.stories.tsx @@ -17,7 +17,7 @@ export const Default = meta.story({ Text item - + diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index cff6927d362d..176d8177b1cb 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -30,6 +30,10 @@ export const groupByType = (filters: Filter[]) => const Wrapper = styled.div({ minWidth: 240, maxWidth: 300, + maxHeight: 15.5 * 32 + 8, // 15.5 items at 32px each + 8px padding + overflow: 'hidden', + overflowY: 'auto', + scrollbarWidth: 'thin', }); const MutedText = styled.span(({ theme }) => ({ From 9c646923fdba3c6192875e5180c0860916ad2013 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 16:22:35 +0100 Subject: [PATCH 09/46] Fix empty group in TagsFilter --- code/core/src/manager/components/sidebar/TagsFilter.tsx | 4 ---- .../src/manager/components/sidebar/TagsFilterPanel.tsx | 9 +++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 6f7b49bfd0ba..7dc40db8dcfc 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -52,10 +52,6 @@ const remove = (set: Set, id: string) => { const equal = (left: Set, right: Set) => left.size === right.size && new Set([...left, ...right]).size === left.size; -const Wrapper = styled.div({ - position: 'relative', -}); - const TagSelected = styled(Badge)(({ theme }) => ({ position: 'absolute', top: 7, diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index 176d8177b1cb..24f7cf000164 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -137,13 +137,14 @@ export const TagsFilterPanel = ({ }; const groups = groupByType(Object.values(filtersById)); - const links: Link[][] = Object.values(groups).map( - (group) => + const links: Link[][] = Object.values(groups) + .map((group) => group .sort((a, b) => a.id.localeCompare(b.id)) .map((filter) => renderLink(filter)) - .filter(Boolean) as Link[] - ); + .filter((value): value is Link => !!value) + ) + .filter((value): value is Link[] => value.length > 0); if (!groups.tag?.length && isDevelopment) { links.push([ From ce2aa7ef2449eeb72452b597256c1498748434a4 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 17:09:21 +0100 Subject: [PATCH 10/46] Always show documentation link when no user tags are defined and fix stories to show built-in filters --- .../components/ActionsList/ActionsList.tsx | 5 +++ .../components/sidebar/TagsFilter.stories.tsx | 37 +++++++++++++------ .../manager/components/sidebar/TagsFilter.tsx | 9 +---- .../sidebar/TagsFilterPanel.stories.tsx | 3 +- .../components/sidebar/TagsFilterPanel.tsx | 37 ++++++++++++------- 5 files changed, 55 insertions(+), 36 deletions(-) diff --git a/code/core/src/components/components/ActionsList/ActionsList.tsx b/code/core/src/components/components/ActionsList/ActionsList.tsx index 6190115e0d45..f971d98afa66 100644 --- a/code/core/src/components/components/ActionsList/ActionsList.tsx +++ b/code/core/src/components/components/ActionsList/ActionsList.tsx @@ -116,6 +116,10 @@ const ActionsListAction = styled(ActionsListButton)(({ theme }) => ({ }, })); +const ActionsListLink = ( + props: ComponentProps & React.AnchorHTMLAttributes +) => ; + const ActionsListText = styled.div({ display: 'flex', alignItems: 'center', @@ -170,6 +174,7 @@ export const ActionsList = Object.assign( HoverItem: ActionsListHoverItem, Button: ActionsListButton, Action: ActionsListAction, + Link: ActionsListLink, Text: ActionsListText, Icon: ActionsListIcon, } diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index a2fc136196b2..e73c3a6e8200 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -20,8 +20,15 @@ const meta = { }), applyQueryParams: fn().mockName('api::applyQueryParams'), } as any, - isDevelopment: true, tagPresets: {}, + indexJson: { + v: 6, + entries: { + 'c1-s1': { tags: ['A', 'B', 'C', 'dev', 'play-fn'], type: 'story' } as any, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as any, + 'c1-doc': { tags: [], type: 'docs' } as any, + }, + }, }, } satisfies Meta; @@ -29,16 +36,7 @@ export default meta; type Story = StoryObj; -export const Closed: Story = { - args: { - indexJson: { - v: 6, - entries: { - 'c1-s1': { tags: ['A', 'B', 'C', 'dev'] } as any, - }, - }, - }, -}; +export const Closed: Story = {}; export const ClosedWithSelection: Story = { args: { @@ -58,6 +56,21 @@ export const Clear = { }, } satisfies Story; +export const NoUserTags = { + ...Clear, + args: { + ...Clear.args, + indexJson: { + v: 6, + entries: { + 'c1-s1': { tags: ['dev', 'play-fn'], type: 'story' } as any, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as any, + 'c1-doc': { tags: [], type: 'docs' } as any, + }, + }, + }, +} satisfies Story; + export const WithSelection = { ...ClosedWithSelection, play: Clear.play, @@ -95,10 +108,10 @@ export const Empty: Story = { play: Clear.play, }; +/** Production is equal to development now */ export const EmptyProduction: Story = { args: { ...Empty.args, - isDevelopment: false, }, play: Clear.play, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 7dc40db8dcfc..2ea5cf0073d0 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -73,11 +73,10 @@ const TagSelected = styled(Badge)(({ theme }) => ({ export interface TagsFilterProps { api: API; indexJson: StoryIndex; - isDevelopment: boolean; tagPresets: TagsOptions; } -export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFilterProps) => { +export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => { const filtersById = useMemo<{ [id: string]: Filter }>(() => { const userTagsCounts = Object.values(indexJson.entries).reduce<{ [key: Tag]: number }>( (acc, entry) => { @@ -224,11 +223,6 @@ export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFi [expanded, setExpanded] ); - // Hide the entire UI if there are no tags and it's a built Storybook - if (Object.keys(filtersById).length === 0 && !isDevelopment) { - return null; - } - return ( 'https://storybook.js.org/docs/', } as any, - isDevelopment: true, }, tags: ['hoho'], } satisfies Meta; @@ -89,10 +88,10 @@ export const BuiltInOnly: Story = { }, }; +/** Production is equal to development now */ export const BuiltInOnlyProduction: Story = { args: { ...BuiltInOnly.args, - isDevelopment: false, }, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index 24f7cf000164..dccb58bab278 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -57,7 +57,6 @@ interface TagsFilterPanelProps { toggleFilter: (key: string, selected: boolean, excluded?: boolean) => void; setAllFilters: (selected: boolean) => void; resetFilters: () => void; - isDevelopment: boolean; isDefaultSelection: boolean; hasDefaultSelection: boolean; } @@ -70,7 +69,6 @@ export const TagsFilterPanel = ({ toggleFilter, setAllFilters, resetFilters, - isDevelopment, isDefaultSelection, hasDefaultSelection, }: TagsFilterPanelProps) => { @@ -146,24 +144,15 @@ export const TagsFilterPanel = ({ ) .filter((value): value is Link[] => value.length > 0); - if (!groups.tag?.length && isDevelopment) { - links.push([ - { - id: 'tags-docs', - title: 'Learn how to add tags', - icon: , - right: , - href: api.getDocsUrl({ subpath: 'writing-stories/tags#custom-tags' }), - }, - ]); - } + const hasItems = links.length > 0; + const hasUserTags = Object.values(filtersById).some(({ type }) => type === 'tag'); const isNothingSelectedYet = includedFilters.size === 0 && excludedFilters.size === 0; const filtersLabel = isNothingSelectedYet ? 'Select all' : 'Clear filters'; return ( - {Object.keys(filtersById).length > 0 && ( + {hasItems && ( {isNothingSelectedYet ? ( @@ -209,6 +198,26 @@ export const TagsFilterPanel = ({ ))} ))} + {!hasUserTags && ( + + + + + + + + Learn how to add tags + + + + + + + + )} ); }; From 2edb10e548dcd03170c7d5c06b8a560ce3a7a32a Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 17:39:41 +0100 Subject: [PATCH 11/46] Change default tag for ActionList/Item to ul/li and fix ChecklistWidget menu to use PopoverProvider --- .../components/ActionsList/ActionsList.tsx | 4 +-- .../components/sidebar/ChecklistWidget.tsx | 34 ++++++++----------- .../src/manager/components/sidebar/Menu.tsx | 4 +-- .../components/sidebar/TagsFilterPanel.tsx | 15 ++++---- .../manager/settings/Checklist/Checklist.tsx | 2 +- 5 files changed, 27 insertions(+), 32 deletions(-) diff --git a/code/core/src/components/components/ActionsList/ActionsList.tsx b/code/core/src/components/components/ActionsList/ActionsList.tsx index f971d98afa66..b8a3db7dda6c 100644 --- a/code/core/src/components/components/ActionsList/ActionsList.tsx +++ b/code/core/src/components/components/ActionsList/ActionsList.tsx @@ -5,7 +5,7 @@ import { styled } from 'storybook/theming'; import { Button } from '../Button/Button'; -const ActionsListItem = styled.div<{ +const ActionsListItem = styled.li<{ active?: boolean; transitionStatus?: TransitionStatus; }>( @@ -159,7 +159,7 @@ const ActionsListIcon = styled.div({ }); export const ActionsList = Object.assign( - styled.div(({ theme, onClick }) => ({ + styled.ul(({ theme, onClick }) => ({ listStyle: 'none', margin: 0, padding: 4, diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx index bb69346d2ad2..a714b4a3e987 100644 --- a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx @@ -4,8 +4,8 @@ import { ActionsList, Card, Collapsible, + PopoverProvider, ProgressSpinner, - WithTooltip, } from 'storybook/internal/components'; import { @@ -203,9 +203,9 @@ export const ChecklistWidget = () => { collapsed={!hasItems} disabled={!hasItems} summary={({ isCollapsed, toggleCollapsed, toggleProps }) => ( - - - + + + {loaded && ( { /> )} - + { /> {loaded && ( - ( - - + ( + + Open full guide - + { @@ -272,23 +271,18 @@ export const ChecklistWidget = () => { /> - + )} )} > - + {transitionItems.map( ([item, { status, isMounted }]) => isMounted && ( - + api.navigate(`/settings/guide#${item.id}`)} diff --git a/code/core/src/manager/components/sidebar/Menu.tsx b/code/core/src/manager/components/sidebar/Menu.tsx index a14a8fc663ce..1847ac79a72d 100644 --- a/code/core/src/manager/components/sidebar/Menu.tsx +++ b/code/core/src/manager/components/sidebar/Menu.tsx @@ -90,9 +90,9 @@ const SidebarMenuList: FC<{ {menu .filter((links) => links.length) .flatMap((links) => ( - link.id).join('_')}> + link.id).join('_')}> {links.map((link) => ( - + - + {isExcluded ? : isIncluded ? null : icon} onToggle(!isChecked)} data-tag={title} - aria-label={toggleTagLabel} + aria-label={toggleLabel} /> @@ -153,8 +154,8 @@ export const TagsFilterPanel = ({ return ( {hasItems && ( - - + + {isNothingSelectedYet ? ( ))} {!hasUserTags && ( - - + + + Date: Mon, 24 Nov 2025 19:28:15 +0100 Subject: [PATCH 12/46] Remove prop --- code/core/src/manager/components/sidebar/Sidebar.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 6c85835d920c..d16b5b1a51b2 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -193,14 +193,7 @@ export const Sidebar = React.memo(function Sidebar({ ) } searchFieldContent={ - indexJson && ( - - ) + indexJson && } {...lastViewedProps} > From 7131e1c0b938b0922514a55c088ba849a27ac91d Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 21:17:42 +0100 Subject: [PATCH 13/46] Update label match patterns according to updated aria-label attributes --- code/e2e-tests/util.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/e2e-tests/util.ts b/code/e2e-tests/util.ts index e3d91c949ef1..620ab733773a 100644 --- a/code/e2e-tests/util.ts +++ b/code/e2e-tests/util.ts @@ -229,10 +229,10 @@ export class SbPage { await this.openTagsFilter(); if (toggleExclusion) { - await this.page.getByLabel(new RegExp(`filter: ${type}`)).hover(); + await this.page.getByLabel(new RegExp(`built-in filter: ${type}`)).hover(); await this.page.getByLabel(new RegExp(`(Exclude|Include) built-in: ${type}`, 'i')).click(); } else { - await this.page.getByLabel(new RegExp(`(Add|Remove) built-in filter: ${type}`)).click(); + await this.page.getByLabel(new RegExp(`built-in filter: ${type}`)).click(); } } From 885aed3533dc65ea43531bca0736ce6977ab1aa7 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 21:26:25 +0100 Subject: [PATCH 14/46] Update code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx Co-authored-by: Steve Dodier-Lazaro --- .../manager/components/sidebar/TagsFilterPanel.stories.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index 78ffe4c222c8..16f32e39d3ca 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -88,7 +88,12 @@ export const BuiltInOnly: Story = { }, }; -/** Production is equal to development now */ +/** + * Production is equal to development now. We want to avoid a + * completely empty TagsFilterPanel and we can't easily detect + * if there'll be items matching the built-in filters. Plus, + * onboarding users on custom tags is still useful in production. + */ export const BuiltInOnlyProduction: Story = { args: { ...BuiltInOnly.args, From f7f12475ed2860b2e993000d9d8f8d7e78d6a241 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 21:27:44 +0100 Subject: [PATCH 15/46] Formatting --- .../manager/components/sidebar/TagsFilterPanel.stories.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index 16f32e39d3ca..f87eb224b4e4 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -89,10 +89,9 @@ export const BuiltInOnly: Story = { }; /** - * Production is equal to development now. We want to avoid a - * completely empty TagsFilterPanel and we can't easily detect - * if there'll be items matching the built-in filters. Plus, - * onboarding users on custom tags is still useful in production. + * Production is equal to development now. We want to avoid a completely empty TagsFilterPanel and + * we can't easily detect if there'll be items matching the built-in filters. Plus, onboarding users + * on custom tags is still useful in production. */ export const BuiltInOnlyProduction: Story = { args: { From 6e62ca45066e5ef404be3b6ef283d1166e7408a8 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 24 Nov 2025 23:27:02 +0100 Subject: [PATCH 16/46] Ensure tags filter panel is closed before asserting sidebar --- code/e2e-tests/tags.spec.ts | 5 +++++ code/e2e-tests/util.ts | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/code/e2e-tests/tags.spec.ts b/code/e2e-tests/tags.spec.ts index 6132b0169ce8..dee950505223 100644 --- a/code/e2e-tests/tags.spec.ts +++ b/code/e2e-tests/tags.spec.ts @@ -82,6 +82,7 @@ test.describe('tags', () => { // When selecting type docs, there should be no stories in the sidebar await sbPage.toggleStoryTypeFilter('Documentation'); + await sbPage.closeAnyPendingModal(); await sbPage.expandAllSidebarNodes(); await expect( page.locator('#storybook-explorer-menu .sidebar-item[data-nodetype="story"]') @@ -91,6 +92,7 @@ test.describe('tags', () => { // When excluding type docs, there should be no stories in the sidebar await sbPage.toggleStoryTypeFilter('Documentation', true); + await sbPage.closeAnyPendingModal(); await expect( page.locator('#storybook-explorer-menu .sidebar-item[data-nodetype="document"]') ).toHaveCount(0); @@ -100,6 +102,7 @@ test.describe('tags', () => { // When selecting type play, there should be no docs in the sidebar await sbPage.toggleStoryTypeFilter('Play'); + await sbPage.closeAnyPendingModal(); await sbPage.expandAllSidebarNodes(); await expect( page.locator('#storybook-explorer-menu .sidebar-item[data-nodetype="document"]') @@ -109,6 +112,7 @@ test.describe('tags', () => { // When selecting type test, there should be tests visible in the sidebar await sbPage.toggleStoryTypeFilter('Testing'); + await sbPage.closeAnyPendingModal(); await sbPage.expandAllSidebarNodes(); const testItems = page.locator( '#storybook-explorer-menu .sidebar-item[data-nodetype="test"]' @@ -119,6 +123,7 @@ test.describe('tags', () => { // When excluding type test, there should be no tests visible in the sidebar await sbPage.toggleStoryTypeFilter('Testing', true); + await sbPage.closeAnyPendingModal(); await expect( page.locator('#storybook-explorer-menu .sidebar-item[data-nodetype="test"]') ).toHaveCount(0); diff --git a/code/e2e-tests/util.ts b/code/e2e-tests/util.ts index 620ab733773a..76d50cd05623 100644 --- a/code/e2e-tests/util.ts +++ b/code/e2e-tests/util.ts @@ -184,7 +184,6 @@ export class SbPage { } async expandAllSidebarNodes() { - await this.page.keyboard.press('Escape'); await this.page.keyboard.press( `${process.platform === 'darwin' ? 'Meta' : 'Control'}+Shift+ArrowDown` ); From 797b49c8421073618a46371f498085352c014ace Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 08:44:58 +0100 Subject: [PATCH 17/46] Tweaks to tags panel tests --- code/e2e-tests/util.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/code/e2e-tests/util.ts b/code/e2e-tests/util.ts index 76d50cd05623..35263173f70c 100644 --- a/code/e2e-tests/util.ts +++ b/code/e2e-tests/util.ts @@ -153,8 +153,10 @@ export class SbPage { */ async closeAnyPendingModal() { const popover = this.page.locator('[role="dialog"]'); - await this.page.keyboard.press('Escape'); - await popover.waitFor({ state: 'hidden' }); + if (await popover.isVisible()) { + await popover.press('Escape'); + await popover.waitFor({ state: 'hidden', timeout: 1000 }); + } } previewIframe() { @@ -214,7 +216,10 @@ export class SbPage { await this.openTagsFilter(); if (toggleExclusion) { - await this.page.getByLabel(new RegExp(`tag filter: ${tag}`)).hover(); + await this.page + .getByRole('listitem') + .filter({ has: this.page.getByLabel(new RegExp(`tag filter: ${tag}`)) }) + .hover(); await this.page.getByLabel(new RegExp(`(Exclude|Include) tag: ${tag}`)).click(); } else { await this.page.getByLabel(new RegExp(`tag filter: ${tag}`)).click(); @@ -228,7 +233,10 @@ export class SbPage { await this.openTagsFilter(); if (toggleExclusion) { - await this.page.getByLabel(new RegExp(`built-in filter: ${type}`)).hover(); + await this.page + .getByRole('listitem') + .filter({ has: this.page.getByLabel(new RegExp(`built-in filter: ${type}`)) }) + .hover(); await this.page.getByLabel(new RegExp(`(Exclude|Include) built-in: ${type}`, 'i')).click(); } else { await this.page.getByLabel(new RegExp(`built-in filter: ${type}`)).click(); From e60b00973ed95a2e8278efb341edc9e0f8800ae0 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 08:51:35 +0100 Subject: [PATCH 18/46] Sometimes it takes two Escape presses to close the popover... --- code/e2e-tests/util.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code/e2e-tests/util.ts b/code/e2e-tests/util.ts index 35263173f70c..8585626528cd 100644 --- a/code/e2e-tests/util.ts +++ b/code/e2e-tests/util.ts @@ -154,7 +154,8 @@ export class SbPage { async closeAnyPendingModal() { const popover = this.page.locator('[role="dialog"]'); if (await popover.isVisible()) { - await popover.press('Escape'); + await this.page.keyboard.press('Escape'); + await this.page.keyboard.press('Escape'); await popover.waitFor({ state: 'hidden', timeout: 1000 }); } } From f77ba5194e04144bef5b5060f1c59f942e1da56c Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 08:59:15 +0100 Subject: [PATCH 19/46] Rename ActionsList to ActionList --- .../ActionList/ActionList.stories.tsx | 113 ++++++++++++++++++ .../ActionList.tsx} | 38 +++--- .../ActionsList/ActionsList.stories.tsx | 113 ------------------ code/core/src/components/index.ts | 2 +- .../components/sidebar/ChecklistWidget.tsx | 80 ++++++------- .../src/manager/components/sidebar/Menu.tsx | 18 +-- .../components/sidebar/TagsFilterPanel.tsx | 72 +++++------ code/core/src/manager/container/Menu.tsx | 10 +- code/core/src/manager/globals/exports.ts | 2 +- .../manager/settings/Checklist/Checklist.tsx | 6 +- 10 files changed, 227 insertions(+), 227 deletions(-) create mode 100644 code/core/src/components/components/ActionList/ActionList.stories.tsx rename code/core/src/components/components/{ActionsList/ActionsList.tsx => ActionList/ActionList.tsx} (80%) delete mode 100644 code/core/src/components/components/ActionsList/ActionsList.stories.tsx diff --git a/code/core/src/components/components/ActionList/ActionList.stories.tsx b/code/core/src/components/components/ActionList/ActionList.stories.tsx new file mode 100644 index 000000000000..d2f011af0b59 --- /dev/null +++ b/code/core/src/components/components/ActionList/ActionList.stories.tsx @@ -0,0 +1,113 @@ +import { CheckIcon, EllipsisIcon, PlayAllHollowIcon } from '@storybook/icons'; + +import { Badge, Form, ProgressSpinner } from '../..'; +import preview from '../../../../../.storybook/preview'; +import { Shortcut } from '../../../manager/container/Menu'; +import { ActionList } from './ActionList'; + +const meta = preview.meta({ + component: ActionList, + decorators: [(Story) =>
{Story()}
], +}); + +export default meta; + +export const Default = meta.story({ + render: () => ( + + + Text item + + + + + + Action item + + + Cool + + + + Hover action + + + Cool + + + + With a button + Go + + + + With an inline button + + + 25% + + + + + + With a badge + Check it out + + + + + + With a checkbox + + + + + + Active with an icon + + + + + + Some very long text which will wrap when the container is too narrow + + + + + Some very long text which will ellipsize when the container is too narrow + + + + ), +}); + +export const Groups = meta.story({ + render: () => ( + <> + + + Alpha + + + Item + + + + + Bravo + + + Item + + + + + Charlie + + + Item + + + + ), +}); diff --git a/code/core/src/components/components/ActionsList/ActionsList.tsx b/code/core/src/components/components/ActionList/ActionList.tsx similarity index 80% rename from code/core/src/components/components/ActionsList/ActionsList.tsx rename to code/core/src/components/components/ActionList/ActionList.tsx index b8a3db7dda6c..6c878120f47b 100644 --- a/code/core/src/components/components/ActionsList/ActionsList.tsx +++ b/code/core/src/components/components/ActionList/ActionList.tsx @@ -5,7 +5,7 @@ import { styled } from 'storybook/theming'; import { Button } from '../Button/Button'; -const ActionsListItem = styled.li<{ +const ActionListItem = styled.li<{ active?: boolean; transitionStatus?: TransitionStatus; }>( @@ -58,11 +58,11 @@ const ActionsListItem = styled.li<{ ); /** - * A ActionsList item that shows/hides child elements on hover based on the targetId. Child elements + * A ActionList item that shows/hides child elements on hover based on the targetId. Child elements * must have a `data-target-id` attribute matching the `targetId` prop to be affected by the hover * behavior. */ -const ActionsListHoverItem = styled(ActionsListItem)<{ targetId: string }>(({ targetId }) => ({ +const ActionListHoverItem = styled(ActionListItem)<{ targetId: string }>(({ targetId }) => ({ gap: 0, [`& [data-target-id="${targetId}"]`]: { inlineSize: 'auto', @@ -89,8 +89,8 @@ const StyledButton = styled(Button)({ }, }); -const ActionsListButton = forwardRef>( - function ActionsListButton( +const ActionListButton = forwardRef>( + function ActionListButton( { padding = 'small', size = 'medium', variant = 'ghost', ...props }, ref ) { @@ -98,7 +98,7 @@ const ActionsListButton = forwardRef ({ +const ActionListAction = styled(ActionListButton)(({ theme }) => ({ flex: '0 1 100%', textAlign: 'start', justifyContent: 'space-between', @@ -116,11 +116,11 @@ const ActionsListAction = styled(ActionsListButton)(({ theme }) => ({ }, })); -const ActionsListLink = ( - props: ComponentProps & React.AnchorHTMLAttributes -) => ; +const ActionListLink = ( + props: ComponentProps & React.AnchorHTMLAttributes +) => ; -const ActionsListText = styled.div({ +const ActionListText = styled.div({ display: 'flex', alignItems: 'center', gap: 8, @@ -148,7 +148,7 @@ const ActionsListText = styled.div({ }, }); -const ActionsListIcon = styled.div({ +const ActionListIcon = styled.div({ display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -158,7 +158,7 @@ const ActionsListIcon = styled.div({ color: 'var(--listbox-item-muted-color)', }); -export const ActionsList = Object.assign( +export const ActionList = Object.assign( styled.ul(({ theme, onClick }) => ({ listStyle: 'none', margin: 0, @@ -170,12 +170,12 @@ export const ActionsList = Object.assign( }, })), { - Item: ActionsListItem, - HoverItem: ActionsListHoverItem, - Button: ActionsListButton, - Action: ActionsListAction, - Link: ActionsListLink, - Text: ActionsListText, - Icon: ActionsListIcon, + Item: ActionListItem, + HoverItem: ActionListHoverItem, + Button: ActionListButton, + Action: ActionListAction, + Link: ActionListLink, + Text: ActionListText, + Icon: ActionListIcon, } ); diff --git a/code/core/src/components/components/ActionsList/ActionsList.stories.tsx b/code/core/src/components/components/ActionsList/ActionsList.stories.tsx deleted file mode 100644 index c14f9976e04c..000000000000 --- a/code/core/src/components/components/ActionsList/ActionsList.stories.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { CheckIcon, EllipsisIcon, PlayAllHollowIcon } from '@storybook/icons'; - -import { Badge, Form, ProgressSpinner } from '../..'; -import preview from '../../../../../.storybook/preview'; -import { Shortcut } from '../../../manager/container/Menu'; -import { ActionsList } from './ActionsList'; - -const meta = preview.meta({ - component: ActionsList, - decorators: [(Story) =>
{Story()}
], -}); - -export default meta; - -export const Default = meta.story({ - render: () => ( - - - Text item - - - - - - Action item - - - Cool - - - - Hover action - - - Cool - - - - With a button - Go - - - - With an inline button - - - 25% - - - - - - With a badge - Check it out - - - - - - With a checkbox - - - - - - Active with an icon - - - - - - Some very long text which will wrap when the container is too narrow - - - - - Some very long text which will ellipsize when the container is too narrow - - - - ), -}); - -export const Groups = meta.story({ - render: () => ( - <> - - - Alpha - - - Item - - - - - Bravo - - - Item - - - - - Charlie - - - Item - - - - ), -}); diff --git a/code/core/src/components/index.ts b/code/core/src/components/index.ts index c418f6f99845..2cc4a62b1bf3 100644 --- a/code/core/src/components/index.ts +++ b/code/core/src/components/index.ts @@ -41,7 +41,7 @@ export { createCopyToClipboardFunction } from './components/syntaxhighlighter/cl // UI export { ActionBar } from './components/ActionBar/ActionBar'; -export { ActionsList } from './components/ActionsList/ActionsList'; +export { ActionList } from './components/ActionList/ActionList'; export { Collapsible } from './components/Collapsible/Collapsible'; export { Card } from './components/Card/Card'; export { Modal, ModalDecorator } from './components/Modal/Modal'; diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx index a714b4a3e987..ba0684085d8a 100644 --- a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { - ActionsList, + ActionList, Card, Collapsible, PopoverProvider, @@ -85,7 +85,7 @@ const HoverCard = styled(Card)({ }, }); -const CollapseToggle = styled(ActionsList.Button)({ +const CollapseToggle = styled(ActionList.Button)({ opacity: 0, transition: 'opacity var(--transition-duration, 0.2s)', '&:focus, &:hover': { @@ -155,7 +155,7 @@ const OpenGuideAction = ({ }) => { const api = useStorybookApi(); return ( - { e.stopPropagation(); @@ -163,11 +163,11 @@ const OpenGuideAction = ({ afterClick?.(); }} > - + - + {children} - + ); }; @@ -203,9 +203,9 @@ export const ChecklistWidget = () => { collapsed={!hasItems} disabled={!hasItems} summary={({ isCollapsed, toggleCollapsed, toggleProps }) => ( - - - + + + {loaded && ( { fallback={} /> )} - - + + { ( - - + + - Open full guide + Open full guide - - - + + { e.stopPropagation(); @@ -250,16 +250,16 @@ export const ChecklistWidget = () => { onHide(); }} > - + - - Remove from sidebar - - - + + Remove from sidebar + + + )} > - e.stopPropagation()} > @@ -270,38 +270,38 @@ export const ChecklistWidget = () => { width={1.5} /> - + )} - - - + + + )} > - + {transitionItems.map( ([item, { status, isMounted }]) => isMounted && ( - - + api.navigate(`/settings/guide#${item.id}`)} > - + {item.isCompleted ? ( ) : ( )} - - + + {item.label} - - + + {item.action && ( - { @@ -313,12 +313,12 @@ export const ChecklistWidget = () => { }} > {item.action.label} - + )} - + ) )} - + diff --git a/code/core/src/manager/components/sidebar/Menu.tsx b/code/core/src/manager/components/sidebar/Menu.tsx index 1847ac79a72d..8a6bcfb484ca 100644 --- a/code/core/src/manager/components/sidebar/Menu.tsx +++ b/code/core/src/manager/components/sidebar/Menu.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, FC } from 'react'; import React, { useState } from 'react'; -import { ActionsList, Button, PopoverProvider, ToggleButton } from 'storybook/internal/components'; +import { ActionList, Button, PopoverProvider, ToggleButton } from 'storybook/internal/components'; import { CloseIcon, CogIcon } from '@storybook/icons'; @@ -90,10 +90,10 @@ const SidebarMenuList: FC<{ {menu .filter((links) => links.length) .flatMap((links) => ( - link.id).join('_')}> + link.id).join('_')}> {links.map((link) => ( - - + {(link.icon || link.input) && ( - {link.icon || link.input} + {link.icon || link.input} )} {(link.title || link.center) && ( - {link.title || link.center} + {link.title || link.center} )} {link.right} - - + + ))} - + ))} ); diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index 645b49bed03b..f5e29573418c 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -1,6 +1,6 @@ import React, { Fragment, useRef } from 'react'; -import { ActionsList, Form } from 'storybook/internal/components'; +import { ActionList, Form } from 'storybook/internal/components'; import type { API_PreparedIndexEntry } from 'storybook/internal/types'; import { @@ -104,9 +104,9 @@ export const TagsFilterPanel = ({ return { id: `filter-${type}-${id}`, content: ( - - - + + + {isExcluded ? : isIncluded ? null : icon} - - + + {title} {isExcluded && (excluded)} - + {isExcluded ? {count} : {count}} - - + onToggle(true, !isExcluded)} > {isExcluded ? 'Include' : 'Exclude'} - - + + ), }; }; @@ -154,20 +154,20 @@ export const TagsFilterPanel = ({ return ( {hasItems && ( - - + + {isNothingSelectedYet ? ( - setAllFilters(true)} > - {filtersLabel} - + {filtersLabel} + ) : ( - {filtersLabel} - + )} {hasDefaultSelection && ( - - + )} - - + + )} {links.map((group) => ( - link.id).join('_')}> + link.id).join('_')}> {group.map((link) => ( {link.content} ))} - + ))} {!hasUserTags && ( - - - + + - + - - + + Learn how to add tags - - + + - - - - + + + + )} ); diff --git a/code/core/src/manager/container/Menu.tsx b/code/core/src/manager/container/Menu.tsx index 59da0ddcc9ad..08bc657f2711 100644 --- a/code/core/src/manager/container/Menu.tsx +++ b/code/core/src/manager/container/Menu.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; import React, { useCallback, useMemo } from 'react'; -import { ActionsList, ProgressSpinner } from 'storybook/internal/components'; +import { ActionList, ProgressSpinner } from 'storybook/internal/components'; import { STORIES_COLLAPSE_ALL } from 'storybook/internal/core-events'; import { global } from '@storybook/global'; @@ -96,10 +96,10 @@ export const useMenu = ({ closeOnClick: true, icon: , right: progress < 100 && ( - + {progress}% - + ), }), [api, progress] @@ -218,9 +218,9 @@ export const useMenu = ({ closeOnClick: true, href: docsUrl, right: ( - + - + ), icon: , }; diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 8d5c78cdccb2..4f855b346150 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -487,7 +487,7 @@ export default { 'A', 'AbstractToolbar', 'ActionBar', - 'ActionsList', + 'ActionList', 'AddonPanel', 'Badge', 'Bar', diff --git a/code/core/src/manager/settings/Checklist/Checklist.tsx b/code/core/src/manager/settings/Checklist/Checklist.tsx index 4b3c50875256..2dd11a0f8657 100644 --- a/code/core/src/manager/settings/Checklist/Checklist.tsx +++ b/code/core/src/manager/settings/Checklist/Checklist.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; -import { ActionsList, Button, Collapsible } from 'storybook/internal/components'; +import { ActionList, Button, Collapsible } from 'storybook/internal/components'; import { CheckIcon, @@ -327,7 +327,7 @@ export const Checklist = ({ const itemContent = content?.({ api }); return ( - + - + ); } )} From 2cc1f59bd42a8cd56d06d5c32796b8253fc88058 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 09:47:39 +0100 Subject: [PATCH 20/46] Cleanup and consistency --- .../src/components/components/ActionList/ActionList.tsx | 2 +- .../core/src/manager/components/sidebar/TagsFilterPanel.tsx | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/code/core/src/components/components/ActionList/ActionList.tsx b/code/core/src/components/components/ActionList/ActionList.tsx index 6c878120f47b..7da06b45511e 100644 --- a/code/core/src/components/components/ActionList/ActionList.tsx +++ b/code/core/src/components/components/ActionList/ActionList.tsx @@ -110,7 +110,7 @@ const ActionListAction = styled(ActionListButton)(({ theme }) => ({ '& input:enabled:focus-visible': { outline: 'none', }, - [`&:has(input:focus-visible)`]: { + '&:has(input:focus-visible)': { outline: `2px solid ${theme.color.secondary}`, outlineOffset: -2, }, diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index f5e29573418c..f6f15599a6e8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -147,9 +147,7 @@ export const TagsFilterPanel = ({ const hasItems = links.length > 0; const hasUserTags = Object.values(filtersById).some(({ type }) => type === 'tag'); - const isNothingSelectedYet = includedFilters.size === 0 && excludedFilters.size === 0; - const filtersLabel = isNothingSelectedYet ? 'Select all' : 'Clear filters'; return ( @@ -164,7 +162,7 @@ export const TagsFilterPanel = ({ onClick={() => setAllFilters(true)} > - {filtersLabel} + Select all ) : ( setAllFilters(false)} > - {filtersLabel} + Clear filters )} {hasDefaultSelection && ( From 327b9df93ced22ca49b946887e9f576eeafc0e8c Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 10:23:24 +0100 Subject: [PATCH 21/46] Replace ListItem with ActionList in Vitest testing module entry --- .../src/components/TestProviderRender.tsx | 122 ++++++++---------- 1 file changed, 53 insertions(+), 69 deletions(-) diff --git a/code/addons/vitest/src/components/TestProviderRender.tsx b/code/addons/vitest/src/components/TestProviderRender.tsx index 2efb1d4a38b2..d45f5b71798c 100644 --- a/code/addons/vitest/src/components/TestProviderRender.tsx +++ b/code/addons/vitest/src/components/TestProviderRender.tsx @@ -1,9 +1,9 @@ import React, { type ComponentProps, type FC } from 'react'; import { + ActionList, Button, Form, - ListItem, ProgressSpinner, ToggleButton, } from 'storybook/internal/components'; @@ -27,10 +27,11 @@ import type { StatusValueToStoryIds } from '../use-test-provider-state'; import { Description } from './Description'; import { TestStatusIcon } from './TestStatusIcon'; -const Container = styled.div({ +const Container = styled.div<{ inContextMenu?: boolean }>(({ inContextMenu }) => ({ display: 'flex', flexDirection: 'column', -}); + paddingBottom: inContextMenu ? 0 : 1, +})); const Heading = styled.div({ display: 'flex', @@ -57,8 +58,8 @@ const Actions = styled.div({ gap: 4, }); -const Extras = styled.div({ - marginBottom: 2, +const StyledActionList = styled(ActionList)({ + padding: 0, }); const Muted = styled.span(({ theme }) => ({ @@ -69,11 +70,6 @@ const Progress = styled(ProgressSpinner)({ margin: 4, }); -const Row = styled.div({ - display: 'flex', - gap: 4, -}); - const StopIcon = styled(StopAltIcon)({ width: 10, }); @@ -147,7 +143,7 @@ export const TestProviderRender: FC = ({ : ['unknown', 'Run tests to see accessibility results']; return ( - + {entry ? ( @@ -257,15 +253,20 @@ export const TestProviderRender: FC = ({ )} - - - } - /> - - + + {!entry && ( - - Coverage (unavailable) : 'Coverage'} - icon={ + + + = ({ })) } /> - } - /> - - {/* FIXME: aria labels were not 100% consistent with the tooltip logic. Double check this logic during review please! */} + + + {watching ? Coverage (unavailable) : 'Coverage'} + + {watching || (currentRun.triggeredBy && !FULL_RUN_TRIGGERS.includes(currentRun.triggeredBy)) ? ( - + ) : currentRun.coverageSummary ? ( - + ) : ( - + )} - + )} {hasA11yAddon && ( - - + {entry ? ( + Accessibility + ) : ( + + = ({ })) } /> - ) - } - /> - - + + )} - + ); }; From 263af5b748020480689b50c2829a735e04a23a03 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 11:03:10 +0100 Subject: [PATCH 22/46] Fix Vitest testing module accessibility --- code/addons/vitest/src/components/TestProviderRender.tsx | 8 +++++--- code/core/src/components/components/Button/Button.tsx | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/code/addons/vitest/src/components/TestProviderRender.tsx b/code/addons/vitest/src/components/TestProviderRender.tsx index d45f5b71798c..68a79a421aff 100644 --- a/code/addons/vitest/src/components/TestProviderRender.tsx +++ b/code/addons/vitest/src/components/TestProviderRender.tsx @@ -261,7 +261,7 @@ export const TestProviderRender: FC = ({ ) : ( - + Interactions @@ -303,9 +303,10 @@ export const TestProviderRender: FC = ({ {!entry && ( - + @@ -375,9 +376,10 @@ export const TestProviderRender: FC = ({ {entry ? ( Accessibility ) : ( - + diff --git a/code/core/src/components/components/Button/Button.tsx b/code/core/src/components/components/Button/Button.tsx index 8ce05b96041c..fea9f6d21b7c 100644 --- a/code/core/src/components/components/Button/Button.tsx +++ b/code/core/src/components/components/Button/Button.tsx @@ -74,7 +74,7 @@ export const Button = forwardRef( ) => { const Comp = asChild ? Slot : as; - if (ariaLabel === undefined || ariaLabel === '') { + if (!readOnly && (ariaLabel === undefined || ariaLabel === '')) { deprecate( `The 'ariaLabel' prop on 'Button' will become mandatory in Storybook 11. Buttons with text content should set 'ariaLabel={false}' to indicate that they are accessible as-is. Buttons without text content must provide a meaningful 'ariaLabel' for accessibility. The button content is: ${props.children}.` ); From 3b9cea113c9a92975f152636b1c3240b9232923c Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 11:04:31 +0100 Subject: [PATCH 23/46] Add data-deprecated props to deprecated components for DOM traceability --- code/core/src/components/components/Bar/Bar.tsx | 1 + .../core/src/components/components/Button/Button.tsx | 6 +++++- .../src/components/components/Modal/Modal.styled.tsx | 4 ++-- code/core/src/components/components/Modal/Modal.tsx | 5 +++++ code/core/src/components/components/Tabs/Button.tsx | 2 +- code/core/src/components/components/Tabs/Tabs.tsx | 7 ++++--- .../src/components/components/tooltip/ListItem.tsx | 2 +- .../components/tooltip/TooltipLinkList.tsx | 4 ++-- .../components/components/tooltip/TooltipMessage.tsx | 4 ++-- .../components/components/tooltip/WithTooltip.tsx | 4 ++-- .../src/manager/components/sidebar/TagsFilter.tsx | 12 ++++++------ 11 files changed, 31 insertions(+), 20 deletions(-) diff --git a/code/core/src/components/components/Bar/Bar.tsx b/code/core/src/components/components/Bar/Bar.tsx index 0994ff81e1e3..88dd97510557 100644 --- a/code/core/src/components/components/Bar/Bar.tsx +++ b/code/core/src/components/components/Bar/Bar.tsx @@ -131,6 +131,7 @@ export const FlexBar = ({ children, backgroundColor, className = '', ...rest }: const [left, right] = Children.toArray(children); return ( ( ) => { const Comp = asChild ? Slot : as; + let deprecated = undefined; if (!readOnly && (ariaLabel === undefined || ariaLabel === '')) { + deprecated = 'ariaLabel'; deprecate( `The 'ariaLabel' prop on 'Button' will become mandatory in Storybook 11. Buttons with text content should set 'ariaLabel={false}' to indicate that they are accessible as-is. Buttons without text content must provide a meaningful 'ariaLabel' for accessibility. The button content is: ${props.children}.` ); @@ -86,6 +88,7 @@ export const Button = forwardRef( } if (active !== undefined) { + deprecated = 'active'; deprecate( 'The `active` prop on `Button` is deprecated and will be removed in Storybook 11. Use specialized components like `ToggleButton` or `Select` instead.' ); @@ -129,6 +132,7 @@ export const Button = forwardRef( tooltip={finalTooltip} > ((props, ref '`IconButton` is deprecated and will be removed in Storybook 11, use `Button` instead.' ); - return diff --git a/code/core/src/components/components/Modal/Modal.tsx b/code/core/src/components/components/Modal/Modal.tsx index 8d7efd17ccdd..80b839bbfae2 100644 --- a/code/core/src/components/components/Modal/Modal.tsx +++ b/code/core/src/components/components/Modal/Modal.tsx @@ -84,7 +84,9 @@ function BaseModal({ variant = 'dialog', ...props }: ModalProps) { + let deprecated = undefined; if (ariaLabel === undefined || ariaLabel === '') { + deprecated = 'ariaLabel'; deprecate('The `ariaLabel` prop on `Modal` will become mandatory in Storybook 11.'); // TODO in Storybook 11 // throw new Error( @@ -93,12 +95,14 @@ function BaseModal({ } if (onEscapeKeyDown !== undefined) { + deprecated = 'onEscapeKeyDown'; deprecate( 'The `onEscapeKeyDown` prop is deprecated and will be removed in Storybook 11. Use `dismissOnEscape` instead.' ); } if (onInteractOutside !== undefined) { + deprecated = 'onInteractOutside'; deprecate( 'The `onInteractOutside` prop is deprecated and will be removed in Storybook 11. Use `dismissOnInteractOutside` instead.' ); @@ -203,6 +207,7 @@ function BaseModal({ {/* We need to set the FocusScope ourselves somehow, Overlay won't set it. */} ((props, ref) => { deprecate('The `TabButton` component is deprecated. Use `TabList` instead.'); - return ; + return ; }); TabButton.displayName = 'TabButton'; diff --git a/code/core/src/components/components/Tabs/Tabs.tsx b/code/core/src/components/components/Tabs/Tabs.tsx index 4d59ac357dd2..e7eb1af433f5 100644 --- a/code/core/src/components/components/Tabs/Tabs.tsx +++ b/code/core/src/components/components/Tabs/Tabs.tsx @@ -61,7 +61,7 @@ const StyledTabBar = styled.div({ export const TabBar = forwardRef>( (props, ref) => { deprecate('The `TabBar` component is deprecated. Use `TabsView` instead.'); - return ; + return ; } ); TabBar.displayName = 'TabBar'; @@ -121,7 +121,7 @@ export const TabWrapper = forwardRef( ({ active, render, children }, ref) => { deprecate('The `TabWrapper` component is deprecated. Use `TabsView` instead.'); return ( - + {render ? render() : children} ); @@ -220,7 +220,7 @@ export const Tabs: FC = memo( return ( // @ts-expect-error (non strict) - + {/* @ts-expect-error (non strict) */} @@ -314,6 +314,7 @@ export class TabsState extends Component { const { selected } = this.state; return ( { ); return ( - + <> {left && {left}} {title || center ? ( diff --git a/code/core/src/components/components/tooltip/TooltipLinkList.tsx b/code/core/src/components/components/tooltip/TooltipLinkList.tsx index f794039d10d1..d59ea51f9953 100644 --- a/code/core/src/components/components/tooltip/TooltipLinkList.tsx +++ b/code/core/src/components/components/tooltip/TooltipLinkList.tsx @@ -69,7 +69,7 @@ export interface TooltipLinkListProps extends ComponentProps { export const TooltipLinkList = ({ links, LinkWrapper, ...props }: TooltipLinkListProps) => { deprecate( - '`TooltipLinkList` is deprecated and will be removed in Storybook 11, use `MenuItem` and `WithMenu` instead.' + '`TooltipLinkList` is deprecated and will be removed in Storybook 11, use `ActionList` or `MenuItem` and `WithMenu` instead.' ); const groups = Array.isArray(links[0]) ? (links as Link[][]) : [links as Link[]]; @@ -77,7 +77,7 @@ export const TooltipLinkList = ({ links, LinkWrapper, ...props }: TooltipLinkLis group.some((link) => ('icon' in link && link.icon) || ('input' in link && link.input)) ); return ( - + {groups .filter((group) => group.length) .map((group, index) => { diff --git a/code/core/src/components/components/tooltip/TooltipMessage.tsx b/code/core/src/components/components/tooltip/TooltipMessage.tsx index 115fdf8a4078..428d8d759883 100644 --- a/code/core/src/components/components/tooltip/TooltipMessage.tsx +++ b/code/core/src/components/components/tooltip/TooltipMessage.tsx @@ -46,11 +46,11 @@ export interface TooltipMessageProps { export const TooltipMessage = ({ title, desc, links }: TooltipMessageProps) => { deprecate( - '`TooltipLinkList` is deprecated and will be removed in Storybook 11, use `Popover` and `PopoverProvider` instead.' + '`TooltipMessage` is deprecated and will be removed in Storybook 11, use `Popover` and `PopoverProvider` instead.' ); return ( - + {title && {title}} {desc && {desc}} diff --git a/code/core/src/components/components/tooltip/WithTooltip.tsx b/code/core/src/components/components/tooltip/WithTooltip.tsx index ad1eccc0e825..f08d77bcf830 100644 --- a/code/core/src/components/components/tooltip/WithTooltip.tsx +++ b/code/core/src/components/components/tooltip/WithTooltip.tsx @@ -378,14 +378,14 @@ const DeprecatedPure: FC = (props: WithTooltipPureProps) = deprecate( 'WithTooltipPure is deprecated and will be removed in Storybook 11. Please use WithTooltip instead.' ); - return ; + return ; }; const DeprecatedState: FC = (props: WithTooltipStateProps) => { deprecate( 'WithToolTipState is deprecated and will be removed in Storybook 11. Please use WithTooltip instead.' ); - return ; + return ; }; export { diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 2ea5cf0073d0..5cb200306bd8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -27,12 +27,11 @@ const BUILT_IN_TAGS = new Set([ 'test-fn', ]); -// Temporary to prevent regressions until TagFilterPanel can be refactored. -const StyledIconButton = styled(Button)<{ active: boolean }>(({ active, theme }) => ({ +const StyledButton = styled(Button)<{ isHighlighted: boolean }>(({ isHighlighted, theme }) => ({ '&:focus-visible': { outlineOffset: 4, }, - ...(active && { + ...(isHighlighted && { background: theme.background.hoverable, color: theme.color.secondary, }), @@ -245,18 +244,19 @@ export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => { /> )} > - {includedFilters.size + excludedFilters.size > 0 && } - + ); }; From 7f46f2d8dde2149e53a631c7bbe13f8de81ce8b6 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 11:10:56 +0100 Subject: [PATCH 24/46] Add TODO to remove in SB 11 --- code/core/src/components/components/icon/icon.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/code/core/src/components/components/icon/icon.tsx b/code/core/src/components/components/icon/icon.tsx index 500f9b0e7c33..7c48ddc3fa13 100644 --- a/code/core/src/components/components/icon/icon.tsx +++ b/code/core/src/components/components/icon/icon.tsx @@ -29,6 +29,7 @@ export interface IconsProps extends ComponentProps { __suppressDeprecationWarning?: boolean; } +// TODO: Remove in SB11 /** * @deprecated No longer used, will be removed in Storybook 9.0 Please use the `@storybook/icons` * package instead. @@ -66,6 +67,7 @@ export interface SymbolsProps { icons?: IconType[]; } +// TODO: Remove in SB11 /** * @deprecated No longer used, will be removed in Storybook 9.0 Please use the `@storybook/icons` * package instead. From 1052074425b94cf4cb53698d309720bcbba23e7f Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Tue, 25 Nov 2025 11:12:54 +0100 Subject: [PATCH 25/46] WIP fix select allowing undefined values from globalTypes --- code/core/src/components/components/Select/Select.tsx | 10 +++------- code/core/src/components/components/Select/helpers.tsx | 4 +++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index ba059983559b..4e65b51fcb52 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -15,7 +15,7 @@ import { Form } from '../Form/Form'; import { Popover } from '../Popover/Popover'; import { SelectOption } from './SelectOption'; import type { Option, ResetOption } from './helpers'; -import { Listbox, PAGE_STEP_SIZE } from './helpers'; +import { Listbox, PAGE_STEP_SIZE, UNDEFINED_VALUE } from './helpers'; export interface SelectProps extends Omit { @@ -54,7 +54,7 @@ export interface SelectProps options: Option[]; /** IDs of the preselected options. */ - defaultOptions?: string | string[]; + defaultOptions?: Option['value'] | Option['value'][]; /** Whether the Select should render open. */ defaultOpen?: boolean; @@ -84,11 +84,7 @@ function setSelectedFromDefault( options: SelectProps['options'], defaultOptions: SelectProps['defaultOptions'] ): Option[] { - if (!defaultOptions) { - return []; - } - - if (typeof defaultOptions === 'string') { + if (defaultOptions === UNDEFINED_VALUE || defaultOptions === null || typeof defaultOptions !== 'object') { return options.filter((opt) => opt.value === defaultOptions); } diff --git a/code/core/src/components/components/Select/helpers.tsx b/code/core/src/components/components/Select/helpers.tsx index 18e6ca58e275..550ba816b311 100644 --- a/code/core/src/components/components/Select/helpers.tsx +++ b/code/core/src/components/components/Select/helpers.tsx @@ -8,7 +8,7 @@ export interface Option { title: string; description?: string; icon?: React.ReactNode; - value: string; + value: string | number | null | boolean | Symbol; } export interface ResetOption extends Omit { @@ -26,3 +26,5 @@ export const Listbox = styled('ul')({ margin: 0, padding: 4, }); + +export const UNDEFINED_VALUE = Symbol.for('undefined'); \ No newline at end of file From f65a5563a72c501476ea798d6163d0bf5dfbd97b Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 25 Nov 2025 12:01:29 +0100 Subject: [PATCH 26/46] Remove ineffective data-deprecated attribute --- code/core/src/components/components/Tabs/Tabs.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/code/core/src/components/components/Tabs/Tabs.tsx b/code/core/src/components/components/Tabs/Tabs.tsx index e7eb1af433f5..0cc1d06ec0be 100644 --- a/code/core/src/components/components/Tabs/Tabs.tsx +++ b/code/core/src/components/components/Tabs/Tabs.tsx @@ -314,7 +314,6 @@ export class TabsState extends Component { const { selected } = this.state; return ( Date: Tue, 25 Nov 2025 12:59:46 +0100 Subject: [PATCH 27/46] UI: Fix crashes in Select when passed falsy non-string options --- .../components/Select/Select.stories.tsx | 276 ++++++++++++++++++ .../components/components/Select/Select.tsx | 163 ++++++----- .../components/components/Select/helpers.tsx | 58 +++- .../toolbar/components/ToolbarMenuSelect.tsx | 2 +- 4 files changed, 424 insertions(+), 75 deletions(-) diff --git a/code/core/src/components/components/Select/Select.stories.tsx b/code/core/src/components/components/Select/Select.stories.tsx index 3024d164eafe..18b232b3a7c5 100644 --- a/code/core/src/components/components/Select/Select.stories.tsx +++ b/code/core/src/components/components/Select/Select.stories.tsx @@ -1055,3 +1055,279 @@ export const WithoutReset = meta.story({ expect(options.length).toBe(3); }, }); + +const nonStringOptions = [ + { title: 'Number (42)', value: 42 }, + { title: 'Number (0)', value: 0 }, + { title: 'Boolean (true)', value: true }, + { title: 'Boolean (false)', value: false }, + { title: 'Null', value: null }, + { title: 'Undefined', value: undefined }, + { title: 'String', value: 'string' }, +]; + +export const NonStringValuesSingleSelect = meta.story({ + name: 'Non-String Values (single)', + args: { + options: nonStringOptions, + onSelect: fn(), + onChange: fn(), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Select number value (42)', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Number (42)' })); + expect(args.onSelect).toHaveBeenLastCalledWith(42); + expect(args.onChange).toHaveBeenLastCalledWith([42]); + }); + + await step('Select boolean value (true)', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Boolean (true)' })); + expect(args.onSelect).toHaveBeenLastCalledWith(true); + expect(args.onChange).toHaveBeenLastCalledWith([true]); + }); + + await step('Select boolean value (false)', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Boolean (false)' })); + expect(args.onSelect).toHaveBeenLastCalledWith(false); + expect(args.onChange).toHaveBeenLastCalledWith([false]); + }); + + await step('Select null value', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Null' })); + expect(args.onSelect).toHaveBeenLastCalledWith(null); + expect(args.onChange).toHaveBeenLastCalledWith([null]); + }); + + await step('Select undefined value', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Undefined' })); + expect(args.onSelect).toHaveBeenLastCalledWith(undefined); + expect(args.onChange).toHaveBeenLastCalledWith([undefined]); + }); + + await step('Select number value (0 - falsy)', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Number (0)' })); + expect(args.onSelect).toHaveBeenLastCalledWith(0); + expect(args.onChange).toHaveBeenLastCalledWith([0]); + }); + }, +}); + +export const NonStringValuesMultiSelect = meta.story({ + name: 'Non-String Values (multi)', + args: { + options: nonStringOptions, + multiSelect: true, + onSelect: fn(), + onDeselect: fn(), + onChange: fn(), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Select number (42)', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Number (42)' })); + expect(args.onSelect).toHaveBeenLastCalledWith(42); + expect(args.onChange).toHaveBeenLastCalledWith([42]); + }); + + await step('Add boolean (true)', async () => { + await userEvent.click(await screen.findByRole('option', { name: 'Boolean (true)' })); + expect(args.onSelect).toHaveBeenLastCalledWith(true); + expect(args.onChange).toHaveBeenLastCalledWith([42, true]); + }); + + await step('Add null', async () => { + await userEvent.click(await screen.findByRole('option', { name: 'Null' })); + expect(args.onSelect).toHaveBeenLastCalledWith(null); + expect(args.onChange).toHaveBeenLastCalledWith([42, true, null]); + }); + + await step('Add undefined', async () => { + await userEvent.click(await screen.findByRole('option', { name: 'Undefined' })); + expect(args.onSelect).toHaveBeenLastCalledWith(undefined); + expect(args.onChange).toHaveBeenLastCalledWith([42, true, null, undefined]); + }); + + await step('Deselect number (42)', async () => { + await userEvent.click(await screen.findByRole('option', { name: 'Number (42)' })); + expect(args.onDeselect).toHaveBeenLastCalledWith(42); + expect(args.onChange).toHaveBeenLastCalledWith([true, null, undefined]); + }); + + await step('Deselect undefined', async () => { + await userEvent.click(await screen.findByRole('option', { name: 'Undefined' })); + expect(args.onDeselect).toHaveBeenLastCalledWith(undefined); + expect(args.onChange).toHaveBeenLastCalledWith([true, null]); + }); + }, +}); + +export const DefaultOptionNumber = meta.story({ + name: 'Default Option - Number', + args: { + options: nonStringOptions, + defaultOptions: 42, + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + await expect(selectButton).toHaveTextContent('Number (42)'); + }, +}); + +export const DefaultOptionZero = meta.story({ + name: 'Default Option - Zero', + args: { + options: nonStringOptions, + defaultOptions: 0, + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + await expect(selectButton).toHaveTextContent('Number (0)'); + }, +}); + +export const DefaultOptionBooleanTrue = meta.story({ + name: 'Default Option - Boolean True', + args: { + options: nonStringOptions, + defaultOptions: true, + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + await expect(selectButton).toHaveTextContent('Boolean (true)'); + }, +}); + +export const DefaultOptionBooleanFalse = meta.story({ + name: 'Default Option - Boolean False', + args: { + options: nonStringOptions, + defaultOptions: false, + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + await expect(selectButton).toHaveTextContent('Boolean (false)'); + }, +}); + +export const DefaultOptionNull = meta.story({ + name: 'Default Option - Null', + args: { + options: nonStringOptions, + defaultOptions: null, + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + await expect(selectButton).toHaveTextContent('Null'); + }, +}); + +export const DefaultOptionUndefinedDoesNotWork = meta.story({ + name: 'Default Option - Bare undefined does NOT select', + args: { + options: nonStringOptions, + defaultOptions: undefined, + children: 'Nothing selected', + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + await expect(selectButton).toHaveTextContent('Nothing selected'); + await expect(selectButton).not.toHaveTextContent('Undefined'); + }, +}); + +export const DefaultOptionUndefinedInArrayWorks = meta.story({ + name: 'Default Option - [undefined] selects undefined option', + args: { + options: nonStringOptions, + defaultOptions: [undefined], + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + await expect(selectButton).toHaveTextContent('Undefined'); + }, +}); + +export const DefaultOptionsMultipleNonStringValues = meta.story({ + name: 'Default Options - Multiple Non-String Values', + args: { + options: nonStringOptions, + defaultOptions: [42, false, null], + multiSelect: true, + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + await expect(selectButton).toHaveTextContent('3'); + + await userEvent.click(selectButton); + const option42 = await screen.findByRole('option', { name: 'Number (42)' }); + const optionFalse = await screen.findByRole('option', { name: 'Boolean (false)' }); + const optionNull = await screen.findByRole('option', { name: 'Null' }); + + expect(option42).toHaveAttribute('aria-selected', 'true'); + expect(optionFalse).toHaveAttribute('aria-selected', 'true'); + expect(optionNull).toHaveAttribute('aria-selected', 'true'); + }, +}); + +const optionsWithUndefinedForReset = [ + { title: 'Apple', value: 'apple' }, + { title: 'Undefined Value', value: undefined }, + { title: 'Banana', value: 'banana' }, +]; + +export const ResetWithUndefinedOption = meta.story({ + name: 'Reset vs Undefined Option', + args: { + options: optionsWithUndefinedForReset, + children: 'Select fruit', + onReset: fn(), + onChange: fn(), + onSelect: fn(), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Select a regular option first', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Apple' })); + expect(args.onSelect).toHaveBeenLastCalledWith('apple'); + expect(args.onChange).toHaveBeenLastCalledWith(['apple']); + await expect(selectButton).toHaveTextContent('Apple'); + }); + + await step('Select the undefined value option - it should work', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Undefined Value' })); + expect(args.onSelect).toHaveBeenLastCalledWith(undefined); + expect(args.onChange).toHaveBeenLastCalledWith([undefined]); + await expect(selectButton).toHaveTextContent('Undefined Value'); + }); + + await step('Click Reset - should clear, not select undefined option', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Reset selection' })); + expect(args.onReset).toHaveBeenCalledTimes(1); + expect(args.onChange).toHaveBeenLastCalledWith([]); + await expect(selectButton).toHaveTextContent('Select fruit'); + await expect(selectButton).not.toHaveTextContent('Undefined Value'); + }); + + await step('Can still select undefined value after reset', async () => { + await userEvent.click(selectButton); + await userEvent.click(await screen.findByRole('option', { name: 'Undefined Value' })); + expect(args.onSelect).toHaveBeenLastCalledWith(undefined); + expect(args.onChange).toHaveBeenLastCalledWith([undefined]); + await expect(selectButton).toHaveTextContent('Undefined Value'); + }); + }, +}); diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 4e65b51fcb52..bf6ff3e2df32 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -14,8 +14,16 @@ import { Button, type ButtonProps } from '../Button/Button'; import { Form } from '../Form/Form'; import { Popover } from '../Popover/Popover'; import { SelectOption } from './SelectOption'; -import type { Option, ResetOption } from './helpers'; -import { Listbox, PAGE_STEP_SIZE, UNDEFINED_VALUE } from './helpers'; +import type { InternalOption, Option, ResetOption, Value } from './helpers'; +import { + Listbox, + PAGE_STEP_SIZE, + externalToValue, + isLiteralValue, + optionOrResetToInternal, + optionToInternal, + valueToExternal, +} from './helpers'; export interface SelectProps extends Omit { @@ -54,7 +62,7 @@ export interface SelectProps options: Option[]; /** IDs of the preselected options. */ - defaultOptions?: Option['value'] | Option['value'][]; + defaultOptions?: Value | Value[]; /** Whether the Select should render open. */ defaultOpen?: boolean; @@ -65,13 +73,13 @@ export interface SelectProps /** Custom text label for the reset option when it exists. */ resetLabel?: string; - onSelect?: (option: string) => void; - onDeselect?: (option: string) => void; - onChange?: (selected: string[]) => void; + onSelect?: (option: Value) => void; + onDeselect?: (option: Value) => void; + onChange?: (selected: Value[]) => void; } -function valueToId(parentId: string, { value }: ResetOption | Option): string { - return `${parentId}-opt-${value ?? 'sb-reset'}`; +function valueToId(parentId: string, { value }: InternalOption | ResetOption): string { + return `${parentId}-opt-${String(value) ?? 'sb-reset'}`; } const SelectedOptionCount = styled.span(({ theme }) => ({ @@ -83,12 +91,18 @@ const SelectedOptionCount = styled.span(({ theme }) => ({ function setSelectedFromDefault( options: SelectProps['options'], defaultOptions: SelectProps['defaultOptions'] -): Option[] { - if (defaultOptions === UNDEFINED_VALUE || defaultOptions === null || typeof defaultOptions !== 'object') { - return options.filter((opt) => opt.value === defaultOptions); +): InternalOption[] { + if (defaultOptions === undefined) { + return []; } - return options.filter((opt) => defaultOptions.some((def) => opt.value === def)); + if (isLiteralValue(defaultOptions)) { + return options.filter((opt) => opt.value === defaultOptions).map(optionToInternal); + } + + return options + .filter((opt) => defaultOptions.some((def) => opt.value === def)) + .map(optionToInternal); } const StyledButton = styled(Button)( @@ -210,15 +224,15 @@ export const Select = forwardRef( }, [triggerRef]); // The last selected option(s), which will be used by the app. - const [selectedOptions, setSelectedOptions] = useState( + const [selectedOptions, setSelectedOptions] = useState( setSelectedFromDefault(calleeOptions, defaultOptions) ); // Selects an option (updating the selection state based on multiSelect). const handleSelectOption = useCallback( - (option: Option | ResetOption) => { + (option: InternalOption | ResetOption) => { // Reset option case. We check value === undefined for cleaner type handling in the other branch. - if (option.value === undefined) { + if (option.type === 'reset') { if (selectedOptions.length) { onChange?.([]); onReset?.(); @@ -226,25 +240,25 @@ export const Select = forwardRef( } } else if (multiSelect) { setSelectedOptions((previous) => { - let newSelected: Option[] = []; + let newSelected: InternalOption[] = []; const isSelected = previous?.some((opt) => opt.value === option.value); if (isSelected) { - onDeselect?.(option.value); + onDeselect?.(valueToExternal(option.value)); newSelected = previous?.filter((opt) => opt.value !== option.value) ?? []; } else { - onSelect?.(option.value); + onSelect?.(valueToExternal(option.value)); newSelected = [...(previous ?? []), option]; } - onChange?.(newSelected.map((opt) => opt.value)); + onChange?.(newSelected.map((opt) => valueToExternal(opt.value))); return newSelected; }); } else { setSelectedOptions((current) => { if (current.every((opt) => opt.value !== option.value)) { - onSelect?.(option.value); - onChange?.([option.value]); + onSelect?.(valueToExternal(option.value)); + onChange?.([valueToExternal(option.value)]); return [option]; } return current; @@ -259,6 +273,7 @@ export const Select = forwardRef( () => onReset ? { + type: 'reset', value: undefined, title: resetLabel ?? 'Reset selection', icon: , @@ -286,7 +301,7 @@ export const Select = forwardRef( }, [defaultOptions, calleeOptions]); // The active option in the listbox, which will receive focus when the listbox is open. - const [activeOption, setActiveOptionState] = useState