From a20da4069e178849b9cce4dfccd0e820113fab66 Mon Sep 17 00:00:00 2001 From: Tomina Date: Thu, 21 Aug 2025 10:04:36 +0200 Subject: [PATCH 001/103] fix(vue3-vite): allow paths in docgen tsconfig option Relative paths are perfectly valid here because it's resolved into a path using path.join at https://github.com/storybookjs/storybook/blob/6ee3f61be916c708e8e4c6629afbd866f13f0500/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts#L179. --- code/frameworks/vue3-vite/src/types.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/code/frameworks/vue3-vite/src/types.ts b/code/frameworks/vue3-vite/src/types.ts index d02b791f294b..926c11d288ca 100644 --- a/code/frameworks/vue3-vite/src/types.ts +++ b/code/frameworks/vue3-vite/src/types.ts @@ -31,16 +31,17 @@ export type FrameworkOptions = { | { plugin: 'vue-component-meta'; /** - * Tsconfig filename to use. Should be set if your main `tsconfig.json` includes references - * to other tsconfig files like `tsconfig.app.json`. Otherwise docgen might not be generated - * correctly (e.g. import aliases are not resolved). + * Tsconfig path to use. Should be set if your main `tsconfig.json` includes references to + * other tsconfig files like `tsconfig.app.json`. Otherwise docgen might not be generated + * correctly (e.g. import aliases are not resolved). The path is resolved relative to + * project root. * * For further information, see our * [docs](https://storybook.js.org/docs/get-started/vue3-vite#override-the-default-configuration). * * @default 'tsconfig.json' */ - tsconfig: `tsconfig${string}.json`; + tsconfig: `${string}/tsconfig${string}.json` | `tsconfig${string}.json`; }; }; From 4e46dfc437ccc6d8158822b6912575c296f9051d Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Sat, 4 Oct 2025 16:15:24 +0900 Subject: [PATCH 002/103] fix: remove FrameworkOptions.image and move to NextJsParameters.nextjs.image --- code/frameworks/nextjs/src/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/code/frameworks/nextjs/src/types.ts b/code/frameworks/nextjs/src/types.ts index 2d9d0874b855..03dea988205b 100644 --- a/code/frameworks/nextjs/src/types.ts +++ b/code/frameworks/nextjs/src/types.ts @@ -19,7 +19,6 @@ type BuilderName = CompatibleString<'@storybook/builder-webpack5'>; export type FrameworkOptions = ReactOptions & { nextConfigPath?: string; - image?: Partial; builder?: BuilderOptions; }; @@ -71,6 +70,9 @@ export interface NextJsParameters { /** Next.js router configuration */ router?: Partial; + + /** Next.js image props */ + image?: Partial; }; } From e25a02e00eb19fa4081937588e806e74bc2e736e Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Sat, 4 Oct 2025 16:16:51 +0900 Subject: [PATCH 003/103] docs: update nextjs doc --- docs/get-started/frameworks/nextjs.mdx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/get-started/frameworks/nextjs.mdx b/docs/get-started/frameworks/nextjs.mdx index 208cfce1e302..c8a50e8fe472 100644 --- a/docs/get-started/frameworks/nextjs.mdx +++ b/docs/get-started/frameworks/nextjs.mdx @@ -967,9 +967,6 @@ export default { framework: { name: '@storybook/your-framework', options: { - image: { - loading: 'eager', - }, nextConfigPath: path.resolve(process.cwd(), 'next.config.js'), }, }, @@ -984,12 +981,6 @@ Type: `Record` Configure options for the [framework's builder](../../api/main-config/main-config-framework.mdx#optionsbuilder). For Next.js, available options can be found in the [Webpack builder docs](../../builders/webpack.mdx). -#### `image` - -Type: `object` - -Props to pass to every instance of `next/image`. See [next/image docs](https://nextjs.org/docs/pages/api-reference/components/image) for more details. - #### `nextConfigPath` Type: `string` @@ -1000,6 +991,12 @@ The absolute path to the `next.config.js` file. This is necessary if you have a This framework contributes the following [parameters](../../writing-stories/parameters.mdx) to Storybook, under the `nextjs` namespace: +#### `image` + +Type: `object` + +Props to pass to every instance of `next/image`. See [next/image docs](https://nextjs.org/docs/pages/api-reference/components/image) for more details. + #### `appDirectory` Type: `boolean` @@ -1044,4 +1041,3 @@ Type: ``` The router object that is passed to the `next/router` context. See [Next.js's router docs](https://nextjs.org/docs/pages/building-your-application/routing) for more details. - From a1745cc126899f4f95a12144cf6564ad4196eb04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:42:09 +0000 Subject: [PATCH 004/103] Initial plan From dc50982bc6a518dc5481bf4459166b9c08ede732 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:47:46 +0000 Subject: [PATCH 005/103] Add NX MCP server configuration for Cursor and VS Code Co-authored-by: kasperpeulen <1035299+kasperpeulen@users.noreply.github.com> --- .cursor/mcp.json | 7 +++++++ .vscode/mcp.json | 9 +++++++++ 2 files changed, 16 insertions(+) create mode 100644 .vscode/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json index e07488b7f3cc..6f3de0571716 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,5 +1,12 @@ { "mcpServers": { + "nx": { + "command": "npx", + "args": [ + "-y", + "nx-mcp" + ] + }, "wallaby": { "command": "node", "args": [ diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000000..a72690535cb8 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "nx": { + "type": "stdio", + "command": "npx", + "args": ["-y", "nx-mcp"] + } + } +} From 0a1c71f812a7ec38fa3da40ab2435dc8331ae223 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:53:11 +0000 Subject: [PATCH 006/103] Add NX MCP server configuration for GitHub Copilot Co-authored-by: kasperpeulen <1035299+kasperpeulen@users.noreply.github.com> --- .github/copilot-mcp.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/copilot-mcp.json diff --git a/.github/copilot-mcp.json b/.github/copilot-mcp.json new file mode 100644 index 000000000000..a8119f83784d --- /dev/null +++ b/.github/copilot-mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "nx": { + "command": "npx", + "args": ["-y", "nx-mcp"] + } + } +} From 0a3cfbee25f634e707aadd99834380bbcdf47534 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:36:59 +0000 Subject: [PATCH 007/103] Use @latest tag for nx-mcp package in all MCP configurations Co-authored-by: kasperpeulen <1035299+kasperpeulen@users.noreply.github.com> --- .cursor/mcp.json | 2 +- .github/copilot-mcp.json | 2 +- .vscode/mcp.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 6f3de0571716..8857b2b28278 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -4,7 +4,7 @@ "command": "npx", "args": [ "-y", - "nx-mcp" + "nx-mcp@latest" ] }, "wallaby": { diff --git a/.github/copilot-mcp.json b/.github/copilot-mcp.json index a8119f83784d..8a383b5f75d0 100644 --- a/.github/copilot-mcp.json +++ b/.github/copilot-mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "nx": { "command": "npx", - "args": ["-y", "nx-mcp"] + "args": ["-y", "nx-mcp@latest"] } } } diff --git a/.vscode/mcp.json b/.vscode/mcp.json index a72690535cb8..4e1c9e04a7bf 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -3,7 +3,7 @@ "nx": { "type": "stdio", "command": "npx", - "args": ["-y", "nx-mcp"] + "args": ["-y", "nx-mcp@latest"] } } } From 67cb79e3ce2725868b50a1bdf4ff1afc63120a52 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 21 Jan 2026 16:08:42 +0100 Subject: [PATCH 008/103] Support complex, unparseable viewport sizes, but disable resizing for such values --- .../components/preview/NumericInput.tsx | 7 +++ .../components/preview/Viewport.stories.tsx | 18 ++++++ .../manager/components/preview/Viewport.tsx | 57 ++++++++++--------- code/core/src/viewport/useViewport.ts | 7 +-- 4 files changed, 57 insertions(+), 32 deletions(-) diff --git a/code/core/src/manager/components/preview/NumericInput.tsx b/code/core/src/manager/components/preview/NumericInput.tsx index 9783f9386171..6dfc98a7a667 100644 --- a/code/core/src/manager/components/preview/NumericInput.tsx +++ b/code/core/src/manager/components/preview/NumericInput.tsx @@ -46,6 +46,9 @@ const Wrapper = styled.div<{ after?: ReactNode; before?: ReactNode }>( outline: 'none', }, }, + 'input:disabled': { + background: 'transparent', + }, 'input + div': { paddingInline: 0, fontSize: 'inherit', @@ -54,6 +57,10 @@ const Wrapper = styled.div<{ after?: ReactNode; before?: ReactNode }>( outline: `2px solid ${theme.color.secondary}`, outlineOffset: -2, }, + '&:has(input:disabled)': { + background: theme.base === 'light' ? theme.color.lighter : theme.input.background, + cursor: 'not-allowed', + }, ...(after && { paddingRight: 2 }), ...(before && { paddingLeft: 2 }), }) diff --git a/code/core/src/manager/components/preview/Viewport.stories.tsx b/code/core/src/manager/components/preview/Viewport.stories.tsx index 67a842259f4e..22354300b4dd 100644 --- a/code/core/src/manager/components/preview/Viewport.stories.tsx +++ b/code/core/src/manager/components/preview/Viewport.stories.tsx @@ -38,6 +38,14 @@ const customViewports = { }, type: 'other', }, + calc: { + name: 'Calculated', + styles: { + height: 'calc(100% - 50px)', + width: 'calc(100% - 50px)', + }, + type: 'other', + }, } as ViewportMap; const meta = preview.meta({ @@ -119,3 +127,13 @@ export const Narrow = meta.story({ }, render: () => <>, }); + +export const Calculated = meta.story({ + globals: { + viewport: { value: 'calc' }, + }, + parameters: { + viewport: { options: customViewports }, + }, + render: () => <>, +}); diff --git a/code/core/src/manager/components/preview/Viewport.tsx b/code/core/src/manager/components/preview/Viewport.tsx index 9c0fa7f46975..25063238453c 100644 --- a/code/core/src/manager/components/preview/Viewport.tsx +++ b/code/core/src/manager/components/preview/Viewport.tsx @@ -197,11 +197,6 @@ const SizeInput = styled(NumericInput)({ minHeight: 28, }); -const parseNumber = (value: string) => { - const [match, number, unit] = value.match(/^(\d+(?:\.\d+)?)(\%|[a-z]{0,4})?$/) || []; - return match ? { number: Number(number), unit } : undefined; -}; - export const Viewport = ({ active, id, @@ -278,18 +273,20 @@ export const Viewport = ({ }, [resize, scale]); const dimensions = useMemo(() => { - const { number: nx, unit: ux = 'px' } = parseNumber(width) ?? { number: 0, unit: 'px' }; - const { number: ny, unit: uy = 'px' } = parseNumber(height) ?? { number: 0, unit: 'px' }; - const frameWidth = Math.max(VIEWPORT_MIN_WIDTH, nx * scale); - const frameHeight = Math.max(VIEWPORT_MIN_HEIGHT, ny * scale); + const [, nx = '', ux = 'px'] = width.match(/^(\d+(?:\.\d+)?)(\%|[a-z]{1,4})?$/) || []; + const [, ny = '', uy = 'px'] = height.match(/^(\d+(?:\.\d+)?)(\%|[a-z]{1,4})?$/) || []; return { frame: { - width: `${frameWidth}${ux}`, - height: `${frameHeight}${uy}`, + width: `calc(${width} * ${scale})`, + height: `calc(${height} * ${scale})`, }, display: { - width: `${nx}${ux === 'px' ? '' : ux}`, - height: `${ny}${uy === 'px' ? '' : uy}`, + width: `${nx || width}${ux === 'px' ? '' : ux}`, + height: `${ny || height}${uy === 'px' ? '' : uy}`, + }, + locked: { + width: !nx, + height: !ny, }, }; }, [width, height, scale]); @@ -311,6 +308,7 @@ export const Viewport = ({ value={width} minValue={0} setValue={(value) => resize(value, height)} + disabled={dimensions.locked.width} /> resize(width, value)} + disabled={dimensions.locked.height} /> {isCustom && lastSelectedOption && ( )} - - - + {!dimensions.locked.width && ( + + )} + {!dimensions.locked.height && ( + + )} + {!dimensions.locked.width && !dimensions.locked.height && ( + + )} ); diff --git a/code/core/src/viewport/useViewport.ts b/code/core/src/viewport/useViewport.ts index d54c31d91a5d..3909a132342c 100644 --- a/code/core/src/viewport/useViewport.ts +++ b/code/core/src/viewport/useViewport.ts @@ -175,12 +175,7 @@ export const useViewport = () => { const w = width.replace(/px$/, '').replace(/%$/, 'pct'); const h = height.replace(/px$/, '').replace(/%$/, 'pct'); const value = isRotated ? `${h}-${w}` : `${w}-${h}`; - const [match, vx, ux, vy, uy] = value.match(URL_VALUE_PATTERN) || []; - - // Don't update to pixel values less than 40 - if (match && (ux || Number(vx) >= 40) && (uy || Number(vy) >= 40)) { - update({ value: match, isRotated }); - } + update({ value, isRotated }); }, [update, isRotated] ); From 218a436a810a327870ddf7b1e3cad4885f14a8f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:03:44 +0000 Subject: [PATCH 009/103] Initial plan From 6ac4b2242bd63e54633ec7b1a66afdfe9f50a4d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:07:03 +0000 Subject: [PATCH 010/103] Rename experimentalComponentsManifest to componentsManifest, default to true Co-authored-by: JReinhold <5678122+JReinhold@users.noreply.github.com> --- code/.storybook/main.ts | 1 - code/core/src/core-server/build-static.ts | 2 +- code/core/src/core-server/dev-server.ts | 2 +- code/core/src/types/modules/core-common.ts | 2 +- code/lib/cli-storybook/src/sandbox-templates.ts | 1 - 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 05b99156056f..4a9c24e16c8b 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -143,7 +143,6 @@ const config = defineMain({ features: { developmentModeForBuild: true, experimentalTestSyntax: true, - experimentalComponentsManifest: true, }, staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }], viteFinal: async (viteConfig, { configType }) => { diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 8feef56617e4..2dea9f019d04 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -157,7 +157,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ) ); - if (features?.experimentalComponentsManifest) { + if (features?.componentsManifest !== false) { effects.push(writeManifests(options.outputDir, presets)); } } diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index accc93fdcca6..ce42724d1c82 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -158,7 +158,7 @@ export async function storybookDevServer( } const features = await options.presets.apply('features'); - if (features?.experimentalComponentsManifest) { + if (features?.componentsManifest !== false) { registerManifests({ app, presets: options.presets }); } // Now the preview has successfully started, we can count this as a 'dev' event. diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 433e4e6af96e..a370d51d541b 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -501,7 +501,7 @@ export interface StorybookConfigRaw { /** Only show input controls in Angular */ angularFilterNonInputControls?: boolean; - experimentalComponentsManifest?: boolean; + componentsManifest?: boolean; /** * Enables the new code example generation for React components. You can see those examples when diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 338057932344..6e0c36b22d84 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -380,7 +380,6 @@ export const baseTemplates = { features: { developmentModeForBuild: true, experimentalTestSyntax: true, - experimentalComponentsManifest: true, }, }, }, From fbe6847762a63ec6a16d92a70704eee5914ccb21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:32:27 +0000 Subject: [PATCH 011/103] Initial plan From 8fcdfe938fbf14b980428288262b91f4febd03f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:04:46 +0000 Subject: [PATCH 012/103] fix: merge multi-argument styled() calls into single functions to fix CSS ordering on theme change When styled() is called with multiple arguments (e.g., styled.h1(withReset, headerCommon, themeFunc)), Emotion generates separate CSS classes for each argument. On theme change, unchanged classes are reused at their original stylesheet position while changed ones are appended at the end. This causes unpredictable CSS cascade ordering since DocsContent's :where() selectors and component classes share the same specificity (0-1-0). By merging all arguments into a single function, each component produces exactly one CSS class that is always regenerated together on theme change, ensuring consistent stylesheet ordering. Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- .../docs/src/blocks/components/DocsPage.tsx | 6 +++-- .../docs/src/blocks/components/Title.tsx | 4 ++- .../components/typography/elements/A.tsx | 4 ++- .../typography/elements/Blockquote.tsx | 5 +++- .../components/typography/elements/Code.tsx | 27 +++++++++---------- .../components/typography/elements/DL.tsx | 7 +++-- .../components/typography/elements/H1.tsx | 5 +++- .../components/typography/elements/H2.tsx | 5 +++- .../components/typography/elements/H3.tsx | 5 +++- .../components/typography/elements/H4.tsx | 5 +++- .../components/typography/elements/H5.tsx | 5 +++- .../components/typography/elements/H6.tsx | 5 +++- .../components/typography/elements/LI.tsx | 3 ++- .../components/typography/elements/OL.tsx | 11 +++++--- .../components/typography/elements/P.tsx | 4 ++- .../components/typography/elements/Pre.tsx | 5 +++- .../components/typography/elements/Span.tsx | 4 ++- .../components/typography/elements/Table.tsx | 5 +++- .../components/typography/elements/UL.tsx | 11 +++++--- 19 files changed, 87 insertions(+), 39 deletions(-) diff --git a/code/addons/docs/src/blocks/components/DocsPage.tsx b/code/addons/docs/src/blocks/components/DocsPage.tsx index 11e01c2c5765..72f17a246ab9 100644 --- a/code/addons/docs/src/blocks/components/DocsPage.tsx +++ b/code/addons/docs/src/blocks/components/DocsPage.tsx @@ -26,7 +26,8 @@ const toGlobalSelector = (element: string): string => const breakpoint = 600; -export const Title = styled.h1(withReset, ({ theme }) => ({ +export const Title = styled.h1(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), color: theme.color.defaultText, fontSize: theme.typography.size.m3, fontWeight: theme.typography.weight.bold, @@ -39,7 +40,8 @@ export const Title = styled.h1(withReset, ({ theme }) => ({ }, })); -export const Subtitle = styled.h2(withReset, ({ theme }) => ({ +export const Subtitle = styled.h2(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), fontWeight: theme.typography.weight.regular, fontSize: theme.typography.size.s3, lineHeight: '20px', diff --git a/code/addons/docs/src/blocks/components/Title.tsx b/code/addons/docs/src/blocks/components/Title.tsx index 7bfb0c71af63..16107d06aab7 100644 --- a/code/addons/docs/src/blocks/components/Title.tsx +++ b/code/addons/docs/src/blocks/components/Title.tsx @@ -1,10 +1,12 @@ import { withReset } from 'storybook/internal/components'; +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; const breakpoint = 600; -export const Title = styled.h1(withReset, ({ theme }) => ({ +export const Title = styled.h1(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), color: theme.color.defaultText, fontSize: theme.typography.size.m3, fontWeight: theme.typography.weight.bold, diff --git a/code/core/src/components/components/typography/elements/A.tsx b/code/core/src/components/components/typography/elements/A.tsx index e91833da6cfc..27e59c64ac82 100644 --- a/code/core/src/components/components/typography/elements/A.tsx +++ b/code/core/src/components/components/typography/elements/A.tsx @@ -1,9 +1,11 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { withReset } from '../lib/common'; import { Link } from './Link'; -export const A = styled(Link)(withReset, ({ theme }) => ({ +export const A = styled(Link)(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), fontSize: 'inherit', lineHeight: '24px', diff --git a/code/core/src/components/components/typography/elements/Blockquote.tsx b/code/core/src/components/components/typography/elements/Blockquote.tsx index f0904b078c0a..b89736cc2ec1 100644 --- a/code/core/src/components/components/typography/elements/Blockquote.tsx +++ b/code/core/src/components/components/typography/elements/Blockquote.tsx @@ -1,8 +1,11 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { withMargin, withReset } from '../lib/common'; -export const Blockquote = styled.blockquote(withReset, withMargin, ({ theme }) => ({ +export const Blockquote = styled.blockquote(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...withMargin, borderLeft: `4px solid ${theme.color.medium}`, padding: '0 15px', color: theme.color.dark, diff --git a/code/core/src/components/components/typography/elements/Code.tsx b/code/core/src/components/components/typography/elements/Code.tsx index b59b22c98184..2c37b22fae01 100644 --- a/code/core/src/components/components/typography/elements/Code.tsx +++ b/code/core/src/components/components/typography/elements/Code.tsx @@ -1,6 +1,7 @@ import type { ComponentProps } from 'react'; import React, { Children } from 'react'; +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { SyntaxHighlighter } from '../../syntaxhighlighter/lazy-syntaxhighlighter'; @@ -10,20 +11,18 @@ import { isReactChildString } from '../lib/isReactChildString'; const isInlineCodeRegex = /[\n\r]/g; -const DefaultCodeBlock = styled.code( - ({ theme }) => ({ - // from reset - fontFamily: theme.typography.fonts.mono, - WebkitFontSmoothing: 'antialiased', - MozOsxFontSmoothing: 'grayscale', - display: 'inline-block', - paddingLeft: 2, - paddingRight: 2, - verticalAlign: 'baseline', - color: 'inherit', - }), - codeCommon -); +const DefaultCodeBlock = styled.code(({ theme }) => ({ + // from reset + fontFamily: theme.typography.fonts.mono, + WebkitFontSmoothing: 'antialiased', + MozOsxFontSmoothing: 'grayscale', + display: 'inline-block', + paddingLeft: 2, + paddingRight: 2, + verticalAlign: 'baseline', + color: 'inherit', + ...(codeCommon({ theme }) as CSSObject), +})); const StyledSyntaxHighlighter = styled(SyntaxHighlighter)(({ theme }) => ({ // DocBlocks-specific styling and overrides diff --git a/code/core/src/components/components/typography/elements/DL.tsx b/code/core/src/components/components/typography/elements/DL.tsx index a91991ca79fa..7f7128e792c3 100644 --- a/code/core/src/components/components/typography/elements/DL.tsx +++ b/code/core/src/components/components/typography/elements/DL.tsx @@ -1,8 +1,11 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { withMargin, withReset } from '../lib/common'; -export const DL = styled.dl(withReset, withMargin, { +export const DL = styled.dl(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...withMargin, padding: 0, '& dt': { fontSize: '14px', @@ -34,4 +37,4 @@ export const DL = styled.dl(withReset, withMargin, { '& dd > :last-child': { marginBottom: 0, }, -}); +})); diff --git a/code/core/src/components/components/typography/elements/H1.tsx b/code/core/src/components/components/typography/elements/H1.tsx index 83ff20ebf270..ec6130edfbc2 100644 --- a/code/core/src/components/components/typography/elements/H1.tsx +++ b/code/core/src/components/components/typography/elements/H1.tsx @@ -1,8 +1,11 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { headerCommon, withReset } from '../lib/common'; -export const H1 = styled.h1(withReset, headerCommon, ({ theme }) => ({ +export const H1 = styled.h1(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...(headerCommon({ theme }) as CSSObject), fontSize: `${theme.typography.size.l1}px`, fontWeight: theme.typography.weight.bold, })); diff --git a/code/core/src/components/components/typography/elements/H2.tsx b/code/core/src/components/components/typography/elements/H2.tsx index fe99631b7c01..21f48af7ae28 100644 --- a/code/core/src/components/components/typography/elements/H2.tsx +++ b/code/core/src/components/components/typography/elements/H2.tsx @@ -1,8 +1,11 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { headerCommon, withReset } from '../lib/common'; -export const H2 = styled.h2(withReset, headerCommon, ({ theme }) => ({ +export const H2 = styled.h2(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...(headerCommon({ theme }) as CSSObject), fontSize: `${theme.typography.size.m2}px`, paddingBottom: 4, borderBottom: `1px solid ${theme.appBorderColor}`, diff --git a/code/core/src/components/components/typography/elements/H3.tsx b/code/core/src/components/components/typography/elements/H3.tsx index 18e91649d968..255bbbe3a552 100644 --- a/code/core/src/components/components/typography/elements/H3.tsx +++ b/code/core/src/components/components/typography/elements/H3.tsx @@ -1,7 +1,10 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { headerCommon, withReset } from '../lib/common'; -export const H3 = styled.h3(withReset, headerCommon, ({ theme }) => ({ +export const H3 = styled.h3(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...(headerCommon({ theme }) as CSSObject), fontSize: `${theme.typography.size.m1}px`, })); diff --git a/code/core/src/components/components/typography/elements/H4.tsx b/code/core/src/components/components/typography/elements/H4.tsx index ab07e958e966..0734c8897d3a 100644 --- a/code/core/src/components/components/typography/elements/H4.tsx +++ b/code/core/src/components/components/typography/elements/H4.tsx @@ -1,7 +1,10 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { headerCommon, withReset } from '../lib/common'; -export const H4 = styled.h4(withReset, headerCommon, ({ theme }) => ({ +export const H4 = styled.h4(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...(headerCommon({ theme }) as CSSObject), fontSize: `${theme.typography.size.s3}px`, })); diff --git a/code/core/src/components/components/typography/elements/H5.tsx b/code/core/src/components/components/typography/elements/H5.tsx index 816deae8a5bd..71e6a8249cf6 100644 --- a/code/core/src/components/components/typography/elements/H5.tsx +++ b/code/core/src/components/components/typography/elements/H5.tsx @@ -1,7 +1,10 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { headerCommon, withReset } from '../lib/common'; -export const H5 = styled.h5(withReset, headerCommon, ({ theme }) => ({ +export const H5 = styled.h5(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...(headerCommon({ theme }) as CSSObject), fontSize: `${theme.typography.size.s2}px`, })); diff --git a/code/core/src/components/components/typography/elements/H6.tsx b/code/core/src/components/components/typography/elements/H6.tsx index 0a7ca673735a..39be66f7672a 100644 --- a/code/core/src/components/components/typography/elements/H6.tsx +++ b/code/core/src/components/components/typography/elements/H6.tsx @@ -1,8 +1,11 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { headerCommon, withReset } from '../lib/common'; -export const H6 = styled.h6(withReset, headerCommon, ({ theme }) => ({ +export const H6 = styled.h6(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...(headerCommon({ theme }) as CSSObject), fontSize: `${theme.typography.size.s2}px`, color: theme.color.dark, })); diff --git a/code/core/src/components/components/typography/elements/LI.tsx b/code/core/src/components/components/typography/elements/LI.tsx index 66465cf9df67..898d0d628940 100644 --- a/code/core/src/components/components/typography/elements/LI.tsx +++ b/code/core/src/components/components/typography/elements/LI.tsx @@ -3,7 +3,8 @@ import { styled } from 'storybook/theming'; import { codeCommon, withReset } from '../lib/common'; -export const LI = styled.li(withReset, ({ theme }) => ({ +export const LI = styled.li(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), fontSize: theme.typography.size.s2, color: theme.color.defaultText, lineHeight: '24px', diff --git a/code/core/src/components/components/typography/elements/OL.tsx b/code/core/src/components/components/typography/elements/OL.tsx index a68b38f52f9e..ac697965cb31 100644 --- a/code/core/src/components/components/typography/elements/OL.tsx +++ b/code/core/src/components/components/typography/elements/OL.tsx @@ -1,9 +1,9 @@ -import type { Interpolation } from 'storybook/theming'; +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { withMargin, withReset } from '../lib/common'; -const listCommon: Interpolation = { +const listCommon = { paddingLeft: 30, '& :first-of-type': { marginTop: 0, @@ -13,6 +13,9 @@ const listCommon: Interpolation = { }, }; -export const OL = styled.ol(withReset, withMargin, listCommon, { +export const OL = styled.ol(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...withMargin, + ...listCommon, listStyle: 'decimal', -}); +})); diff --git a/code/core/src/components/components/typography/elements/P.tsx b/code/core/src/components/components/typography/elements/P.tsx index 57dd66bbbe55..4e4f0e6ebf43 100644 --- a/code/core/src/components/components/typography/elements/P.tsx +++ b/code/core/src/components/components/typography/elements/P.tsx @@ -3,7 +3,9 @@ import { styled } from 'storybook/theming'; import { codeCommon, withMargin, withReset } from '../lib/common'; -export const P = styled.p(withReset, withMargin, ({ theme }) => ({ +export const P = styled.p(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...withMargin, fontSize: theme.typography.size.s2, lineHeight: '24px', color: theme.color.defaultText, diff --git a/code/core/src/components/components/typography/elements/Pre.tsx b/code/core/src/components/components/typography/elements/Pre.tsx index f08c986003c5..e308ab28b302 100644 --- a/code/core/src/components/components/typography/elements/Pre.tsx +++ b/code/core/src/components/components/typography/elements/Pre.tsx @@ -1,8 +1,11 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { withMargin, withReset } from '../lib/common'; -export const Pre = styled.pre(withReset, withMargin, ({ theme }) => ({ +export const Pre = styled.pre(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...withMargin, // reset fontFamily: theme.typography.fonts.mono, WebkitFontSmoothing: 'antialiased', diff --git a/code/core/src/components/components/typography/elements/Span.tsx b/code/core/src/components/components/typography/elements/Span.tsx index 836370e8f46b..6e9ebc0a537c 100644 --- a/code/core/src/components/components/typography/elements/Span.tsx +++ b/code/core/src/components/components/typography/elements/Span.tsx @@ -1,8 +1,10 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { withReset } from '../lib/common'; -export const Span = styled.span(withReset, ({ theme }) => ({ +export const Span = styled.span(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), '&.frame': { display: 'block', overflow: 'hidden', diff --git a/code/core/src/components/components/typography/elements/Table.tsx b/code/core/src/components/components/typography/elements/Table.tsx index 349430b696be..87d510a24756 100644 --- a/code/core/src/components/components/typography/elements/Table.tsx +++ b/code/core/src/components/components/typography/elements/Table.tsx @@ -1,8 +1,11 @@ +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { withMargin, withReset } from '../lib/common'; -export const Table = styled.table(withReset, withMargin, ({ theme }) => ({ +export const Table = styled.table(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...withMargin, fontSize: theme.typography.size.s2, lineHeight: '24px', padding: 0, diff --git a/code/core/src/components/components/typography/elements/UL.tsx b/code/core/src/components/components/typography/elements/UL.tsx index a3edef90968a..8f52c2b033ee 100644 --- a/code/core/src/components/components/typography/elements/UL.tsx +++ b/code/core/src/components/components/typography/elements/UL.tsx @@ -1,9 +1,9 @@ -import type { Interpolation } from 'storybook/theming'; +import type { CSSObject } from 'storybook/theming'; import { styled } from 'storybook/theming'; import { withMargin, withReset } from '../lib/common'; -const listCommon: Interpolation = { +const listCommon = { paddingLeft: 30, '& :first-of-type': { marginTop: 0, @@ -13,4 +13,9 @@ const listCommon: Interpolation = { }, }; -export const UL = styled.ul(withReset, withMargin, listCommon, { listStyle: 'disc' }); +export const UL = styled.ul(({ theme }) => ({ + ...(withReset({ theme }) as CSSObject), + ...withMargin, + ...listCommon, + listStyle: 'disc', +})); From 42fbd9ca2ec2fc543dbf29dff96c0e1c40c4873e Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 5 Mar 2026 11:17:10 +0100 Subject: [PATCH 013/103] Apply suggestion from @JReinhold --- code/core/src/types/modules/core-common.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index a370d51d541b..678bf377d647 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -501,6 +501,11 @@ export interface StorybookConfigRaw { /** Only show input controls in Angular */ angularFilterNonInputControls?: boolean; + /** + * Enable component manifest generation for MCP and other tooling integrations. + * + * @default true + */ componentsManifest?: boolean; /** From 07e199644879cad9b4413b24cfd4d664cb61d011 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 9 Mar 2026 14:54:59 +0100 Subject: [PATCH 014/103] Test: Fix clearing mocks in Vitest --- code/.storybook/main.ts | 4 ++ code/.storybook/preview.tsx | 1 + code/core/src/mocking-utils/automock.ts | 16 +++++++- code/core/src/test/spy.ts | 19 +++++++++ .../stories/test/ClearModuleMocks.api.ts | 17 ++++++++ .../stories/test/ClearModuleMocks.stories.tsx | 41 +++++++++++++++++++ .../stories/test/ClearModuleMocks.tsx | 15 +++++++ scripts/tasks/sandbox-parts.ts | 1 + 8 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 code/core/template/stories/test/ClearModuleMocks.api.ts create mode 100644 code/core/template/stories/test/ClearModuleMocks.stories.tsx create mode 100644 code/core/template/stories/test/ClearModuleMocks.tsx diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 05b99156056f..0d32d19e1260 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -55,6 +55,10 @@ const config = defineMain({ directory: '../core/src/highlight', titlePrefix: 'highlight', }, + { + directory: '../core/src/test', + titlePrefix: 'test', + }, { directory: '../addons/docs/src/blocks', titlePrefix: 'addons/docs/blocks', diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index adc5556ffb0c..5a4eea04543c 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -37,6 +37,7 @@ import { isChromatic } from './isChromatic'; sb.mock('../core/template/stories/test/ModuleMocking.utils.ts'); sb.mock('../core/template/stories/test/ModuleSpyMocking.utils.ts', { spy: true }); sb.mock('../core/template/stories/test/ModuleAutoMocking.utils.ts'); +sb.mock('../core/template/stories/test/ClearModuleMocks.api.ts', {spy: true}); /* eslint-disable depend/ban-dependencies */ sb.mock(import('lodash-es')); sb.mock(import('lodash-es/add')); diff --git a/code/core/src/mocking-utils/automock.ts b/code/core/src/mocking-utils/automock.ts index aac315198c81..9cd30d5cb8a6 100644 --- a/code/core/src/mocking-utils/automock.ts +++ b/code/core/src/mocking-utils/automock.ts @@ -171,6 +171,20 @@ const __vitest_current_es_module__ = { } const __vitest_mocked_module__ = globalThis[${globalThisAccessor}].mockObject(__vitest_current_es_module__, "${mockType}") `; + + // Register module mock spies in the global registry so that clearAllMocks/resetAllMocks/ + // restoreAllMocks from storybook/test can find and clear them. This is needed because the + // module mocker may use a different @vitest/spy instance than the one bundled with storybook/test. + const spyRegistration = ` +if (!globalThis.__STORYBOOK_MODULE_MOCK_SPIES__) { globalThis.__STORYBOOK_MODULE_MOCK_SPIES__ = new Set(); } +for (const __vitest_key__ of Object.keys(__vitest_mocked_module__)) { + const __vitest_val__ = __vitest_mocked_module__[__vitest_key__]; + if (__vitest_val__ && typeof __vitest_val__ === "function" && __vitest_val__._isMockFunction === true) { + globalThis.__STORYBOOK_MODULE_MOCK_SPIES__.add(__vitest_val__); + } +} +`; + const assigning = allSpecifiers .map(({ name }, index) => { return `const __vitest_mocked_${index}__ = __vitest_mocked_module__["${name}"]`; @@ -187,6 +201,6 @@ export { ${redeclarations} } `; - m.append(moduleObject + assigning + specifiersExports); + m.append(moduleObject + spyRegistration + assigning + specifiersExports); return m; } diff --git a/code/core/src/test/spy.ts b/code/core/src/test/spy.ts index 8e9537e5f091..913635a584bb 100644 --- a/code/core/src/test/spy.ts +++ b/code/core/src/test/spy.ts @@ -16,6 +16,18 @@ export type * from '@vitest/spy'; export { isMockFunction, mocks }; +/** + * Global registry for module mock spies created by `sb.mock('...', { spy: true })`. + * + * These spies are created by the module mocker (via `__vitest_mocker__.mockObject()`) and may use a + * different `@vitest/spy` instance than the one bundled with storybook/test. This means they won't + * appear in the `mocks` Set that `clearAllMocks`/`resetAllMocks`/`restoreAllMocks` iterate over. + * + * The automock code generation registers spies here so they can be properly cleared between stories. + */ +const moduleMockSpies: Set = ((globalThis as any).__STORYBOOK_MODULE_MOCK_SPIES__ ??= + new Set()); + type Listener = (mock: MockInstance, args: unknown[]) => void; const listeners = new Set(); @@ -63,6 +75,7 @@ function listenWhenCalled(mock: MockInstance) { */ export function clearAllMocks() { mocks.forEach((spy) => spy.mockClear()); + moduleMockSpies.forEach((spy) => spy.mockClear()); } /** @@ -74,6 +87,7 @@ export function clearAllMocks() { */ export function resetAllMocks() { mocks.forEach((spy) => spy.mockReset()); + moduleMockSpies.forEach((spy) => spy.mockReset()); } /** @@ -82,6 +96,11 @@ export function resetAllMocks() { */ export function restoreAllMocks() { mocks.forEach((spy) => spy.mockRestore()); + // For module mock spies, we only clear call history (not restore), because: + // - mockRestore() would try to undo the spyOn on the module export object, which is not + // meaningful for automocked modules where the spy reference is captured at module load time + // - The spy needs to remain active for subsequent stories + moduleMockSpies.forEach((spy) => spy.mockClear()); } /** diff --git a/code/core/template/stories/test/ClearModuleMocks.api.ts b/code/core/template/stories/test/ClearModuleMocks.api.ts new file mode 100644 index 000000000000..8dc03b90db0f --- /dev/null +++ b/code/core/template/stories/test/ClearModuleMocks.api.ts @@ -0,0 +1,17 @@ +export type Data = { + userId: number; + id: number; + title: string; + body: string; +}; + +export const fetchData = async (): Promise => { + return Promise.resolve([ + { + userId: 1, + id: 1, + title: 'mocked title', + body: 'mocked body', + }, + ]); +}; diff --git a/code/core/template/stories/test/ClearModuleMocks.stories.tsx b/code/core/template/stories/test/ClearModuleMocks.stories.tsx new file mode 100644 index 000000000000..7c4b173779e6 --- /dev/null +++ b/code/core/template/stories/test/ClearModuleMocks.stories.tsx @@ -0,0 +1,41 @@ +// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { ClearModuleMocks } from './ClearModuleMocks'; +import { clearAllMocks, expect, waitFor } from 'storybook/test'; + +import { fetchData } from './ClearModuleMocks.api'; + +/** + * The purpose of this story is to verify that the `clearAllMocks` function properly clears mocks created with the `spy: true` option in `sb.mock()`. + * This is necessary because those mocks are created with a different instance of `@vitest/spy` than the one bundled with storybook/test. + * This means they won't be cleared by the `clearMocks` option of Vitest, and we need to use `clearAllMocks` to clear them manually. + * See issue: https://github.com/storybookjs/storybook/issues/34075 + */ +const meta = { + component: ClearModuleMocks, + beforeEach: async () => { + clearAllMocks(); + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const First: Story = { + args: {}, + play: async () => { + await waitFor(() => { + expect(fetchData).toHaveBeenCalledTimes(1); + }); + } +}; + +export const Second: Story = { + args: {}, + play: async () => { + await waitFor(() => { + expect(fetchData).toHaveBeenCalledTimes(1); + }); + } +}; \ No newline at end of file diff --git a/code/core/template/stories/test/ClearModuleMocks.tsx b/code/core/template/stories/test/ClearModuleMocks.tsx new file mode 100644 index 000000000000..65c26376d697 --- /dev/null +++ b/code/core/template/stories/test/ClearModuleMocks.tsx @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; +import { Data, fetchData } from "./ClearModuleMocks.api" + +export const ClearModuleMocks = () => { + + const [data, setData] = useState([]); + + useEffect(() => { + fetchData().then(data => setData(data)); + }, []); + + return
    + {data.map((elm, idx) =>
  • {elm.title}
  • )} +
+} \ No newline at end of file diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index 4558e4725d7b..aebf7990705b 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -855,6 +855,7 @@ export const extendPreview: Task['run'] = async ({ template, sandboxDir }) => { "sb.mock('../template-stories/core/test/ModuleMocking.utils.ts');", "sb.mock('../template-stories/core/test/ModuleSpyMocking.utils.ts', { spy: true });", "sb.mock('../template-stories/core/test/ModuleAutoMocking.utils.ts');", + "sb.mock('../template-stories/core/test/ClearModuleMocks.api.ts');", "sb.mock(import('lodash-es'));", "sb.mock(import('lodash-es/add'));", "sb.mock(import('lodash-es/sum'));", From 8bba443f342004d10d6680271ae3834450e62d56 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 9 Mar 2026 16:14:57 +0100 Subject: [PATCH 015/103] Cleanup --- code/.storybook/preview.tsx | 2 +- code/core/src/test/spy.ts | 3 ++- .../stories/test/ClearModuleMocks.stories.tsx | 18 +++++++++------- .../stories/test/ClearModuleMocks.tsx | 21 ++++++++++++------- scripts/tasks/sandbox-parts.ts | 2 +- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index 5a4eea04543c..54b9eca2ee3c 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -37,7 +37,7 @@ import { isChromatic } from './isChromatic'; sb.mock('../core/template/stories/test/ModuleMocking.utils.ts'); sb.mock('../core/template/stories/test/ModuleSpyMocking.utils.ts', { spy: true }); sb.mock('../core/template/stories/test/ModuleAutoMocking.utils.ts'); -sb.mock('../core/template/stories/test/ClearModuleMocks.api.ts', {spy: true}); +sb.mock('../core/template/stories/test/ClearModuleMocks.api.ts', { spy: true }); /* eslint-disable depend/ban-dependencies */ sb.mock(import('lodash-es')); sb.mock(import('lodash-es/add')); diff --git a/code/core/src/test/spy.ts b/code/core/src/test/spy.ts index 913635a584bb..673c800001cc 100644 --- a/code/core/src/test/spy.ts +++ b/code/core/src/test/spy.ts @@ -23,7 +23,8 @@ export { isMockFunction, mocks }; * different `@vitest/spy` instance than the one bundled with storybook/test. This means they won't * appear in the `mocks` Set that `clearAllMocks`/`resetAllMocks`/`restoreAllMocks` iterate over. * - * The automock code generation registers spies here so they can be properly cleared between stories. + * The automock code generation registers spies here so they can be properly cleared between + * stories. */ const moduleMockSpies: Set = ((globalThis as any).__STORYBOOK_MODULE_MOCK_SPIES__ ??= new Set()); diff --git a/code/core/template/stories/test/ClearModuleMocks.stories.tsx b/code/core/template/stories/test/ClearModuleMocks.stories.tsx index 7c4b173779e6..6e33524c9ee0 100644 --- a/code/core/template/stories/test/ClearModuleMocks.stories.tsx +++ b/code/core/template/stories/test/ClearModuleMocks.stories.tsx @@ -1,16 +1,18 @@ // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. import type { Meta, StoryObj } from '@storybook/react-vite'; -import { ClearModuleMocks } from './ClearModuleMocks'; import { clearAllMocks, expect, waitFor } from 'storybook/test'; +import { ClearModuleMocks } from './ClearModuleMocks'; import { fetchData } from './ClearModuleMocks.api'; /** - * The purpose of this story is to verify that the `clearAllMocks` function properly clears mocks created with the `spy: true` option in `sb.mock()`. - * This is necessary because those mocks are created with a different instance of `@vitest/spy` than the one bundled with storybook/test. - * This means they won't be cleared by the `clearMocks` option of Vitest, and we need to use `clearAllMocks` to clear them manually. - * See issue: https://github.com/storybookjs/storybook/issues/34075 + * The purpose of this story is to verify that the `clearAllMocks` function properly clears mocks + * created with the `spy: true` option in `sb.mock()`. This is necessary because those mocks are + * created with a different instance of `@vitest/spy` than the one bundled with storybook/test. This + * means they won't be cleared by the `clearMocks` option of Vitest, and we need to use + * `clearAllMocks` to clear them manually. See issue: + * https://github.com/storybookjs/storybook/issues/34075 */ const meta = { component: ClearModuleMocks, @@ -28,7 +30,7 @@ export const First: Story = { await waitFor(() => { expect(fetchData).toHaveBeenCalledTimes(1); }); - } + }, }; export const Second: Story = { @@ -37,5 +39,5 @@ export const Second: Story = { await waitFor(() => { expect(fetchData).toHaveBeenCalledTimes(1); }); - } -}; \ No newline at end of file + }, +}; diff --git a/code/core/template/stories/test/ClearModuleMocks.tsx b/code/core/template/stories/test/ClearModuleMocks.tsx index 65c26376d697..53d27a45b0a5 100644 --- a/code/core/template/stories/test/ClearModuleMocks.tsx +++ b/code/core/template/stories/test/ClearModuleMocks.tsx @@ -1,15 +1,20 @@ -import { useEffect, useState } from "react"; -import { Data, fetchData } from "./ClearModuleMocks.api" +import { useEffect, useState } from 'react'; -export const ClearModuleMocks = () => { +import type { Data } from './ClearModuleMocks.api'; +import { fetchData } from './ClearModuleMocks.api'; +export const ClearModuleMocks = () => { const [data, setData] = useState([]); useEffect(() => { - fetchData().then(data => setData(data)); + fetchData().then((data) => setData(data)); }, []); - return
    - {data.map((elm, idx) =>
  • {elm.title}
  • )} -
-} \ No newline at end of file + return ( +
    + {data.map((elm, idx) => ( +
  • {elm.title}
  • + ))} +
+ ); +}; diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index aebf7990705b..240e4cf8a9c3 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -855,7 +855,7 @@ export const extendPreview: Task['run'] = async ({ template, sandboxDir }) => { "sb.mock('../template-stories/core/test/ModuleMocking.utils.ts');", "sb.mock('../template-stories/core/test/ModuleSpyMocking.utils.ts', { spy: true });", "sb.mock('../template-stories/core/test/ModuleAutoMocking.utils.ts');", - "sb.mock('../template-stories/core/test/ClearModuleMocks.api.ts');", + "sb.mock('../template-stories/core/test/ClearModuleMocks.api.ts', { spy: true });", "sb.mock(import('lodash-es'));", "sb.mock(import('lodash-es/add'));", "sb.mock(import('lodash-es/sum'));", From 3f53d044374715d36dd460ff9ce1478390eb6191 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 10 Mar 2026 15:32:36 +0100 Subject: [PATCH 016/103] Lock dimensions in tandem --- code/core/src/manager/components/preview/Viewport.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/core/src/manager/components/preview/Viewport.tsx b/code/core/src/manager/components/preview/Viewport.tsx index 282f619aa1d9..4e52292d7f77 100644 --- a/code/core/src/manager/components/preview/Viewport.tsx +++ b/code/core/src/manager/components/preview/Viewport.tsx @@ -285,8 +285,8 @@ export const Viewport = ({ height: `${ny || height}${uy === 'px' ? '' : uy}`, }, locked: { - width: !nx, - height: !ny, + width: !nx || !ny, + height: !nx || !ny, }, }; }, [width, height, scale]); From 49b8341ad0af6b4cce53bb5c47831a106edbf440 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 30 Dec 2025 13:30:37 +0100 Subject: [PATCH 017/103] UI: Make TagsFilter state part of the layout module --- code/.storybook/preview.tsx | 2 + code/core/src/manager-api/modules/layout.ts | 282 ++++++++++++++++-- code/core/src/manager-api/modules/url.ts | 10 +- code/core/src/manager-api/store.ts | 43 ++- .../core/src/manager-api/tests/layout.test.ts | 4 +- .../manager/components/sidebar/Sidebar.tsx | 16 +- .../components/sidebar/TagsFilter.stories.tsx | 236 ++++++++++++--- .../manager/components/sidebar/TagsFilter.tsx | 196 ++---------- .../sidebar/TagsFilterPanel.stories.tsx | 213 ++++++++----- .../components/sidebar/TagsFilterPanel.tsx | 175 ++++++++--- code/core/src/types/modules/api.ts | 17 +- 11 files changed, 800 insertions(+), 394 deletions(-) diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index adc5556ffb0c..df899c18e33b 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -34,6 +34,8 @@ import * as templatePreview from '../core/template/stories/preview'; import '../renderers/react/template/components/index'; import { isChromatic } from './isChromatic'; +sb.mock(import('@storybook/global'), { spy: true }); + sb.mock('../core/template/stories/test/ModuleMocking.utils.ts'); sb.mock('../core/template/stories/test/ModuleSpyMocking.utils.ts', { spy: true }); sb.mock('../core/template/stories/test/ModuleAutoMocking.utils.ts'); diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index a0df07067e5b..69b539be3f78 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -1,21 +1,28 @@ -import { SET_CONFIG } from 'storybook/internal/core-events'; +import { SET_CONFIG, STORY_INDEX_INVALIDATED } from 'storybook/internal/core-events'; import type { API_Layout, API_LayoutCustomisations, API_PanelPositions, + API_PreparedIndexEntry, API_UI, + FilterFunction, + Tag, + TagsOptions, } from 'storybook/internal/types'; import { global } from '@storybook/global'; import { pick, toMerged } from 'es-toolkit/object'; import { isEqual as deepEqual } from 'es-toolkit/predicate'; +import memoize from 'memoizerific'; import type { ThemeVars } from 'storybook/theming'; import { create } from 'storybook/theming/create'; +import { Tag as TagEnum } from '../../shared/constants/tags'; import merge from '../lib/merge'; import type { ModuleFn } from '../lib/types'; import type { State } from '../root'; +import type Store from '../store'; const { document } = global; @@ -35,6 +42,24 @@ export interface SubState { theme: ThemeVars; } +const TAGS_FILTER = 'tags-filter'; + +const BUILT_IN_FILTERS = { + _docs: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded ? entry.type !== 'docs' : entry.type === 'docs', + _play: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded + ? entry.type !== 'story' || !entry.tags?.includes(TagEnum.PLAY_FN) + : entry.type === 'story' && !!entry.tags?.includes(TagEnum.PLAY_FN), + _test: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded + ? entry.type !== 'story' || entry.subtype !== 'test' + : entry.type === 'story' && entry.subtype === 'test', +}; + +const USER_TAG_FILTER = (tag: Tag) => (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); + export interface SubAPI { /** * Toggles the fullscreen mode of the Storybook UI. @@ -117,34 +142,95 @@ export interface SubAPI { elementId?: string, options?: boolean | { forceFocus?: boolean; select?: boolean; poll?: boolean } ) => boolean | Promise; + + /** Resets tag filters in the sidebar to the default filters. */ + resetTagFilters(): void; + /** + * Replaces all tag filters in the sidebar with the provided included and excluded lists. + * + * @param included The tags to include in the filtered stories list + * @param excluded The tags to filter out (exclude) from the stories list + */ + setAllTagFilters(included: Tag[], excluded: Tag[]): void; + /** + * Adds tag filters to the included or excluded filter lists. Included filters are included in the + * stories list, whereas excluded filters are filtered out. + * + * @param tags The tags to add as filters. + * @param excluded Whether to add the tags to the include or exclude filter list. + */ + addTagFilters(tags: Tag[], excluded: boolean): void; + /** + * Removes tag filters from both the included and excluded filter lists. + * + * @param tags The tags to remove from filters. + */ + removeTagFilters(tags: Tag[]): void; + /** Gets the function to use to filter the index based on a given tag. */ + getFilterFunction(tag: Tag): FilterFunction | null; + /** Gets the default included tag filters. */ + getDefaultIncludedTagFilters(): Tag[]; + /** Gets the default excluded tag filters. */ + getDefaultExcludedTagFilters(): Tag[]; + /** Gets the currently included tag filters. */ + getIncludedTagFilters(): Tag[]; + /** Gets the currently excluded tag filters. */ + getExcludedTagFilters(): Tag[]; } type PartialSubState = Partial; -export const defaultLayoutState: SubState = { - ui: { - enableShortcuts: true, - }, - layout: { - initialActive: ActiveTabs.CANVAS, - showToolbar: true, - navSize: 300, - bottomPanelHeight: 300, - rightPanelWidth: 400, - recentVisibleSizes: { - navSize: 300, - bottomPanelHeight: 300, - rightPanelWidth: 400, +const getDefaultTagsFromPreset = memoize(1)(( + presets: TagsOptions +): { included: Tag[]; excluded: Tag[] } => { + const presetEntries = Object.entries(presets); + return { + included: presetEntries + .filter(([, option]) => option.defaultFilterSelection === 'include') + .map(([tag]) => tag), + excluded: presetEntries + .filter(([, option]) => option.defaultFilterSelection === 'exclude') + .map(([tag]) => tag), + }; +}); + +export const DEFAULT_NAV_SIZE = 300; +export const DEFAULT_BOTTOM_PANEL_HEIGHT = 300; +export const DEFAULT_RIGHT_PANEL_WIDTH = 400; + +export const getDefaultLayoutState: () => SubState = () => { + // tagPresets is a local copy of global.TAGS_OPTIONS. Neither is expected to change at runtime. + const tagPresets = global.TAGS_OPTIONS || {}; + const defaultTags = getDefaultTagsFromPreset(tagPresets); + + return { + ui: { + enableShortcuts: true, }, - panelPosition: 'bottom', - showTabs: true, - }, - layoutCustomisations: { - showSidebar: undefined, - showToolbar: undefined, - }, - selectedPanel: undefined, - theme: create(), + layout: { + initialActive: ActiveTabs.CANVAS, + tagPresets, + includedTagFilters: defaultTags.included, + excludedTagFilters: defaultTags.excluded, + showToolbar: true, + navSize: DEFAULT_NAV_SIZE, + bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, + rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH, + recentVisibleSizes: { + navSize: DEFAULT_NAV_SIZE, + bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, + rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH, + }, + panelPosition: 'bottom', + showTabs: true, + }, + layoutCustomisations: { + showSidebar: undefined, + showToolbar: undefined, + }, + selectedPanel: undefined, + theme: create(), + }; }; export const focusableUIElements = { @@ -184,7 +270,41 @@ const getRecentVisibleSizes = (layoutState: API_Layout) => { }; }; -export const init: ModuleFn = ({ store, provider, singleStory }) => { +const recomputeFilters = (fullAPI: Parameters[0]['fullAPI'], store: Store) => { + const { + layout: { includedTagFilters, excludedTagFilters }, + } = store.getState(); + + const computeFilterFunctions = (set: Tag[]): FilterFunction[][] => { + return Object.values( + set.reduce( + (acc, tag) => { + if (tag in BUILT_IN_FILTERS) { + acc['built-in'].push(BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]); + } else { + acc.user.push(USER_TAG_FILTER(tag)); + } + return acc; + }, + { 'built-in': [], user: [] } as { 'built-in': FilterFunction[]; user: FilterFunction[] } + ) + ).filter((group) => group.length > 0); + }; + + fullAPI.experimental_setFilter?.(TAGS_FILTER, (item: API_PreparedIndexEntry) => { + const included = computeFilterFunctions(includedTagFilters); + const excluded = computeFilterFunctions(excludedTagFilters); + + return ( + (!included.length || + included.every((group) => group.some((filterFn) => filterFn(item, false)))) && + (!excluded.length || + excluded.every((group) => group.every((filterFn) => filterFn(item, true)))) + ); + }); +}; + +export const init: ModuleFn = ({ fullAPI, store, provider, singleStory }) => { const api = { toggleFullscreen(nextState?: boolean) { return store.setState( @@ -432,6 +552,7 @@ export const init: ModuleFn = ({ store, provider, singleStory getInitialOptions() { const { theme, selectedPanel, layoutCustomisations, ...options } = provider.getConfig(); + const defaultLayoutState = getDefaultLayoutState(); return { ...defaultLayoutState, @@ -541,6 +662,110 @@ export const init: ModuleFn = ({ store, provider, singleStory store.setState({ theme: updatedTheme }); } }, + + getDefaultIncludedTagFilters: () => { + const state = store.getState(); + const { tagPresets } = state.layout; + return getDefaultTagsFromPreset(tagPresets).included; + }, + + getDefaultExcludedTagFilters: () => { + const state = store.getState(); + const { tagPresets } = state.layout; + return getDefaultTagsFromPreset(tagPresets).excluded; + }, + + getIncludedTagFilters: () => { + const state = store.getState(); + return state.layout.includedTagFilters; + }, + + getExcludedTagFilters: () => { + const state = store.getState(); + return state.layout.excludedTagFilters; + }, + + resetTagFilters: async () => { + const state = store.getState(); + const { tagPresets } = state.layout; + const { included, excluded } = getDefaultTagsFromPreset(tagPresets); + await store.setState( + (s: State) => ({ + layout: { + ...s.layout, + includedTagFilters: included, + excludedTagFilters: excluded, + }, + }), + { persistence: 'permanent' } + ); + recomputeFilters(fullAPI, store); + }, + + setAllTagFilters: async (included: Tag[], excluded: Tag[]) => { + await store.setState( + (s: State) => ({ + layout: { + ...s.layout, + includedTagFilters: included, + excludedTagFilters: excluded, + }, + }), + { persistence: 'permanent' } + ); + recomputeFilters(fullAPI, store); + }, + + addTagFilters: async (tags: Tag[], excluded: boolean) => { + await store.setState( + (s: State) => { + const newIncluded = new Set(s.layout.includedTagFilters); + const newExcluded = new Set(s.layout.excludedTagFilters); + for (const tag of tags) { + if (excluded) { + newIncluded.delete(tag); + newExcluded.add(tag); + } else { + newIncluded.add(tag); + newExcluded.delete(tag); + } + } + return { + layout: { + ...s.layout, + includedTagFilters: Array.from(newIncluded), + excludedTagFilters: Array.from(newExcluded), + }, + }; + }, + { persistence: 'permanent' } + ); + recomputeFilters(fullAPI, store); + }, + + removeTagFilters: async (tags: Tag[]) => { + await store.setState( + (s: State) => { + return { + layout: { + ...s.layout, + includedTagFilters: s.layout.includedTagFilters.filter((tag) => !tags.includes(tag)), + excludedTagFilters: s.layout.excludedTagFilters.filter((tag) => !tags.includes(tag)), + }, + }; + }, + { persistence: 'permanent' } + ); + recomputeFilters(fullAPI, store); + }, + + getFilterFunction(tag: Tag): FilterFunction | null { + if (tag in BUILT_IN_FILTERS) { + return BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]; + } else { + return USER_TAG_FILTER(tag); + } + }, }; const persisted = pick(store.getState(), ['layout', 'selectedPanel']); @@ -549,8 +774,15 @@ export const init: ModuleFn = ({ store, provider, singleStory api.setOptions(merge(api.getInitialOptions(), persisted)); }); + provider.channel?.on(STORY_INDEX_INVALIDATED, () => { + recomputeFilters(fullAPI, store); + }); + return { api, state: merge(api.getInitialOptions(), persisted), + init: () => { + recomputeFilters(fullAPI, store); + }, }; }; diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index e46494a34cf4..faa74bfb351c 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -17,7 +17,7 @@ import { stringify } from 'picoquery'; import merge from '../lib/merge'; import type { ModuleArgs, ModuleFn } from '../lib/types'; -import { defaultLayoutState } from './layout'; +import { DEFAULT_BOTTOM_PANEL_HEIGHT, DEFAULT_NAV_SIZE, DEFAULT_RIGHT_PANEL_WIDTH } from './layout'; export interface SubState { customQueryParams: QueryParams; @@ -87,14 +87,14 @@ const initialUrlSupport = ({ bottomPanelHeight = 0; rightPanelWidth = 0; } else if (parseBoolean(full) === false) { - navSize = defaultLayoutState.layout.navSize; - bottomPanelHeight = defaultLayoutState.layout.bottomPanelHeight; - rightPanelWidth = defaultLayoutState.layout.rightPanelWidth; + navSize = DEFAULT_NAV_SIZE; + bottomPanelHeight = DEFAULT_BOTTOM_PANEL_HEIGHT; + rightPanelWidth = DEFAULT_RIGHT_PANEL_WIDTH; } // set sizes based on nav if (!singleStory) { if (parseBoolean(nav) === true) { - navSize = defaultLayoutState.layout.navSize; + navSize = DEFAULT_NAV_SIZE; } if (parseBoolean(nav) === false) { navSize = 0; diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index ca19eb4f4293..de9c7acf6438 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -28,6 +28,7 @@ type GetState = () => State; type SetState = (a: any, b: any) => any; interface Upstream { + allowPersistence?: boolean; getState: GetState; setState: SetState; } @@ -46,11 +47,12 @@ type CallbackOrOptions = CallBack | Options; // Our store piggybacks off the internal React state of the Context Provider // It has been augmented to persist state to local/sessionStorage export default class Store { + upstreamPersistence: boolean; upstreamGetState: GetState; - upstreamSetState: SetState; - constructor({ setState, getState }: Upstream) { + constructor({ allowPersistence, setState, getState }: Upstream) { + this.upstreamPersistence = allowPersistence ?? true; this.upstreamSetState = setState; this.upstreamGetState = getState; } @@ -108,7 +110,7 @@ export default class Store { }); }); - if (persistence !== 'none') { + if (persistence !== 'none' && this.upstreamPersistence) { const storage = persistence === 'session' ? store.session : store.local; await update(storage, delta); } @@ -120,3 +122,38 @@ export default class Store { return newState; } } + +/** + * Factory function to create a valid Store instance for testing purposes. Provides a simple + * in-memory store without persistence logic. Useful for mocking the store in stories. + * + * @param initialState - The initial state for the store + * @param onChange - Optional callback invoked whenever state changes + * @returns A Store instance configured for testing + */ +export function createTestingStore( + initialState: State, + onChange?: (internalState: State) => void +): Store { + let internalState = { ...initialState }; + + const upstream = { + allowPersistence: false, + getState: () => internalState, + setState: (patch: any, callback?: any) => { + if (typeof patch === 'function') { + internalState = { ...internalState, ...patch(internalState) }; + } else { + internalState = { ...internalState, ...patch }; + } + if (callback && typeof callback === 'function') { + callback(internalState); + } + if (onChange) { + onChange(internalState); + } + }, + }; + + return new Store(upstream); +} diff --git a/code/core/src/manager-api/tests/layout.test.ts b/code/core/src/manager-api/tests/layout.test.ts index 0c382d586455..8bd96d737b8e 100644 --- a/code/core/src/manager-api/tests/layout.test.ts +++ b/code/core/src/manager-api/tests/layout.test.ts @@ -9,7 +9,7 @@ import { themes } from 'storybook/theming'; import type { ModuleArgs } from '../lib/types'; import type { SubState as AddonsSubState } from '../modules/addons'; import type { SubAPI, SubState } from '../modules/layout'; -import { defaultLayoutState, init as initLayout } from '../modules/layout'; +import { getDefaultLayoutState, init as initLayout } from '../modules/layout'; import type { API, State } from '../root'; import type Store from '../store'; @@ -24,7 +24,7 @@ describe('layout API', () => { beforeEach(() => { currentState = { - ...defaultLayoutState, + ...getDefaultLayoutState(), selectedPanel: 'storybook/internal/action/panel', theme: themes.light, singleStory: false, diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 6981f8701ec4..1ac66dd6b58c 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useRef, useState } from 'react'; import { Button, ScrollArea } from 'storybook/internal/components'; -import type { API_LoadedRefData, StoryIndex, TagsOptions } from 'storybook/internal/types'; +import type { API_LoadedRefData, StoryIndex } from 'storybook/internal/types'; import type { StatusesByStoryIdAndTypeId } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -130,16 +130,6 @@ export const Sidebar = React.memo(function Sidebar({ const api = useStorybookApi(); const { viewMode } = api.getUrlState(); - const tagPresets = useMemo( - () => - Object.entries(global.TAGS_OPTIONS ?? {}).reduce((acc, entry) => { - const [tag, option] = entry; - acc[tag] = option; - return acc; - }, {} as TagsOptions), - [] - ); - const headerRef = useRef(null); const { landmarkProps } = useLandmark( { 'aria-labelledby': 'global-site-h1', role: 'banner' }, @@ -194,9 +184,7 @@ export const Sidebar = React.memo(function Sidebar({ ) } - searchFieldContent={ - indexJson && - } + searchFieldContent={indexJson && } {...lastViewedProps} > {({ diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index e73c3a6e8200..2e617d50b6fe 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -1,32 +1,148 @@ +import React from 'react'; + +import { Channel } from 'storybook/internal/channels'; +import type { + API_Provider, + DecoratorFunction, + DocsIndexEntry, + StoryIndexEntry, +} from 'storybook/internal/types'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { findByRole, fn } from 'storybook/test'; +import { deepMerge } from '@vitest/utils'; +import type { API, State } from 'storybook/manager-api'; +import { expect, fn, screen, waitFor } from 'storybook/test'; +import type { ModuleArgs, ModuleFn } from '../../../manager-api/lib/types'; +import { init as initLayout } from '../../../manager-api/modules/layout'; +import { createTestingStore } from '../../../manager-api/store'; import { TagsFilter } from './TagsFilter'; +/** Mock API wrapper that forces component updates when store state changes. */ +export class MockAPIWrapper extends React.Component<{ + children: React.ReactNode; + args: Record; + initFn: ModuleFn; + initOptions?: Partial; + initialStoryState?: Partial; +}> { + api: ReturnType['api']; + store: ReturnType; + channel: Channel; + mounted: boolean; + + constructor(props: { + children: React.ReactNode; + args: Record; + initFn: ModuleFn; + initOptions?: Partial; + initialStoryState?: Partial; + }) { + super(props); + + // Set up store. + this.mounted = false; + this.store = createTestingStore({} as State, (newState) => { + if (this.mounted) { + this.setState(newState); + } + }); + + // Mock channel and provider. + this.channel = new Channel({}); + const provider: API_Provider = { + getConfig: () => ({}), + handleAPI: () => {}, + channel: this.channel, + }; + + // Mock other submodules we depend on. + const fullAPI = { + experimental_setFilter: fn().mockName('API::experimental_setFilter'), + } as unknown as API; + + const { api, init, state } = props.initFn({ + fullAPI, + store: this.store, + provider, + location: { search: '' }, + navigate: () => {}, + path: '', + docsOptions: {}, + state: {} as State, + ...(props.initOptions ?? {}), + }); + + // Apply module and initial story states. + if (props.initialStoryState) { + this.store.setState(deepMerge(state as State, props.initialStoryState)); + } else { + this.store.setState(state as State); + } + + // Call module's post init function if it exists. + if (init && typeof init === 'function') { + init(); + } + + this.api = api as API; + this.state = this.store.getState(); + } + + componentDidMount() { + this.mounted = true; + } + + render() { + const { children, args } = this.props; + return ( + <> + {React.cloneElement(children as React.ReactElement, { + args: { + ...args, + api: { + ...this.api, + getDocsUrl: () => 'https://storybook.js.org/docs/', + getUrlState: () => ({ + queryParams: {}, + path: '', + viewMode: 'story', + url: 'http://localhost:6006/', + }), + applyQueryParams: fn().mockName('api::applyQueryParams'), + }, + }, + })} + + ); + } +} + +export const MockAPIDecorator: DecoratorFunction = (Story, { args, parameters }) => ( + + + +); + const meta = { component: TagsFilter, title: 'Sidebar/TagsFilter', tags: ['haha', 'this-is-a-very-long-tag-that-will-be-truncated-after-a-while'], + decorators: [MockAPIDecorator], args: { - api: { - experimental_setFilter: fn(), - getDocsUrl: () => 'https://storybook.js.org/docs/', - getUrlState: () => ({ - queryParams: {}, - path: '', - viewMode: 'story', - url: 'http://localhost:6006/', - }), - applyQueryParams: fn().mockName('api::applyQueryParams'), - } as any, - tagPresets: {}, + api: {} as API, // Will be overridden by MockAPIWrapper 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, + 'c1-s1': { tags: ['A', 'B', 'C', 'dev', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c1-doc': { tags: [], type: 'docs' } as unknown as DocsIndexEntry, }, }, }, @@ -38,21 +154,68 @@ type Story = StoryObj; export const Closed: Story = {}; -export const ClosedWithSelection: Story = { - args: { - ...Closed.args, - tagPresets: { +export const ClosedWithDefaultTags: Story = { + beforeEach: () => { + const originalTagsOptions = global.TAGS_OPTIONS; + global.TAGS_OPTIONS = { A: { defaultFilterSelection: 'include' }, B: { defaultFilterSelection: 'include' }, + }; + + return () => { + global.TAGS_OPTIONS = originalTagsOptions; + }; + }, +}; + +export const ClosedWithSelection: Story = { + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['A', 'B'], + }, }, }, }; +// We can't properly test resetting to default, because resetting goes through +// global.TAGS_OPTIONS, which I didn't manage to mock. Setting defaultIncludedTagFilters +// still causes the API resetTagFilters function to reset based on the global rather than +// the initial state mocked in the story. + export const Clear = { - ...Closed, - play: async ({ canvasElement }) => { - const button = await findByRole(canvasElement, 'button', {}, { timeout: 3000 }); + ...ClosedWithSelection, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); button.click(); + + const clearButton = await screen.findByRole('button', { name: 'Clear filters' }); + + expect(clearButton).toBeInTheDocument(); + clearButton.click(); + await waitFor(() => expect(clearButton).not.toBeInTheDocument()); + }, +} satisfies Story; + +export const ResetToDefaults: Story = { + ...ClosedWithDefaultTags, + parameters: { + initialStoryState: { + layout: { + excludedTagFilters: ['A', 'B', 'C'], + }, + }, + }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + + const resetButton = await screen.findByRole('button', { name: 'Reset filters' }); + + expect(resetButton).toBeInTheDocument(); + expect(resetButton).not.toBeDisabled(); + resetButton.click(); + await waitFor(() => expect(resetButton).toBeDisabled()); }, } satisfies Story; @@ -63,9 +226,9 @@ export const NoUserTags = { 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, + 'c1-s1': { tags: ['dev', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c1-doc': { tags: [], type: 'docs' } as unknown as DocsIndexEntry, }, }, }, @@ -78,22 +241,23 @@ export const WithSelection = { export const WithSelectionInverted = { ...Clear, - args: { - ...Clear.args, - tagPresets: { - A: { defaultFilterSelection: 'exclude' }, - B: { defaultFilterSelection: 'exclude' }, + parameters: { + initialStoryState: { + layout: { + excludedTagFilters: ['A', 'B'], + }, }, }, } satisfies Story; export const WithSelectionMixed = { ...Clear, - args: { - ...Clear.args, - tagPresets: { - A: { defaultFilterSelection: 'include' }, - B: { defaultFilterSelection: 'exclude' }, + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['A'], + excludedTagFilters: ['B'], + }, }, }, } satisfies Story; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 6c8dd4667ba6..87416106aa5a 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -1,19 +1,14 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Badge, Button, PopoverProvider } from 'storybook/internal/components'; -import type { API_PreparedIndexEntry, StoryIndex, TagsOptions } from 'storybook/internal/types'; +import type { StoryIndex } from 'storybook/internal/types'; -import { BeakerIcon, DocumentIcon, FilterIcon, PlayHollowIcon } from '@storybook/icons'; +import { FilterIcon } from '@storybook/icons'; import type { API } from 'storybook/manager-api'; -import { Tag } from 'storybook/manager-api'; -import { color, styled } from 'storybook/theming'; +import { styled } from 'storybook/theming'; -import { type Filter, type FilterFunction, TagsFilterPanel, groupByType } from './TagsFilterPanel'; - -const TAGS_FILTER = 'tags-filter'; - -const BUILT_IN_TAGS = new Set(Object.values(Tag)); +import { TagsFilterPanel } from './TagsFilterPanel'; const StyledButton = styled(Button)<{ isHighlighted: boolean }>(({ isHighlighted, theme }) => ({ '&:focus-visible': { @@ -25,20 +20,6 @@ const StyledButton = styled(Button)<{ isHighlighted: boolean }>(({ isHighlighted }), })); -// Immutable set operations -const add = (set: Set, id: string) => { - const copy = new Set(set); - copy.add(id); - return copy; -}; -const remove = (set: Set, id: string) => { - const copy = new Set(set); - copy.delete(id); - return copy; -}; -const equal = (left: Set, right: Set) => - left.size === right.size && new Set([...left, ...right]).size === left.size; - const TagSelected = styled(Badge)(({ theme }) => ({ position: 'absolute', top: 7, @@ -60,147 +41,14 @@ const TagSelected = styled(Badge)(({ theme }) => ({ export interface TagsFilterProps { api: API; indexJson: StoryIndex; - tagPresets: TagsOptions; } -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) => { - entry.tags?.forEach((tag: Tag) => { - if (!BUILT_IN_TAGS.has(tag)) { - acc[tag] = (acc[tag] || 0) + 1; - } - }); - return acc; - }, - {} - ); - - const userFilters = Object.fromEntries( - Object.entries(userTagsCounts).map(([tag, count]) => { - const filterFn = (entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); - return [tag, { id: tag, type: 'tag', title: tag, count, filterFn }]; - }) - ); - - const withCount = (filterFn: FilterFunction) => ({ - count: Object.values(indexJson.entries).filter((entry) => filterFn(entry)).length, - filterFn, - }); - - const builtInFilters = { - _docs: { - id: '_docs', - type: 'built-in', - title: 'Documentation', - icon: , - ...withCount((entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded ? entry.type !== 'docs' : entry.type === 'docs' - ), - }, - _play: { - id: '_play', - type: 'built-in', - title: 'Play', - icon: , - ...withCount((entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded - ? entry.type !== 'story' || !entry.tags?.includes(Tag.PLAY_FN) - : entry.type === 'story' && !!entry.tags?.includes(Tag.PLAY_FN) - ), - }, - _test: { - id: '_test', - type: 'built-in', - title: 'Testing', - icon: , - ...withCount((entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded - ? entry.type !== 'story' || entry.subtype !== 'test' - : entry.type === 'story' && entry.subtype === 'test' - ), - }, - }; - - return { ...userFilters, ...builtInFilters }; - }, [indexJson.entries]); - - const { defaultIncluded, defaultExcluded } = useMemo(() => { - return Object.entries(tagPresets).reduce( - (acc, [tag, { defaultFilterSelection }]) => { - if (defaultFilterSelection === 'include') { - acc.defaultIncluded.add(tag); - } else if (defaultFilterSelection === 'exclude') { - acc.defaultExcluded.add(tag); - } - return acc; - }, - { defaultIncluded: new Set(), defaultExcluded: new Set() } - ); - }, [tagPresets]); +export const TagsFilter = ({ api, indexJson }: TagsFilterProps) => { + const includedFilters = api.getIncludedTagFilters(); + const excludedFilters = api.getExcludedTagFilters(); - const [includedFilters, setIncludedFilters] = useState(new Set(defaultIncluded)); - const [excludedFilters, setExcludedFilters] = useState(new Set(defaultExcluded)); const [expanded, setExpanded] = useState(false); - const tagsActive = includedFilters.size > 0 || excludedFilters.size > 0; - - const resetFilters = useCallback(() => { - setIncludedFilters(new Set(defaultIncluded)); - setExcludedFilters(new Set(defaultExcluded)); - }, [defaultIncluded, defaultExcluded]); - - useEffect(resetFilters, [resetFilters]); - - useEffect(() => { - api.experimental_setFilter(TAGS_FILTER, (item) => { - const included = Object.values( - groupByType(Array.from(includedFilters).map((id) => filtersById[id])) - ); - const excluded = Object.values( - groupByType(Array.from(excludedFilters).map((id) => filtersById[id])) - ); - - return ( - (!included.length || - included.every((group) => group.some(({ filterFn }) => filterFn(item, false)))) && - (!excluded.length || - excluded.every((group) => group.every(({ filterFn }) => filterFn(item, true)))) - ); - }); - }, [api, includedFilters, excludedFilters, filtersById]); - - const toggleFilter = useCallback( - (id: string, selected: boolean, excluded?: boolean) => { - if (excluded === true) { - setExcludedFilters(add(excludedFilters, id)); - setIncludedFilters(remove(includedFilters, id)); - } else if (excluded === false) { - setIncludedFilters(add(includedFilters, id)); - setExcludedFilters(remove(excludedFilters, id)); - } else if (selected) { - setIncludedFilters(add(includedFilters, id)); - setExcludedFilters(remove(excludedFilters, id)); - } else { - setIncludedFilters(remove(includedFilters, id)); - setExcludedFilters(remove(excludedFilters, id)); - } - }, - [includedFilters, excludedFilters] - ); - - const setAllFilters = useCallback( - (selected: boolean) => { - if (selected) { - setIncludedFilters(new Set(Object.keys(filtersById))); - } else { - setIncludedFilters(new Set()); - } - setExcludedFilters(new Set()); - }, - [filtersById] - ); + const activeFilterCount = includedFilters.length + excludedFilters.length; const handleToggleExpand = useCallback( (event: React.SyntheticEvent): void => { @@ -217,33 +65,23 @@ export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => { onVisibleChange={setExpanded} offset={8} padding={0} - popover={() => ( - 0 || defaultExcluded.size > 0} - /> - )} + popover={() => } > - {includedFilters.size + excludedFilters.size > 0 && } + {activeFilterCount > 0 && } ); diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index f87eb224b4e4..75a9e916f46f 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,77 +1,99 @@ -import { BeakerIcon, DocumentIcon, PlayHollowIcon } from '@storybook/icons'; +import type { DocsIndexEntry, StoryIndexEntry } from 'storybook/internal/types'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; -import { color } from 'storybook/theming'; +import type { API } from 'storybook/manager-api'; +import { MockAPIDecorator } from './TagsFilter.stories'; import { TagsFilterPanel } from './TagsFilterPanel'; -const builtInFilters = { - _docs: { - id: '_docs', - type: 'built-in', - title: 'Documentation', - icon: , - count: 8, - filterFn: fn(), - }, - _play: { - id: '_play', - type: 'built-in', - title: 'Play', - icon: , - count: 21, - filterFn: fn(), - }, - _test: { - id: '_test', - type: 'built-in', - title: 'Testing', - icon: , - count: 42, - filterFn: fn(), - }, +const getEntries = (includeUserTags: boolean) => { + const entries = { + 'c1-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c1-story1': { tags: ['tag1', 'dev'], type: 'story' } as StoryIndexEntry, + 'c1-story2': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c2-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c2-story1': { tags: ['tag1', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c2-story2': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c2-story3': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c3-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c3-story1': { tags: ['tag1', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c3-story2': { tags: ['tag1', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c3-story3': { tags: ['tag1', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c4-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c4-story1': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c4-story2': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c5-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c5-story1': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c5-story2': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c5-story3': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c6-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c6-story1': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c6-story2': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c6-story3': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c7-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c7-story1': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c7-story2': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c7-story3': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c8-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c8-story1': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c8-story2': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c8-story3': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c9-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c9-story1': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c9-story2': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c9-story3': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c10-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c10-story1': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c10-story2': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c10-story3': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c11-story1': { + tags: ['tag3-which-is-very-long-and-will-be-truncated-after-a-while'], + type: 'story', + } as StoryIndexEntry, + 'c11-story2': { + tags: ['tag3-which-is-very-long-and-will-be-truncated-after-a-while'], + type: 'story', + } as StoryIndexEntry, + 'c12-s1-test1': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test2': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test3': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test4': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test5': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test6': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test7': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test8': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test1': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test2': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test3': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test4': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test5': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test6': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test7': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test8': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + }; + + if (!includeUserTags) { + Object.values(entries).forEach((entry) => { + entry.tags = entry.tags?.filter((tag) => + ['autodocs', 'dev', 'play-fn', 'test-fn'].includes(tag) + ); + }); + } + + return entries; }; const meta = { component: TagsFilterPanel, title: 'Sidebar/TagsFilterPanel', + decorators: [MockAPIDecorator], args: { - toggleFilter: fn(), - setAllFilters: fn(), - filtersById: { - tag1: { - id: 'tag1', - type: 'tag', - title: 'Tag1', - count: 11, - filterFn: fn(), - }, - tag2: { - id: 'tag2', - type: 'tag', - title: 'Tag2', - count: 24, - filterFn: fn(), - }, - 'tag3-which-is-very-long-and-will-be-truncated-after-a-while': { - id: 'tag3-which-is-very-long-and-will-be-truncated-after-a-while', - type: 'tag', - title: 'Tag3', - count: 2, - filterFn: fn(), - }, - ...builtInFilters, + api: {} as API, // Will be overridden by MockAPIWrapper + indexJson: { + v: 6, + entries: getEntries(true), }, - includedFilters: new Set(), - excludedFilters: new Set(), - resetFilters: fn(), - isDefaultSelection: true, - hasDefaultSelection: false, - api: { - getDocsUrl: () => 'https://storybook.js.org/docs/', - } as any, }, tags: ['hoho'], } satisfies Meta; @@ -84,7 +106,10 @@ export const Basic: Story = {}; export const BuiltInOnly: Story = { args: { - filtersById: builtInFilters, + indexJson: { + v: 6, + entries: getEntries(false), + }, }, }; @@ -100,39 +125,67 @@ export const BuiltInOnlyProduction: Story = { }; export const Included: Story = { - args: { - includedFilters: new Set(['tag1', '_play']), - isDefaultSelection: false, + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['tag1'], + }, + }, }, }; export const Excluded: Story = { - args: { - excludedFilters: new Set(['tag1', '_play']), - isDefaultSelection: false, + parameters: { + initialStoryState: { + layout: { + excludedTagFilters: ['tag1'], + }, + }, }, }; export const Mixed: Story = { - args: { - includedFilters: new Set(['tag1', '_play']), - excludedFilters: new Set(['tag2', '_test']), - isDefaultSelection: false, + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['tag1'], + excludedTagFilters: ['tag2'], + }, + }, }, }; export const DefaultSelection: Story = { - args: { - ...Mixed.args, - isDefaultSelection: true, - hasDefaultSelection: true, + beforeEach: () => { + const originalTagsOptions = global.TAGS_OPTIONS; + global.TAGS_OPTIONS = { + tag1: { defaultFilterSelection: 'include' }, + tag2: { defaultFilterSelection: 'exclude' }, + }; + + return () => { + global.TAGS_OPTIONS = originalTagsOptions; + }; }, }; export const DefaultSelectionModified: Story = { - args: { - ...Mixed.args, - isDefaultSelection: false, - hasDefaultSelection: true, + beforeEach: () => { + const originalTagsOptions = global.TAGS_OPTIONS; + global.TAGS_OPTIONS = { + tag1: { defaultFilterSelection: 'include' }, + tag2: { defaultFilterSelection: 'exclude' }, + }; + + return () => { + global.TAGS_OPTIONS = originalTagsOptions; + }; + }, + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['tag1', 'tag2'], + }, + }, }, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index fa5ed4914059..fbd95a652b18 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -1,22 +1,31 @@ -import React, { Fragment, useRef } from 'react'; +import React, { Fragment, useCallback, useMemo, useRef } from 'react'; import { ActionList, Form } from 'storybook/internal/components'; -import type { API_PreparedIndexEntry } from 'storybook/internal/types'; +import type { FilterFunction, StoryIndex, Tag } from 'storybook/internal/types'; import { BatchAcceptIcon, + BeakerIcon, DeleteIcon, DocumentIcon, + PlayHollowIcon, ShareAltIcon, SweepIcon, UndoIcon, } from '@storybook/icons'; import type { API } from 'storybook/manager-api'; -import { styled } from 'storybook/theming'; +import { color, styled } from 'storybook/theming'; import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; +type Filter = { + id: string; + type: string; + title: string; + count: number; +}; + export const groupByType = (filters: Filter[]) => filters.filter(Boolean).reduce( (acc, filter) => { @@ -40,61 +49,131 @@ const MutedText = styled.span(({ theme }) => ({ color: theme.textMutedColor, })); -export type FilterFunction = (entry: API_PreparedIndexEntry, excluded?: boolean) => boolean; -export type Filter = { - id: string; - type: string; - title: string; - count: number; - filterFn: FilterFunction; -}; - interface TagsFilterPanelProps { api: API; - filtersById: { [id: string]: Filter }; - includedFilters: Set; - excludedFilters: Set; - toggleFilter: (key: string, selected: boolean, excluded?: boolean) => void; - setAllFilters: (selected: boolean) => void; - resetFilters: () => void; - isDefaultSelection: boolean; - hasDefaultSelection: boolean; + indexJson: StoryIndex; } -export const TagsFilterPanel = ({ - api, - filtersById, - includedFilters, - excludedFilters, - toggleFilter, - setAllFilters, - resetFilters, - isDefaultSelection, - hasDefaultSelection, -}: TagsFilterPanelProps) => { +const BUILT_IN_TAGS = new Set([ + 'dev', + 'test', + 'autodocs', + 'attached-mdx', + 'unattached-mdx', + 'play-fn', + 'test-fn', +]); + +// This equality check works on the basis that there are no duplicates in the arrays. +// We use arrays because we need arrays for data persistence in the layout module. +const equal = (left: string[], right: string[]) => + left.length === right.length && new Set([...left, ...right]).size === left.length; + +export const TagsFilterPanel = ({ api, indexJson }: TagsFilterPanelProps) => { const ref = useRef(null); - const renderLink = ({ - id, - type, - title, - icon, - count, - }: { - id: string; - type: string; - title: string; - icon?: React.ReactNode; - count: number; - }): Link | undefined => { + const defaultIncluded = api.getDefaultIncludedTagFilters(); + const defaultExcluded = api.getDefaultExcludedTagFilters(); + const includedFilters = api.getIncludedTagFilters(); + const excludedFilters = api.getExcludedTagFilters(); + + const filtersById = useMemo<{ [id: string]: Filter }>(() => { + const userTagsCounts = Object.values(indexJson.entries).reduce<{ [key: Tag]: number }>( + (acc, entry) => { + entry.tags?.forEach((tag: Tag) => { + if (!BUILT_IN_TAGS.has(tag)) { + acc[tag] = (acc[tag] || 0) + 1; + } + }); + return acc; + }, + {} + ); + + const userFilters = Object.fromEntries( + Object.entries(userTagsCounts).map(([tag, count]) => { + return [tag, { id: tag, type: 'tag', title: tag, count }]; + }) + ); + + const getBuiltInCount = (filterFn: FilterFunction | null) => + Object.values(indexJson.entries).filter((entry) => filterFn?.(entry)).length; + + const builtInFilters = { + _docs: { + id: '_docs', + type: 'built-in', + title: 'Documentation', + icon: , + count: getBuiltInCount(api.getFilterFunction('_docs')), + }, + _play: { + id: '_play', + type: 'built-in', + title: 'Play', + icon: , + count: getBuiltInCount(api.getFilterFunction('_play')), + }, + _test: { + id: '_test', + type: 'built-in', + title: 'Testing', + icon: , + count: getBuiltInCount(api.getFilterFunction('_test')), + }, + }; + + return { ...userFilters, ...builtInFilters }; + }, [api, indexJson.entries]); + + const toggleFilter = useCallback( + (id: string, selected: boolean, excluded?: boolean) => { + if (excluded !== undefined) { + api.addTagFilters([id], excluded); + } else if (selected) { + api.addTagFilters([id], false); + } else { + api.removeTagFilters([id]); + } + }, + [api] + ); + + const setAllFilters = useCallback( + (selected: boolean) => { + api.setAllTagFilters(selected ? Object.keys(filtersById) : [], []); + }, + [api, filtersById] + ); + + const isDefaultSelection = useMemo(() => { + return equal(includedFilters, defaultIncluded) && equal(excludedFilters, defaultExcluded); + }, [includedFilters, excludedFilters, defaultIncluded, defaultExcluded]); + + const hasDefaultSelection = useMemo(() => { + return defaultIncluded.length > 0 || defaultExcluded.length > 0; + }, [defaultIncluded, defaultExcluded]); + + const builtInFilterIcons = useMemo( + () => ({ + _docs: , + _play: , + _test: , + }), + [] + ); + + const renderLink = ({ id, type, title, count }: Filter): Link | undefined => { const onToggle = (selected: boolean, excluded?: boolean) => toggleFilter(id, selected, excluded); - const isIncluded = includedFilters.has(id); - const isExcluded = excludedFilters.has(id); + const isIncluded = includedFilters.includes(id); + const isExcluded = excludedFilters.includes(id); const isChecked = isIncluded || isExcluded; const toggleLabel = `${type} filter: ${isExcluded ? `exclude ${title}` : title}`; const toggleTooltip = `${isChecked ? 'Remove' : 'Add'} ${type} filter: ${title}`; const invertButtonLabel = `${isExcluded ? 'Include' : 'Exclude'} ${type}: ${title}`; + const icon = + type === 'built-in' ? builtInFilterIcons[id as keyof typeof builtInFilterIcons] : null; // for built-in filters (docs, play, test), don't show if there are no matches if (count === 0 && type === 'built-in') { @@ -147,7 +226,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 isNothingSelectedYet = includedFilters.length === 0 && excludedFilters.length === 0; return ( @@ -179,7 +258,7 @@ export const TagsFilterPanel = ({ api.resetTagFilters()} ariaLabel="Reset filters" tooltip="Reset to default selection" disabled={isDefaultSelection} diff --git a/code/core/src/types/modules/api.ts b/code/core/src/types/modules/api.ts index 2d61616aff85..473c641624ac 100644 --- a/code/core/src/types/modules/api.ts +++ b/code/core/src/types/modules/api.ts @@ -5,9 +5,14 @@ import type { State } from '../../manager-api'; import type { RenderData } from '../../router/types'; import type { ThemeVars } from '../../theming/types'; import type { Addon_RenderOptions } from './addons'; -import type { API_FilterFunction, API_HashEntry, API_IndexHash } from './api-stories'; +import type { + API_FilterFunction, + API_HashEntry, + API_IndexHash, + API_PreparedIndexEntry, +} from './api-stories'; import type { SetStoriesStory, SetStoriesStoryData } from './channelApi'; -import type { DocsOptions } from './core-common'; +import type { DocsOptions, TagsOptions } from './core-common'; import type { StoryIndex } from './indexer'; type OrString = T | (string & {}); @@ -69,6 +74,8 @@ export interface API_UIOptions { selectedPanel?: string; } +export type FilterFunction = (entry: API_PreparedIndexEntry, excluded?: boolean) => boolean; + export interface API_Layout { initialActive: API_ActiveTabsType; navSize: number; @@ -86,6 +93,12 @@ export interface API_Layout { panelPosition: API_PanelPositions; showTabs: boolean; showToolbar: boolean; + /** Initial tag filters applied when Storybook loads (not accounting for persisted store state). */ + tagPresets: TagsOptions; + /** Tags to include in the filter (entries with this tag are shown). Persisted permanently. */ + includedTagFilters: string[]; + /** Tags to exclude from the filter (entries with this tag are hidden). Persisted permanently. */ + excludedTagFilters: string[]; } export interface API_LayoutCustomisations { From e987318ff5338a0604f0bb1b714216604dbc33bb Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 23 Jan 2026 11:03:10 +0100 Subject: [PATCH 018/103] Address code quality issues --- code/core/src/manager-api/modules/layout.ts | 4 ++-- code/core/src/manager-api/store.ts | 13 ++++++++++++- .../components/sidebar/TagsFilter.stories.tsx | 12 ++++-------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index 69b539be3f78..c9eb033ae9fa 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -279,7 +279,7 @@ const recomputeFilters = (fullAPI: Parameters[0]['fullAPI'], store: St return Object.values( set.reduce( (acc, tag) => { - if (tag in BUILT_IN_FILTERS) { + if (Object.hasOwn(BUILT_IN_FILTERS, tag)) { acc['built-in'].push(BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]); } else { acc.user.push(USER_TAG_FILTER(tag)); @@ -760,7 +760,7 @@ export const init: ModuleFn = ({ fullAPI, store, provider, sin }, getFilterFunction(tag: Tag): FilterFunction | null { - if (tag in BUILT_IN_FILTERS) { + if (Object.hasOwn(BUILT_IN_FILTERS, tag)) { return BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]; } else { return USER_TAG_FILTER(tag); diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index de9c7acf6438..397fc51ecc1e 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -123,6 +123,17 @@ export default class Store { } } +/** Store guaranteed not to read from storage, for testing purposes. */ +class InMemoryStore extends Store { + constructor({ setState, getState }: Upstream) { + super({ allowPersistence: false, setState, getState }); + } + + getInitialState(base: State) { + return base; + } +} + /** * Factory function to create a valid Store instance for testing purposes. Provides a simple * in-memory store without persistence logic. Useful for mocking the store in stories. @@ -155,5 +166,5 @@ export function createTestingStore( }, }; - return new Store(upstream); + return new InMemoryStore(upstream); } diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index 2e617d50b6fe..c1d7498633ae 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -8,6 +8,8 @@ import type { StoryIndexEntry, } from 'storybook/internal/types'; +import { global } from '@storybook/global'; + import type { Meta, StoryObj } from '@storybook/react-vite'; import { deepMerge } from '@vitest/utils'; @@ -29,7 +31,7 @@ export class MockAPIWrapper extends React.Component<{ }> { api: ReturnType['api']; store: ReturnType; - channel: Channel; + channel: API_Provider['channel']; mounted: boolean; constructor(props: { @@ -50,7 +52,7 @@ export class MockAPIWrapper extends React.Component<{ }); // Mock channel and provider. - this.channel = new Channel({}); + this.channel = new Channel({}) satisfies API_Provider['channel']; const provider: API_Provider = { getConfig: () => ({}), handleAPI: () => {}, @@ -87,7 +89,6 @@ export class MockAPIWrapper extends React.Component<{ } this.api = api as API; - this.state = this.store.getState(); } componentDidMount() { @@ -178,11 +179,6 @@ export const ClosedWithSelection: Story = { }, }; -// We can't properly test resetting to default, because resetting goes through -// global.TAGS_OPTIONS, which I didn't manage to mock. Setting defaultIncludedTagFilters -// still causes the API resetTagFilters function to reset based on the global rather than -// the initial state mocked in the story. - export const Clear = { ...ClosedWithSelection, play: async ({ canvas }) => { From 99cc0164fb88c2e264b7d06eb237b11f35356e2c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 23 Jan 2026 11:13:04 +0100 Subject: [PATCH 019/103] Address code quality issues --- .../src/manager/components/sidebar/TagsFilter.stories.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index c1d7498633ae..ff8b653b8ce8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -95,6 +95,10 @@ export class MockAPIWrapper extends React.Component<{ this.mounted = true; } + componentWillUnmount() { + this.mounted = false; + } + render() { const { children, args } = this.props; return ( From 14ba070e549200f70908616d71628da7f872989d Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 2 Mar 2026 13:43:10 +0100 Subject: [PATCH 020/103] Fix stories --- .../components/sidebar/Sidebar.stories.tsx | 2 + .../components/sidebar/TagsFilter.stories.tsx | 143 ++---------------- .../sidebar/TagsFilter.story-helpers.tsx | 126 +++++++++++++++ .../sidebar/TagsFilterPanel.stories.tsx | 4 +- 4 files changed, 143 insertions(+), 132 deletions(-) create mode 100644 code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index bf4e3f4db5b1..db771c99c9da 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -51,6 +51,8 @@ const managerContext: any = { getShortcutKeys: fn(() => ({ search: ['control', 'shift', 's'] })).mockName( 'api::getShortcutKeys' ), + getIncludedTagFilters: fn(() => []).mockName('api::getIncludedTagFilters'), + getExcludedTagFilters: fn(() => []).mockName('api::getExcludedTagFilters'), getChannel: fn().mockName('api::getChannel'), getElements: fn(() => ({})), navigate: fn().mockName('api::navigate'), diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index ff8b653b8ce8..bf2e2bbb3ed8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -1,139 +1,14 @@ -import React from 'react'; - -import { Channel } from 'storybook/internal/channels'; -import type { - API_Provider, - DecoratorFunction, - DocsIndexEntry, - StoryIndexEntry, -} from 'storybook/internal/types'; +import type { DocsIndexEntry, StoryIndexEntry } from 'storybook/internal/types'; import { global } from '@storybook/global'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { deepMerge } from '@vitest/utils'; -import type { API, State } from 'storybook/manager-api'; -import { expect, fn, screen, waitFor } from 'storybook/test'; +import type { API } from 'storybook/manager-api'; +import { expect, screen, waitFor } from 'storybook/test'; -import type { ModuleArgs, ModuleFn } from '../../../manager-api/lib/types'; -import { init as initLayout } from '../../../manager-api/modules/layout'; -import { createTestingStore } from '../../../manager-api/store'; import { TagsFilter } from './TagsFilter'; - -/** Mock API wrapper that forces component updates when store state changes. */ -export class MockAPIWrapper extends React.Component<{ - children: React.ReactNode; - args: Record; - initFn: ModuleFn; - initOptions?: Partial; - initialStoryState?: Partial; -}> { - api: ReturnType['api']; - store: ReturnType; - channel: API_Provider['channel']; - mounted: boolean; - - constructor(props: { - children: React.ReactNode; - args: Record; - initFn: ModuleFn; - initOptions?: Partial; - initialStoryState?: Partial; - }) { - super(props); - - // Set up store. - this.mounted = false; - this.store = createTestingStore({} as State, (newState) => { - if (this.mounted) { - this.setState(newState); - } - }); - - // Mock channel and provider. - this.channel = new Channel({}) satisfies API_Provider['channel']; - const provider: API_Provider = { - getConfig: () => ({}), - handleAPI: () => {}, - channel: this.channel, - }; - - // Mock other submodules we depend on. - const fullAPI = { - experimental_setFilter: fn().mockName('API::experimental_setFilter'), - } as unknown as API; - - const { api, init, state } = props.initFn({ - fullAPI, - store: this.store, - provider, - location: { search: '' }, - navigate: () => {}, - path: '', - docsOptions: {}, - state: {} as State, - ...(props.initOptions ?? {}), - }); - - // Apply module and initial story states. - if (props.initialStoryState) { - this.store.setState(deepMerge(state as State, props.initialStoryState)); - } else { - this.store.setState(state as State); - } - - // Call module's post init function if it exists. - if (init && typeof init === 'function') { - init(); - } - - this.api = api as API; - } - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - render() { - const { children, args } = this.props; - return ( - <> - {React.cloneElement(children as React.ReactElement, { - args: { - ...args, - api: { - ...this.api, - getDocsUrl: () => 'https://storybook.js.org/docs/', - getUrlState: () => ({ - queryParams: {}, - path: '', - viewMode: 'story', - url: 'http://localhost:6006/', - }), - applyQueryParams: fn().mockName('api::applyQueryParams'), - }, - }, - })} - - ); - } -} - -export const MockAPIDecorator: DecoratorFunction = (Story, { args, parameters }) => ( - - - -); +import { MockAPIDecorator } from './TagsFilter.story-helpers'; const meta = { component: TagsFilter, @@ -269,7 +144,13 @@ export const Empty: Story = { entries: {}, }, }, - play: Clear.play, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + + const learnButton = await screen.findByText('Learn how to add tags'); + expect(learnButton).toBeInTheDocument(); + }, }; /** Production is equal to development now */ @@ -277,5 +158,5 @@ export const EmptyProduction: Story = { args: { ...Empty.args, }, - play: Clear.play, + play: Empty.play, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx new file mode 100644 index 000000000000..016ef19e6ecb --- /dev/null +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import { Channel } from 'storybook/internal/channels'; +import type { API_Provider, DecoratorFunction } from 'storybook/internal/types'; + +import { deepMerge } from '@vitest/utils'; +import type { API, State } from 'storybook/manager-api'; +import { fn } from 'storybook/test'; + +import type { ModuleArgs, ModuleFn } from '../../../manager-api/lib/types'; +import { init as initLayout } from '../../../manager-api/modules/layout'; +import { createTestingStore } from '../../../manager-api/store'; + +/** Mock API wrapper that forces component updates when store state changes. */ +export class MockAPIWrapper extends React.Component<{ + children: React.ReactNode; + args: Record; + initFn: ModuleFn; + initOptions?: Partial; + initialStoryState?: Partial; +}> { + api: ReturnType['api']; + store: ReturnType; + channel: API_Provider['channel']; + mounted: boolean; + + constructor(props: { + children: React.ReactNode; + args: Record; + initFn: ModuleFn; + initOptions?: Partial; + initialStoryState?: Partial; + }) { + super(props); + + // Set up store. + this.mounted = false; + this.store = createTestingStore({} as State, (newState) => { + if (this.mounted) { + this.setState(newState); + } + }); + + // Mock channel and provider. + this.channel = new Channel({}) satisfies API_Provider['channel']; + const provider: API_Provider = { + getConfig: () => ({}), + handleAPI: () => {}, + channel: this.channel, + }; + + // Mock other submodules we depend on. + const fullAPI = { + experimental_setFilter: fn().mockName('API::experimental_setFilter'), + } as unknown as API; + + const { api, init, state } = props.initFn({ + fullAPI, + store: this.store, + provider, + location: { search: '' }, + navigate: () => {}, + path: '', + docsOptions: {}, + state: {} as State, + ...(props.initOptions ?? {}), + }); + + // Apply module and initial story states. + if (props.initialStoryState) { + this.store.setState(deepMerge(state as State, props.initialStoryState)); + } else { + this.store.setState(state as State); + } + + // Call module's post init function if it exists. + if (init && typeof init === 'function') { + init(); + } + + this.api = api as API; + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + render() { + const { children, args } = this.props; + return ( + <> + {React.cloneElement(children as React.ReactElement, { + args: { + ...args, + api: { + ...this.api, + getDocsUrl: () => 'https://storybook.js.org/docs/', + getUrlState: () => ({ + queryParams: {}, + path: '', + viewMode: 'story', + url: 'http://localhost:6006/', + }), + applyQueryParams: fn().mockName('api::applyQueryParams'), + }, + }, + })} + + ); + } +} + +export const MockAPIDecorator: DecoratorFunction = (Story, { args, parameters }) => ( + + + +); diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index 75a9e916f46f..3d043d0e8108 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,10 +1,12 @@ import type { DocsIndexEntry, StoryIndexEntry } from 'storybook/internal/types'; +import { global } from '@storybook/global'; + import type { Meta, StoryObj } from '@storybook/react-vite'; import type { API } from 'storybook/manager-api'; -import { MockAPIDecorator } from './TagsFilter.stories'; +import { MockAPIDecorator } from './TagsFilter.story-helpers'; import { TagsFilterPanel } from './TagsFilterPanel'; const getEntries = (includeUserTags: boolean) => { From 60dda80b7b100c3f0a03c47f6ac0896c7c548aab Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 10 Mar 2026 19:24:29 +0100 Subject: [PATCH 021/103] Disable Vitest for calculated viewport story --- code/core/src/manager/components/preview/Viewport.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/code/core/src/manager/components/preview/Viewport.stories.tsx b/code/core/src/manager/components/preview/Viewport.stories.tsx index 22354300b4dd..0153401fd0e1 100644 --- a/code/core/src/manager/components/preview/Viewport.stories.tsx +++ b/code/core/src/manager/components/preview/Viewport.stories.tsx @@ -136,4 +136,5 @@ export const Calculated = meta.story({ viewport: { options: customViewports }, }, render: () => <>, + tags: ['!test'], // Vitest browser does not support calculated viewports }); From a399e6125072a38e44b908bd7533ff1ca3e02fba Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 10 Mar 2026 19:28:07 +0100 Subject: [PATCH 022/103] No use in capturing empty stories --- code/core/src/manager/components/preview/Viewport.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/code/core/src/manager/components/preview/Viewport.stories.tsx b/code/core/src/manager/components/preview/Viewport.stories.tsx index 0153401fd0e1..44405854638f 100644 --- a/code/core/src/manager/components/preview/Viewport.stories.tsx +++ b/code/core/src/manager/components/preview/Viewport.stories.tsx @@ -114,6 +114,7 @@ export const Short = meta.story({ }, parameters: { viewport: { options: customViewports }, + chromatic: { disableSnapshot: true }, }, render: () => <>, }); @@ -124,6 +125,7 @@ export const Narrow = meta.story({ }, parameters: { viewport: { options: customViewports }, + chromatic: { disableSnapshot: true }, }, render: () => <>, }); @@ -134,6 +136,7 @@ export const Calculated = meta.story({ }, parameters: { viewport: { options: customViewports }, + chromatic: { disableSnapshot: true }, }, render: () => <>, tags: ['!test'], // Vitest browser does not support calculated viewports From 210f213313edc4bcba93b028f18c3dd7c2f941df Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 11 Mar 2026 11:25:27 +0100 Subject: [PATCH 023/103] Fix FramesRenderer to ensure iframe path is updated even when a composed Storybook is on a subpath of the root Storybook (or otherwise share a common base URL) --- code/core/src/manager/components/preview/FramesRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager/components/preview/FramesRenderer.tsx b/code/core/src/manager/components/preview/FramesRenderer.tsx index 5628cfef9e68..7f33ece21b51 100644 --- a/code/core/src/manager/components/preview/FramesRenderer.tsx +++ b/code/core/src/manager/components/preview/FramesRenderer.tsx @@ -78,7 +78,7 @@ export const FramesRenderer: FC = ({ refsToLoad.forEach((ref) => { const id = `storybook-ref-${ref.id}`; - if (!frames[id]?.startsWith(ref.url)) { + if (!frames[id]?.startsWith(`${ref.url.replace(/\/?$/, '/')}iframe.html`)) { frames[id] = api.getStoryHrefs(storyId, { queryParams: { ...queryParams, ...(version && { version }) }, refId: ref.id, From fb9a74b84473907e4860e201a09fd5eb6b1c8e8f Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 11 Mar 2026 11:56:05 +0100 Subject: [PATCH 024/103] fix attached MDX files causing wrong component entries in manifests --- .../src/componentManifest/generator.test.ts | 95 +++++++++++++++++++ .../react/src/componentManifest/generator.ts | 49 +++++++--- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index a9140d47e4b1..71473530eab5 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -578,6 +578,101 @@ test('should create component manifest when only attached-mdx docs have manifest `); }); +test('should prefer story entries over attached-mdx docs entries for the same component id', async () => { + vol.fromJSON( + { + ['./package.json']: JSON.stringify({ name: 'some-package' }), + ['./src/Primary/Primary.stories.tsx']: dedent` + import type { Meta } from '@storybook/react'; + import { Primary } from './Primary'; + + const meta = { + title: 'Example/Primary', + component: Primary, + } satisfies Meta; + export default meta; + + export const Default = () => ; + `, + ['./src/Primary/Primary.tsx']: dedent` + import React from 'react'; + + export interface PrimaryProps { + title: string; + } + + /** Primary component description */ + export const Primary = ({ title }: PrimaryProps) =>
{title}
; + `, + ['./src/OtherFile/OtherFile.stories.tsx']: dedent` + import type { Meta } from '@storybook/react'; + import { OtherFile } from './OtherFile'; + + const meta = { + title: 'Example/Other File', + component: OtherFile, + } satisfies Meta; + export default meta; + + export const Default = () => ; + `, + ['./src/OtherFile/OtherFile.tsx']: dedent` + import React from 'react'; + + export interface OtherFileProps { + label: string; + } + + /** Other file component description */ + export const OtherFile = ({ label }: OtherFileProps) => ( + + ); + `, + }, + '/app' + ); + + const manifestEntries = [ + { + type: 'docs', + id: 'example-primary--docs', + name: 'Docs', + title: 'Example/Primary', + importPath: './src/Primary/Primary.mdx', + tags: [Tag.DEV, Tag.TEST, Tag.MANIFEST, Tag.ATTACHED_MDX], + storiesImports: [ + './src/OtherFile/OtherFile.stories.tsx', + './src/Primary/Primary.stories.tsx', + ], + }, + { + type: 'story', + subtype: 'story', + id: 'example-primary--default', + name: 'Default', + title: 'Example/Primary', + importPath: './src/Primary/Primary.stories.tsx', + componentPath: './src/Primary/Primary.tsx', + tags: [Tag.DEV, Tag.TEST, Tag.MANIFEST], + exportName: 'Default', + }, + ]; + + const result = await manifests(undefined, { manifestEntries } as any); + + const component = result?.components?.components?.['example-primary']; + + expect(component?.name).toBe('Primary'); + expect(component?.path).toBe('./src/Primary/Primary.stories.tsx'); + expect(component?.stories).toMatchObject([ + { + id: 'example-primary--default', + name: 'Default', + }, + ]); + expect(component?.stories[0]?.snippet).toContain(' { vol.fromJSON( { diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index cab8ceea09f2..2c613863c048 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -9,7 +9,6 @@ import { type StorybookConfigRaw, } from 'storybook/internal/types'; -import { uniqBy } from 'es-toolkit/array'; import path from 'pathe'; import { getCodeSnippet } from './generateCodeSnippet'; @@ -24,6 +23,41 @@ interface ReactComponentManifest extends ComponentManifest { reactDocgenTypescript?: ComponentDocWithExportName; } +function selectComponentEntries(manifestEntries: IndexEntry[]) { + const entriesByComponentId = new Map(); + + manifestEntries + .filter( + (entry) => + (entry.type === 'story' && entry.subtype === 'story') || + // Attached docs entries are the only docs entries that can contribute to a + // component manifest, because they point back to a story file through storiesImports. + (entry.type === 'docs' && + entry.tags?.includes(Tag.ATTACHED_MDX) && + entry.storiesImports.length > 0) + ) + .forEach((entry) => { + const componentId = entry.id.split('--')[0]; + const existingEntry = entriesByComponentId.get(componentId); + + if (!existingEntry) { + // Keep the first eligible entry as a fallback so docs-only manifest coverage + // continues to work when no story entry for that component carries the manifest tag. + entriesByComponentId.set(componentId, entry); + return; + } + + if (existingEntry.type === 'docs' && entry.type === 'story') { + // When both entries exist for the same component id, the story entry is authoritative. + // Attached docs may list unrelated stories first in storiesImports, so using the story + // entry avoids resolving the manifest from the wrong file. + entriesByComponentId.set(componentId, entry); + } + }); + + return [...entriesByComponentId.values()]; +} + function findMatchingComponent( components: ReturnType, componentName: string | undefined, @@ -114,18 +148,7 @@ export const manifests: PresetPropertyFn< const startTime = performance.now(); - const entriesByUniqueComponent = uniqBy( - manifestEntries.filter( - (entry) => - (entry.type === 'story' && entry.subtype === 'story') || - // addon-docs will add docs entries to these manifest entries afterwards - // Docs entries have importPath pointing to MDX file, but storiesImports[0] points to the story file - (entry.type === 'docs' && - entry.tags?.includes(Tag.ATTACHED_MDX) && - entry.storiesImports.length > 0) - ), - (entry) => entry.id.split('--')[0] - ); + const entriesByUniqueComponent = selectComponentEntries(manifestEntries); const components = entriesByUniqueComponent .map((entry): ReactComponentManifest | undefined => { From 3baeeeb3de29d17f05a18457dbab46c0a86649a3 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 11 Mar 2026 11:56:39 +0100 Subject: [PATCH 025/103] Fix storiesImports not correctly putting the first in the array --- .../utils/StoryIndexGenerator.test.ts | 25 +++++++++++++++++++ .../core-server/utils/StoryIndexGenerator.ts | 12 +++++++-- .../complex/MetaOfImportOrder.mdx | 9 +++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts index 6692ea1881b4..ea3bfc2d51c8 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts @@ -1956,6 +1956,31 @@ describe('StoryIndexGenerator', () => { } `); }); + + it('puts the Meta of stories file first in storiesImports even when it is not the last import', async () => { + const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/*.stories.(js|ts)', + options + ); + + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './complex/MetaOfImportOrder.mdx', + options + ); + + const generator = new StoryIndexGenerator([csfSpecifier, docsSpecifier], options); + await generator.initialize(); + + const { storyIndex } = await generator.getIndexAndStats(); + const docsEntry = storyIndex.entries['a--metaofimportorder']; + + expect(docsEntry).toMatchObject({ + type: 'docs', + title: 'A', + importPath: './complex/MetaOfImportOrder.mdx', + storiesImports: ['./src/A.stories.js', './src/B.stories.ts'], + }); + }); }); describe('errors', () => { diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index 11a7b9d4981a..6882ffdce05e 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -553,6 +553,8 @@ export class StoryIndexGenerator { let csfEntry: StoryIndexEntryWithExtra | undefined; if (result.of) { const absoluteOf = makeAbsolute(result.of, normalizedPath, this.options.workingDir); + let metaDependency: StoriesCacheEntry | undefined; + dependencies.forEach((dep) => { if (dep.entries.length > 0) { const first = dep.entries.find((e) => e.type !== 'docs') as StoryIndexEntryWithExtra; @@ -563,12 +565,18 @@ export class StoryIndexGenerator { ) ) { csfEntry = first; + metaDependency = dep; } } - - sortedDependencies = [dep, ...dependencies.filter((d) => d !== dep)]; }); + if (metaDependency) { + sortedDependencies = [ + metaDependency, + ...dependencies.filter((d) => d !== metaDependency), + ]; + } + invariant( csfEntry, dedent` diff --git a/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx b/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx new file mode 100644 index 000000000000..5d33133db848 --- /dev/null +++ b/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx @@ -0,0 +1,9 @@ +{/* References BStories first, but is attached to A */} +import * as BStories from '../src/B.stories'; +import * as AStories from '../src/A.stories'; + + + +# This file references two story files + +It is important that A.stories is the first listed in `storiesImports` even when it is not the last import. \ No newline at end of file From 03cf9ff35a828941f2610ae9e0ad408f3b058f46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:23:44 +0000 Subject: [PATCH 026/103] Initial plan From 9f380ec7dd7179c13f0f7545e60a98b21fbbb317 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:54:02 +0000 Subject: [PATCH 027/103] feat(a11y): make resize handles for addon panel and sidebar accessible - Add role="separator", tabIndex, aria-orientation, aria-label, aria-valuenow, aria-valuemin, aria-valuemax to sidebar and panel drag handles - Add keyboard interaction (arrow keys, Home/End) for resizing via keyboard - Add focus-visible styles so drag handles are visible when focused Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- .../src/manager/components/layout/Layout.tsx | 32 ++++++++++- .../manager/components/layout/useDragging.ts | 54 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index f1b019d5a3d3..47c365bc39a7 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, FC } from 'react'; +import type { CSSProperties } from 'react'; import React, { useEffect, useLayoutEffect, useState } from 'react'; import type { API_Layout, API_ViewMode } from 'storybook/internal/types'; @@ -175,7 +175,16 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s <> {isDesktop && ( - + {slots.slotSidebar} )} @@ -200,6 +209,25 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s panelResizerRef={panelResizerRef} position={panelPosition} > + {slots.slotPanel} )} diff --git a/code/core/src/manager/components/layout/useDragging.ts b/code/core/src/manager/components/layout/useDragging.ts index 1028f45df9f5..2de46c16067c 100644 --- a/code/core/src/manager/components/layout/useDragging.ts +++ b/code/core/src/manager/components/layout/useDragging.ts @@ -8,6 +8,7 @@ const SNAP_THRESHOLD_PX = 30; const SIDEBAR_MIN_WIDTH_PX = 240; const RIGHT_PANEL_MIN_WIDTH_PX = 270; const MIN_WIDTH_STIFFNESS = 0.9; +const KEYBOARD_STEP_PX = 10; /** Clamps a value between min and max. */ function clamp(value: number, min: number, max: number): number { @@ -172,12 +173,65 @@ export function useDragging({ }); }; + const onSidebarKeyDown = (e: KeyboardEvent) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { + return; + } + e.preventDefault(); + setState((state) => { + const maxSize = window.innerWidth; + switch (e.key) { + case 'ArrowRight': + return { ...state, navSize: clamp(state.navSize + KEYBOARD_STEP_PX, 0, maxSize) }; + case 'ArrowLeft': + return { ...state, navSize: clamp(state.navSize - KEYBOARD_STEP_PX, 0, maxSize) }; + case 'Home': + return { ...state, navSize: 0 }; + case 'End': + return { ...state, navSize: maxSize }; + default: + return state; + } + }); + }; + + const onPanelKeyDown = (e: KeyboardEvent) => { + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { + return; + } + e.preventDefault(); + setState((state) => { + const isBottom = state.panelPosition === 'bottom'; + const sizeKey = isBottom ? 'bottomPanelHeight' : 'rightPanelWidth'; + const maxSize = isBottom ? window.innerHeight : window.innerWidth; + const increaseKey = isBottom ? 'ArrowUp' : 'ArrowLeft'; + const decreaseKey = isBottom ? 'ArrowDown' : 'ArrowRight'; + + switch (e.key) { + case increaseKey: + return { ...state, [sizeKey]: clamp(state[sizeKey] + KEYBOARD_STEP_PX, 0, maxSize) }; + case decreaseKey: + return { ...state, [sizeKey]: clamp(state[sizeKey] - KEYBOARD_STEP_PX, 0, maxSize) }; + case 'Home': + return { ...state, [sizeKey]: 0 }; + case 'End': + return { ...state, [sizeKey]: maxSize }; + default: + return state; + } + }); + }; + panelResizer?.addEventListener('mousedown', onDragStart); sidebarResizer?.addEventListener('mousedown', onDragStart); + panelResizer?.addEventListener('keydown', onPanelKeyDown); + sidebarResizer?.addEventListener('keydown', onSidebarKeyDown); return () => { panelResizer?.removeEventListener('mousedown', onDragStart); sidebarResizer?.removeEventListener('mousedown', onDragStart); + panelResizer?.removeEventListener('keydown', onPanelKeyDown); + sidebarResizer?.removeEventListener('keydown', onSidebarKeyDown); // make iframe capture pointer events again previewIframe?.removeAttribute('style'); }; From 8e0fbf5346d305d2b5f2acbed4f81918493418fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:42:58 +0000 Subject: [PATCH 028/103] refactor(a11y): address review feedback on resize handles - Refactor keyboard handlers into shared applyResizeKeyboard function with position parameter (left/right/bottom/top) - Add Shift key support for 5x step size - Move slotSidebar before Drag handle in sidebar container for better keyboard navigation order - Update focus-visible styles with outline:none, height:7, and boxShadow for the drag handle Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- .../src/manager/components/layout/Layout.tsx | 2 +- .../manager/components/layout/useDragging.ts | 108 ++++++++++++------ 2 files changed, 72 insertions(+), 38 deletions(-) diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 47c365bc39a7..071077cdb9be 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -175,6 +175,7 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s <> {isDesktop && ( + {slots.slotSidebar} - {slots.slotSidebar} )} {isMobile && ( diff --git a/code/core/src/manager/components/layout/useDragging.ts b/code/core/src/manager/components/layout/useDragging.ts index 2de46c16067c..831aa8b034e2 100644 --- a/code/core/src/manager/components/layout/useDragging.ts +++ b/code/core/src/manager/components/layout/useDragging.ts @@ -9,6 +9,8 @@ const SIDEBAR_MIN_WIDTH_PX = 240; const RIGHT_PANEL_MIN_WIDTH_PX = 270; const MIN_WIDTH_STIFFNESS = 0.9; const KEYBOARD_STEP_PX = 10; +const KEYBOARD_SHIFT_MULTIPLIER = 5; +const RESIZE_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']; /** Clamps a value between min and max. */ function clamp(value: number, min: number, max: number): number { @@ -20,6 +22,65 @@ function interpolate(relativeValue: number, min: number, max: number): number { return min + (max - min) * relativeValue; } +/** + * Given the current layout state, the position of the resize handle, and the key pressed, + * returns the next layout state with the resized panel/sidebar. + * + * @param position - The position of the resize handle relative to the content it resizes. + * 'left' for sidebar, 'bottom' or 'right' for the addon panel. + */ +function applyResizeKeyboard( + state: LayoutState, + position: 'left' | 'right' | 'bottom' | 'top', + key: string, + step: number +): LayoutState { + const sizeKey = + position === 'left' + ? 'navSize' + : position === 'bottom' || position === 'top' + ? 'bottomPanelHeight' + : 'rightPanelWidth'; + const maxSize = + position === 'bottom' || position === 'top' ? window.innerHeight : window.innerWidth; + + let increaseKey: string; + let decreaseKey: string; + switch (position) { + case 'left': + increaseKey = 'ArrowRight'; + decreaseKey = 'ArrowLeft'; + break; + case 'right': + increaseKey = 'ArrowLeft'; + decreaseKey = 'ArrowRight'; + break; + case 'bottom': + increaseKey = 'ArrowUp'; + decreaseKey = 'ArrowDown'; + break; + case 'top': + increaseKey = 'ArrowDown'; + decreaseKey = 'ArrowUp'; + break; + } + + const currentSize = state[sizeKey]; + + switch (key) { + case increaseKey: + return { ...state, [sizeKey]: clamp(currentSize + step, 0, maxSize) }; + case decreaseKey: + return { ...state, [sizeKey]: clamp(currentSize - step, 0, maxSize) }; + case 'Home': + return { ...state, [sizeKey]: 0 }; + case 'End': + return { ...state, [sizeKey]: maxSize }; + default: + return state; + } +} + export function useDragging({ setState, isPanelShown, @@ -174,52 +235,25 @@ export function useDragging({ }; const onSidebarKeyDown = (e: KeyboardEvent) => { - if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { + if (!RESIZE_KEYS.includes(e.key)) { return; } e.preventDefault(); - setState((state) => { - const maxSize = window.innerWidth; - switch (e.key) { - case 'ArrowRight': - return { ...state, navSize: clamp(state.navSize + KEYBOARD_STEP_PX, 0, maxSize) }; - case 'ArrowLeft': - return { ...state, navSize: clamp(state.navSize - KEYBOARD_STEP_PX, 0, maxSize) }; - case 'Home': - return { ...state, navSize: 0 }; - case 'End': - return { ...state, navSize: maxSize }; - default: - return state; - } - }); + const step = e.shiftKey + ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER + : KEYBOARD_STEP_PX; + setState((state) => applyResizeKeyboard(state, 'left', e.key, step)); }; const onPanelKeyDown = (e: KeyboardEvent) => { - if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { + if (!RESIZE_KEYS.includes(e.key)) { return; } e.preventDefault(); - setState((state) => { - const isBottom = state.panelPosition === 'bottom'; - const sizeKey = isBottom ? 'bottomPanelHeight' : 'rightPanelWidth'; - const maxSize = isBottom ? window.innerHeight : window.innerWidth; - const increaseKey = isBottom ? 'ArrowUp' : 'ArrowLeft'; - const decreaseKey = isBottom ? 'ArrowDown' : 'ArrowRight'; - - switch (e.key) { - case increaseKey: - return { ...state, [sizeKey]: clamp(state[sizeKey] + KEYBOARD_STEP_PX, 0, maxSize) }; - case decreaseKey: - return { ...state, [sizeKey]: clamp(state[sizeKey] - KEYBOARD_STEP_PX, 0, maxSize) }; - case 'Home': - return { ...state, [sizeKey]: 0 }; - case 'End': - return { ...state, [sizeKey]: maxSize }; - default: - return state; - } - }); + const step = e.shiftKey + ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER + : KEYBOARD_STEP_PX; + setState((state) => applyResizeKeyboard(state, state.panelPosition, e.key, step)); }; panelResizer?.addEventListener('mousedown', onDragStart); From 2082668095fabc031b9a64ca3a841c98e2797c77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:53:28 +0000 Subject: [PATCH 029/103] refactor(a11y): rework resize handles based on next branch structure - Extract Drag.tsx component with built-in role=separator, tabIndex, aria-valuemin, and TooltipProvider (focus-only) showing arrow keys - Extract PanelContainer.tsx with ARIA attributes and hidden/aria-hidden for collapsed panel content - Extract MainAreaContainer.tsx matching next branch structure - Simplify Layout.tsx to use extracted components, reducing markup - Fix focus-visible: use height when horizontal, width when vertical - Sidebar slot stays before Drag handle for keyboard nav order Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- .../src/manager/components/layout/Drag.tsx | 88 ++++++++++++++++++- .../src/manager/components/layout/Layout.tsx | 22 ----- .../components/layout/PanelContainer.tsx | 10 +++ .../manager/components/layout/useDragging.ts | 16 ++-- 4 files changed, 100 insertions(+), 36 deletions(-) diff --git a/code/core/src/manager/components/layout/Drag.tsx b/code/core/src/manager/components/layout/Drag.tsx index ba1fe4053d3d..8ab18a22c3f7 100644 --- a/code/core/src/manager/components/layout/Drag.tsx +++ b/code/core/src/manager/components/layout/Drag.tsx @@ -1,12 +1,84 @@ +import React, { forwardRef, useMemo } from 'react'; + import { styled } from 'storybook/theming'; +import { TooltipNote } from '../../../components/components/tooltip/TooltipNote'; +import { TooltipProvider } from '../../../components/components/tooltip/TooltipProvider'; + +interface DragProps { + /** Visual orientation of the drag handle line. */ + orientation?: 'horizontal' | 'vertical'; + + /** Whether the drag handle overlaps the adjacent content area. */ + overlapping?: boolean; + + /** Which side the drag handle sits on, relative to the content it resizes. */ + position?: 'left' | 'right'; + + /** Accessible orientation for the separator role (determines arrow key mapping). */ + 'aria-orientation'?: 'horizontal' | 'vertical'; + + /** Accessible label describing what this separator resizes. */ + 'aria-label': string; + + /** Current size (in pixels) of the region controlled by this separator. */ + 'aria-valuenow': number; + + /** Maximum size (in pixels) for the region controlled by this separator. */ + 'aria-valuemax'?: number; +} + /** * Drag handle for the sidebar and panel resizers. Can be horizontal (bottom panel) or vertical - * (sidebar or right panel). Can optionally be set to not overlap the content area (only render - * outside of it), which is necessary when the panel is collapsed to prevent a layout shift when - * scrollIntoView is used. + * (sidebar or right panel). Implements the WAI-ARIA separator role with keyboard resize support. + * + * The component automatically sets `role="separator"`, `tabIndex={0}`, and `aria-valuemin={0}`. A + * tooltip is shown on focus advertising the arrow keys available for keyboard resizing. */ -export const Drag = styled.div<{ +export const Drag = forwardRef(function Drag(props, ref) { + const { + orientation, + overlapping, + position, + 'aria-orientation': ariaOrientation = 'horizontal', + 'aria-label': ariaLabel, + 'aria-valuenow': ariaValueNow, + 'aria-valuemax': ariaValueMax, + ...rest + } = props; + + const tooltipNote = useMemo(() => { + if (ariaOrientation === 'vertical') { + return '← → to resize'; + } + return '↑ ↓ to resize'; + }, [ariaOrientation]); + + return ( + } + > + + + ); +}); + +const DragHandle = styled.div<{ orientation?: 'horizontal' | 'vertical'; overlapping?: boolean; position?: 'left' | 'right'; @@ -27,6 +99,14 @@ export const Drag = styled.div<{ opacity: 1, }, }), + ({ theme, orientation = 'vertical' }) => ({ + '&:focus-visible': { + opacity: 1, + outline: 'none', + ...(orientation === 'horizontal' ? { height: 7 } : { width: 7 }), + boxShadow: `inset 0 0 0 4px ${theme.color.secondary}`, + }, + }), ({ orientation = 'vertical', overlapping = true, position = 'left' }) => orientation === 'vertical' ? { diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 071077cdb9be..d3d4a18743c2 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -178,12 +178,9 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s {slots.slotSidebar} @@ -209,25 +206,6 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s panelResizerRef={panelResizerRef} position={panelPosition} > - {slots.slotPanel} )} diff --git a/code/core/src/manager/components/layout/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx index 9ddce06d569e..d529b9783e65 100644 --- a/code/core/src/manager/components/layout/PanelContainer.tsx +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -43,6 +43,16 @@ const PanelContainer = React.memo(function PanelContainer(p overlapping={position === 'bottom' ? !!bottomPanelHeight : !!rightPanelWidth} position={position === 'bottom' ? 'left' : 'right'} ref={panelResizerRef} + aria-orientation={position === 'bottom' ? 'horizontal' : 'vertical'} + aria-label="Addon panel resize handle" + aria-valuenow={position === 'bottom' ? bottomPanelHeight : rightPanelWidth} + aria-valuemax={ + typeof window !== 'undefined' + ? position === 'bottom' + ? window.innerHeight + : window.innerWidth + : undefined + } /> applyResizeKeyboard(state, 'left', e.key, step)); }; @@ -250,9 +248,7 @@ export function useDragging({ return; } e.preventDefault(); - const step = e.shiftKey - ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER - : KEYBOARD_STEP_PX; + const step = e.shiftKey ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER : KEYBOARD_STEP_PX; setState((state) => applyResizeKeyboard(state, state.panelPosition, e.key, step)); }; From b5b2969a971c8552330731c4aa3c62fa838b4e33 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 9 Mar 2026 10:59:50 +0100 Subject: [PATCH 030/103] Refactor smelly code and rebase leftovers --- .../src/manager/components/layout/Drag.tsx | 65 ++++++++++--------- .../src/manager/components/layout/Layout.tsx | 10 ++- .../components/layout/PanelContainer.tsx | 6 +- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/code/core/src/manager/components/layout/Drag.tsx b/code/core/src/manager/components/layout/Drag.tsx index 8ab18a22c3f7..1b14462bbf8a 100644 --- a/code/core/src/manager/components/layout/Drag.tsx +++ b/code/core/src/manager/components/layout/Drag.tsx @@ -2,22 +2,17 @@ import React, { forwardRef, useMemo } from 'react'; import { styled } from 'storybook/theming'; +import type { PopperPlacement } from '../../../components'; import { TooltipNote } from '../../../components/components/tooltip/TooltipNote'; import { TooltipProvider } from '../../../components/components/tooltip/TooltipProvider'; interface DragProps { - /** Visual orientation of the drag handle line. */ - orientation?: 'horizontal' | 'vertical'; + /** Which side the drag handle sits on, relative to the content it resizes. Determines orientation. */ + position: 'left' | 'right' | 'top' | 'bottom'; /** Whether the drag handle overlaps the adjacent content area. */ overlapping?: boolean; - /** Which side the drag handle sits on, relative to the content it resizes. */ - position?: 'left' | 'right'; - - /** Accessible orientation for the separator role (determines arrow key mapping). */ - 'aria-orientation'?: 'horizontal' | 'vertical'; - /** Accessible label describing what this separator resizes. */ 'aria-label': string; @@ -28,6 +23,13 @@ interface DragProps { 'aria-valuemax'?: number; } +const oppositePosition: Record = { + left: 'right', + right: 'left', + top: 'bottom', + bottom: 'top', +}; + /** * Drag handle for the sidebar and panel resizers. Can be horizontal (bottom panel) or vertical * (sidebar or right panel). Implements the WAI-ARIA separator role with keyboard resize support. @@ -37,37 +39,37 @@ interface DragProps { */ export const Drag = forwardRef(function Drag(props, ref) { const { - orientation, overlapping, position, - 'aria-orientation': ariaOrientation = 'horizontal', 'aria-label': ariaLabel, 'aria-valuenow': ariaValueNow, 'aria-valuemax': ariaValueMax, ...rest } = props; + const orientation = position === 'left' || position === 'right' ? 'vertical' : 'horizontal'; + const tooltipNote = useMemo(() => { - if (ariaOrientation === 'vertical') { + if (orientation === 'vertical') { return '← → to resize'; } return '↑ ↓ to resize'; - }, [ariaOrientation]); + }, [orientation]); return ( } > (function Drag(props, r }); const DragHandle = styled.div<{ - orientation?: 'horizontal' | 'vertical'; - overlapping?: boolean; - position?: 'left' | 'right'; + $orientation?: 'horizontal' | 'vertical'; + $overlapping?: boolean; + $position: 'left' | 'right' | 'top' | 'bottom'; }>( ({ theme }) => ({ position: 'absolute', @@ -99,27 +101,29 @@ const DragHandle = styled.div<{ opacity: 1, }, }), - ({ theme, orientation = 'vertical' }) => ({ + ({ theme, $orientation = 'vertical' }) => ({ '&:focus-visible': { opacity: 1, outline: 'none', - ...(orientation === 'horizontal' ? { height: 7 } : { width: 7 }), + ...($orientation === 'horizontal' ? { height: 7 } : { width: 7 }), boxShadow: `inset 0 0 0 4px ${theme.color.secondary}`, }, }), - ({ orientation = 'vertical', overlapping = true, position = 'left' }) => - orientation === 'vertical' + ({ $orientation = 'vertical', $overlapping = true, $position = 'left' }) => + $orientation === 'vertical' ? { - width: overlapping ? (position === 'left' ? 10 : 13) : 7, + // This is an old code smell, where 10px matches the sidebar and 13px matches the addon panel. + // It should be tidied up at some point. + width: $overlapping ? ($position === 'left' ? 10 : 13) : 7, height: '100%', top: 0, - right: position === 'left' ? -7 : undefined, - left: position === 'right' ? -7 : undefined, + right: $position === 'left' ? -7 : undefined, + left: $position === 'right' ? -7 : undefined, '&:after': { width: 1, height: '100%', - marginLeft: position === 'left' ? 3 : 6, + marginLeft: $position === 'left' ? 3 : 6, }, '&:hover': { @@ -128,8 +132,9 @@ const DragHandle = styled.div<{ } : { width: '100%', - height: overlapping ? 13 : 7, - top: -7, + height: $overlapping ? 13 : 7, + top: $position === 'bottom' ? -7 : undefined, + bottom: $position === 'top' ? -7 : undefined, left: 0, '&:after': { diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index d3d4a18743c2..310fa560ebf4 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -131,9 +131,7 @@ const useLayoutSyncingState = ({ panelResizerRef, sidebarResizerRef, showPages: isPagesShown, - showPanel: - customisedShowPanel && - (managerLayoutState.panelPosition === 'right' ? rightPanelWidth > 0 : bottomPanelHeight > 0), + showPanel: customisedShowPanel, isDragging: internalDraggingSizeState.isDragging, }; }; @@ -178,7 +176,7 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s {slots.slotSidebar} - {isDesktop && showPanel && ( + {isDesktop && ( - {slots.slotPanel} + {showPanel && slots.slotPanel} )} {isMobile && } diff --git a/code/core/src/manager/components/layout/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx index d529b9783e65..c97931fcde57 100644 --- a/code/core/src/manager/components/layout/PanelContainer.tsx +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -39,11 +39,9 @@ const PanelContainer = React.memo(function PanelContainer(p return ( Date: Mon, 9 Mar 2026 10:13:30 +0000 Subject: [PATCH 031/103] feat(a11y): add SidebarContainer with focus management for sidebar toggle - Create SidebarContainer.tsx with hidden/aria-hidden when navSize === 0 - Add sidebarRegion and showSidebar IDs to focusableUIElements - Update menu.tsx tool with focus management and landmark animation - Update shortcuts.ts to move focus to Show sidebar button when closing - Simplify Layout.tsx to use SidebarContainer component Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/manager-api/modules/layout.ts | 2 + .../core/src/manager-api/modules/shortcuts.ts | 21 ++++++ .../src/manager/components/layout/Layout.tsx | 18 +---- .../components/layout/SidebarContainer.tsx | 56 +++++++++++++++ .../manager/components/preview/tools/menu.tsx | 68 +++++++++++++------ 5 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 code/core/src/manager/components/layout/SidebarContainer.tsx diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index a0df07067e5b..a5b47b2b432f 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -153,6 +153,8 @@ export const focusableUIElements = { storyListMenu: 'storybook-explorer-menu', storyPanelRoot: 'storybook-panel-root', showAddonPanel: 'storybook-show-addon-panel', + sidebarRegion: 'storybook-sidebar-region', + showSidebar: 'storybook-show-sidebar', }; const getIsNavShown = (state: State) => { diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index 83520110c131..d52747d3c853 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -382,7 +382,28 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { } case 'toggleNav': { + const wasNavShown = fullAPI.getIsNavShown(); + const sidebarElement = document.getElementById(focusableUIElements.sidebarRegion); + const wasFocusInSidebar = + sidebarElement && + document.activeElement && + sidebarElement.contains(document.activeElement); + fullAPI.toggleNav(); + + if (wasNavShown && wasFocusInSidebar) { + // poll: true always returns a Promise. + ( + fullAPI.focusOnUIElement(focusableUIElements.showSidebar, { + poll: true, + }) as Promise + ).then((success) => { + // Fallback to body for predictable behavior. + if (success === false) { + document.body.focus(); + } + }); + } break; } diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 310fa560ebf4..69780fef5cc3 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -9,10 +9,10 @@ import { styled } from 'storybook/theming'; import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; import { Notifications } from '../../container/Notifications'; import { MobileNavigation } from '../mobile/navigation/MobileNavigation'; -import { Drag } from './Drag'; import { useLayout } from './LayoutProvider'; import { MainAreaContainer } from './MainAreaContainer'; import { PanelContainer } from './PanelContainer'; +import { SidebarContainer } from './SidebarContainer'; import { useDragging } from './useDragging'; import { useLandmarkIndicator } from './useLandmarkIndicator'; @@ -172,15 +172,8 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s > <> {isDesktop && ( - + {slots.slotSidebar} - )} {isMobile && ( @@ -245,10 +238,3 @@ const LayoutContainer = styled.div<{ })(), }, })); - -const SidebarContainer = styled.div(({ theme }) => ({ - backgroundColor: theme.appBg, - gridArea: 'sidebar', - position: 'relative', - borderRight: `1px solid ${theme.appBorderColor}`, -})); diff --git a/code/core/src/manager/components/layout/SidebarContainer.tsx b/code/core/src/manager/components/layout/SidebarContainer.tsx new file mode 100644 index 000000000000..eff60f76aa0c --- /dev/null +++ b/code/core/src/manager/components/layout/SidebarContainer.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { styled } from 'storybook/theming'; + +import { focusableUIElements } from '../../../manager-api/modules/layout'; +import { Drag } from './Drag'; + +interface SidebarContainerProps { + children: React.ReactNode; + navSize: number; + sidebarResizerRef: React.Ref; +} + +const Container = styled.div(({ theme }) => ({ + backgroundColor: theme.appBg, + gridArea: 'sidebar', + position: 'relative', + borderRight: `1px solid ${theme.appBorderColor}`, +})); + +const SidebarSlot = styled.div({ + height: '100%', +}); + +/** + * Shows the sidebar and its resize drag handle. The drag handle is always rendered so users can + * reopen the sidebar. The sidebar is always rendered (to preserve internal state), but it's + * excluded from the Accessibility Object Model when effectively collapsed. + */ +const SidebarContainer = React.memo(function SidebarContainer(props) { + const { children, navSize, sidebarResizerRef } = props; + + const shouldHideSidebarContent = navSize === 0; + + return ( + + + + + ); +}); + +export { SidebarContainer }; diff --git a/code/core/src/manager/components/preview/tools/menu.tsx b/code/core/src/manager/components/preview/tools/menu.tsx index 0cb0531d5705..404177ba6f57 100644 --- a/code/core/src/manager/components/preview/tools/menu.tsx +++ b/code/core/src/manager/components/preview/tools/menu.tsx @@ -8,10 +8,23 @@ import { MenuIcon } from '@storybook/icons'; import { Consumer, types } from 'storybook/manager-api'; import type { Combo } from 'storybook/manager-api'; +import { focusableUIElements } from '../../../../manager-api/modules/layout'; +import { useRegionFocusAnimation } from '../../layout/useLandmarkIndicator'; + const menuMapper = ({ api, state }: Combo) => ({ isVisible: api.getIsNavShown(), singleStory: state.singleStory, - toggle: () => api.toggleNav(), + viewMode: state.viewMode, + showSidebar: async (animateLandmark?: (e: HTMLElement | null) => void) => { + api.toggleNav(true); + const success = await api.focusOnUIElement(focusableUIElements.sidebarRegion, { + forceFocus: true, + poll: true, + }); + if (success) { + animateLandmark?.(document.getElementById(focusableUIElements.sidebarRegion)); + } + }, }); export const menuTool: Addon_BaseType = { @@ -20,25 +33,36 @@ export const menuTool: Addon_BaseType = { type: types.TOOL, // @ts-expect-error (non strict) match: ({ viewMode }) => ['story', 'docs'].includes(viewMode), - render: () => ( - - {({ isVisible, toggle, singleStory }) => - !singleStory && - !isVisible && ( - <> - - - - ) - } - - ), + render: () => { + const animateLandmark = useRegionFocusAnimation(); + + return ( + + {({ isVisible, showSidebar, singleStory }) => + !singleStory && + !isVisible && ( + <> + + + + ) + } + + ); + }, }; From a1286097c495295d1f15dd3ca875bcdc072502ab Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 9 Mar 2026 14:03:04 +0100 Subject: [PATCH 032/103] Fix element IDs for landmark animations --- .../src/manager/components/layout/SidebarContainer.tsx | 2 +- code/core/src/manager/components/panel/Panel.tsx | 3 ++- code/core/src/manager/components/sidebar/Sidebar.tsx | 8 +++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/code/core/src/manager/components/layout/SidebarContainer.tsx b/code/core/src/manager/components/layout/SidebarContainer.tsx index eff60f76aa0c..d762fa53cb8c 100644 --- a/code/core/src/manager/components/layout/SidebarContainer.tsx +++ b/code/core/src/manager/components/layout/SidebarContainer.tsx @@ -33,7 +33,7 @@ const SidebarContainer = React.memo(function SidebarConta const shouldHideSidebarContent = navSize === 0; return ( - + +