diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 98a3f8475e6d..67fca336be4f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -70,7 +70,7 @@ _This section is mandatory for all contributions. If you believe no manual test This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the `@storybookjs/core` team here. -_core team members can create a canary release [here](https://github.com/storybookjs/storybook/actions/workflows/canary-release-pr.yml) or locally with `gh workflow run --repo storybookjs/storybook canary-release-pr.yml --field pr=`_ +_core team members can create a canary release [here](https://github.com/storybookjs/storybook/actions/workflows/publish.yml) or locally with `gh workflow run --repo storybookjs/storybook publish.yml --field pr=`_ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e673363d10d7..6f087ec9d92d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -310,6 +310,47 @@ cd code && yarn storybook:vitest - TypeScript strict mode is enabled - Follow existing patterns in the codebase +### Code Quality Checks +After making file changes, always run both formatting and linting checks: +1. **Prettier**: Format code with `yarn prettier --write ` +2. **ESLint**: Check for linting issues with `yarn lint:js:cmd ` + - The full eslint command is: `cross-env NODE_ENV=production eslint --cache --cache-location=../.cache/eslint --ext .js,.jsx,.json,.html,.ts,.tsx,.mjs --report-unused-disable-directives` + - Use the `lint:js:cmd` script for convenience + - Fix any errors or warnings before committing + +### Testing Guidelines +When writing unit tests: +1. **Export functions for testing**: If functions need to be tested, export them from the module +2. **Write meaningful tests**: Tests should actually import and call the functions being tested, not just verify syntax patterns +3. **Use coverage reports**: Run tests with coverage to identify untested code + - Run coverage: `yarn vitest run --coverage ` + - Aim for high coverage of business logic (75%+ for statements/lines) + - Use coverage reports to identify missing test cases + - Focus on covering: + - All branches and conditions + - Edge cases and error paths + - Different input variations +4. **Mock external dependencies**: Use `vi.mock()` to mock file system, loggers, and other external dependencies +5. **Run tests before committing**: Ensure all tests pass with `yarn test` or `yarn vitest run` + +### Logging +When adding logging to code, always use the appropriate logger: +- **Server-side code** (Node.js): Use `logger` from `storybook/internal/node-logger` + ```typescript + import { logger } from 'storybook/internal/node-logger'; + logger.info('Server message'); + logger.warn('Warning message'); + logger.error('Error message'); + ``` +- **Client-side code** (browser): Use `logger` from `storybook/internal/client-logger` + ```typescript + import { logger } from 'storybook/internal/client-logger'; + logger.info('Client message'); + logger.warn('Warning message'); + logger.error('Error message'); + ``` +- **DO NOT** use `console.log`, `console.warn`, or `console.error` directly unless in isolated files where importing loggers would significantly increase bundle size + ### Git Workflow - Work on feature branches - Ensure all builds and tests pass before submitting PRs @@ -320,4 +361,4 @@ cd code && yarn storybook:vitest - Include code examples in addon/framework documentation - Update migration guides for breaking changes -This document should be updated as the repository evolves and new build requirements or limitations are discovered. \ No newline at end of file +This document should be updated as the repository evolves and new build requirements or limitations are discovered. diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index e604d48fb17b..0578ee43fed7 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,14 @@ +## 10.0.0-beta.11 + +- Automigration: Improve the viewport/backgrounds automigration - [#32619](https://github.com/storybookjs/storybook/pull/32619), thanks @valentinpalkovic! +- CSF: Enhance config-to-csf-factory to support type wrappers - [#32543](https://github.com/storybookjs/storybook/pull/32543), thanks @yannbf! +- Core: Ensure valid QR code URL - [#32661](https://github.com/storybookjs/storybook/pull/32661), thanks @ghengeveld! +- Mocking: Fix `sb.mock` usage in Storybook's deployed in subpaths - [#32678](https://github.com/storybookjs/storybook/pull/32678), thanks @valentinpalkovic! +- NextJS-Vite: Automatically fix bad PostCSS configuration - [#32691](https://github.com/storybookjs/storybook/pull/32691), thanks @ndelangen! +- Nextjs: Fix Nextjs version detection with prereleases - [#32724](https://github.com/storybookjs/storybook/pull/32724), thanks @yannbf! +- Presets: Support extensionless imports in TS-based presets - [#32641](https://github.com/storybookjs/storybook/pull/32641), thanks @JReinhold! +- React Native Web: Fix REACT_NATIVE_AND_RNW should detect vite builder - [#32718](https://github.com/storybookjs/storybook/pull/32718), thanks @dannyhw! + ## 10.0.0-beta.10 - Core: Make `subtype` an optional property on an index input - [#32602](https://github.com/storybookjs/storybook/pull/32602), thanks @JReinhold! diff --git a/MIGRATION.md b/MIGRATION.md index c2a35edea82c..74944b889906 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -8,6 +8,7 @@ - [Require `tsconfig.json` `moduleResolution` set to value that supports `types` condition](#require-tsconfigjson-moduleresolution-set-to-value-that-supports-types-condition) - [`core.builder` configuration must be a fully resolved path](#corebuilder-configuration-must-be-a-fully-resolved-path) - [Removed x-only builtin tags](#removed-x-only-builtin-tags) + - [Extensionless imports in JS-based preset files are no longer supported](#extensionless-imports-in-js-based-preset-files-are-no-longer-supported) - [From version 8.x to 9.0.0](#from-version-8x-to-900) - [Core Changes and Removals](#core-changes-and-removals) - [Dropped support for legacy packages](#dropped-support-for-legacy-packages) @@ -585,6 +586,24 @@ export const core = { During development of Storybook [Tags](https://storybook.js.org/docs/writing-stories/tags), we created `dev-only`, `docs-only`, and `test-only` built-in tags. These tags were never documented and superseded by the currently-documented `dev`, `autodocs`, and `test` tags which provide more precise control. The outdated `x-only` tags are removed in 10.0. During development of Storybook [Tags](https://storybook.js.org/docs/writing-stories/tags), we created `dev-only`, `docs-only`, and `test-only` built-in tags. These tags were never documented and superceded by the currently-documented `dev`, `autodocs`, and `test` tags which provide more precise control. The outdated `x-only` tags are removed in 10.0. +#### Extensionless imports in JS-based preset files are no longer supported + +Storybook 10 no longer supports extensionless relative imports in JavaScript-based preset and configuration files (e.g., `.storybook/main.js`). All relative imports must now include explicit file extensions. + +**Before (no longer works):** +```js +// .storybook/main.js +import myPreset from './my-file'; +``` + +**After:** +```js +// .storybook/main.js +import myPreset from './my-file.js'; +``` + +This change aligns with Node.js ESM requirements, where relative imports must specify the full file extension. While TypeScript-based files (`.storybook/main.ts`) will continue to work with extensionless imports for now through automatic resolution, we recommend migrating to explicit extensions for consistency and better compatibility. + ## From version 8.x to 9.0.0 ### Core Changes and Removals diff --git a/code/core/src/bin/loader.test.ts b/code/core/src/bin/loader.test.ts new file mode 100644 index 000000000000..e282569aae0a --- /dev/null +++ b/code/core/src/bin/loader.test.ts @@ -0,0 +1,352 @@ +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deprecate } from 'storybook/internal/node-logger'; + +import { addExtensionsToRelativeImports, resolveWithExtension } from './loader'; + +// Mock dependencies +vi.mock('node:fs'); +vi.mock('storybook/internal/node-logger'); + +describe('loader', () => { + describe('resolveWithExtension', () => { + it('should return the path as-is if it already has an extension', () => { + const result = resolveWithExtension('./test.js', '/project/src/file.ts'); + + expect(result).toBe('./test.js'); + expect(deprecate).not.toHaveBeenCalled(); + }); + + it('should resolve extensionless import to .ts extension when file exists', () => { + const currentFile = '/project/src/file.ts'; + const expectedPath = path.resolve(path.dirname(currentFile), './utils.ts'); + + vi.mocked(existsSync).mockImplementation((filePath) => { + return filePath === expectedPath; + }); + + const result = resolveWithExtension('./utils', currentFile); + + expect(result).toBe('./utils.ts'); + expect(deprecate).toHaveBeenCalledWith( + expect.stringContaining('One or more extensionless imports detected: "./utils"') + ); + expect(deprecate).toHaveBeenCalledWith( + expect.stringContaining( + 'For maximum compatibility, you should add an explicit file extension' + ) + ); + }); + + it('should resolve extensionless import to .js extension when file exists', () => { + const currentFile = '/project/src/file.ts'; + const expectedPath = path.resolve(path.dirname(currentFile), './utils.js'); + + vi.mocked(existsSync).mockImplementation((filePath) => { + return filePath === expectedPath; + }); + + const result = resolveWithExtension('./utils', currentFile); + + expect(result).toBe('./utils.js'); + expect(deprecate).toHaveBeenCalledWith( + expect.stringContaining('One or more extensionless imports detected: "./utils"') + ); + }); + + it('should show deprecation message when encountering an extensionless import', () => { + vi.mocked(existsSync).mockReturnValue(true); + + resolveWithExtension('./utils', '/project/src/file.ts'); + + expect(deprecate).toHaveBeenCalledWith( + expect.stringContaining('One or more extensionless imports detected: "./utils"') + ); + expect(deprecate).toHaveBeenCalledWith( + expect.stringContaining('in file "/project/src/file.ts"') + ); + }); + + it('should return original path when file cannot be resolved', () => { + vi.mocked(existsSync).mockReturnValue(false); + + const result = resolveWithExtension('./missing', '/project/src/file.ts'); + + expect(result).toBe('./missing'); + expect(deprecate).toHaveBeenCalledWith( + expect.stringContaining('One or more extensionless imports detected: "./missing"') + ); + }); + + it('should resolve relative to parent directory', () => { + const currentFile = '/project/src/file.ts'; + const expectedPath = path.resolve(path.dirname(currentFile), '../utils.ts'); + + vi.mocked(existsSync).mockImplementation((filePath) => { + return filePath === expectedPath; + }); + + const result = resolveWithExtension('../utils', currentFile); + + expect(result).toBe('../utils.ts'); + expect(deprecate).toHaveBeenCalledWith( + expect.stringContaining('One or more extensionless imports detected: "../utils"') + ); + }); + }); + + describe('addExtensionsToRelativeImports', () => { + beforeEach(() => { + // Default: all files exist with .ts extension + vi.mocked(existsSync).mockImplementation((filePath) => { + return (filePath as string).endsWith('.ts'); + }); + }); + + it('should not modify imports that already have extensions', () => { + const testCases = [ + { input: `import foo from './test.js';`, expected: `import foo from './test.js';` }, + { input: `import foo from './test.ts';`, expected: `import foo from './test.ts';` }, + { input: `import foo from '../utils.mjs';`, expected: `import foo from '../utils.mjs';` }, + { + input: `export { bar } from './module.tsx';`, + expected: `export { bar } from './module.tsx';`, + }, + ]; + + testCases.forEach(({ input, expected }) => { + const result = addExtensionsToRelativeImports(input, '/project/src/file.ts'); + expect(result).toBe(expected); + expect(deprecate).not.toHaveBeenCalled(); + }); + }); + + it('should add extension to static import statements', () => { + const source = `import { foo } from './utils';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import { foo } from './utils.ts';`); + }); + + it('should add extension to static export statements', () => { + const source = `export { foo } from './utils';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`export { foo } from './utils.ts';`); + }); + + it('should add extension to dynamic import statements', () => { + const source = `const module = await import('./utils');`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`const module = await import('./utils.ts');`); + }); + + it('should handle default imports', () => { + const source = `import foo from './module';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import foo from './module.ts';`); + }); + + it('should handle named imports', () => { + const source = `import { foo, bar } from './module';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import { foo, bar } from './module.ts';`); + }); + + it('should handle namespace imports', () => { + const source = `import * as utils from './module';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import * as utils from './module.ts';`); + }); + + it('should handle side-effect imports', () => { + const source = `import './styles';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import './styles.ts';`); + }); + + it('should handle export all statements', () => { + const source = `export * from './module';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`export * from './module.ts';`); + }); + + it('should not modify absolute imports', () => { + const testCases = [ + `import foo from 'react';`, + `import bar from '@storybook/react';`, + `import baz from 'node:fs';`, + ]; + + testCases.forEach((source) => { + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + expect(result).toBe(source); + }); + }); + + it('should not modify imports that match the pattern but are not relative paths', () => { + // Edge case: a path that starts with a dot but not ./ or ../ + // This tests the condition that returns 'match' unchanged + const source = `import foo from '.config';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + // Should not be modified since it doesn't start with ./ or ../ + expect(result).toBe(source); + expect(deprecate).not.toHaveBeenCalled(); + }); + + it('should handle single quotes', () => { + const source = `import foo from './utils';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import foo from './utils.ts';`); + }); + + it('should handle double quotes', () => { + const source = `import foo from "./utils";`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import foo from "./utils.ts";`); + }); + + it('should handle paths starting with ./', () => { + const source = `import foo from './utils';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import foo from './utils.ts';`); + }); + + it('should handle paths starting with ../', () => { + const source = `import foo from '../utils';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import foo from '../utils.ts';`); + }); + + it('should handle multiple imports in the same file', () => { + const source = `import foo from './foo';\nimport bar from './bar';\nexport { baz } from '../baz';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe( + `import foo from './foo.ts';\nimport bar from './bar.ts';\nexport { baz } from '../baz.ts';` + ); + }); + + it('should preserve the import structure after adding extensions', () => { + const source = `import { foo, bar } from './utils';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toContain('{ foo, bar }'); + expect(result).toBe(`import { foo, bar } from './utils.ts';`); + }); + + it('should handle imports with comments', () => { + const source = `// This is a comment\nimport foo from './utils'; // inline comment`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`// This is a comment\nimport foo from './utils.ts'; // inline comment`); + }); + + it('should handle multi-line imports with named exports on separate lines', () => { + const source = `import { + foo, + bar, + baz +} from './utils';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`import { + foo, + bar, + baz +} from './utils.ts';`); + }); + + it('should handle multi-line exports with named exports on separate lines', () => { + const source = `export { + foo, + bar +} from '../module';`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`export { + foo, + bar +} from '../module.ts';`); + }); + + it('should handle multi-line dynamic imports', () => { + const source = `const module = await import( + './utils' +);`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`const module = await import( + './utils.ts' +);`); + }); + + it('should handle dynamic imports with backticks', () => { + const source = 'import(`./foo`);'; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe('import(`./foo.ts`);'); + }); + + it('should not modify dynamic imports with template literal interpolation', () => { + const source = 'import(`${foo}/bar`);'; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + // Cannot be supported: template interpolation ${foo} is a runtime value + // The regex stops at $ to avoid matching interpolated expressions + expect(result).toBe('import(`${foo}/bar`);'); + }); + + it('should not modify dynamic imports with template literal interpolation and relative path', () => { + const source = 'import(`./${foo}/bar`);'; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + // Cannot be supported: template interpolation ${foo} is a runtime value + // The regex stops at $ to avoid matching interpolated expressions + expect(result).toBe('import(`./${foo}/bar`);'); + }); + + it('should handle array of dynamic imports', () => { + const source = `const [] = [ + import('./foo'), + import('./bar'), +];`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe(`const [] = [ + import('./foo.ts'), + import('./bar.ts'), +];`); + }); + + it('should handle multi-line backtick dynamic imports', () => { + const source = 'const module = await import(\n `./utils`\n);'; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe('const module = await import(\n `./utils.ts`\n);'); + }); + + it('should handle mixed quote types in same file', () => { + const source = `import foo from './foo';\nimport bar from "./bar";\nimport(\`./baz\`);`; + const result = addExtensionsToRelativeImports(source, '/project/src/file.ts'); + + expect(result).toBe( + `import foo from './foo.ts';\nimport bar from "./bar.ts";\nimport(\`./baz.ts\`);` + ); + }); + }); +}); diff --git a/code/core/src/bin/loader.ts b/code/core/src/bin/loader.ts index 909761c374cd..05ed8864e017 100644 --- a/code/core/src/bin/loader.ts +++ b/code/core/src/bin/loader.ts @@ -3,14 +3,90 @@ * using esbuild. Do _not_ import from other modules in core unless strictly necessary, as it will * cause the dist to get huge. */ +import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import type { LoadHook } from 'node:module'; +import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { deprecate } from 'storybook/internal/node-logger'; + import { transform } from 'esbuild'; +import { dedent } from 'ts-dedent'; import { NODE_TARGET } from '../shared/constants/environments-support'; +export const supportedExtensions = [ + '.js', + '.mjs', + '.cjs', + '.jsx', + '.ts', + '.mts', + '.cts', + '.tsx', +] as const; + +/** + * Resolves an extensionless file path by trying different extensions. Returns the path with the + * correct extension if found, otherwise returns the original path. + */ +export function resolveWithExtension(importPath: string, currentFilePath: string): string { + // If the import already has an extension, return it as-is + if (path.extname(importPath)) { + return importPath; + } + + deprecate(dedent`One or more extensionless imports detected: "${importPath}" in file "${currentFilePath}". + For maximum compatibility, you should add an explicit file extension to this import. + Storybook will attempt to resolve it automatically, but this may change in the future. + If adding the extension results in an error from TypeScript, we recommend setting moduleResolution to "bundler" in tsconfig.json + or alternatively look into the allowImportingTsExtensions option.`); + + // Resolve the import path relative to the current file + const currentDir = path.dirname(currentFilePath); + const absolutePath = path.resolve(currentDir, importPath); + + for (const ext of supportedExtensions) { + const candidatePath = `${absolutePath}${ext}`; + if (existsSync(candidatePath)) { + return `${importPath}${ext}`; + } + } + + return importPath; +} + +/** + * Adds extensions to relative imports in the source code. This is necessary because Node.js ESM + * requires explicit extensions for relative imports. + */ +export function addExtensionsToRelativeImports(source: string, filePath: string): string { + // Regex patterns to match different import/export syntaxes with relative paths + const patterns = [ + // import/export ... from './path' or "../path" (including side-effect imports) + /(\b(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"])(\.[^'"]+)(['"])/g, + // import('./path') or import("../path") - dynamic imports with quotes (with closing paren, no concatenation) + /(\bimport\s*\(\s*['"])(\.[^'"]+)(['"]\s*\))/g, + // import(`./path`) - dynamic imports with backticks (with closing paren, no template interpolation) + /(\bimport\s*\(\s*`)(\.[^`$]+)(`\s*\))/g, + ]; + + let result = source; + for (const pattern of patterns) { + result = result.replace(pattern, (match, prefix, path, suffix) => { + // Only process relative paths (starting with ./ or ../) + if (path.startsWith('./') || path.startsWith('../')) { + const resolvedPath = resolveWithExtension(path, filePath); + return `${prefix}${resolvedPath}${suffix}`; + } + return match; + }); + } + + return result; +} + export const load: LoadHook = async (url, context, nextLoad) => { /** Convert TS to ESM using esbuild */ if ( @@ -21,7 +97,8 @@ export const load: LoadHook = async (url, context, nextLoad) => { url.endsWith('.mtsx') || url.endsWith('.ctsx') ) { - const rawSource = await readFile(fileURLToPath(url), 'utf-8'); + const filePath = fileURLToPath(url); + const rawSource = await readFile(filePath, 'utf-8'); const transformedSource = await transform(rawSource, { loader: 'ts', target: NODE_TARGET, @@ -29,10 +106,13 @@ export const load: LoadHook = async (url, context, nextLoad) => { platform: 'neutral', }); + // Add extensions to relative imports so Node.js ESM can resolve them + const sourceWithExtensions = addExtensionsToRelativeImports(transformedSource.code, filePath); + return { format: 'module', shortCircuit: true, - source: transformedSource.code, + source: sourceWithExtensions, }; } diff --git a/code/core/src/cli/detect.ts b/code/core/src/cli/detect.ts index c07dc8c119cc..9002e0d7a3ab 100644 --- a/code/core/src/cli/detect.ts +++ b/code/core/src/cli/detect.ts @@ -133,6 +133,7 @@ export async function detectBuilder(packageManager: JsPackageManager, projectTyp // Fallback to Vite or Webpack based on project type switch (projectType) { + case ProjectType.REACT_NATIVE_AND_RNW: case ProjectType.REACT_NATIVE_WEB: return CoreBuilder.Vite; case ProjectType.REACT_SCRIPTS: diff --git a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts b/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts index eec9b0490405..ec468cf21d3e 100644 --- a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts +++ b/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts @@ -11,7 +11,7 @@ import { resolvePackageDir } from '../../../../shared/utils/module'; const entryPath = '/vite-inject-mocker-entry.js'; const entryCode = dedent` - + `; let server: ViteDevServer; diff --git a/code/core/src/manager/components/preview/tools/share.stories.tsx b/code/core/src/manager/components/preview/tools/share.stories.tsx index 7884890e1a29..fe0b80327030 100644 --- a/code/core/src/manager/components/preview/tools/share.stories.tsx +++ b/code/core/src/manager/components/preview/tools/share.stories.tsx @@ -41,12 +41,31 @@ type Story = StoryObj; export const Default: Story = { beforeEach: () => { + const originalConfigType = global.CONFIG_TYPE; global.STORYBOOK_NETWORK_ADDRESS = 'http://127.0.0.1:6006'; + global.CONFIG_TYPE = 'DEVELOPMENT'; + + return () => { + global.CONFIG_TYPE = originalConfigType; + }; }, play: async ({ userEvent, canvas }) => { await waitFor(async () => { await userEvent.click(canvas.getByRole('button')); - await expect(await screen.findByText('Scan me')).toBeVisible(); + await expect(await screen.findByText('Scan to open')).toBeVisible(); }); }, }; + +export const Production: Story = { + ...Default, + beforeEach: () => { + const originalConfigType = global.CONFIG_TYPE; + global.STORYBOOK_NETWORK_ADDRESS = 'http://127.0.0.1:6006'; + global.CONFIG_TYPE = 'PRODUCTION'; + + return () => { + global.CONFIG_TYPE = originalConfigType; + }; + }, +}; diff --git a/code/core/src/manager/components/preview/tools/share.tsx b/code/core/src/manager/components/preview/tools/share.tsx index b374ab5ab1e7..2549a76461fa 100644 --- a/code/core/src/manager/components/preview/tools/share.tsx +++ b/code/core/src/manager/components/preview/tools/share.tsx @@ -82,11 +82,13 @@ function ShareMenu({ storyId, queryParams, qrUrl, + isDevelopment, }: { baseUrl: string; storyId: string; queryParams: Record; qrUrl: string; + isDevelopment: boolean; }) { const api = useStorybookApi(); const shortcutKeys = api.getShortcutKeys(); @@ -129,8 +131,12 @@ function ShareMenu({ - Scan me - Must be on the same network as this device. + Scan to open + + {isDevelopment + ? 'Device must be on the same network.' + : 'View story on another device.'} + ), @@ -138,9 +144,9 @@ function ShareMenu({ ]); return baseLinks; - }, [baseUrl, storyId, queryParams, copied, qrUrl, enableShortcuts, copyStoryLink]); + }, [baseUrl, storyId, queryParams, copied, qrUrl, enableShortcuts, copyStoryLink, isDevelopment]); - return ; + return ; } export const shareTool: Addon_BaseType = { @@ -152,15 +158,18 @@ export const shareTool: Addon_BaseType = { return ( {({ baseUrl, storyId, queryParams }) => { + const isDevelopment = global.CONFIG_TYPE === 'DEVELOPMENT'; const storyUrl = global.STORYBOOK_NETWORK_ADDRESS - ? `${global.STORYBOOK_NETWORK_ADDRESS}${window.location.search}` + ? new URL(window.location.search, global.STORYBOOK_NETWORK_ADDRESS).href : window.location.href; return storyId ? ( } + tooltip={ + + } > diff --git a/code/frameworks/nextjs-vite/package.json b/code/frameworks/nextjs-vite/package.json index 676a7b589aea..5f5c44e08334 100644 --- a/code/frameworks/nextjs-vite/package.json +++ b/code/frameworks/nextjs-vite/package.json @@ -87,6 +87,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "lilconfig": "^3.0.0", "next": "^15.2.3", "postcss-load-config": "^6.0.1", "semver": "^7.3.5", diff --git a/code/frameworks/nextjs-vite/src/find-postcss-config.ts b/code/frameworks/nextjs-vite/src/find-postcss-config.ts new file mode 100644 index 000000000000..5f1a31a4c1ea --- /dev/null +++ b/code/frameworks/nextjs-vite/src/find-postcss-config.ts @@ -0,0 +1,139 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; + +import { getProjectRoot } from 'storybook/internal/common'; +import { IncompatiblePostCssConfigError } from 'storybook/internal/server-errors'; + +import config from 'lilconfig'; +import postCssLoadConfig from 'postcss-load-config'; + +type Options = import('lilconfig').Options; + +const require = createRequire(import.meta.url); + +async function loader(filepath: string) { + return require(filepath); +} + +const withLoaders = (options: Options = {}) => { + const moduleName = 'postcss'; + + return { + ...options, + loaders: { + ...options.loaders, + '.cjs': loader, + '.cts': loader, + '.js': loader, + '.mjs': loader, + '.mts': loader, + '.ts': loader, + }, + searchPlaces: [ + ...(options.searchPlaces ?? []), + 'package.json', + `.${moduleName}rc`, + `.${moduleName}rc.json`, + `.${moduleName}rc.ts`, + `.${moduleName}rc.cts`, + `.${moduleName}rc.mts`, + `.${moduleName}rc.js`, + `.${moduleName}rc.cjs`, + `.${moduleName}rc.mjs`, + `${moduleName}.config.ts`, + `${moduleName}.config.cts`, + `${moduleName}.config.mts`, + `${moduleName}.config.js`, + `${moduleName}.config.cjs`, + `${moduleName}.config.mjs`, + ], + } satisfies Options; +}; + +/** + * Find PostCSS config file path (without loading the config) + * + * @param {String} path Config Path + * @param {Object} options Config Options + * @returns {Promise} Config file path or null if not found + */ +async function postCssFindConfig(path: string, options: Options = {}) { + const result = await config.lilconfig('postcss', withLoaders(options)).search(path); + + return result ? result.filepath : null; +} + +export { postCssLoadConfig }; + +/** + * Normalizes PostCSS configuration for NextJS compatibility. + * + * This function handles the incompatibility between NextJS's PostCSS plugin format and Storybook's + * requirements. NextJS uses array format for plugins while Storybook expects object format. + * + * Process: + * + * 1. First attempts to load the config as-is + * 2. If that fails due to "Invalid PostCSS Plugin found" error, modifies the config file to convert + * array format to object format (e.g., ["@tailwindcss/postcss"] becomes { + * "@tailwindcss/postcss": {} }) + * 3. Retries loading with the modified config + * + * @param searchPath - Directory path to search for PostCSS config + * @returns Promise - true if config loads successfully (or no config found), false if + * config exists but cannot be loaded + * @throws {IncompatiblePostCssConfigError} - When config cannot be fixed automatically + * @sideEffect Modifies the PostCSS config file on disk when fixing plugin format + */ export const normalizePostCssConfig = async (searchPath: string): Promise => { + const configPath = await postCssFindConfig(searchPath); + if (!configPath) { + return true; + } + + let error: Error | undefined; + + // First attempt: try loading config as-is + try { + await postCssLoadConfig({}, searchPath, { stopDir: getProjectRoot() }); + return true; // Success! + } catch (e: unknown) { + if (e instanceof Error) { + error = e; + } + } + + if (!error) { + return true; + } + + // No config found is not an error we need to handle + if (error.message.includes('No PostCSS Config found')) { + return true; + } + + // NextJS uses an incompatible format for PostCSS plugins, we make an attempt to fix it + if (error.message.includes('Invalid PostCSS Plugin found')) { + // Second attempt: try with modified config + const originalContent = await readFile(configPath, 'utf8'); + try { + const modifiedContent = originalContent.replace( + 'plugins: ["@tailwindcss/postcss"]', + 'plugins: { "@tailwindcss/postcss": {} }' + ); + + // Write the modified content + await writeFile(configPath, modifiedContent, 'utf8'); + + // Retry loading the config + await postCssLoadConfig({}, searchPath, { stopDir: getProjectRoot() }); + return true; // Success with modified config! + } catch (e: any) { + // We were unable to fix the config, so we change the file back to the original content + await writeFile(configPath, originalContent, 'utf8'); + // and throw an error + throw new IncompatiblePostCssConfigError({ error }); + } + } + + return false; +}; diff --git a/code/frameworks/nextjs-vite/src/preset.ts b/code/frameworks/nextjs-vite/src/preset.ts index b34294dd7977..cc1ed65d4b65 100644 --- a/code/frameworks/nextjs-vite/src/preset.ts +++ b/code/frameworks/nextjs-vite/src/preset.ts @@ -3,16 +3,14 @@ import { createRequire } from 'node:module'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getProjectRoot } from 'storybook/internal/common'; -import { IncompatiblePostCssConfigError } from 'storybook/internal/server-errors'; import type { PresetProperty } from 'storybook/internal/types'; import type { StorybookConfigVite } from '@storybook/builder-vite'; import { viteFinal as reactViteFinal } from '@storybook/react-vite/preset'; -import postCssLoadConfig from 'postcss-load-config'; import semver from 'semver'; +import { normalizePostCssConfig } from './find-postcss-config'; import type { FrameworkOptions } from './types'; import { getNextjsVersion } from './utils'; @@ -63,17 +61,11 @@ export const optimizeViteDeps = [ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, options) => { const reactConfig = await reactViteFinal(config, options); - try { - const inlineOptions = config.css?.postcss; - const searchPath = typeof inlineOptions === 'string' ? inlineOptions : config.root; - await postCssLoadConfig({}, searchPath, { stopDir: getProjectRoot() }); - } catch (e: any) { - if (!e.message.includes('No PostCSS Config found')) { - // This is a custom error that we throw when the PostCSS config is invalid - if (e.message.includes('Invalid PostCSS Plugin found')) { - throw new IncompatiblePostCssConfigError({ error: e }); - } - } + const inlineOptions = config.css?.postcss; + const searchPath = typeof inlineOptions === 'string' ? inlineOptions : config.root; + + if (searchPath) { + await normalizePostCssConfig(searchPath); } const { nextConfigPath } = await options.presets.apply('frameworkOptions'); diff --git a/code/frameworks/nextjs/src/config/webpack.ts b/code/frameworks/nextjs/src/config/webpack.ts index 369f885557bc..340cf65cea86 100644 --- a/code/frameworks/nextjs/src/config/webpack.ts +++ b/code/frameworks/nextjs/src/config/webpack.ts @@ -1,13 +1,11 @@ import { fileURLToPath } from 'node:url'; import type { NextConfig } from 'next'; -import semver from 'semver'; import type { Configuration as WebpackConfig } from 'webpack'; -import { addScopedAlias, getNextjsVersion, resolveNextConfig } from '../utils'; +import { addScopedAlias, isNextVersionGte, resolveNextConfig } from '../utils'; -const nextjsVersion = getNextjsVersion(); -const isNext16orNewer = semver.gte(nextjsVersion, '16.0.0'); +const isNext16orNewer = isNextVersionGte('16.0.0'); const tryResolve = (path: string) => { try { diff --git a/code/frameworks/nextjs/src/preset.ts b/code/frameworks/nextjs/src/preset.ts index 27b5c5ed1a18..86da3fba457a 100644 --- a/code/frameworks/nextjs/src/preset.ts +++ b/code/frameworks/nextjs/src/preset.ts @@ -15,7 +15,7 @@ import nextBabelPreset from './babel/preset'; import { configureConfig } from './config/webpack'; import TransformFontImports from './font/babel'; import type { FrameworkOptions, StorybookConfig } from './types'; -import { getNextjsVersion } from './utils'; +import { isNextVersionGte } from './utils'; export const addons: PresetProperty<'addons'> = [ fileURLToPath(import.meta.resolve('@storybook/preset-react-webpack')), @@ -51,8 +51,7 @@ export const core: PresetProperty<'core'> = async (config, options) => { export const previewAnnotations: PresetProperty<'previewAnnotations'> = (entry = []) => { const annotations = [...entry, fileURLToPath(import.meta.resolve('@storybook/nextjs/preview'))]; - const nextjsVersion = getNextjsVersion(); - const isNext16orNewer = semver.gte(nextjsVersion, '16.0.0'); + const isNext16orNewer = isNextVersionGte('16.0.0'); // TODO: Remove this once we only support Next.js v16 and above if (!isNext16orNewer) { diff --git a/code/frameworks/nextjs/src/utils.ts b/code/frameworks/nextjs/src/utils.ts index 21f838e71ebf..9193e884f097 100644 --- a/code/frameworks/nextjs/src/utils.ts +++ b/code/frameworks/nextjs/src/utils.ts @@ -9,6 +9,7 @@ import { WebpackDefinePlugin } from '@storybook/builder-webpack5'; import type { NextConfig } from 'next'; import { PHASE_DEVELOPMENT_SERVER } from 'next/constants.js'; import nextJsLoadConfigModule from 'next/dist/server/config.js'; +import semver from 'semver'; import type { Configuration as WebpackConfig } from 'webpack'; import { resolvePackageDir } from '../../../core/src/shared/utils/module'; @@ -24,6 +25,12 @@ export const configureRuntimeNextjsVersionResolution = (baseConfig: WebpackConfi export const getNextjsVersion = (): string => JSON.parse(readFileSync(join(resolvePackageDir('next'), 'package.json'), 'utf8')).version; +export const isNextVersionGte = (version: string): boolean => { + const currentVersion = getNextjsVersion(); + const coercedVersion = semver.coerce(currentVersion); + return coercedVersion ? semver.gte(coercedVersion, version) : false; +}; + export const resolveNextConfig = async ({ nextConfigPath, }: { diff --git a/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts index 7eb0ffc0b894..01cab31471a3 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts @@ -3,11 +3,13 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { printCsf } from 'storybook/internal/csf-tools'; + // Import common to mock import dedent from 'ts-dedent'; // Import FixResult type -import { addonGlobalsApi, transformStoryFileSync } from './addon-globals-api'; +import { addonGlobalsApi, transformStoryFile } from './addon-globals-api'; // Mock fs/promises vi.mock('node:fs/promises', async () => import('../../../../../__mocks__/fs/promises')); @@ -44,10 +46,7 @@ const runMigrationAndGetTransformFn = async (previewContents: string) => { const result = await check(previewContents); const mockWriteFile = vi.mocked(fsp.writeFile); - let transformFn: ( - filePath: string, - content: string - ) => ReturnType = () => null; + let transformFn: (filePath: string, content: string) => string | null = () => null; let transformOptions: any; @@ -60,14 +59,15 @@ const runMigrationAndGetTransformFn = async (previewContents: string) => { } as any); if (result) { - // Create a transform function that uses the sync version + // Create a transform function that uses transformStoryFile + printCsf transformFn = (filePath: string, content: string) => { - return transformStoryFileSync(content, { + const transformed = transformStoryFile(content, { needsViewportMigration: result.needsViewportMigration, needsBackgroundsMigration: result.needsBackgroundsMigration, viewportsOptions: result.viewportsOptions, backgroundsOptions: result.backgroundsOptions, }); + return transformed ? printCsf(transformed, {}).code : null; }; // Extract options passed to transformStoryFile from the closure // This is a bit indirect, relying on the implementation detail @@ -245,25 +245,25 @@ describe('addon-globals-api', () => { } `); - expect(previewFileContent).toMatchInlineSnapshot(dedent` - "export default { - parameters: { - viewport: { - options: { - mobile: { name: 'Mobile', width: '320px', height: '568px' }, - tablet: { name: 'Tablet', width: '768px', height: '1024px' } - } - } - }, + expect(previewFileContent).toMatchInlineSnapshot(` + "export default { + parameters: { + viewport: { + options: { + mobile: { name: 'Mobile', width: '320px', height: '568px' }, + tablet: { name: 'Tablet', width: '768px', height: '1024px' } + } + } + }, - initialGlobals: { - viewport: { - value: 'mobile', - isRotated: false - } - } - };" -`); + initialGlobals: { + viewport: { + value: 'mobile', + isRotated: false + } + } + };" + `); }); it('should migrate backgrounds configuration correctly', async () => { @@ -281,24 +281,24 @@ describe('addon-globals-api', () => { } `); - expect(previewFileContent).toMatchInlineSnapshot(dedent` - "export default { - parameters: { - backgrounds: { - options: { - light: { name: 'Light', value: '#F8F8F8' }, - dark: { name: 'Dark', value: '#333333' } - } - } - }, + expect(previewFileContent).toMatchInlineSnapshot(` + "export default { + parameters: { + backgrounds: { + options: { + light: { name: 'Light', value: '#F8F8F8' }, + dark: { name: 'Dark', value: '#333333' } + } + } + }, - initialGlobals: { - backgrounds: { - value: 'light' - } - } - };" -`); + initialGlobals: { + backgrounds: { + value: 'light' + } + } + };" + `); }); it('should rename backgrounds disable property to disabled', async () => { @@ -316,7 +316,7 @@ describe('addon-globals-api', () => { `); // Verify the transformation results - expect(previewFileContent).toMatchInlineSnapshot(dedent` + expect(previewFileContent).toMatchInlineSnapshot(` "export default { parameters: { backgrounds: { @@ -351,7 +351,7 @@ describe('addon-globals-api', () => { } `); - expect(previewFileContent).toMatchInlineSnapshot(dedent` + expect(previewFileContent).toMatchInlineSnapshot(` "export default { parameters: { viewport: { @@ -400,7 +400,7 @@ describe('addon-globals-api', () => { `); // Verify the transformation results - expect(previewFileContent).toMatchInlineSnapshot(dedent` + expect(previewFileContent).toMatchInlineSnapshot(` "export default { parameters: { viewport: { @@ -439,33 +439,33 @@ describe('addon-globals-api', () => { } `); - expect(previewFileContent).toMatchInlineSnapshot(dedent` - "import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; + expect(previewFileContent).toMatchInlineSnapshot(` + "import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; - export default { - parameters: { - viewport: { - options: INITIAL_VIEWPORTS - }, - backgrounds: { - options: { - light: { name: 'Light', value: '#F8F8F8' }, - dark: { name: 'Dark', value: '#333333' } - } - } + export default { + parameters: { + viewport: { + options: INITIAL_VIEWPORTS }, + backgrounds: { + options: { + light: { name: 'Light', value: '#F8F8F8' }, + dark: { name: 'Dark', value: '#333333' } + } + } + }, - initialGlobals: { - viewport: { - value: 'tablet', - isRotated: false - }, + initialGlobals: { + viewport: { + value: 'tablet', + isRotated: false + }, - backgrounds: { - value: 'light' - } + backgrounds: { + value: 'light' } - };" + } + };" `); }); @@ -486,7 +486,7 @@ describe('addon-globals-api', () => { } `); - expect(previewFileContent).toMatchInlineSnapshot(dedent` + expect(previewFileContent).toMatchInlineSnapshot(` "export default { parameters: { backgrounds: { @@ -542,21 +542,18 @@ describe('addon-globals-api', () => { }; `; - const expectedContent = dedent` - import Button from './Button'; - export default { - component: Button - }; - export const Default = { - globals: { - backgrounds: { - value: "dark" - } - } - }; - `; expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + export default { component: Button }; + export const Default = { + globals: { + backgrounds: { + value: 'dark' + } + } + };" + `); }); it('should migrate parameters.backgrounds.disable: true to disabled: true', async () => { @@ -570,21 +567,16 @@ describe('addon-globals-api', () => { } }; `; - const expectedContent = dedent` - import Button from './Button'; - export default { - component: Button - }; - export const Disabled = { - parameters: { - backgrounds: { - disabled: true - } - } - }; - `; expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + export default { component: Button }; + export const Disabled = { + parameters: { + backgrounds: { disabled: true } + } + };" + `); }); it('should rename parameters.backgrounds.disable: false to disabled: false', async () => { @@ -598,22 +590,16 @@ describe('addon-globals-api', () => { } }; `; - // disable should be renamed to disabled - const expectedContent = dedent` - import Button from './Button'; - export default { - component: Button - }; - export const Disabled = { - parameters: { - backgrounds: { - disabled: false - } - } - }; - `; expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + export default { component: Button }; + export const Disabled = { + parameters: { + backgrounds: { disabled: false } + } + };" + `); }); it('should migrate parameters.viewport.defaultViewport to globals.viewport', async () => { @@ -627,22 +613,19 @@ describe('addon-globals-api', () => { } }; `; - const expectedContent = dedent` - import Button from './Button'; - export default { - component: Button - }; - export const MobileOnly = { - globals: { - viewport: { - value: "mobile", - isRotated: false - } - } - }; - `; expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + export default { component: Button }; + export const MobileOnly = { + globals: { + viewport: { + value: 'mobile', + isRotated: false + } + } + };" + `); }); it('should migrate both viewport and backgrounds in the same story', async () => { @@ -657,25 +640,23 @@ describe('addon-globals-api', () => { } }; `; - const expectedContent = dedent` - import Button from './Button'; - export default { - component: Button - }; - export const DarkMobile = { - globals: { - viewport: { - value: "mobile", - isRotated: false - }, - backgrounds: { - value: "dark" - } - } - }; - `; expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + export default { component: Button }; + export const DarkMobile = { + globals: { + viewport: { + value: 'mobile', + isRotated: false + }, + + backgrounds: { + value: 'dark' + } + } + };" + `); }); it('should handle migration in meta (export default)', async () => { @@ -690,20 +671,20 @@ describe('addon-globals-api', () => { }; export const Default = {}; `; - const expectedContent = dedent` - import Button from './Button'; - export default { - component: Button, - globals: { - backgrounds: { - value: "tweet" - } - } - }; - export const Default = {}; - `; + expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + export default { + component: Button, + globals: { + backgrounds: { + value: 'tweet' + } + } + }; + export const Default = {};" + `); }); it('should return null if no changes are needed', async () => { @@ -723,98 +704,235 @@ describe('addon-globals-api', () => { const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); const storyContent = dedent` import Button from './Button'; - export default { component: Button }; - export const ExistingGlobals = { globals: { backgrounds: { value: 'dark' } } }; - export const NeedsMigration = { parameters: { backgrounds: { default: 'Dark' } } }; + + export default { + component: Button + }; + + export const ExistingGlobals = { + globals: { + backgrounds: { + value: 'dark' + } + } + }; + + export const NeedsMigration = { + parameters: { + backgrounds: { + default: 'Dark' + } + } + }; `; - const expectedContent = dedent` - import Button from './Button'; - export default { - component: Button + expect(transformFn).toBeDefined(); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + + export default { + component: Button }; - export const ExistingGlobals = { - globals: { - backgrounds: { - value: 'dark' - } - } + + export const ExistingGlobals = { + globals: { + backgrounds: { + value: 'dark' + } + } }; - export const NeedsMigration = { + + export const NeedsMigration = { globals: { backgrounds: { - value: "dark" + value: 'dark' } - } - }; - `; - expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + } + };" + `); }); it('should merge new globals with existing globals in the same story', async () => { const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); const storyContent = dedent` import Button from './Button'; + export default { component: Button }; - export const ExistingAndNeedsMigration = { - globals: { backgrounds: { value: 'light' } }, - parameters: { backgrounds: { default: 'Dark' } } - }; - `; - const expectedContent = dedent` - import Button from './Button'; - export default { - component: Button - }; - export const ExistingAndNeedsMigration = { - globals: { + + export const ExistingAndNeedsMigration = { + globals: { + backgrounds: { + value: 'light' + } + }, + parameters: { backgrounds: { - value: 'light' + default: 'Dark' } - } + } }; `; expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + + export default { component: Button }; + + export const ExistingAndNeedsMigration = { + globals: { + backgrounds: { + value: 'light' + } + } + };" + `); }); it('should remove empty parameters/backgrounds/viewport objects after migration', async () => { const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); const storyContent = dedent` import Button from './Button'; + export default { component: Button }; + export const TestStory = { parameters: { otherParam: true, - backgrounds: { default: 'Dark' }, // This will move - viewport: { defaultViewport: 'tablet' } // This will move + backgrounds: { + default: 'Dark' + }, + viewport: { + defaultViewport: 'tablet' + } } }; `; - // parameters.backgrounds and parameters.viewport become empty and are removed - // parameters still has otherParam, so it remains - const expectedContent = dedent` + expect(transformFn).toBeDefined(); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + + export default { component: Button }; + + export const TestStory = { + parameters: { + otherParam: true + }, + + globals: { + viewport: { + value: 'tablet', + isRotated: false + }, + + backgrounds: { + value: 'dark' + } + } + };" + `); + }); + + it('should transform defaultOrientation and disabled properties in viewport stories', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` import Button from './Button'; - export default { - component: Button - }; - export const TestStory = { + export default { component: Button }; + export const Mobile = { parameters: { - otherParam: true + viewport: { + defaultOrientation: 'portrait', + defaultViewport: 'iphonex', + disable: true, + }, + }, + }; + `; + expect(transformFn).toBeDefined(); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + export default { component: Button }; + export const Mobile = { + parameters: { + viewport: { + disabled: true }, - globals: { + }, + + globals: { + viewport: { + value: 'iphonex', + isRotated: true + } + } + };" + `); + }); + + it('should transform member expression references in viewport stories', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import { MINIMAL_VIEWPORTS } from 'storybook/viewport'; + import Button from './Button'; + export default { component: Button }; + export const Mobile = { + parameters: { viewport: { - value: "tablet", - isRotated: false + defaultViewport: MINIMAL_VIEWPORTS.mobile2 }, - backgrounds: { - value: "dark" - } + }, + }; + `; + expect(transformFn).toBeDefined(); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import { MINIMAL_VIEWPORTS } from 'storybook/viewport'; + import Button from './Button'; + export default { component: Button }; + export const Mobile = { + globals: { + viewport: { + value: MINIMAL_VIEWPORTS.mobile2, + isRotated: false } + }, + };" + `); + }); + + it('should transform backgrounds values to options and migrate default in story files', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const Mobile = { + parameters: { + backgrounds: { + default: 'Light', + values: [ + { name: 'Gray', value: '#CCC' }, + ], + }, + }, }; `; expect(transformFn).toBeDefined(); - expect(transformFn!('story.js', storyContent)).toBe(expectedContent); + expect(transformFn!('story.js', storyContent)).toMatchInlineSnapshot(` + "import Button from './Button'; + export default { component: Button }; + export const Mobile = { + parameters: { + backgrounds: { + options: { + gray: { name: 'Gray', value: '#CCC' } + } + }, + }, + + globals: { + backgrounds: { + value: 'light' + } + } + };" + `); }); }); }); diff --git a/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.ts b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.ts index e7d59be0a7fd..8fce0c320a86 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.ts @@ -2,13 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { types as t } from 'storybook/internal/babel'; import type { ConfigFile, CsfFile } from 'storybook/internal/csf-tools'; -import { - formatConfig, - formatCsf, - loadConfig, - loadCsf, - writeCsf, -} from 'storybook/internal/csf-tools'; +import { formatConfig, loadConfig, loadCsf, writeCsf } from 'storybook/internal/csf-tools'; import type { ArrayExpression, Expression, ObjectExpression } from '@babel/types'; @@ -232,20 +226,6 @@ export const addonGlobalsApi: Fix = { }, }; -// Individual story transformation function for testing -export function transformStoryFileSync( - source: string, - options: { - needsViewportMigration: boolean; - needsBackgroundsMigration: boolean; - viewportsOptions: any; - backgroundsOptions: any; - } -) { - const result = transformStoryFile(source, options); - return result ? formatCsf(result, {}, source) : null; -} - // Story transformation function async function transformStoryFiles( files: string[], @@ -282,7 +262,7 @@ async function transformStoryFiles( } // Transform a single story file -function transformStoryFile( +export function transformStoryFile( source: string, options: { needsViewportMigration: boolean; @@ -310,18 +290,38 @@ function transformStoryFile( viewportParams = getObjectProperty(parameters, 'viewport') as ObjectExpression; if (viewportParams) { const defaultViewport = getObjectProperty(viewportParams, 'defaultViewport'); - if (defaultViewport && t.isStringLiteral(defaultViewport)) { + const defaultOrientation = getObjectProperty(viewportParams, 'defaultOrientation'); + const disableViewport = getObjectProperty(viewportParams, 'disable'); + + // Handle both string literals and member expressions for defaultViewport + let viewportValue: t.StringLiteral | t.MemberExpression | null = null; + if (defaultViewport) { + if (t.isStringLiteral(defaultViewport)) { + viewportValue = defaultViewport; + } else if (t.isMemberExpression(defaultViewport)) { + // Preserve the member expression as-is + viewportValue = defaultViewport; + } + } + + if (viewportValue) { // Create globals.viewport if (!newGlobals) { newGlobals = t.objectExpression([]); } + // Determine isRotated based on defaultOrientation + let isRotated = false; + if (defaultOrientation && t.isStringLiteral(defaultOrientation)) { + isRotated = defaultOrientation.value === 'portrait'; + } + newGlobals.properties.push( t.objectProperty( t.identifier('viewport'), t.objectExpression([ - t.objectProperty(t.identifier('value'), t.stringLiteral(defaultViewport.value)), - t.objectProperty(t.identifier('isRotated'), t.booleanLiteral(false)), + t.objectProperty(t.identifier('value'), viewportValue), + t.objectProperty(t.identifier('isRotated'), t.booleanLiteral(isRotated)), ]) ) ); @@ -330,6 +330,20 @@ function transformStoryFile( removeProperty(viewportParams, 'defaultViewport'); storyHasChanges = true; } + + // Handle defaultOrientation removal + if (defaultOrientation) { + removeProperty(viewportParams, 'defaultOrientation'); + storyHasChanges = true; + } + + // Handle disable -> disabled rename + if (disableViewport && t.isBooleanLiteral(disableViewport)) { + removeProperty(viewportParams, 'disable'); + // Rename disable to disabled (preserve both true and false values) + addProperty(viewportParams, 'disabled', disableViewport); + storyHasChanges = true; + } } } @@ -339,6 +353,18 @@ function transformStoryFile( if (backgroundsParams) { const defaultBackground = getObjectProperty(backgroundsParams, 'default'); const disableBackground = getObjectProperty(backgroundsParams, 'disable'); + const valuesBackground = getObjectProperty(backgroundsParams, 'values'); + + // Handle values -> options transformation + if (valuesBackground && t.isArrayExpression(valuesBackground)) { + // Transform values array to options object + const optionsObject = transformValuesToOptions(valuesBackground); + + // Remove the old values property + removeProperty(backgroundsParams, 'values'); + addProperty(backgroundsParams, 'options', optionsObject); + storyHasChanges = true; + } if (defaultBackground && t.isStringLiteral(defaultBackground)) { // Create globals.backgrounds diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts index f0405236345a..d7aeba97d34b 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts @@ -242,7 +242,7 @@ describe('preview specific functionality', () => { }); `); }); - it('should work', async () => { + it('should wrap definePreview for mixed annotations and default export', async () => { await expect( transform(dedent` export const decorators = [1] @@ -259,4 +259,61 @@ describe('preview specific functionality', () => { }); `); }); + + it('should wrap definePreview for const defined preview with type annotations', async () => { + await expect( + transform(dedent` + import { type Preview } from '@storybook/react-vite'; + + const preview = { + decorators: [], + + parameters: { + options: {} + } + } satisfies Preview; + + export default preview; + + `) + ).resolves.toMatchInlineSnapshot(` + import { definePreview } from '@storybook/react-vite'; + + export default definePreview({ + decorators: [], + + parameters: { + options: {}, + }, + }); + `); + }); + + it('should wrap definePreview for mixed annotations and default const export', async () => { + await expect( + transform(dedent` + import { type Preview } from '@storybook/react-vite'; + export const decorators = [] + const preview = { + + parameters: { + options: {} + } + } satisfies Preview; + + export default preview; + + `) + ).resolves.toMatchInlineSnapshot(` + import { definePreview } from '@storybook/react-vite'; + + export default definePreview({ + decorators: [], + + parameters: { + options: {}, + }, + }); + `); + }); }); diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts index 1db0859b31a9..c49d802979cb 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts @@ -32,6 +32,25 @@ export async function configToCsfFactory( const defineConfigProps = getConfigProperties(exportDecls, { configType }); const hasNamedExports = defineConfigProps.length > 0; + function findDeclarationNodeIndex(declarationName: string): number { + return programNode.body.findIndex( + (n) => + t.isVariableDeclaration(n) && + n.declarations.some((d) => { + let declaration = d.init; + // unwrap TS type annotations + if (t.isTSAsExpression(declaration) || t.isTSSatisfiesExpression(declaration)) { + declaration = declaration.expression; + } + return ( + t.isIdentifier(d.id) && + d.id.name === declarationName && + t.isObjectExpression(declaration) + ); + }) + ); + } + /** * Scenario 1: Mixed exports * @@ -60,16 +79,7 @@ export async function configToCsfFactory( if (t.isExportDefaultDeclaration(node) && t.isIdentifier(node.declaration)) { const declarationName = node.declaration.name; - declarationNodeIndex = programNode.body.findIndex( - (n) => - t.isVariableDeclaration(n) && - n.declarations.some( - (d) => - t.isIdentifier(d.id) && - d.id.name === declarationName && - t.isObjectExpression(d.init) - ) - ); + declarationNodeIndex = findDeclarationNodeIndex(declarationName); if (declarationNodeIndex !== -1) { exportDefaultNode = node; @@ -97,7 +107,7 @@ export async function configToCsfFactory( /** * Scenario 2: Default exports * - * - Syntax 1: `default export const config = {}; export default config;` + * - Syntax 1: `const config = {}; export default config;` * - Syntax 2: `export default {};` * * Transform into: `export default defineMain({})` @@ -112,16 +122,7 @@ export async function configToCsfFactory( if (t.isExportDefaultDeclaration(node) && t.isIdentifier(node.declaration)) { const declarationName = node.declaration.name; - declarationNodeIndex = programNode.body.findIndex( - (n) => - t.isVariableDeclaration(n) && - n.declarations.some( - (d) => - t.isIdentifier(d.id) && - d.id.name === declarationName && - t.isObjectExpression(d.init) - ) - ); + declarationNodeIndex = findDeclarationNodeIndex(declarationName); if (declarationNodeIndex !== -1) { exportDefaultNode = node; diff --git a/code/package.json b/code/package.json index fd7a273fc229..6fd988185098 100644 --- a/code/package.json +++ b/code/package.json @@ -283,5 +283,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.0.0-beta.11" } diff --git a/code/yarn.lock b/code/yarn.lock index 9f5cdb31e37b..fbc0b812a177 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6564,6 +6564,7 @@ __metadata: "@storybook/react": "workspace:*" "@storybook/react-vite": "workspace:*" "@types/node": "npm:^22.0.0" + lilconfig: "npm:^3.0.0" next: "npm:^15.2.3" postcss-load-config: "npm:^6.0.1" semver: "npm:^7.3.5" @@ -18126,7 +18127,7 @@ __metadata: languageName: node linkType: hard -"lilconfig@npm:^3.1.1": +"lilconfig@npm:^3.0.0, lilconfig@npm:^3.1.1": version: 3.1.3 resolution: "lilconfig@npm:3.1.3" checksum: 10c0/f5604e7240c5c275743561442fbc5abf2a84ad94da0f5adc71d25e31fa8483048de3dcedcb7a44112a942fed305fd75841cdf6c9681c7f640c63f1049e9a5dcc diff --git a/docs/_snippets/actions-filtering-example.md b/docs/_snippets/actions-filtering-example.md index 6cca5a26b27e..45bc0092f855 100644 --- a/docs/_snippets/actions-filtering-example.md +++ b/docs/_snippets/actions-filtering-example.md @@ -1,4 +1,4 @@ -```ts filename=".storybook/preview.ts" renderer="common" language="ts" +```ts filename=".storybook/preview.ts" renderer="common" language="ts" tabTitle="CSF 3" // Replace your-framework with the framework you are using (e.g., react-vite, nextjs, svelte) import type { Preview } from '@storybook/your-framework'; @@ -9,25 +9,25 @@ const originalConsoleLog = console.log; const preview: Preview = { async beforeEach() { spyOn(console, 'log') - // Disable automatic logging in the actions panel - .mockName('') - .mockImplementation((args) => { - // Check if the log message matches a certain pattern - if (someCondition(args)) { - // Manually log an action - action('console.log')(args); - } - - // Call the original console.log function - originalConsoleLog(...args); - }); + // Disable automatic logging in the actions panel + .mockName('') + .mockImplementation((args) => { + // Check if the log message matches a certain pattern + if (someCondition(args)) { + // Manually log an action + action('console.log')(args); + } + + // Call the original console.log function + originalConsoleLog(...args); + }); }, }; export default preview; ``` -```js filename=".storybook/preview.js" renderer="common" language="js" +```js filename=".storybook/preview.js" renderer="common" language="js" tabTitle="CSF 3" import { action } from 'storybook/actions'; import { spyOn } from 'storybook/test'; @@ -35,18 +35,75 @@ const originalConsoleLog = console.log; export default { async beforeEach() { spyOn(console, 'log') - // Disable automatic logging in the actions panel - .mockName('') - .mockImplementation((args) => { - // Check if the log message matches a certain pattern - if (someCondition(args)) { - // Manually log an action - action('console.log')(args); - } - - // Call the original console.log function - originalConsoleLog(...args); - }); + // Disable automatic logging in the actions panel + .mockName('') + .mockImplementation((args) => { + // Check if the log message matches a certain pattern + if (someCondition(args)) { + // Manually log an action + action('console.log')(args); + } + + // Call the original console.log function + originalConsoleLog(...args); + }); }, }; ``` + +```ts filename=".storybook/preview.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +import { action } from 'storybook/actions'; +import { spyOn } from 'storybook/test'; + +const originalConsoleLog = console.log; + +export default definePreview({ + async beforeEach() { + spyOn(console, 'log') + // Disable automatic logging in the actions panel + .mockName('') + .mockImplementation((args) => { + // Check if the log message matches a certain pattern + if (someCondition(args)) { + // Manually log an action + action('console.log')(args); + } + + // Call the original console.log function + originalConsoleLog(...args); + }); + }, +}); +``` + + + +```js filename=".storybook/preview.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +import { action } from 'storybook/actions'; +import { spyOn } from 'storybook/test'; + +const originalConsoleLog = console.log; +export default definePreview({ + async beforeEach() { + spyOn(console, 'log') + // Disable automatic logging in the actions panel + .mockName('') + .mockImplementation((args) => { + // Check if the log message matches a certain pattern + if (someCondition(args)) { + // Manually log an action + action('console.log')(args); + } + + // Call the original console.log function + originalConsoleLog(...args); + }); + }, +}); +``` diff --git a/docs/_snippets/actions-spyon-basic-example.md b/docs/_snippets/actions-spyon-basic-example.md index c51c665b684f..89471b84c56c 100644 --- a/docs/_snippets/actions-spyon-basic-example.md +++ b/docs/_snippets/actions-spyon-basic-example.md @@ -1,4 +1,4 @@ -```ts filename=".storybook/preview.ts" renderer="common" language="ts" +```ts filename=".storybook/preview.ts" renderer="common" language="ts" tabTitle="CSF 3" // Replace your-framework with the framework you are using (e.g., react-vite, nextjs, svelte) import type { Preview } from '@storybook/your-framework'; @@ -13,7 +13,7 @@ const preview: Preview = { export default preview; ``` -```js filename=".storybook/preview.js" renderer="common" language="js" +```js filename=".storybook/preview.js" renderer="common" language="js" tabTitle="CSF 3" import { spyOn } from 'storybook/test'; export default { @@ -22,3 +22,31 @@ export default { }, }; ``` + +```ts filename=".storybook/preview.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +import { spyOn } from 'storybook/test'; + +export default definePreview({ + async beforeEach() { + spyOn(console, 'log').mockName('console.log'); + }, +}); +``` + + + +```js filename=".storybook/preview.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +import { spyOn } from 'storybook/test'; + +export default definePreview({ + async beforeEach() { + spyOn(console, 'log').mockName('console.log'); + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-config-context-in-story.md b/docs/_snippets/addon-a11y-config-context-in-story.md index 8d52ad486e2d..6137488cea2b 100644 --- a/docs/_snippets/addon-a11y-config-context-in-story.md +++ b/docs/_snippets/addon-a11y-config-context-in-story.md @@ -1,4 +1,4 @@ -```ts filename="Button.stories.ts" renderer="common" language="ts" +```ts filename="Button.stories.ts" renderer="common" language="ts" tabTitle="CSF 3" // ...rest of story file export const ExampleStory: Story = { @@ -135,3 +135,57 @@ export const ExampleStory = { }, }; ``` + +```ts filename="Button.stories.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import Button from './Button'; + +const meta = preview.meta({ + component: Button, +}); + +export const ExampleStory = meta.story({ + parameters: { + a11y: { + /* + * Axe's context parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter + * to learn more. + */ + context: { + include: ['body'], + exclude: ['.no-a11y-check'], + }, + }, + }, +}); +``` + + + +```js filename="Button.stories.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import Button from './Button'; + +const meta = preview.meta({ + component: Button, +}); + +export const ExampleStory = meta.story({ + parameters: { + a11y: { + /* + * Axe's context parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter + * to learn more. + */ + context: { + include: ['body'], + exclude: ['.no-a11y-check'], + }, + }, + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-config-in-meta-and-story.md b/docs/_snippets/addon-a11y-config-in-meta-and-story.md index b38c96394c30..643f79253e71 100644 --- a/docs/_snippets/addon-a11y-config-in-meta-and-story.md +++ b/docs/_snippets/addon-a11y-config-in-meta-and-story.md @@ -57,7 +57,7 @@ export const ExampleStory: Story = { }; ``` -```ts filename="Button.stories.ts" renderer="common" language="ts" +```ts filename="Button.stories.ts" renderer="common" language="ts" tabTitle="CSF 3" // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Meta, StoryObj } from '@storybook/your-framework'; @@ -117,7 +117,7 @@ export const ExampleStory: Story = { }; ``` -```js filename="Button.stories.js" renderer="common" language="js" +```js filename="Button.stories.js" renderer="common" language="js" tabTitle="CSF 3" import { Button } from './Button'; export default { @@ -511,3 +511,117 @@ export const ExampleStory = { }, }; ``` + +```ts filename="Button.stories.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import { Button } from './Button'; + +const meta = preview.meta({ + component: Button, + parameters: { + a11y: { + /* + * Axe's context parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter + * to learn more. + */ + context: {}, + /* + * Axe's configuration + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#api-name-axeconfigure + * to learn more about the available properties. + */ + config: {}, + /* + * Axe's options parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter + * to learn more about the available options. + */ + options: {}, + /* + * Configure test behavior + * See: https://storybook.js.org/docs/next/writing-tests/accessibility-testing#test-behavior + */ + test: 'error', + }, + }, + globals: { + a11y: { + // Optional flag to prevent the automatic check + manual: true, + }, + }, +}); + +export const ExampleStory = meta.story({ + parameters: { + a11y: { + // ...same config available as above + }, + }, + globals: { + a11y: { + // ...same config available as above + }, + }, +}); +``` + + + +```js filename="Button.stories.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import { Button } from './Button'; + +const meta = preview.meta({ + component: Button, + parameters: { + a11y: { + /* + * Axe's context parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter + * to learn more. + */ + context: {}, + /* + * Axe's configuration + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#api-name-axeconfigure + * to learn more about the available properties. + */ + config: {}, + /* + * Axe's options parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter + * to learn more about the available options. + */ + options: {}, + /* + * Configure test behavior + * See: https://storybook.js.org/docs/next/writing-tests/accessibility-testing#test-behavior + */ + test: 'error', + }, + }, + globals: { + a11y: { + // Optional flag to prevent the automatic check + manual: true, + }, + }, +}); + +export const ExampleStory = meta.story({ + parameters: { + a11y: { + // ...same config available as above + }, + }, + globals: { + a11y: { + // ...same config available as above + }, + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-config-in-preview.md b/docs/_snippets/addon-a11y-config-in-preview.md index 4dfbf5e8bd55..9569c9fe451a 100644 --- a/docs/_snippets/addon-a11y-config-in-preview.md +++ b/docs/_snippets/addon-a11y-config-in-preview.md @@ -1,4 +1,4 @@ -```js filename=".storybook/preview.js" renderer="common" language="js" +```js filename=".storybook/preview.js" renderer="common" language="js" tabTitle="CSF 3" export default { parameters: { a11y: { @@ -31,7 +31,7 @@ export default { }; ``` -```ts filename=".storybook/preview.ts" renderer="common" language="ts" +```ts filename=".storybook/preview.ts" renderer="common" language="ts" tabTitle="CSF 3" // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Preview } from '@storybook/your-framework'; @@ -68,3 +68,77 @@ const preview: Preview = { export default preview; ``` + +```ts filename=".storybook/preview.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +export default definePreview({ + parameters: { + a11y: { + /* + * Axe's context parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter + * to learn more. Typically, this is the CSS selector for the part of the DOM you want to analyze. + */ + context: 'body', + /* + * Axe's configuration + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#api-name-axeconfigure + * to learn more about the available properties. + */ + config: {}, + /* + * Axe's options parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter + * to learn more about the available options. + */ + options: {}, + }, + }, + globals: { + a11y: { + // Optional flag to prevent the automatic check + manual: true, + }, + }, +}); +``` + + + +```js filename=".storybook/preview.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +export default definePreview({ + parameters: { + a11y: { + /* + * Axe's context parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter + * to learn more. Typically, this is the CSS selector for the part of the DOM you want to analyze. + */ + context: 'body', + /* + * Axe's configuration + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#api-name-axeconfigure + * to learn more about the available properties. + */ + config: {}, + /* + * Axe's options parameter + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter + * to learn more about the available options. + */ + options: {}, + }, + }, + globals: { + a11y: { + // Optional flag to prevent the automatic check + manual: true, + }, + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-config-rules-in-story.md b/docs/_snippets/addon-a11y-config-rules-in-story.md index 5d02ed6ee1a6..3ea1497e0184 100644 --- a/docs/_snippets/addon-a11y-config-rules-in-story.md +++ b/docs/_snippets/addon-a11y-config-rules-in-story.md @@ -1,4 +1,4 @@ -```ts filename="Button.stories.ts" renderer="common" language="ts" +```ts filename="Button.stories.ts" renderer="common" language="ts" tabTitle="CSF 3" // ...rest of story file export const IndividualA11yRulesExample: Story = { @@ -23,7 +23,7 @@ export const IndividualA11yRulesExample: Story = { }; ``` -```js filename="Button.stories.js" renderer="common" language="js" +```js filename="Button.stories.js" renderer="common" language="js" tabTitle="CSF 3" // ...rest of story file export const IndividualA11yRulesExample = { @@ -165,3 +165,67 @@ export const IndividualA11yRulesExample = { }, }; ``` + +```ts filename="Button.stories.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import Button from './Button'; + +const meta = preview.meta({ + component: Button, +}); + +export const IndividualA11yRulesExample = meta.story({ + parameters: { + a11y: { + config: { + rules: [ + { + // The autocomplete rule will not run based on the CSS selector provided + id: 'autocomplete-valid', + selector: '*:not([autocomplete="nope"])', + }, + { + // Setting the enabled option to false will disable checks for this particular rule on all stories. + id: 'image-alt', + enabled: false, + }, + ], + }, + }, + }, +}); +``` + + + +```js filename="Button.stories.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import Button from './Button'; + +const meta = preview.meta({ + component: Button, +}); + +export const IndividualA11yRulesExample = meta.story({ + parameters: { + a11y: { + config: { + rules: [ + { + // The autocomplete rule will not run based on the CSS selector provided + id: 'autocomplete-valid', + selector: '*:not([autocomplete="nope"])', + }, + { + // Setting the enabled option to false will disable checks for this particular rule on all stories. + id: 'image-alt', + enabled: false, + }, + ], + }, + }, + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-config-rulesets-in-preview.md b/docs/_snippets/addon-a11y-config-rulesets-in-preview.md index e06ccd559029..cc242d556a5f 100644 --- a/docs/_snippets/addon-a11y-config-rulesets-in-preview.md +++ b/docs/_snippets/addon-a11y-config-rulesets-in-preview.md @@ -1,4 +1,4 @@ -```ts filename=".storybook/preview.ts" renderer="common" language="ts" +```ts filename=".storybook/preview.ts" renderer="common" language="ts" tabTitle="CSF 3" // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Preview } from '@storybook/your-framework'; @@ -20,7 +20,7 @@ const preview: Preview = { export default preview; ``` -```js filename=".storybook/preview.js" renderer="common" language="js" +```js filename=".storybook/preview.js" renderer="common" language="js" tabTitle="CSF 3" export default { parameters: { a11y: { @@ -36,3 +36,45 @@ export default { }, }; ``` + +```ts filename=".storybook/preview.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +export default definePreview({ + parameters: { + a11y: { + options: { + /* + * Opt in to running WCAG 2.x AAA rules + * Note that you must explicitly re-specify the defaults (all but the last array entry) + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter-examples for more details + */ + runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice', 'wcag2aaa'], + }, + }, + }, +}); +``` + + + +```js filename=".storybook/preview.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +export default definePreview({ + parameters: { + a11y: { + options: { + /* + * Opt in to running WCAG 2.x AAA rules + * Note that you must explicitly re-specify the defaults (all but the last array entry) + * See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter-examples for more details + */ + runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice', 'wcag2aaa'], + }, + }, + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-disable.md b/docs/_snippets/addon-a11y-disable.md index 588c16b2637e..1e4e9c5d5706 100644 --- a/docs/_snippets/addon-a11y-disable.md +++ b/docs/_snippets/addon-a11y-disable.md @@ -20,7 +20,7 @@ export const NonA11yStory: Story = { }; ``` -```js filename="MyComponent.stories.js|jsx" renderer="react" language="js" +```js filename="MyComponent.stories.js|jsx" renderer="react" language="js" tabTitle="CSF 3" import { MyComponent } from './MyComponent'; export default { @@ -37,7 +37,7 @@ export const NonA11yStory = { }; ``` -```ts filename="MyComponent.stories.ts|tsx" renderer="react" language="ts" +```ts filename="MyComponent.stories.ts|tsx" renderer="react" language="ts" tabTitle="CSF 3" // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. import type { Meta, StoryObj } from '@storybook/your-framework'; @@ -217,3 +217,43 @@ export const ExampleStory: Story = { }, }; ``` + +```ts filename="MyComponent.stories.ts|tsx" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import { MyComponent } from './MyComponent'; + +const meta = preview.meta({ + component: MyComponent, +}); + +export const NonA11yStory = meta.story({ + globals: { + a11y: { + // This option disables all automatic a11y checks on this story + manual: true, + }, + }, +}); +``` + + + +```js filename="MyComponent.stories.js|jsx" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import { MyComponent } from './MyComponent'; + +const meta = preview.meta({ + component: MyComponent, +}); + +export const NonA11yStory = meta.story({ + globals: { + a11y: { + // This option disables all automatic a11y checks on this story + manual: true, + }, + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-parameter-error-in-preview.md b/docs/_snippets/addon-a11y-parameter-error-in-preview.md index 4cdced1913a4..8938dff41f35 100644 --- a/docs/_snippets/addon-a11y-parameter-error-in-preview.md +++ b/docs/_snippets/addon-a11y-parameter-error-in-preview.md @@ -1,4 +1,4 @@ -```ts filename=".storybook/preview.ts" renderer="common" language="ts" +```ts filename=".storybook/preview.ts" renderer="common" language="ts" tabTitle="CSF 3" // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Preview } from '@storybook/your-framework'; @@ -12,7 +12,7 @@ const preview: Preview = { export default preview; ``` -```js filename=".storybook/preview.js" renderer="common" language="js" +```js filename=".storybook/preview.js" renderer="common" language="js" tabTitle="CSF 3" export default { // ... parameters: { @@ -21,3 +21,31 @@ export default { }, }; ``` + +```ts filename=".storybook/preview.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +export default definePreview({ + // ... + parameters: { + // ๐Ÿ‘‡ Fail all accessibility tests when violations are found + a11y: { test: 'error' }, + }, +}); +``` + + + +```js filename=".storybook/preview.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { definePreview } from '@storybook/your-framework'; + +export default definePreview({ + // ... + parameters: { + // ๐Ÿ‘‡ Fail all accessibility tests when violations are found + a11y: { test: 'error' }, + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-parameter-example.md b/docs/_snippets/addon-a11y-parameter-example.md index 42df11421336..e57bdca01097 100644 --- a/docs/_snippets/addon-a11y-parameter-example.md +++ b/docs/_snippets/addon-a11y-parameter-example.md @@ -28,7 +28,7 @@ export const NoA11yFail: Story = { }; ``` -```ts filename="Button.stories.ts" renderer="common" language="ts" +```ts filename="Button.stories.ts" renderer="common" language="ts" tabTitle="CSF 3" // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Meta, StoryObj } from '@storybook/your-framework'; @@ -59,7 +59,7 @@ export const NoA11yFail: Story = { }; ``` -```js filename="Button.stories.js" renderer="common" language="js" +```js filename="Button.stories.js" renderer="common" language="js" tabTitle="CSF 3" import { Button } from './Button'; export default { @@ -252,3 +252,59 @@ export const NoA11yFail = { }, }; ``` + +```ts filename="Button.stories.ts" renderer="react" language="ts" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import { Button } from './Button'; + +const meta = preview.meta({ + component: Button, + parameters: { + // ๐Ÿ‘‡ Applies to all stories in this file + a11y: { test: 'error' }, + }, +}); + +// ๐Ÿ‘‡ This story will use the 'error' value and fail on accessibility violations +export const Primary = meta.story({ + args: { primary: true }, +}); + +// ๐Ÿ‘‡ This story will not fail on accessibility violations +// (but will still run the tests and show warnings) +export const NoA11yFail = meta.story({ + parameters: { + a11y: { test: 'todo' }, + }, +}); +``` + + + +```js filename="Button.stories.js" renderer="react" language="js" tabTitle="CSF Next ๐Ÿงช" +import preview from '../.storybook/preview'; + +import { Button } from './Button'; + +const meta = preview.meta({ + component: Button, + parameters: { + // ๐Ÿ‘‡ Applies to all stories in this file + a11y: { test: 'error' }, + }, +}); + +// ๐Ÿ‘‡ This story will use the 'error' value and fail on accessibility violations +export const Primary = meta.story({ + args: { primary: true }, +}); + +// ๐Ÿ‘‡ This story will not fail on accessibility violations +// (but will still run the tests and show warnings) +export const NoA11yFail = meta.story({ + parameters: { + a11y: { test: 'todo' }, + }, +}); +``` diff --git a/docs/_snippets/addon-a11y-parameter-remove.md b/docs/_snippets/addon-a11y-parameter-remove.md index 2b376f75a269..2108405d0b64 100644 --- a/docs/_snippets/addon-a11y-parameter-remove.md +++ b/docs/_snippets/addon-a11y-parameter-remove.md @@ -13,7 +13,7 @@ const meta: Meta