diff --git a/.agents/skills/canary/SKILL.md b/.agents/skills/canary/SKILL.md new file mode 100644 index 000000000000..df58c93c3ff6 --- /dev/null +++ b/.agents/skills/canary/SKILL.md @@ -0,0 +1,68 @@ +--- +name: canary +description: Triggers a canary release for a Storybook PR. Use when the user wants to publish a canary version, create a pre-release, or test a PR via npm. +allowed-tools: Bash +--- + +# Publish Canary Release + +Publishes a canary version of Storybook from a PR to npm. + +## Usage + +To trigger a canary release, run: + +```bash +gh workflow run --repo storybookjs/storybook publish.yml --field pr= +``` + +## What happens + +1. GitHub Actions builds and publishes the PR as `0.0.0-pr--sha-` +2. The version is published to npm with the `canary` tag +3. The PR body is updated with the exact version and install instructions + +## Version format + +The canary version follows a **predictable structure**: + +``` +0.0.0-pr--sha- +``` + +- ``: The PR number (e.g., `33526`) +- ``: First 8 characters of the commit SHA (e.g., `a2e09fa2`) + +**Example:** For PR #33526 with commit `a2e09fa284a...`, the canary version is: +`0.0.0-pr-33526-sha-a2e09fa2` + +You can construct the version yourself if you know the PR number and the latest commit SHA on that PR. + +## After publishing + +Check the PR body for the published version. It will show something like: + +> This pull request has been released as version `0.0.0-pr-33365-sha-b6656566` + +Then test with: + +```bash +npx storybook@ sandbox +``` + +Or upgrade an existing project: + +```bash +npx storybook@ upgrade +``` + +## Requirements + +- You must have admin permissions on the storybookjs/storybook repo +- The PR must exist and be open +- You need `gh` CLI authenticated + +## Monitor progress + +Watch the workflow run at: +https://github.com/storybookjs/storybook/actions/workflows/publish.yml diff --git a/.agents/skills/github-qa-labels/SKILL.md b/.agents/skills/github-qa-labels/SKILL.md new file mode 100644 index 000000000000..6783e7a09892 --- /dev/null +++ b/.agents/skills/github-qa-labels/SKILL.md @@ -0,0 +1,58 @@ +--- +name: github-qa-labels +description: Label GitHub issues and PRs found during QA testing. Use when organizing QA findings with proper labels. +allowed-tools: Bash +--- + +# GitHub QA Labels + +When creating or organizing issues/PRs found during QA testing, apply these labels. + +## QA tracking label + +Add `upgrade:` label to track all QA findings for a release: + +```bash +# Create label if it doesn't exist +gh label create "upgrade:10.2" --repo storybookjs/storybook --color "0E8A16" --description "Issues/PRs found during 10.2 upgrade QA" + +# Add to issue/PR +gh issue edit --repo storybookjs/storybook --add-label "upgrade:10.2" +gh pr edit --repo storybookjs/storybook --add-label "upgrade:10.2" +``` + +## Severity labels + +Add `sev:S1` through `sev:S4` to **bugs only** (not docs or feature requests): + +```bash +gh issue edit --repo storybookjs/storybook --add-label "sev:S2" +``` + +Severity levels: + +- **sev:S1**: Critical, blocking, no workaround +- **sev:S2**: Significant issue, may have workaround +- **sev:S3**: Moderate issue, workaround exists +- **sev:S4**: Minor issue, edge case, easy workaround + +## What gets severity labels + +| Type | Severity label? | +| ----------------------- | --------------- | +| Bug (runtime error) | Yes | +| Bug (type error) | Yes | +| Bug (automigrate issue) | Yes | +| Documentation issue | No | +| Feature request | No | +| Enhancement | No | + +## Batch labeling + +Label multiple issues at once: + +```bash +gh issue edit 33524 --repo storybookjs/storybook --add-label "upgrade:10.2" && \ +gh issue edit 33527 --repo storybookjs/storybook --add-label "upgrade:10.2" && \ +gh pr edit 33526 --repo storybookjs/storybook --add-label "upgrade:10.2" +``` diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md new file mode 100644 index 000000000000..ba97bec26cd9 --- /dev/null +++ b/.agents/skills/pr/SKILL.md @@ -0,0 +1,56 @@ +--- +name: pr +description: Creates a pull request following Storybook conventions. Use when creating PRs, opening pull requests, or submitting changes for review. +allowed-tools: Bash, Read +--- + +# Create Pull Request + +Creates a PR following Storybook conventions. + +## Title format + +`[Area]: [Description]` + +- Area is capitalized, no spaces (dashes allowed) +- Examples: + - `CSFFactories: Fix type export` + - `Nextjs-Vite: Add support` + - `CLI: Fix automigrate issue` + +## Labels + +Add these labels to the PR: + +**Category (required, pick one):** + +- `bug` - fixes incorrect behavior +- `maintenance` - user-facing maintenance +- `dependencies` - upgrading/downgrading deps +- `build` - internal build/test updates (no changelog) +- `cleanup` - minor cleanup (no changelog) +- `documentation` - docs only (no changelog) +- `feature request` - new feature +- `BREAKING CHANGE` - breaks compatibility +- `other` - doesn't fit above + +**CI (required, pick one):** + +- `ci:normal` - standard sandbox set; default for most code changes +- `ci:merged` - merged sandbox set +- `ci:daily` - daily sandbox set; use this when changes affect prerelease sandboxes or sandboxes pinned to a framework or React version other than latest +- `ci:docs` - documentation-only changes (use with `documentation` category) + +## PR body + +Read `.github/PULL_REQUEST_TEMPLATE.md` from the repository root. + +Copy that template **EXACTLY**, including all HTML comments (``). Fill in the relevant sections based on the changes, but keep all comments intact. + +## Command + +Always create PRs in draft mode: + +```bash +gh pr create --draft --title ": " --body "" --label "," +``` diff --git a/.agents/skills/storybook-upgrade/SKILL.md b/.agents/skills/storybook-upgrade/SKILL.md new file mode 100644 index 000000000000..e33c8372cd37 --- /dev/null +++ b/.agents/skills/storybook-upgrade/SKILL.md @@ -0,0 +1,58 @@ +--- +name: storybook-upgrade +description: Upgrade Storybook to a specific version (canary or release). Use this when upgrading Storybook packages in an external app, reproduction, or test project. +allowed-tools: Bash +--- + +# Storybook Upgrade + +Upgrades all Storybook packages in a project to a specific version. + +## Why this matters for the monorepo + +This skill is mainly for validating Storybook changes outside this repository. + +- QA a canary build from a Storybook PR in a downstream app +- Reproduce or verify a bug in an external project + +## Usage + +```bash +npx storybook@ upgrade +``` + +## Examples + +### Upgrade to canary version + +```bash +npx storybook@0.0.0-pr-33526-sha-a2e09fa2 upgrade +``` + +### Upgrade to latest stable + +```bash +npx storybook@latest upgrade +``` + +### Upgrade to specific release + +```bash +npx storybook@8.5.0 upgrade +``` + +## What it does + +1. Detects all `@storybook/*` packages in your project +2. Upgrades them all to the specified version +3. Handles peer dependencies automatically +4. Works with npm, yarn, and pnpm + +## Important + +- **DO NOT** manually install storybook packages with `npm add` / `yarn add` / `pnpm add` +- Always use `npx storybook@ upgrade` to ensure all packages stay in sync +- The upgrade command handles version resolution across all storybook packages +- **ALWAYS upgrade only 1 major version at a time!** + - Example: 8.x → 9.x → 10.x → canary of 10 + - Never skip major versions (e.g., don't go from 8.x directly to 10.x) diff --git a/.claude/skills/canary/SKILL.md b/.claude/skills/canary/SKILL.md new file mode 100644 index 000000000000..409383aedfcc --- /dev/null +++ b/.claude/skills/canary/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/canary/SKILL.md diff --git a/.claude/skills/github-qa-labels/SKILL.md b/.claude/skills/github-qa-labels/SKILL.md new file mode 100644 index 000000000000..3f2d5d88f721 --- /dev/null +++ b/.claude/skills/github-qa-labels/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/github-qa-labels/SKILL.md diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md new file mode 100644 index 000000000000..e49c172623d4 --- /dev/null +++ b/.claude/skills/pr/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/pr/SKILL.md diff --git a/.claude/skills/storybook-upgrade/SKILL.md b/.claude/skills/storybook-upgrade/SKILL.md new file mode 100644 index 000000000000..5fc27a1e7e57 --- /dev/null +++ b/.claude/skills/storybook-upgrade/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/storybook-upgrade/SKILL.md diff --git a/.cursor/mcp.json b/.cursor/mcp.json index e07488b7f3cc..8857b2b28278 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,5 +1,12 @@ { "mcpServers": { + "nx": { + "command": "npx", + "args": [ + "-y", + "nx-mcp@latest" + ] + }, "wallaby": { "command": "node", "args": [ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index dc9785e14913..000000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,419 +0,0 @@ -# GitHub Copilot Instructions for Storybook - -This document provides comprehensive instructions for GitHub Copilot when working on the Storybook repository. - -## Repository Overview - -Storybook is a large monorepo built with TypeScript, React, and various other frameworks. The monorepo root is at the git root (not `code/`), with the main codebase in `code/` and build tooling in `scripts/`. - -## System Requirements - -- **Node.js**: 22.21.1 (see `.nvmrc`) -- **Package Manager**: Yarn 4.10.3 -- **Operating System**: Linux/macOS (CI environment) - -## Repository Structure - -``` -storybook/ # Yarn monorepo root -├── .github/ # GitHub configurations and workflows -├── .nx/ # NX workflows and configuration -├── code/ # Main codebase -│ ├── .storybook/ # Configuration for internal UI Storybook -│ ├── core/ # Core Storybook package -│ ├── lib/ # Core supporting libraries -│ ├── addons/ # Core Storybook addons -│ ├── builders/ # Builder integrations -│ ├── renderers/ # Renderer integrations -│ ├── frameworks/ # Framework integrations -│ ├── presets/ # Preset packages for Webpack-based integrations -│ └── sandbox/ # Internal build artifacts (ignore) -├── scripts/ # Build and development scripts -├── docs/ # Documentation -├── test-storybooks/ # Test repos -└── ../storybook-sandboxes/ # Generated sandbox environments (outside repo) -``` - -## Essential Commands and Build Times - -All commands run from the **repository root** unless otherwise specified. - -### Installation & Setup - -```bash -yarn # Install all dependencies (~2.5 min) -``` - -### Compilation (Two Approaches) - -**Using yarn task (custom task runner):** - -```bash -yarn task compile # Compile all packages (~3 min) -``` - -**Using NX (recommended for better caching):** - -```bash -yarn nx run-many -t compile -c production # Compile all packages -yarn nx compile -c production # Compile specific package -``` - -### Linting - -```bash -yarn lint # Run all linting checks (~4 min) -``` - -Fix linting on all touched files by running the following command before commiting: - -```bash -yarn --cwd code lint:js:cmd --fix -``` - -### Type Checking - -```bash -yarn task check # TypeScript type checking -# OR with NX: -yarn nx run-many -t check -c production -``` - -### Development Server - -```bash -# Start Storybook UI development server -cd code && yarn storybook:ui # Serves on http://localhost:6006/ -# Requires compilation first! - -# Build Storybook UI for production -cd code && yarn storybook:ui:build # Output: code/storybook-static/ -``` - -### Testing - -```bash -cd code && yarn test # Run all tests -cd code && yarn test:watch # Watch mode -cd code && yarn storybook:vitest # Storybook UI specific tests - -# Task-based testing (with template sandboxes) -yarn task e2e-tests-dev --template react-vite/default-ts --start-from auto -yarn task e2e-tests-build --template react-vite/default-ts --start-from auto -yarn task test-runner-dev --template react-vite/default-ts --start-from auto -yarn task test-runner-build --template react-vite/default-ts --start-from auto -``` - -## NX Task Runner (Recommended) - -The repository uses NX for task orchestration with better caching and dependency management. NX correctly invalidates compile/check steps when `scripts/` changes. - -### yarn task vs NX equivalents - -```bash -# Compilation -yarn task compile --no-link -yarn nx run-many -t compile -c production - -# E2E tests on specific template -yarn task e2e-tests-dev --template react-vite/default-ts --start-from auto --no-link -yarn nx e2e-tests-dev react-vite/default-ts -c production - -# Skip task dependencies (start from a specific step) -yarn task e2e-tests-dev --start-from e2e-tests --template react-vite/default-ts --no-link -yarn nx e2e-tests-dev -c production --exclude-task-dependencies -``` - -### Key NX Concepts - -- `-c production` flag is **required** for sandbox-related commands -- `react-vite/default-ts` is the default project (can omit in NX commands) -- NX automatically handles task dependencies via `nx.json` configuration -- Uses NX Cloud for distributed caching in CI - -## Important Warnings and Limitations - -### Commands to Avoid - -- **DO NOT RUN**: `yarn task dev` - This starts a permanent development server that runs indefinitely -- **DO NOT RUN**: `yarn start` - Also starts a long-running development server - -### Sandbox Location Change - -Sandboxes are now generated **outside** the repository at `../storybook-sandboxes/` by default. - -- Set `STORYBOOK_SANDBOX_ROOT=./sandbox` for local sandbox directory (not recommended) -- The `./sandbox` directory exists only for NX outputs (not for CI tests) - -### Available Task Commands - -The repository includes task scripts in `scripts/tasks/`: - -- `compile` - TypeScript compilation -- `check` - Package validation -- `build` - Package building -- `sandbox` - Sandbox creation -- `dev` - Development server (AVOID - runs indefinitely) -- `e2e-tests-build` / `e2e-tests-dev` - E2E tests -- `test-runner-build` / `test-runner-dev` - Test runner scenarios -- `chromatic` - Visual testing with Chromatic -- `publish` - Package publishing -- `run-registry` - Local npm registry (verdaccio) -- `smoke-test` - Basic functionality tests -- `vitest-test` - Vitest integration tests - -## Recommended Development Workflow - -### For Code Changes - -1. Install dependencies: `yarn` (if needed) -2. Compile packages: `yarn nx run-many -t compile -c production` -3. Make your changes -4. Recompile changed packages -5. Test changes with: `cd code && yarn storybook:ui:build` -6. Run relevant tests: `cd code && yarn test` - -### For Testing UI Changes - -1. Generate a sandbox: `yarn task sandbox --template react-vite/default-ts --start-from auto` - - Sandboxes are created at `../storybook-sandboxes/` by default -2. If sandbox generation fails, use Storybook UI: `cd code && yarn storybook:ui` -3. Access at http://localhost:6006/ - -### For Addon/Framework/Renderers Development - -1. Navigate to the relevant package in `code/addons/`, `code/frameworks/` or `code/renderers/` -2. Make changes to source files -3. Recompile: `yarn nx compile -c production` -4. Generate a sandbox matching the framework/renderer -5. Test with appropriate test tasks - -## Bash Command Guidelines - -### Timeout Settings - -- **Short commands** (< 30s): Default timeout (120s) is sufficient -- **Dependency installation**: Use 300+ seconds timeout -- **Compilation**: Use 300+ seconds timeout -- **Linting**: Use 300+ seconds timeout -- **Development servers**: Use async mode or timeout commands - -### Example Bash Commands - -```bash -# Safe compilation with proper timeout -bash(command="cd /path/to/storybook && yarn nx run-many -t compile -c production", timeout=300, async=false) - -# Start development server with timeout to prevent hanging -bash(command="cd /path/to/storybook/code && timeout 30s yarn storybook:ui", timeout=45, async=false) - -# Use async for interactive or long-running commands -bash(command="cd /path/to/storybook/code && yarn storybook:ui", async=true) -``` - -## Sandbox Environments - -### Generating New Sandboxes - -Sandboxes are test environments that allow you to test Storybook changes with different framework combinations. **Note**: Sandboxes are now generated outside the repo by default at `../storybook-sandboxes/`. - -```bash -# Generate a new sandbox (run from repository root) -yarn task sandbox --template react-vite/default-ts --start-from auto -# Creates: ../storybook-sandboxes/react-vite-default-ts/ - -# Using NX (with -c production flag required) -yarn nx sandbox react-vite/default-ts -c production -``` - -### Available Framework/Builder Templates - -Common templates include: - -- `react-vite/default-ts` - React with Vite and TypeScript -- `react-webpack/default-ts` - React with Webpack and TypeScript -- `angular-cli/default-ts` - Angular CLI with TypeScript -- `svelte-vite/default-ts` - Svelte with Vite and TypeScript -- `vue3-vite/default-ts` - Vue 3 with Vite and TypeScript -- `nextjs/default-ts` - Next.js with TypeScript -- And many more... - -### Working with Generated Sandboxes - -Once a sandbox is successfully generated, you can work with it: - -```bash -# Navigate to the generated sandbox (now outside the repo) -cd ../storybook-sandboxes/react-vite-default-ts - -# Install dependencies if needed -yarn install - -# Start the sandbox Storybook -yarn storybook -``` - -### Current Limitations - -- **Sandbox Location**: Sandboxes are generated at `../storybook-sandboxes/` by default, outside the repository -- **NX Outputs**: The `./sandbox` directory in the repo exists only for NX outputs, not for CI tests -- **Workaround**: For testing changes when sandbox generation fails, you can work directly with the Storybook UI instead - -### Testing Changes Without Sandboxes - -When sandbox generation is not available: - -1. Make your changes to the relevant packages in `code/` -2. Compile: `yarn nx run-many -t compile -c production` -3. Test with Storybook UI: `cd code && yarn storybook:ui` -4. Access at http://localhost:6006/ to test your changes - -## Package Management - -### Adding Dependencies - -```bash -# Add to specific workspace -cd code/frameworks/react-vite && yarn add - -# Add to root workspace -yarn add -W -``` - -### Building Specific Packages - -```bash -# Build specific package (run from code/ directory) -cd code && yarn build -``` - -## Testing Strategy - -### Unit Tests - -```bash -cd code && yarn test -# Run specific test suites as needed -``` - -### Visual Testing - -- Use Storybook UI for visual regression testing -- Chromatic integration available for visual reviews - -### End-to-End Testing - -- Playwright tests available (version 1.52.0 configured) -- E2E test tasks: `yarn task e2e-tests-build --start-from auto` or `yarn task e2e-tests-dev --start-from auto` -- Test runner scenarios: `yarn task test-runner-build --start-from auto` or `yarn task test-runner-dev --start-from auto` -- Smoke tests: `yarn task smoke-test --start-from auto` - -### Watch Mode Commands - -```bash -# Watch mode for unit tests -cd code && yarn test:watch - -# Watch mode for affected tests only -yarn affected:test - -# Storybook UI vitest watch mode -cd code && yarn storybook:vitest -``` - -## Troubleshooting - -### Common Issues - -1. **Build Failures**: Often resolved by running `yarn` followed by `yarn nx run-many -t compile -c production` -2. **Port Conflicts**: Storybook UI uses port 6006 by default -3. **Memory Issues**: Large compilation tasks may require increased Node.js memory limits -4. **Sandbox Directory Confusion**: Sandboxes are at `../storybook-sandboxes/`, not `./sandbox` or `code/sandbox/` - -### Debug Information - -- Storybook logs available in generated sandbox directories -- Use `--debug` flag with CLI commands for verbose output -- Check `.cache/` directories for build artifacts - -## Performance Tips - -1. **Incremental Builds**: Use compilation cache when possible -2. **Selective Building**: Build only changed packages during development -3. **Memory Management**: Monitor memory usage during large operations -4. **Parallel Processing**: Yarn commands use parallel processing by default - -## Contributing Guidelines - -### Code Style - -- ESLint and Prettier configurations are enforced -- 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 -- Include relevant documentation updates - -### Documentation - -- Update relevant README files for significant changes -- 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. diff --git a/.github/copilot-mcp.json b/.github/copilot-mcp.json new file mode 100644 index 000000000000..8a383b5f75d0 --- /dev/null +++ b/.github/copilot-mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "nx": { + "command": "npx", + "args": ["-y", "nx-mcp@latest"] + } + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000000..8a383b5f75d0 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "nx": { + "command": "npx", + "args": ["-y", "nx-mcp@latest"] + } + } +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000000..4e1c9e04a7bf --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "nx": { + "type": "stdio", + "command": "npx", + "args": ["-y", "nx-mcp@latest"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..a62d7ddb5bf1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,278 @@ +# Storybook Agent Instructions + +Keep this file, `AGENTS.md`, up to date when Storybook's architecture, tooling, workflows, or contributor guidance changes. + +This file is the canonical instruction source for coding agents. Files like `CLAUDE.md` should point here instead of duplicating instructions. + +## Repository Overview + +Storybook is a large TypeScript monorepo. The git root is the repo root, the main code lives in `code/`, and build tooling lives in `scripts/`. + +- **Node.js**: `22.21.1` (see `.nvmrc`) +- **Package Manager**: Yarn Berry +- **Task orchestration**: NX plus the custom `yarn task` runner +- **CI environment**: Linux and Windows + +## Repository Structure + +```text +storybook/ +├── .github/ # GitHub configs and workflows +├── .nx/ # NX workflow state +├── code/ # Main codebase +│ ├── .storybook/ # Internal Storybook UI config +│ ├── core/ # Core package published as "storybook" +│ ├── addons/ # Core addons +│ ├── builders/ # Builder integrations +│ ├── renderers/ # Renderer integrations +│ ├── frameworks/ # Framework integrations +│ ├── lib/ # Supporting libraries +│ ├── presets/ # Webpack-oriented presets +│ └── sandbox/ # Internal build artifacts +├── scripts/ # Build and development scripts +├── docs/ # Documentation +├── test-storybooks/ # Test repos +└── ../storybook-sandboxes/ # Generated sandboxes outside repo +``` + +## Architecture + +### Renderer vs builder vs framework + +| Concept | Role | Example | +| --------- | ------------------------------------- | ------------------------- | +| Renderer | Mounts UI framework to the DOM | `@storybook/react` | +| Builder | Bundles and serves Storybook | `@storybook/builder-vite` | +| Framework | Renderer + builder + framework config | `@storybook/react-vite` | + +### Core package + +The main package is `code/core/src/`. The most important areas are: + +- `core-server/` for dev server, static build, and presets +- `manager/` and `manager-api/` for the Storybook UI +- `preview/` and `preview-api/` for story rendering +- `channels/` for manager <-> preview communication +- `csf-tools/` for AST-based story indexing +- `common/` for shared Node.js utilities +- `test/` and `instrumenter/` for testing support + +Public exports include: + +- `storybook/actions` +- `storybook/preview-api` +- `storybook/manager-api` +- `storybook/theming` +- `storybook/test` + +Internal exports include: + +- `storybook/internal/core-server` +- `storybook/internal/csf-tools` +- `storybook/internal/common` +- `storybook/internal/channels` + +### Key flow + +- `.storybook/main.ts` is loaded at startup +- `.storybook/preview.ts` is bundled into preview +- `.storybook/manager.ts` is bundled into manager +- `*.stories.*` files are indexed by AST before runtime +- Story selection loads the module, prepares the story, and renders it + +AST indexing keeps the sidebar fast and prevents one broken story file from breaking the whole UI. + +## Common Commands + +Run commands from the repository root unless stated otherwise. + +For routine agent work, prefer the faster non-production commands first. Add `-c production` only when you need sandbox-related NX tasks or you are explicitly matching CI behavior. + +### Install and compile + +```bash +yarn +yarn task compile +yarn nx run-many -t compile +yarn nx compile +``` + +### Lint and typecheck + +```bash +yarn lint +yarn --cwd code lint:js:cmd --fix +yarn task check +yarn nx run-many -t check +``` + +### Development and tests + +```bash +cd code && yarn storybook:ui +cd code && yarn storybook:ui:build +cd code && yarn test +cd code && yarn test:watch +cd code && yarn storybook:vitest +``` + +### Common task scenarios + +| Scenario | Command | +| ------------------------------- | ------------------------------------------------------------------------------ | +| Compile everything quickly | `yarn nx run-many -t compile` | +| Compile one package | `yarn nx compile ` | +| Check TypeScript errors quickly | `yarn nx run-many -t check` | +| Start the internal Storybook UI | `cd code && yarn storybook:ui` | +| Build the internal Storybook UI | `cd code && yarn storybook:ui:build` | +| Run unit tests | `cd code && yarn test` | +| Run Storybook Vitest tests | `cd code && yarn storybook:vitest` | +| Generate a sandbox | `yarn task sandbox --template react-vite/default-ts --start-from auto` | +| Run sandbox E2E tests | `yarn task e2e-tests-dev --template react-vite/default-ts --start-from auto` | +| Run sandbox test-runner tests | `yarn task test-runner-dev --template react-vite/default-ts --start-from auto` | + +## NX and `yarn task` + +Use NX when you want better caching and dependency tracking. Prefer these faster defaults first, and only add `-c production` or `--no-link` when you specifically need sandbox parity or CI-like behavior. + +```bash +# Compile all packages +yarn task compile +yarn nx run-many -t compile + +# Check all packages +yarn task check +yarn nx run-many -t check + +# Run E2E tests for a template +yarn task e2e-tests-dev --template react-vite/default-ts --start-from auto +yarn nx e2e-tests-dev react-vite/default-ts -c production + +# Jump to a later step +yarn task e2e-tests-dev --start-from e2e-tests --template react-vite/default-ts +yarn nx e2e-tests-dev -c production --exclude-task-dependencies +``` + +Key points: + +- `-c production` is required for sandbox-related NX commands and CI-parity runs +- `react-vite/default-ts` is the default sandbox template +- `--no-link` is opt-in, not the default +- NX handles task dependencies via `nx.json` + +## Sandbox Notes + +Sandboxes are generated outside the repository at `../storybook-sandboxes/` by default. + +- `STORYBOOK_SANDBOX_ROOT=./sandbox` forces local output, but is usually not preferred +- `./sandbox` inside the repo mainly exists for NX outputs, not CI sandboxes +- If sandbox generation fails, fall back to `cd code && yarn storybook:ui` + +Generate and use a sandbox with the same `sandbox` command shape used elsewhere in this file: + +```bash +yarn task sandbox --template react-vite/default-ts --start-from auto +# Same sandbox step via NX +yarn nx sandbox react-vite/default-ts -c production +cd ../storybook-sandboxes/react-vite-default-ts +yarn install +yarn storybook +``` + +Common templates: + +- `react-vite/default-ts` +- `react-webpack/default-ts` +- `angular-cli/default-ts` +- `svelte-vite/default-ts` +- `vue3-vite/default-ts` +- `nextjs/default-ts` + +## How To Work In This Repo + +### For normal code changes + +1. Install if needed: `yarn` +2. Compile with NX: `yarn nx run-many -t compile` +3. Make changes +4. Recompile affected packages +5. Validate there are no TypeScript errors with `yarn nx run-many -t check` +6. Run relevant lint and tests +7. Validate behavior in the internal Storybook UI first, then switch to sandbox or `-c production` flows only if you need template or CI parity + +### For addon, framework, or renderer work + +1. Edit the relevant package under `code/addons/`, `code/frameworks/`, or `code/renderers/` +2. Recompile with NX, starting without `-c production` +3. Generate a matching sandbox +4. Run the relevant test-runner, E2E, or Storybook UI validation flow + +## Testing Expectations + +- Use `cd code && yarn test` for unit tests +- Use Storybook UI or Chromatic for visual validation +- Use `yarn task e2e-tests --start-from auto` or `yarn task e2e-tests-dev --start-from auto` for E2E coverage +- Use `yarn task test-runner --start-from auto` or `yarn task test-runner-dev --start-from auto` for test-runner scenarios +- Use `yarn task smoke-test --start-from auto` for smoke checks + +Watch-mode commands: + +```bash +cd code && yarn test:watch +yarn affected:test +cd code && yarn storybook:vitest +``` + +When writing tests: + +- Export functions that need direct tests +- Test real behavior, not just syntax patterns +- Use coverage when useful: `yarn vitest run --coverage ` +- Mock external dependencies like file system access and loggers + +## Quality and Logging + +After changing files: + +1. Format with `yarn prettier --write ` +2. Lint with `yarn --cwd code lint:js:cmd --fix` or `cd code && yarn lint:js:cmd ` +3. Run relevant tests before submitting a PR + +Use Storybook loggers instead of raw `console.*` in normal code paths: + +- Server-side: `storybook/internal/node-logger` +- Client-side: `storybook/internal/client-logger` + +Avoid `console.log`, `console.warn`, and `console.error` unless the file is isolated enough that importing the logger is not reasonable. + +## Troubleshooting + +- Build failures are often fixed by rerunning `yarn` and `yarn nx run-many -t compile` +- Storybook UI uses port `6006` by default +- Large compiles may require more Node.js memory +- Sandbox paths are `../storybook-sandboxes/`, not `./sandbox` or `code/sandbox/` +- Use `--debug` for verbose CLI output +- Check generated sandbox directories and `.cache/` for build artifacts + +## Environment Variables + +| Variable | Purpose | +| ----------------------------- | --------------------------- | +| `IN_STORYBOOK_SANDBOX` | Set during sandbox creation | +| `STORYBOOK_DISABLE_TELEMETRY` | Disable telemetry | +| `STORYBOOK_TELEMETRY_DEBUG` | Log telemetry events | +| `DEBUG` | Enable debug logging | + +## Commands To Avoid + +- **DO NOT RUN** `yarn task dev` without an explicit sandbox template +- **DO NOT RUN** `yarn start` + +These usually start long-running development servers and are the wrong default for agents. + +## Maintenance Rules For Agents + +- Use this file as the canonical instruction source +- Update `AGENTS.md` when architecture, commands, versions, release flows, or contributor guidance changes +- Keep `CLAUDE.md` and other agent entrypoints as thin references to `AGENTS.md` +- Do not reintroduce duplicated instruction files when a reference will do diff --git a/CHANGELOG.md b/CHANGELOG.md index 41efbe690ea2..a20b30a1a233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 10.2.19 + +- Maintenance: Support vite-plugin-svelte@7 which supports Vite 8 - [#34115](https://github.com/storybookjs/storybook/pull/34115), thanks @valentinpalkovic! +- Vite: Support Vite 8 - [#33788](https://github.com/storybookjs/storybook/pull/33788), thanks @valentinpalkovic! + +## 10.2.18 + +- Core: Correctly fallback to first detected vitest config file - [#33865](https://github.com/storybookjs/storybook/pull/33865), thanks @yannbf! +- Core: Fix error reporting in ManagerErrorBoundary - [#33915](https://github.com/storybookjs/storybook/pull/33915), thanks @ghengeveld! + ## 10.2.17 - Next.js: Add support for v16.2 - [#34046](https://github.com/storybookjs/storybook/pull/34046), thanks @valentinpalkovic! diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..43c994c2d361 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 05b99156056f..398cf4a59e1f 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -55,10 +55,6 @@ const config = defineMain({ directory: '../core/src/highlight', titlePrefix: 'highlight', }, - { - directory: '../addons/docs/src/blocks', - titlePrefix: 'addons/docs/blocks', - }, { directory: '../addons/a11y/src', titlePrefix: 'addons/accessibility', @@ -71,6 +67,10 @@ const config = defineMain({ directory: '../addons/docs/template/stories', titlePrefix: 'addons/docs', }, + { + directory: '../addons/docs/src', + titlePrefix: 'addons/docs', + }, { directory: '../addons/links/template/stories', titlePrefix: 'addons/links', @@ -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/.storybook/preview.tsx b/code/.storybook/preview.tsx index adc5556ffb0c..d6ca299ed18f 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -34,9 +34,12 @@ 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'); +sb.mock('../core/template/stories/test/ClearModuleMocksMocking.api.ts', { spy: true }); /* eslint-disable depend/ban-dependencies */ sb.mock(import('lodash-es')); sb.mock(import('lodash-es/add')); diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx index 4887d8193d66..e8868f91e82f 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx @@ -13,6 +13,7 @@ import type { SortType } from '../components'; import { ArgsTableError, ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; import { useOf } from './useOf'; import { getComponentName } from './utils'; +import { withMdxComponentOverride } from './with-mdx-component-override'; type ArgTypesParameters = { include?: PropDescriptor; @@ -62,7 +63,7 @@ function getArgTypesFromResolved(resolved: ReturnType) { return { argTypes, parameters, component, subcomponents }; } -export const ArgTypes: FC = (props) => { +const ArgTypesImpl: FC = (props) => { const { of } = props; if ('of' in props && of === undefined) { throw new Error('Unexpected `of={undefined}`, did you mistype a CSF file reference?'); @@ -103,3 +104,5 @@ export const ArgTypes: FC = (props) => { }; return ; }; + +export const ArgTypes = withMdxComponentOverride('ArgTypes', ArgTypesImpl); diff --git a/code/addons/docs/src/blocks/blocks/Canvas.tsx b/code/addons/docs/src/blocks/blocks/Canvas.tsx index ff4d131d63a0..50f2c7b9d7d0 100644 --- a/code/addons/docs/src/blocks/blocks/Canvas.tsx +++ b/code/addons/docs/src/blocks/blocks/Canvas.tsx @@ -13,6 +13,7 @@ import { SourceContext } from './SourceContainer'; import type { StoryProps } from './Story'; import { Story } from './Story'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; type CanvasProps = Pick & { /** @@ -60,7 +61,7 @@ type CanvasProps = Pick; }; -export const Canvas: FC = (props) => { +const CanvasImpl: FC = (props) => { const docsContext = useContext(DocsContext); const sourceContext = useContext(SourceContext); const { of, source } = props; @@ -95,3 +96,5 @@ export const Canvas: FC = (props) => { ); }; + +export const Canvas = withMdxComponentOverride('Canvas', CanvasImpl); diff --git a/code/addons/docs/src/blocks/blocks/Controls.tsx b/code/addons/docs/src/blocks/blocks/Controls.tsx index 5f7e9eee69fa..a1b784662f40 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.tsx @@ -16,6 +16,7 @@ import { useArgs } from './useArgs'; import { useGlobals } from './useGlobals'; import { usePrimaryStory } from './usePrimaryStory'; import { getComponentName } from './utils'; +import { withMdxComponentOverride } from './with-mdx-component-override'; type ControlsParameters = { include?: PropDescriptor; @@ -38,7 +39,7 @@ function extractComponentArgTypes( return extractArgTypes(component) as StrictArgTypes; } -export const Controls: FC = (props) => { +const ControlsImpl: FC = (props) => { const { of } = props; const context = useContext(DocsContext); const primaryStory = usePrimaryStory(); @@ -104,3 +105,5 @@ export const Controls: FC = (props) => { /> ); }; + +export const Controls = withMdxComponentOverride('Controls', ControlsImpl); diff --git a/code/addons/docs/src/blocks/blocks/Description.tsx b/code/addons/docs/src/blocks/blocks/Description.tsx index fa257611a01a..cb0adc758d6e 100644 --- a/code/addons/docs/src/blocks/blocks/Description.tsx +++ b/code/addons/docs/src/blocks/blocks/Description.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { Markdown } from './Markdown'; import type { Of } from './useOf'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; export enum DescriptionType { INFO = 'info', @@ -58,7 +59,7 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str } }; -const DescriptionContainer: FC = (props) => { +const DescriptionImpl: FC = (props) => { const { of } = props; if ('of' in props && of === undefined) { @@ -70,4 +71,4 @@ const DescriptionContainer: FC = (props) => { return markdown ? {markdown} : null; }; -export { DescriptionContainer as Description }; +export const Description = withMdxComponentOverride('Description', DescriptionImpl); diff --git a/code/addons/docs/src/blocks/blocks/DocsStory.tsx b/code/addons/docs/src/blocks/blocks/DocsStory.tsx index 64d12fd0efe7..730757ce97b1 100644 --- a/code/addons/docs/src/blocks/blocks/DocsStory.tsx +++ b/code/addons/docs/src/blocks/blocks/DocsStory.tsx @@ -7,8 +7,9 @@ import { Description } from './Description'; import { Subheading } from './Subheading'; import type { DocsStoryProps } from './types'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; -export const DocsStory: FC = ({ +const DocsStoryImpl: FC = ({ of, expanded = true, withToolbar: withToolbarProp = false, @@ -37,3 +38,5 @@ export const DocsStory: FC = ({ ); }; + +export const DocsStory = withMdxComponentOverride('DocsStory', DocsStoryImpl); diff --git a/code/addons/docs/src/blocks/blocks/Heading.tsx b/code/addons/docs/src/blocks/blocks/Heading.tsx index f274d835b9c4..d0b1c06894a4 100644 --- a/code/addons/docs/src/blocks/blocks/Heading.tsx +++ b/code/addons/docs/src/blocks/blocks/Heading.tsx @@ -6,6 +6,7 @@ import { H2 } from 'storybook/internal/components'; import GithubSlugger from 'github-slugger'; import { HeaderMdx } from './mdx'; +import { withMdxComponentOverride } from './with-mdx-component-override'; export interface HeadingProps { disableAnchor?: boolean; @@ -13,7 +14,7 @@ export interface HeadingProps { export const slugs = new GithubSlugger(); -export const Heading: FC> = ({ +const HeadingImpl: FC> = ({ children, disableAnchor, ...props @@ -28,3 +29,5 @@ export const Heading: FC> = ({ ); }; + +export const Heading = withMdxComponentOverride('Heading', HeadingImpl); diff --git a/code/addons/docs/src/blocks/blocks/Markdown.tsx b/code/addons/docs/src/blocks/blocks/Markdown.tsx index c683d92fef06..0770a4e0d065 100644 --- a/code/addons/docs/src/blocks/blocks/Markdown.tsx +++ b/code/addons/docs/src/blocks/blocks/Markdown.tsx @@ -5,11 +5,12 @@ import PureMarkdown from 'markdown-to-jsx'; import { dedent } from 'ts-dedent'; import { AnchorMdx, CodeOrSourceMdx, HeadersMdx } from './mdx'; +import { withMdxComponentOverride } from './with-mdx-component-override'; // mirror props from markdown-to-jsx. From https://react-typescript-cheatsheet.netlify.app/docs/advanced/patterns_by_usecase#wrappingmirroring-a-component type MarkdownProps = typeof PureMarkdown extends React.ComponentType ? Props : never; -export const Markdown = (props: MarkdownProps) => { +const MarkdownImpl = (props: MarkdownProps) => { if (!props.children) { return null; } @@ -50,3 +51,5 @@ export const Markdown = (props: MarkdownProps) => { /> ); }; + +export const Markdown = withMdxComponentOverride('Markdown', MarkdownImpl); diff --git a/code/addons/docs/src/blocks/blocks/Meta.tsx b/code/addons/docs/src/blocks/blocks/Meta.tsx index b53ee212519c..eebe07ca5931 100644 --- a/code/addons/docs/src/blocks/blocks/Meta.tsx +++ b/code/addons/docs/src/blocks/blocks/Meta.tsx @@ -21,8 +21,8 @@ export const Meta: FC = ({ of }) => { try { const primary = context.storyById(); return ; - } catch (err) { - // It is possible to use in a unnattached MDX file + } catch { + // It is possible to use in an unattached MDX file return null; } }; diff --git a/code/addons/docs/src/blocks/blocks/Primary.tsx b/code/addons/docs/src/blocks/blocks/Primary.tsx index 4c22ad1eccd1..98f1e39c9279 100644 --- a/code/addons/docs/src/blocks/blocks/Primary.tsx +++ b/code/addons/docs/src/blocks/blocks/Primary.tsx @@ -3,11 +3,14 @@ import React from 'react'; import { DocsStory } from './DocsStory'; import { usePrimaryStory } from './usePrimaryStory'; +import { withMdxComponentOverride } from './with-mdx-component-override'; -export const Primary: FC = () => { +const PrimaryImpl: FC = () => { const primaryStory = usePrimaryStory(); return primaryStory ? ( ) : null; }; + +export const Primary = withMdxComponentOverride('Primary', PrimaryImpl); diff --git a/code/addons/docs/src/blocks/blocks/Source.tsx b/code/addons/docs/src/blocks/blocks/Source.tsx index 135ec93cec76..328a5ac0110b 100644 --- a/code/addons/docs/src/blocks/blocks/Source.tsx +++ b/code/addons/docs/src/blocks/blocks/Source.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, FC } from 'react'; +import type { ComponentProps } from 'react'; import React, { useContext, useMemo } from 'react'; import { SourceType } from 'storybook/internal/docs-tools'; @@ -11,6 +11,7 @@ import { DocsContext } from './DocsContext'; import type { SourceContextProps, SourceItem } from './SourceContainer'; import { SourceContext, UNKNOWN_ARGS_HASH, argsHash } from './SourceContainer'; import { useTransformCode } from './useTransformCode'; +import { withMdxComponentOverride } from './with-mdx-component-override'; export type SourceParameters = SourceCodeProps & { /** Where to read the source code from, see `SourceType` */ @@ -112,7 +113,7 @@ export const useSourceProps = ( try { // Always fall back to the primary story for source parameters, even if code is set. return docsContext.storyById(); - } catch (err) { + } catch { // You are allowed to use and unattached. } } @@ -170,9 +171,11 @@ export const useSourceProps = ( * Story source doc block renders source code if provided, or the source for a story if `storyId` is * provided, or the source for the current story if nothing is provided. */ -export const Source = (props: SourceProps) => { +const SourceImpl = (props: SourceProps) => { const sourceContext = useContext(SourceContext); const docsContext = useContext(DocsContext); const sourceProps = useSourceProps(props, docsContext, sourceContext); return ; }; + +export const Source = withMdxComponentOverride('Source', SourceImpl); diff --git a/code/addons/docs/src/blocks/blocks/Stories.tsx b/code/addons/docs/src/blocks/blocks/Stories.tsx index e6760638d5f5..555163f539e6 100644 --- a/code/addons/docs/src/blocks/blocks/Stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Stories.tsx @@ -8,6 +8,7 @@ import { styled } from 'storybook/theming'; import { DocsContext } from './DocsContext'; import { DocsStory } from './DocsStory'; import { Heading } from './Heading'; +import { withMdxComponentOverride } from './with-mdx-component-override'; interface StoriesProps { title?: ReactElement | string; @@ -30,7 +31,7 @@ const StyledHeading: typeof Heading = styled(Heading)(({ theme }) => ({ }, })); -export const Stories: FC = ({ title = 'Stories', includePrimary = true }) => { +const StoriesImpl: FC = ({ title = 'Stories', includePrimary = true }) => { const { componentStories, projectAnnotations, getStoryContext } = useContext(DocsContext); let stories = componentStories(); @@ -69,3 +70,5 @@ export const Stories: FC = ({ title = 'Stories', includePrimary = ); }; + +export const Stories = withMdxComponentOverride('Stories', StoriesImpl); diff --git a/code/addons/docs/src/blocks/blocks/Story.tsx b/code/addons/docs/src/blocks/blocks/Story.tsx index f9feca6d1311..5ec2b7fd0363 100644 --- a/code/addons/docs/src/blocks/blocks/Story.tsx +++ b/code/addons/docs/src/blocks/blocks/Story.tsx @@ -13,6 +13,7 @@ import { Story as PureStory, StorySkeleton } from '../components'; import type { DocsContextProps } from './DocsContext'; import { DocsContext } from './DocsContext'; import { useStory } from './useStory'; +import { withMdxComponentOverride } from './with-mdx-component-override'; type PureStoryProps = ComponentProps; @@ -113,7 +114,7 @@ export const getStoryProps = ( }; }; -const Story: FC = (props = { __forceInitialArgs: false, __primary: false }) => { +const StoryImpl: FC = (props = { __forceInitialArgs: false, __primary: false }) => { const context = useContext(DocsContext); const storyId = getStoryId(props, context); const story = useStory(storyId, context); @@ -130,4 +131,4 @@ const Story: FC = (props = { __forceInitialArgs: false, __primary: f return ; }; -export { Story }; +export const Story = withMdxComponentOverride('Story', StoryImpl); diff --git a/code/addons/docs/src/blocks/blocks/Subheading.tsx b/code/addons/docs/src/blocks/blocks/Subheading.tsx index 206fd6e3101f..177ead07d057 100644 --- a/code/addons/docs/src/blocks/blocks/Subheading.tsx +++ b/code/addons/docs/src/blocks/blocks/Subheading.tsx @@ -6,8 +6,9 @@ import { H3 } from 'storybook/internal/components'; import type { HeadingProps } from './Heading'; import { slugs } from './Heading'; import { HeaderMdx } from './mdx'; +import { withMdxComponentOverride } from './with-mdx-component-override'; -export const Subheading: FC> = ({ children, disableAnchor }) => { +const SubheadingImpl: FC> = ({ children, disableAnchor }) => { if (disableAnchor || typeof children !== 'string') { return

{children}

; } @@ -18,3 +19,5 @@ export const Subheading: FC> = ({ children, disa ); }; + +export const Subheading = withMdxComponentOverride('Subheading', SubheadingImpl); diff --git a/code/addons/docs/src/blocks/blocks/Subtitle.tsx b/code/addons/docs/src/blocks/blocks/Subtitle.tsx index c20df8a97a99..776bd0660fca 100644 --- a/code/addons/docs/src/blocks/blocks/Subtitle.tsx +++ b/code/addons/docs/src/blocks/blocks/Subtitle.tsx @@ -6,6 +6,7 @@ import { deprecate } from 'storybook/internal/client-logger'; import { Subtitle as PureSubtitle } from '../components'; import type { Of } from './useOf'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; interface SubtitleProps { children?: ReactNode; @@ -19,7 +20,7 @@ interface SubtitleProps { const DEPRECATION_MIGRATION_LINK = 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#subtitle-block-and-parameterscomponentsubtitle'; -export const Subtitle: FunctionComponent = (props) => { +const SubtitleImpl: FunctionComponent = (props) => { const { of, children } = props; if ('of' in props && of === undefined) { @@ -50,3 +51,5 @@ export const Subtitle: FunctionComponent = (props) => { {content} ) : null; }; + +export const Subtitle = withMdxComponentOverride('Subtitle', SubtitleImpl); diff --git a/code/addons/docs/src/blocks/blocks/Title.tsx b/code/addons/docs/src/blocks/blocks/Title.tsx index 9aa93abbb3be..29d9484f1c87 100644 --- a/code/addons/docs/src/blocks/blocks/Title.tsx +++ b/code/addons/docs/src/blocks/blocks/Title.tsx @@ -6,6 +6,7 @@ import type { ComponentTitle } from 'storybook/internal/types'; import { Title as PureTitle } from '../components'; import type { Of } from './useOf'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; interface TitleProps { /** @@ -25,7 +26,7 @@ export const extractTitle = (title: ComponentTitle) => { return groups?.[groups?.length - 1] || title; }; -export const Title: FunctionComponent = (props) => { +const TitleImpl: FunctionComponent = (props) => { const { children, of } = props; if ('of' in props && of === undefined) { @@ -50,3 +51,5 @@ export const Title: FunctionComponent = (props) => { return content ? {content} : null; }; + +export const Title = withMdxComponentOverride('Title', TitleImpl); diff --git a/code/addons/docs/src/blocks/blocks/Unstyled.tsx b/code/addons/docs/src/blocks/blocks/Unstyled.tsx index d32b687e88fb..b55b9bd01bc4 100644 --- a/code/addons/docs/src/blocks/blocks/Unstyled.tsx +++ b/code/addons/docs/src/blocks/blocks/Unstyled.tsx @@ -1,5 +1,9 @@ import React from 'react'; -export const Unstyled: React.FC< +import { withMdxComponentOverride } from './with-mdx-component-override'; + +const UnstyledImpl: React.FC< React.DetailedHTMLProps, HTMLDivElement> > = (props) =>
; + +export const Unstyled = withMdxComponentOverride('Unstyled', UnstyledImpl); diff --git a/code/addons/docs/src/blocks/blocks/Wrapper.tsx b/code/addons/docs/src/blocks/blocks/Wrapper.tsx index c48ad0a02fc8..d675446aa5b8 100644 --- a/code/addons/docs/src/blocks/blocks/Wrapper.tsx +++ b/code/addons/docs/src/blocks/blocks/Wrapper.tsx @@ -1,6 +1,10 @@ import type { FC } from 'react'; import React from 'react'; -export const Wrapper: FC< +import { withMdxComponentOverride } from './with-mdx-component-override'; + +const WrapperImpl: FC< React.DetailedHTMLProps, HTMLDivElement> > = ({ children }) =>
{children}
; + +export const Wrapper = withMdxComponentOverride('Wrapper', WrapperImpl); diff --git a/code/addons/docs/src/blocks/blocks/with-mdx-component-override.tsx b/code/addons/docs/src/blocks/blocks/with-mdx-component-override.tsx new file mode 100644 index 000000000000..627311d0f4de --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/with-mdx-component-override.tsx @@ -0,0 +1,49 @@ +import type { ComponentType } from 'react'; +import React from 'react'; + +import { useMDXComponents } from '@mdx-js/react'; + +const MDX_WRAPPED_BLOCK = Symbol('mdxWrappedBlock'); +const MdxWrappedBlockContext = React.createContext | null>(null); + +type WrappedBlockComponent

= ComponentType

& { + [MDX_WRAPPED_BLOCK]?: true; +}; + +// Imported MDX doc blocks bypass MDXProvider in MDX2+, so this restores `docs.components` overrides. +export const withMdxComponentOverride =

( + blockName: string, + Block: ComponentType

+): ComponentType

=> { + const WrappedBlock = (props: P) => { + // Some overrides intentionally compose with the public block export, e.g. + // `components.Title = (props) => `. Track which wrapped blocks are already + // being resolved so those recursive re-entries render the underlying block instead of looping + // back through the MDX override lookup forever. + const wrappedBlocks = React.useContext(MdxWrappedBlockContext); + const components = useMDXComponents(); + const Override = components[blockName] as WrappedBlockComponent<P> | undefined; + + if (wrappedBlocks?.has(blockName) || Override === WrappedBlock) { + return <Block {...props} />; + } + + if (Override) { + const nextWrappedBlocks = new Set(wrappedBlocks ?? []); + nextWrappedBlocks.add(blockName); + + return ( + <MdxWrappedBlockContext.Provider value={nextWrappedBlocks}> + <Override {...props} /> + </MdxWrappedBlockContext.Provider> + ); + } + + return <Block {...props} />; + }; + + WrappedBlock.displayName = blockName; + (WrappedBlock as WrappedBlockComponent<P>)[MDX_WRAPPED_BLOCK] = true; + + return WrappedBlock; +}; diff --git a/code/addons/docs/src/blocks/component-overrides.mdx b/code/addons/docs/src/blocks/component-overrides.mdx new file mode 100644 index 000000000000..87b49591b8a4 --- /dev/null +++ b/code/addons/docs/src/blocks/component-overrides.mdx @@ -0,0 +1,61 @@ +import { + ArgTypes, + Canvas, + Controls, + Description, + DocsStory, + Heading, + Markdown, + Meta, + Primary, + Source, + Stories, + Story, + Subheading, + Subtitle, + Title, + Unstyled, + Wrapper, +} from '@storybook/addon-docs/blocks'; +import * as ComponentOverrideStories from './component-overrides.stories.tsx'; + +<Meta of={ComponentOverrideStories} name="MDX" /> + +# Component override verification + +Every block below should render its `override:*` marker rather than the default Storybook block UI. +The `Title` override intentionally renders `<Title />` again, so it also proves recursive composition does not loop infinitely. +`Meta` is intentionally excluded because it is not overridable. + +<Title /> +<Subtitle /> +<Description of="meta" /> + +<Primary /> +<Controls /> +<ArgTypes /> +<Stories /> +<Story of={ComponentOverrideStories.UsesDefaultImplementation} /> +<Canvas of={ComponentOverrideStories.UsesDefaultImplementation} /> +<Source of={ComponentOverrideStories.UsesDefaultImplementation} /> +<DocsStory of={ComponentOverrideStories.UsesDefaultImplementation} /> + +<Heading>Heading block</Heading> +<Subheading>Subheading block</Subheading> + +<Markdown> + {` +# Markdown block + +This should render as the Markdown override. +`} + +</Markdown> + +<Wrapper> + <div>Wrapper child content</div> +</Wrapper> + +<Unstyled> + <div>Unstyled child content</div> +</Unstyled> diff --git a/code/addons/docs/src/blocks/component-overrides.stories.tsx b/code/addons/docs/src/blocks/component-overrides.stories.tsx new file mode 100644 index 000000000000..35d151f9a4fc --- /dev/null +++ b/code/addons/docs/src/blocks/component-overrides.stories.tsx @@ -0,0 +1,151 @@ +/** + * These stories use JSX so they are not part of the template stories. Even though they _do_ work in + * non-React frameworks, we are keeping them out of the sandboxes and only have them in the main UI + * Storybook. + */ +import React from 'react'; +import type { FC, ReactNode } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Title as DocsTitle } from '@storybook/addon-docs/blocks'; + +import { MDXProvider } from '@mdx-js/react'; +import { expect } from 'storybook/test'; + +import { withMdxComponentOverride } from './blocks/with-mdx-component-override'; + +type OverrideProps = { + children?: ReactNode; +}; + +type TestBlockProps = { + label: string; +}; + +const OverrideShell = ({ name, children }: { name: string; children?: ReactNode }) => ( + <div + data-testid={`override-${name}`} + style={{ + border: '2px solid #ff4785', + borderRadius: 6, + color: '#ff4785', + fontFamily: 'monospace', + margin: '8px 0', + padding: '8px 12px', + }} + > + override:{name} + {children ? <div style={{ marginTop: 8 }}>{children}</div> : null} + </div> +); + +const createOverride = (name: string, renderChildren = false): FC<OverrideProps> => + function Override({ children }) { + return <OverrideShell name={name}>{renderChildren ? children : null}</OverrideShell>; + }; + +const RecursiveTitleOverride: FC<OverrideProps> = (props) => ( + <OverrideShell name="Title (via <Title /> composition)"> + <DocsTitle {...props} /> + </OverrideShell> +); + +const TestBlockImpl: FC<TestBlockProps> = ({ label }) => ( + <span data-testid="default">default:{label}</span> +); +const TestBlock = withMdxComponentOverride('TestBlock', TestBlockImpl); + +const SubtitleBlockImpl: FC<TestBlockProps> = ({ label }) => ( + <span data-testid="subtitle">subtitle:{label}</span> +); +const SubtitleBlock = withMdxComponentOverride('SubtitleBlock', SubtitleBlockImpl); + +const TestBlockOverride: FC<TestBlockProps> = ({ label }) => ( + <span data-testid="override">override:{label}</span> +); + +const RecursiveTestBlockOverride: FC<TestBlockProps> = (props) => <TestBlock {...props} />; + +type TestBlockComponents = { + TestBlock: React.ComponentType<TestBlockProps>; +}; + +const renderTestBlock = (components: TestBlockComponents | undefined) => ( + <MDXProvider components={components as React.ComponentProps<typeof MDXProvider>['components']}> + <TestBlock label="Hello" /> + </MDXProvider> +); + +const meta = { + tags: ['autodocs'], + args: { + label: 'Primary action', + }, + parameters: { + docs: { + name: 'ComponentOverrides', + subtitle: 'Subtitle supplied from docs parameters', + description: { + component: 'Component description used by the Description block.', + }, + components: { + ArgTypes: createOverride('ArgTypes'), + Canvas: createOverride('Canvas'), + Controls: createOverride('Controls'), + Description: createOverride('Description'), + DocsStory: createOverride('DocsStory'), + Heading: createOverride('Heading', true), + Markdown: createOverride('Markdown', true), + Primary: createOverride('Primary'), + Source: createOverride('Source'), + Stories: createOverride('Stories'), + Story: createOverride('Story'), + Subheading: createOverride('Subheading', true), + Subtitle: createOverride('Subtitle'), + Title: RecursiveTitleOverride, + Unstyled: createOverride('Unstyled', true), + Wrapper: createOverride('Wrapper', true), + }, + }, + }, +} satisfies Meta<typeof TestBlock>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const UsesDefaultImplementation: Story = { + render: () => renderTestBlock(undefined), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('default')).resolves.toHaveTextContent('default:Hello'); + }, +}; + +export const UsesMdxOverride: Story = { + render: () => renderTestBlock({ TestBlock: TestBlockOverride }), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('override')).resolves.toHaveTextContent('override:Hello'); + }, +}; + +export const FallsBackWhenOverrideIsWrappedBlock: Story = { + render: () => renderTestBlock({ TestBlock }), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('default')).resolves.toHaveTextContent('default:Hello'); + }, +}; + +export const FallsBackWhenOverrideComposesPublicBlock: Story = { + render: () => renderTestBlock({ TestBlock: RecursiveTestBlockOverride }), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('default')).resolves.toHaveTextContent('default:Hello'); + }, +}; + +export const AllowsDifferentWrappedBlockOverride: Story = { + render: () => renderTestBlock({ TestBlock: SubtitleBlock }), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('subtitle')).resolves.toHaveTextContent('subtitle:Hello'); + }, +}; diff --git a/code/addons/docs/src/blocks/components/DocsPage.tsx b/code/addons/docs/src/blocks/components/DocsPage.tsx index d2f1537c2ab4..ac5576ed7d58 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/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 543d5c579b4f..d627fa416497 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import * as fs from 'node:fs/promises'; import { writeFile } from 'node:fs/promises'; +import os from 'node:os'; import { babelParse, generate, traverse } from 'storybook/internal/babel'; import { AddonVitestService } from 'storybook/internal/cli'; @@ -165,9 +166,17 @@ export default async function postInstall(options: PostinstallOptions) { useRemotePkg: !!options.skipInstall, }); } else { + const platform = os.platform(); + const useWithDeps = platform === 'darwin' || platform === 'win32'; + const manualCommand = useWithDeps + ? 'npx playwright install chromium --with-deps' + : 'npx playwright install chromium'; + const linuxNote = !useWithDeps + ? '\n Note: add --with-deps to the command above if you are on Debian or Ubuntu.' + : ''; logger.warn(dedent` Playwright browser binaries installation skipped. Please run the following command manually later: - ${CLI_COLORS.cta('npx playwright install chromium --with-deps')} + ${CLI_COLORS.cta(manualCommand)}${linuxNote} `); } } diff --git a/code/core/package.json b/code/core/package.json index 3a08e3a9cd59..7b6611f79ddb 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -357,6 +357,7 @@ "sirv": "^2.0.4", "slash": "^5.0.0", "source-map": "^0.7.4", + "std-env": "^4.0.0", "store2": "^2.14.2", "strip-ansi": "^7.1.0", "strip-json-comments": "^5.0.1", diff --git a/code/core/src/channels/postmessage/getEventSourceUrl.ts b/code/core/src/channels/postmessage/getEventSourceUrl.ts index 1be3c5742772..e8ef28861ac5 100644 --- a/code/core/src/channels/postmessage/getEventSourceUrl.ts +++ b/code/core/src/channels/postmessage/getEventSourceUrl.ts @@ -1,23 +1,43 @@ import { logger } from 'storybook/internal/client-logger'; -export const getEventSourceUrl = (event: MessageEvent) => { +/** + * When multiple iframes match the event origin (e.g. composed refs from the same origin), + * disambiguate by refId: the preview includes refId in the URL, so we pick the iframe whose src + * contains that refId. If there is only one candidate, return it. + */ +const pickFrameByRefId = ( + candidates: HTMLIFrameElement[], + refId: string | undefined +): HTMLIFrameElement | undefined => { + if (candidates.length === 1) { + return candidates[0]; + } + if (candidates.length === 0 || !refId) { + return undefined; + } + return candidates.find((el) => + (el.getAttribute('src') ?? '').includes(`refId=${encodeURIComponent(refId)}`) + ); +}; + +export const getEventSourceUrl = (event: MessageEvent, refId?: string): string | null => { const frames: HTMLIFrameElement[] = Array.from( document.querySelectorAll('iframe[data-is-storybook]') ); // try to find the originating iframe by matching it's contentWindow // This might not be cross-origin safe - const [frame, ...remainder] = frames.filter((element) => { + const candidates = frames.filter((element) => { try { return ( element.contentWindow?.location.origin === (event.source as Window).location.origin && element.contentWindow?.location.pathname === (event.source as Window).location.pathname ); - } catch (err) { + } catch { // continue } try { return element.contentWindow === event.source; - } catch (err) { + } catch { // continue } @@ -30,23 +50,23 @@ export const getEventSourceUrl = (event: MessageEvent) => { } ({ origin } = new URL(src, document.location.toString())); - } catch (err) { + } catch { return false; } return origin === event.origin; }); - const src = frame?.getAttribute('src'); - if (src && remainder.length === 0) { + const src = pickFrameByRefId(candidates, refId)?.getAttribute('src'); + + if (src) { const { protocol, host, pathname } = new URL(src, document.location.toString()); return `${protocol}//${host}${pathname}`; } - if (remainder.length > 0) { - // If we found multiple matches, there's going to be trouble + if (candidates.length > 1) { + // Multiple matches and we couldn't disambiguate (e.g. no refId in message) logger.error('found multiple candidates for event source'); } - // If we found no frames of matches return null; }; diff --git a/code/core/src/channels/postmessage/index.ts b/code/core/src/channels/postmessage/index.ts index 49e0aa307475..9498e31a28b1 100644 --- a/code/core/src/channels/postmessage/index.ts +++ b/code/core/src/channels/postmessage/index.ts @@ -212,7 +212,7 @@ export class PostMessageTransport implements ChannelTransport { } event.source = - this.config.page === 'preview' ? rawEvent.origin : getEventSourceUrl(rawEvent); + this.config.page === 'preview' ? rawEvent.origin : getEventSourceUrl(rawEvent, refId); if (!event.source) { pretty.error( diff --git a/code/core/src/cli/AddonVitestService.test.ts b/code/core/src/cli/AddonVitestService.test.ts index 17379adac94a..e54f5b54f907 100644 --- a/code/core/src/cli/AddonVitestService.test.ts +++ b/code/core/src/cli/AddonVitestService.test.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs/promises'; +import os from 'node:os'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -14,6 +15,7 @@ import { SupportedBuilder, SupportedFramework } from '../types'; import { AddonVitestService } from './AddonVitestService'; vi.mock('node:fs/promises', { spy: true }); +vi.mock('node:os', { spy: true }); vi.mock('storybook/internal/common', { spy: true }); vi.mock('storybook/internal/node-logger', { spy: true }); vi.mock('empathic/find', { spy: true }); @@ -391,7 +393,7 @@ describe('AddonVitestService', () => { vi.mocked(logger.warn).mockImplementation(() => {}); // Mock getPackageCommand to return a string vi.mocked(mockPackageManager.getPackageCommand).mockReturnValue( - 'npx playwright install chromium --with-deps' + 'npx playwright install chromium' ); }); @@ -416,25 +418,127 @@ describe('AddonVitestService', () => { }); it('should execute playwright install command', async () => { - type ChildProcessFactory = (signal?: AbortSignal) => ResultPromise; - let commandFactory: ChildProcessFactory | ChildProcessFactory[]; - vi.mocked(prompt.confirm).mockResolvedValue(true); - vi.mocked(prompt.executeTaskWithSpinner).mockImplementation( - async (factory: ChildProcessFactory | ChildProcessFactory[]) => { - commandFactory = Array.isArray(factory) ? factory[0] : factory; - // Simulate the child process completion - commandFactory(); + const originalCI = process.env.CI; + delete process.env.CI; + vi.mocked(os.platform).mockReturnValue('linux'); + try { + type ChildProcessFactory = (signal?: AbortSignal) => ResultPromise; + let commandFactory: ChildProcessFactory | ChildProcessFactory[]; + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockImplementation( + async (factory: ChildProcessFactory | ChildProcessFactory[]) => { + commandFactory = Array.isArray(factory) ? factory[0] : factory; + // Simulate the child process completion + commandFactory(); + } + ); + + await service.installPlaywright(); + + expect(mockPackageManager.runPackageCommand).toHaveBeenCalledWith({ + args: ['playwright', 'install', 'chromium'], + signal: undefined, + stdio: ['inherit', 'pipe', 'pipe'], + }); + } finally { + if (originalCI !== undefined) { + process.env.CI = originalCI; } - ); - - await service.installPlaywright(); - - expect(mockPackageManager.runPackageCommand).toHaveBeenCalledWith({ - args: ['playwright', 'install', 'chromium', '--with-deps'], - signal: undefined, - stdio: ['inherit', 'pipe', 'pipe'], - }); - }); + } + }); + + it('should warn about missing system dependencies after install on Linux', async () => { + const originalCI = process.env.CI; + delete process.env.CI; + vi.mocked(os.platform).mockReturnValue('linux'); + try { + type ChildProcessFactory = (signal?: AbortSignal) => ResultPromise; + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockImplementation( + async (factory: ChildProcessFactory | ChildProcessFactory[]) => { + const commandFactory = Array.isArray(factory) ? factory[0] : factory; + commandFactory(); + } + ); + + const { result } = await service.installPlaywright(); + + expect(result).toBe('installed'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('installed without system dependencies') + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('run Storybook Test from the Storybook UI') + ); + } finally { + if (originalCI !== undefined) { + process.env.CI = originalCI; + } + } + }); + + it('should execute playwright install command with --with-deps in CI', async () => { + const originalCI = process.env.CI; + process.env.CI = 'true'; + vi.mocked(os.platform).mockReturnValue('linux'); + try { + type ChildProcessFactory = (signal?: AbortSignal) => ResultPromise; + let commandFactory: ChildProcessFactory | ChildProcessFactory[]; + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockImplementation( + async (factory: ChildProcessFactory | ChildProcessFactory[]) => { + commandFactory = Array.isArray(factory) ? factory[0] : factory; + commandFactory(); + } + ); + + await service.installPlaywright(); + + expect(mockPackageManager.runPackageCommand).toHaveBeenCalledWith({ + args: ['playwright', 'install', 'chromium', '--with-deps'], + signal: undefined, + stdio: ['inherit', 'pipe', 'pipe'], + }); + } finally { + if (originalCI === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCI; + } + } + }); + + it.each(['darwin', 'win32'] as const)( + 'should execute playwright install command with --with-deps on %s', + async (platform) => { + const originalCI = process.env.CI; + delete process.env.CI; + vi.mocked(os.platform).mockReturnValue(platform); + try { + type ChildProcessFactory = (signal?: AbortSignal) => ResultPromise; + let commandFactory: ChildProcessFactory | ChildProcessFactory[]; + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockImplementation( + async (factory: ChildProcessFactory | ChildProcessFactory[]) => { + commandFactory = Array.isArray(factory) ? factory[0] : factory; + commandFactory(); + } + ); + + await service.installPlaywright(); + + expect(mockPackageManager.runPackageCommand).toHaveBeenCalledWith({ + args: ['playwright', 'install', 'chromium', '--with-deps'], + signal: undefined, + stdio: ['inherit', 'pipe', 'pipe'], + }); + } finally { + if (originalCI !== undefined) { + process.env.CI = originalCI; + } + } + } + ); it('should capture error stack when installation fails', async () => { const error = new Error('Installation failed'); diff --git a/code/core/src/cli/AddonVitestService.ts b/code/core/src/cli/AddonVitestService.ts index 2212f74b9b4d..9fd7fc8c964f 100644 --- a/code/core/src/cli/AddonVitestService.ts +++ b/code/core/src/cli/AddonVitestService.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import os from 'node:os'; import * as babel from 'storybook/internal/babel'; import type { JsPackageManager } from 'storybook/internal/common'; @@ -106,7 +107,10 @@ export class AddonVitestService { /** * Install Playwright browser binaries for @storybook/addon-vitest * - * Installs Chromium with dependencies via `npx playwright install chromium --with-deps` + * Installs Chromium via `npx playwright install chromium`. In CI environments and on + * macOS/Windows (officially supported platforms), also installs system-level browser dependencies + * via `--with-deps`. On other platforms (e.g. Linux), `--with-deps` is omitted to avoid requiring + * `sudo` — system packages are typically managed by the distro package manager. * * @param packageManager - The package manager to use for installation * @param prompt - The prompt instance for displaying progress @@ -123,7 +127,11 @@ export class AddonVitestService { ): Promise<{ errors: string[]; result: 'installed' | 'skipped' | 'aborted' | 'failed' }> { const errors: string[] = []; - const playwrightCommand = ['playwright', 'install', 'chromium', '--with-deps']; + const platform = os.platform(); + const useWithDeps = !!process.env.CI || platform === 'darwin' || platform === 'win32'; + const playwrightCommand = useWithDeps + ? ['playwright', 'install', 'chromium', '--with-deps'] + : ['playwright', 'install', 'chromium']; const playwrightCommandString = this.packageManager.getPackageCommand(playwrightCommand); let result: 'installed' | 'skipped' | 'aborted' | 'failed'; @@ -168,6 +176,14 @@ export class AddonVitestService { result = 'aborted'; } else { result = 'installed'; + if (!useWithDeps) { + logger.warn(dedent` + Playwright was installed without system dependencies. Depending on your operating system, you may need to install additional libraries for Playwright to work correctly. + To check for missing dependencies, run Storybook Test from the Storybook UI — it will report any libraries that need to be installed. + On MacOS, Windows, Debian and Ubuntu, you can install system dependencies manually by running: + ${CLI_COLORS.cta(this.packageManager.getPackageCommand(['playwright', 'install', 'chromium', '--with-deps']))} + `); + } } } else { logger.warn('Playwright installation skipped'); diff --git a/code/core/src/components/components/typography/elements/A.tsx b/code/core/src/components/components/typography/elements/A.tsx index 9ab4e9bf03b2..bd1061e5150a 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', +})); diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 8feef56617e4..ced678c1562a 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) { 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 8fa66c62499f..920848afb9a3 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -157,7 +157,7 @@ export async function storybookDevServer( } const features = await options.presets.apply('features'); - if (features?.experimentalComponentsManifest) { + if (features?.componentsManifest) { 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/core-server/presets/common-manager.ts b/code/core/src/core-server/presets/common-manager.ts index bd4c358e837b..9ce7d3219a05 100644 --- a/code/core/src/core-server/presets/common-manager.ts +++ b/code/core/src/core-server/presets/common-manager.ts @@ -1,7 +1,4 @@ /* these imports are in the exact order in which the panels need to be registered */ -import { global } from '@storybook/global'; - -import { addons, Tag } from 'storybook/manager-api'; // THE ORDER OF THESE IMPORTS MATTERS! IT DEFINES THE ORDER OF PANELS AND TOOLS! import controlsManager from '../../controls/manager'; @@ -12,36 +9,8 @@ import measureManager from '../../measure/manager'; import outlineManager from '../../outline/manager'; import viewportManager from '../../viewport/manager'; -const TAG_FILTERS = 'tag-filters'; -const STATIC_FILTER = 'static-filter'; - -const tagFiltersManager = addons.register(TAG_FILTERS, (api) => { - // FIXME: this ensures the filter is applied after the first render - // to avoid a strange race condition in Webkit only. - const staticExcludeTags = Object.entries(global.TAGS_OPTIONS ?? {}).reduce( - (acc, entry) => { - const [tag, option] = entry; - if ((option as any).excludeFromSidebar) { - acc[tag] = true; - } - return acc; - }, - {} as Record<string, boolean> - ); - - api.experimental_setFilter(STATIC_FILTER, (item) => { - const tags = item.tags ?? []; - return ( - // we can filter out the primary story, but we still want to show autodocs - (tags.includes(Tag.DEV) || item.type === 'docs') && - tags.filter((tag) => staticExcludeTags[tag]).length === 0 - ); - }); -}); - export default [ measureManager, - tagFiltersManager, actionsManager, backgroundsManager, componentTestingManager, diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index f1004214dc8b..c3479f2e42b1 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -216,6 +216,7 @@ export const features: PresetProperty<'features'> = async (existing) => ({ outline: true, measure: true, sidebarOnboardingChecklist: true, + componentsManifest: true, }); export const csfIndexer: Indexer = { 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..2774c1863d95 --- /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'; + +<Meta of={AStories}/> + +# This file references two story files + +It is important that A.stories is the first listed in `storiesImports` even when it is not the first import. \ No newline at end of file diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index a0df07067e5b..9c1b33ef2119 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -121,30 +121,36 @@ export interface SubAPI { type PartialSubState = Partial<SubState>; -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, +export const DEFAULT_NAV_SIZE = 300; +export const DEFAULT_BOTTOM_PANEL_HEIGHT = 300; +export const DEFAULT_RIGHT_PANEL_WIDTH = 400; + +export const getDefaultLayoutState: () => SubState = () => { + return { + ui: { + enableShortcuts: true, + }, + layout: { + initialActive: ActiveTabs.CANVAS, + 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, }, - panelPosition: 'bottom', - showTabs: true, - }, - layoutCustomisations: { - showSidebar: undefined, - showToolbar: undefined, - }, - selectedPanel: undefined, - theme: create(), + layoutCustomisations: { + showSidebar: undefined, + showToolbar: undefined, + }, + selectedPanel: undefined, + theme: create(), + }; }; export const focusableUIElements = { @@ -153,6 +159,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) => { @@ -432,6 +440,7 @@ export const init: ModuleFn<SubAPI, SubState> = ({ store, provider, singleStory getInitialOptions() { const { theme, selectedPanel, layoutCustomisations, ...options } = provider.getConfig(); + const defaultLayoutState = getDefaultLayoutState(); return { ...defaultLayoutState, diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index 83520110c131..7895ec219f8a 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -382,7 +382,25 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { } case 'toggleNav': { + const wasNavShown = fullAPI.getIsNavShown(); + const sidebarElement = document.getElementById(focusableUIElements.sidebarRegion); + const wasFocusInSidebar = sidebarElement?.contains(document?.activeElement); + fullAPI.toggleNav(); + + if (wasNavShown && wasFocusInSidebar) { + // poll: true always returns a Promise. + ( + fullAPI.focusOnUIElement(focusableUIElements.showSidebar, { + poll: true, + }) as Promise<boolean> + ).then((success) => { + // Fallback to body for predictable behavior. + if (success === false) { + document.body.focus(); + } + }); + } break; } diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 30a9b769eef9..dfd4e08e7f6c 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -28,6 +28,7 @@ import type { API_IndexHash, API_LeafEntry, API_LoadedRefData, + API_PreparedIndexEntry, API_PreparedStoryIndex, API_StoryEntry, API_TestEntry, @@ -35,16 +36,22 @@ import type { Args, ComponentTitle, DocsPreparedPayload, + FilterFunction, SetStoriesPayload, StoryId, StoryIndex, StoryKind, StoryName, StoryPreparedPayload, + Tag, + TagsOptions, } from 'storybook/internal/types'; import { global } from '@storybook/global'; +import memoize from 'memoizerific'; + +import { BUILT_IN_FILTERS, Tag as TagEnum, USER_TAG_FILTER } from '../../shared/constants/tags'; import { getEventMetadata } from '../lib/events'; import { addPreparedStories, @@ -60,6 +67,83 @@ import { fullStatusStore } from '../stores/status'; const { fetch } = global; const STORY_INDEX_PATH = './index.json'; +const TAGS_FILTER = 'tags-filter'; +const STATIC_FILTER = 'static-filter'; + +export 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), + }; + } +); + +const computeStaticFilterFn = (tagPresets: TagsOptions) => { + const staticExcludeTags = Object.entries(tagPresets).reduce( + (acc, entry) => { + const [tag, option] = entry; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((option as any).excludeFromSidebar) { + acc[tag] = true; + } + return acc; + }, + {} as Record<string, boolean> + ); + + return (item: API_PreparedIndexEntry) => { + const tags = item.tags ?? []; + return ( + (tags.includes(TagEnum.DEV) || item.type === 'docs') && + tags.filter((tag) => staticExcludeTags[tag]).length === 0 + ); + }; +}; + +const computeTagsFilterFn = ( + includedTagFilters: Tag[], + excludedTagFilters: Tag[] +): ((item: API_PreparedIndexEntry) => boolean) => { + const computeFilterFunctions = (set: Tag[]): FilterFunction[][] => { + return Object.values( + set.reduce( + (acc, tag) => { + 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)); + } + return acc; + }, + { 'built-in': [], user: [] } as { 'built-in': FilterFunction[]; user: FilterFunction[] } + ) + ).filter((group) => group.length > 0); + }; + + return (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)))) + ); + }; +}; + type Direction = -1 | 1; type ParameterName = string; @@ -74,6 +158,11 @@ export interface SubState extends API_LoadedRefData { internal_index?: API_PreparedStoryIndex; viewMode: API_ViewMode; filters: Record<string, API_FilterFunction>; + tagPresets: TagsOptions; + defaultIncludedTagFilters: Tag[]; + defaultExcludedTagFilters: Tag[]; + includedTagFilters: Tag[]; + excludedTagFilters: Tag[]; } export interface SubAPI { @@ -291,6 +380,30 @@ export interface SubAPI { * @returns {Promise<void>} A promise that resolves when the state has been updated. */ experimental_setFilter: (addonId: string, filterFunction: API_FilterFunction) => Promise<void>; + + /** 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; } const removedOptions = ['enableShortcuts', 'theme', 'showRoots']; @@ -711,6 +824,70 @@ export const init: ModuleFn<SubAPI, SubState> = ({ provider.channel?.emit(SET_FILTER, { id }); }, + resetTagFilters: async () => { + await store.setState( + (s) => ({ + includedTagFilters: s.defaultIncludedTagFilters, + excludedTagFilters: s.defaultExcludedTagFilters, + }), + { persistence: 'permanent' } + ); + recomputeFilters(); + }, + + setAllTagFilters: async (included: Tag[], excluded: Tag[]) => { + await store.setState( + { + includedTagFilters: included, + excludedTagFilters: excluded, + }, + { persistence: 'permanent' } + ); + recomputeFilters(); + }, + + addTagFilters: async (tags: Tag[], excluded: boolean) => { + const state = store.getState(); + const newIncluded = new Set(state.includedTagFilters); + const newExcluded = new Set(state.excludedTagFilters); + for (const tag of tags) { + if (excluded) { + newIncluded.delete(tag); + newExcluded.add(tag); + } else { + newIncluded.add(tag); + newExcluded.delete(tag); + } + } + await store.setState( + { + includedTagFilters: Array.from(newIncluded), + excludedTagFilters: Array.from(newExcluded), + }, + { persistence: 'permanent' } + ); + recomputeFilters(); + }, + + removeTagFilters: async (tags: Tag[]) => { + const state = store.getState(); + await store.setState( + { + includedTagFilters: state.includedTagFilters.filter((tag) => !tags.includes(tag)), + excludedTagFilters: state.excludedTagFilters.filter((tag) => !tags.includes(tag)), + }, + { persistence: 'permanent' } + ); + recomputeFilters(); + }, + }; + + const recomputeFilters = () => { + const { includedTagFilters, excludedTagFilters } = store.getState(); + api.experimental_setFilter( + TAGS_FILTER, + computeTagsFilterFn(includedTagFilters, excludedTagFilters) + ); }; // On initial load, the local iframe will select the first story (or other "selection specifier") @@ -908,14 +1085,18 @@ export const init: ModuleFn<SubAPI, SubState> = ({ provider.channel?.on(SET_CONFIG, () => { const config = provider.getConfig(); - if (config?.sidebar?.filters) { - store.setState({ - filters: { - ...store.getState().filters, - ...config?.sidebar?.filters, - }, - }); - } + const configFilters = config?.sidebar?.filters || {}; + const { includedTagFilters, excludedTagFilters, tagPresets } = store.getState(); + + // Config sidebar filters first, then our managed filters override any conflicts + store.setState({ + filters: { + ...store.getState().filters, + ...configFilters, + [STATIC_FILTER]: computeStaticFilterFn(tagPresets), + [TAGS_FILTER]: computeTagsFilterFn(includedTagFilters, excludedTagFilters), + }, + }); }); fullStatusStore.onAllStatusChange(async () => { @@ -936,6 +1117,30 @@ export const init: ModuleFn<SubAPI, SubState> = ({ }); const config = provider.getConfig(); + const configFilters = config?.sidebar?.filters || {}; + + // Compute default tag filter values from presets + const tagPresets: TagsOptions = global.TAGS_OPTIONS || {}; + const defaultTags = getDefaultTagsFromPreset(tagPresets); + + // Read persisted tag filter state, supporting migration from the old layout.xxx path + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const persistedState = store.getState() as Record<string, any>; + const initialIncluded: Tag[] = + persistedState.includedTagFilters ?? + persistedState.layout?.includedTagFilters ?? + defaultTags.included; + const initialExcluded: Tag[] = + persistedState.excludedTagFilters ?? + persistedState.layout?.excludedTagFilters ?? + defaultTags.excluded; + + // Build initial filters: config sidebar filters first, then our managed filters take priority + const initialFilters: Record<string, API_FilterFunction> = { + ...configFilters, + [STATIC_FILTER]: computeStaticFilterFn(tagPresets), + [TAGS_FILTER]: computeTagsFilterFn(initialIncluded, initialExcluded), + }; return { api, @@ -944,7 +1149,12 @@ export const init: ModuleFn<SubAPI, SubState> = ({ viewMode: initialViewMode, hasCalledSetOptions: false, previewInitialized: false, - filters: config?.sidebar?.filters || {}, + filters: initialFilters, + tagPresets, + defaultIncludedTagFilters: defaultTags.included, + defaultExcludedTagFilters: defaultTags.excluded, + includedTagFilters: initialIncluded, + excludedTagFilters: initialExcluded, }, init: async () => { provider.channel?.on(STORY_INDEX_INVALIDATED, () => api.fetchIndex()); 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..b1071e47002b 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -27,7 +27,12 @@ function update(storage: StoreAPI, patch: Patch) { type GetState = () => State; type SetState = (a: any, b: any) => any; -interface Upstream { +export interface Upstream { + /** + * Whether to allow persistence of state to local/sessionStorage. This is used to disable + * persistence in Storybook's own tests. True by default. + */ + allowPersistence?: boolean; getState: GetState; setState: SetState; } @@ -46,11 +51,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 +114,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); } diff --git a/code/core/src/manager-api/test-utils/store.ts b/code/core/src/manager-api/test-utils/store.ts new file mode 100644 index 000000000000..6fb8a13d4ad9 --- /dev/null +++ b/code/core/src/manager-api/test-utils/store.ts @@ -0,0 +1,48 @@ +import type { State } from '../root'; +import Store, { type Upstream } from '../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. + * + * @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 InMemoryStore(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-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index ea2e43e79b9f..3bdbeec046fd 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -1458,7 +1458,15 @@ describe('stories API', () => { expect(state).toEqual( expect.objectContaining({ - filters: {}, + defaultExcludedTagFilters: expect.arrayContaining([]), + defaultIncludedTagFilters: expect.arrayContaining([]), + excludedTagFilters: expect.arrayContaining([]), + includedTagFilters: expect.arrayContaining([]), + filters: expect.objectContaining({ + 'static-filter': expect.any(Function), + 'tags-filter': expect.any(Function), + }), + tagPresets: expect.objectContaining({}), }) ); }); diff --git a/code/core/src/manager/components/layout/Drag.stories.tsx b/code/core/src/manager/components/layout/Drag.stories.tsx new file mode 100644 index 000000000000..0b1eb0bf2c62 --- /dev/null +++ b/code/core/src/manager/components/layout/Drag.stories.tsx @@ -0,0 +1,274 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { expect, userEvent, within } from 'storybook/test'; + +import { Drag } from './Drag'; + +/** + * Presentational drag-handle component used for the sidebar and addon-panel resizers. + * + * Covers positioning, tooltips and hover/focus states, and ARIA attribute markup. The actual + * keyboard-resize logic lives in `useDragging` and can be tested in Layout stories. + */ +const meta = { + title: 'Layout/Drag', + component: Drag, + parameters: { + layout: 'centered', + }, + decorators: [ + (storyFn) => ( + // Drag uses `position: absolute` so it needs a positioned parent. + <div + style={{ + position: 'relative', + width: 200, + height: 200, + border: '2px dashed #aaa', + background: 'thistle', + }} + > + {storyFn()} + </div> + ), + ], +} satisfies Meta<typeof Drag>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const PositionLeft: Story = { + name: 'Position: left (sidebar)', + args: { + position: 'left', + 'aria-label': 'Sidebar resize handle', + 'aria-valuenow': 200, + 'aria-valuemax': 500, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Has aria-orientation="vertical" for left position', async () => { + expect(handle).toHaveAttribute('aria-orientation', 'vertical'); + }); + + await step('Shows on the right of the parent element', async () => { + handle.focus(); + const parentRect = handle.parentElement?.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + expect(handleRect.left).toBeGreaterThan(parentRect?.left ?? 0); + }); + }, +}; + +export const PositionRight: Story = { + name: 'Position: right (addon panel)', + args: { + position: 'right', + 'aria-label': 'Addon panel resize handle', + 'aria-valuenow': 300, + 'aria-valuemax': 600, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Has aria-orientation="vertical" for right position', async () => { + expect(handle).toHaveAttribute('aria-orientation', 'vertical'); + }); + + await step('Shows on the left of the parent element', async () => { + handle.focus(); + const parentRect = handle.parentElement?.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + expect(handleRect.left).toBeLessThan(parentRect?.left ?? 0); + }); + }, +}; + +export const PositionBottom: Story = { + name: 'Position: bottom (bottom panel)', + args: { + position: 'bottom', + 'aria-label': 'Addon panel resize handle', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Has aria-orientation="horizontal" for bottom position', async () => { + expect(handle).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + await step('Shows on the top of the parent element', async () => { + handle.focus(); + const parentRect = handle.parentElement?.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + expect(handleRect.top).toBeLessThan(parentRect?.top ?? 0); + }); + }, +}; + +export const PositionTop: Story = { + name: 'Position: top', + args: { + position: 'top', + 'aria-label': 'Top panel resize handle', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Has aria-orientation="horizontal" for top position', async () => { + expect(handle).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + await step('Shows on the bottom of the parent element', async () => { + handle.focus(); + const parentRect = handle.parentElement?.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + expect(handleRect.top).toBeGreaterThan(parentRect?.top ?? 0); + }); + }, +}; + +export const AriaRole: Story = { + name: 'ARIA: role separator', + args: { + position: 'left', + 'aria-label': 'Sidebar resize handle', + 'aria-valuenow': 240, + 'aria-valuemax': 480, + }, + play: async ({ canvas, step }) => { + await step('Has role="separator"', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toBeInTheDocument(); + }); + }, +}; + +export const AriaOrientationVertical: Story = { + name: 'ARIA: orientation vertical', + args: { + position: 'left', + 'aria-label': 'Sidebar resize handle', + 'aria-valuenow': 240, + 'aria-valuemax': 480, + }, + play: async ({ canvas, step }) => { + await step('Has aria-orientation="vertical" for left position', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toHaveAttribute('aria-orientation', 'vertical'); + }); + }, +}; + +export const AriaOrientationHorizontal: Story = { + name: 'ARIA: orientation horizontal', + args: { + position: 'bottom', + 'aria-label': 'Bottom panel resize handle', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + await step('Has aria-orientation="horizontal" for bottom position', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toHaveAttribute('aria-orientation', 'horizontal'); + }); + }, +}; + +export const AriaLabel: Story = { + name: 'ARIA: aria-label', + args: { + position: 'bottom', + 'aria-label': 'Specific resize handle label', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + await step('Has correct aria-label', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toHaveAttribute('aria-label', 'Specific resize handle label'); + }); + }, +}; + +export const AriaValue: Story = { + name: 'ARIA: aria-value* attributes', + args: { + position: 'bottom', + 'aria-label': 'Specific resize handle label', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + await step('Has correct aria-value* attributes', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toHaveAttribute('aria-valuemin', '0'); + expect(handle).toHaveAttribute('aria-valuenow', '150'); + expect(handle).toHaveAttribute('aria-valuemax', '400'); + }); + }, +}; + +export const FocusTooltipVertical: Story = { + name: 'Keyboard: vertical focus tooltip', + args: { + position: 'left', + 'aria-label': 'Sidebar resize handle', + 'aria-valuenow': 200, + 'aria-valuemax': 500, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Tab onto the handle', async () => { + await userEvent.tab(); + expect(handle).toHaveFocus(); + }); + + await step('Tooltip with ← → hint is visible', async () => { + // The tooltip is rendered in a portal outside canvas. + const tooltip = await within(document.body).findByText('← → to resize'); + expect(tooltip).toBeInTheDocument(); + }); + + await step('Tooltip disappears on blur', async () => { + handle.blur(); + // Give the tooltip time to un-mount / fade out. + await new Promise((r) => setTimeout(r, 250)); + const tooltip = within(document.body).queryByText('← → to resize'); + expect(tooltip).not.toBeInTheDocument(); + }); + }, +}; + +export const FocusTooltipHorizontal: Story = { + name: 'Keyboard: horizontal focus tooltip', + args: { + position: 'bottom', + 'aria-label': 'Bottom panel resize handle', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Tab onto the handle', async () => { + await userEvent.tab(); + expect(handle).toHaveFocus(); + }); + + await step('Tooltip with ↑ ↓ hint is visible', async () => { + const tooltip = await within(document.body).findByText('↑ ↓ to resize'); + expect(tooltip).toBeInTheDocument(); + }); + }, +}; diff --git a/code/core/src/manager/components/layout/Drag.tsx b/code/core/src/manager/components/layout/Drag.tsx index ba1fe4053d3d..0ffe5a79c12d 100644 --- a/code/core/src/manager/components/layout/Drag.tsx +++ b/code/core/src/manager/components/layout/Drag.tsx @@ -1,15 +1,83 @@ +import React, { forwardRef } 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 { + /** 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; + + /** 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; +} + +const oppositePosition: Record<string, PopperPlacement> = { + 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). 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<{ - orientation?: 'horizontal' | 'vertical'; - overlapping?: boolean; - position?: 'left' | 'right'; +export const Drag = forwardRef<HTMLDivElement, DragProps>(function Drag(props, ref) { + const { + overlapping, + position, + 'aria-label': ariaLabel, + 'aria-valuenow': ariaValueNow, + 'aria-valuemax': ariaValueMax, + ...rest + } = props; + + const orientation = position === 'left' || position === 'right' ? 'vertical' : 'horizontal'; + const tooltipNote = orientation === 'vertical' ? '← → to resize' : '↑ ↓ to resize'; + + return ( + <TooltipProvider + triggerOnFocusOnly + placement={oppositePosition[position]} + tooltip={<TooltipNote note={tooltipNote} />} + > + <DragHandle + ref={ref} + $orientation={orientation} + $overlapping={overlapping} + $position={position} + role="separator" + tabIndex={0} + aria-orientation={orientation} + aria-label={ariaLabel} + aria-valuenow={ariaValueNow} + aria-valuemin={0} + aria-valuemax={ariaValueMax} + {...rest} + /> + </TooltipProvider> + ); +}); + +const DragHandle = styled.div<{ + $orientation?: 'horizontal' | 'vertical'; + $overlapping?: boolean; + $position: 'left' | 'right' | 'top' | 'bottom'; }>( ({ theme }) => ({ position: 'absolute', @@ -27,19 +95,33 @@ export const Drag = styled.div<{ opacity: 1, }, }), - ({ orientation = 'vertical', overlapping = true, position = 'left' }) => - orientation === 'vertical' + ({ theme, $orientation = 'vertical' }) => ({ + '&:focus-visible': { + opacity: 1, + outline: '2px solid transparent', + ...($orientation === 'horizontal' ? { height: 7 } : { width: 7 }), + boxShadow: `inset 0 0 0 4px ${theme.color.secondary}`, + + '@media (forced-colors: active)': { + outline: '2px solid Highlight', + }, + }, + }), + ({ $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': { @@ -48,8 +130,9 @@ export const Drag = 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.stories.tsx b/code/core/src/manager/components/layout/Layout.stories.tsx index 1cabd5957091..3c7d33ba5ea6 100644 --- a/code/core/src/manager/components/layout/Layout.stories.tsx +++ b/code/core/src/manager/components/layout/Layout.stories.tsx @@ -8,10 +8,15 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { startCase } from 'es-toolkit/string'; import { action } from 'storybook/actions'; import { ManagerContext } from 'storybook/manager-api'; -import { expect, fn } from 'storybook/test'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import { styled } from 'storybook/theming'; import { isChromatic } from '../../../../../.storybook/isChromatic'; +import { + MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX, + MINIMUM_RIGHT_PANEL_WIDTH_PX, + MINIMUM_SIDEBAR_WIDTH_PX, +} from '../../constants'; import { Layout } from './Layout'; import { LayoutProvider } from './LayoutProvider'; @@ -167,9 +172,15 @@ export const DesktopCollapsedPanel: Story = { managerLayoutState: { ...defaultState, bottomPanelHeight: 0 }, }, play: async ({ canvas, step }) => { - await step('Verify panel is not rendered', async () => { + await step('Verify panel is aria-hidden and not interactive', async () => { const panel = canvas.queryByTestId('panel'); - expect(panel).not.toBeInTheDocument(); + + const ariaHiddenNode = panel?.closest('[aria-hidden="true"]'); + expect(ariaHiddenNode).toBeInTheDocument(); + expect(ariaHiddenNode).toHaveAttribute('aria-hidden', 'true'); + + panel?.focus(); + expect(panel).not.toHaveFocus(); }); }, }; @@ -209,6 +220,275 @@ export const DesktopPages: Story = { }, }; +export const KeyboardSidebarResize: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Sidebar resize handle' }); + + await step('Focus the sidebar handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('ArrowRight widens the sidebar', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeGreaterThan(before) + ); + }); + + await step('Shift+ArrowRight widens by a larger step', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow')) - before).toBeGreaterThanOrEqual(50) + ); + }); + + await step('ArrowLeft narrows the sidebar', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + + await step('Home collapses the sidebar to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('End expands the sidebar to its maximum', async () => { + await userEvent.keyboard('{End}'); + await waitFor(() => { + const valuenow = Number(handle.getAttribute('aria-valuenow')); + const valuemax = Number(handle.getAttribute('aria-valuemax')); + expect(valuenow).toBe(valuemax); + }); + }); + + await step('ArrowLeft narrows the sidebar again', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + }, +}; + +export const KeyboardSidebarMinSize: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Sidebar resize handle' }); + + await step('Focus the sidebar handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('Home collapses the sidebar to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('ArrowRight brings the sidebar to its min size', async () => { + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => + expect(handle).toHaveAttribute('aria-valuenow', `${MINIMUM_SIDEBAR_WIDTH_PX}`) + ); + }); + + await step('ArrowLeft collapses it again', async () => { + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + }, +}; + +export const KeyboardBottomPanelResize: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Addon panel resize handle' }); + + await step('Focus the panel handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('ArrowUp increases the panel height', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowUp}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeGreaterThan(before) + ); + }); + + await step('Shift+ArrowUp increases by a larger step', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{Shift>}{ArrowUp}{/Shift}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow')) - before).toBeGreaterThanOrEqual(50) + ); + }); + + await step('ArrowDown decreases the panel height', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + + await step('Home collapses the panel to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('End expands the panel to its maximum', async () => { + await userEvent.keyboard('{End}'); + await waitFor(() => { + const valuenow = Number(handle.getAttribute('aria-valuenow')); + const valuemax = Number(handle.getAttribute('aria-valuemax')); + expect(valuenow).toBe(valuemax); + }); + }); + + await step('ArrowDown decreases the panel height again', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + }, +}; + +export const KeyboardBottomPanelMinSize: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Addon panel resize handle' }); + + await step('Focus the addon panel handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('Home collapses the addon panel to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('ArrowUp brings the addon panel to its min size', async () => { + await userEvent.keyboard('{ArrowUp}'); + await waitFor(() => + expect(handle).toHaveAttribute('aria-valuenow', `${MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX}`) + ); + }); + + await step('ArrowDown collapses it again', async () => { + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + }, +}; + +export const KeyboardRightPanelResize: Story = { + args: { + managerLayoutState: { ...defaultState, panelPosition: 'right' }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Addon panel resize handle' }); + + await step('Focus the panel handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('ArrowLeft widens the right panel', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeGreaterThan(before) + ); + }); + + await step('Shift+ArrowLeft widens by a larger step', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow')) - before).toBeGreaterThanOrEqual(50) + ); + }); + + await step('ArrowRight narrows the right panel', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + + await step('Home collapses the right panel to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('End expands the right panel to its maximum', async () => {}); + + await step('End expands the right panel to its maximum', async () => { + await userEvent.keyboard('{End}'); + await waitFor(() => { + const valuenow = Number(handle.getAttribute('aria-valuenow')); + const valuemax = Number(handle.getAttribute('aria-valuemax')); + expect(valuenow).toBe(valuemax); + }); + }); + + await step('ArrowRight narrows the right panel again', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + }, +}; + +export const KeyboardRightPanelMinSize: Story = { + args: { + managerLayoutState: { ...defaultState, panelPosition: 'right' }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Addon panel resize handle' }); + + await step('Focus the addon panel handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('Home collapses the addon panel to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('ArrowLeft brings the addon panel to its min size', async () => { + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => + expect(handle).toHaveAttribute('aria-valuenow', `${MINIMUM_RIGHT_PANEL_WIDTH_PX}`) + ); + }); + + await step('ArrowRight collapses it again', async () => { + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + }, +}; + export const Mobile = { parameters: { viewport: { diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index f1b019d5a3d3..2b1abc7d5e76 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'; @@ -6,13 +6,13 @@ import type { API_Layout, API_ViewMode } from 'storybook/internal/types'; import { type API, useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; -import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; +import { MEDIA_DESKTOP_BREAKPOINT, MINIMUM_CONTENT_WIDTH_PX } 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'; @@ -38,7 +38,6 @@ interface Props { slotPages?: React.ReactNode; hasTab: boolean; } -const MINIMUM_CONTENT_WIDTH_PX = 100; const layoutStateIsEqual = (state: ManagerLayoutState, other: ManagerLayoutState) => state.navSize === other.navSize && @@ -111,11 +110,6 @@ const useLayoutSyncingState = ({ managerLayoutState.viewMode !== 'docs'; const isPanelShown = managerLayoutState.viewMode === 'story' && !hasTab; - const { panelResizerRef, sidebarResizerRef } = useDragging({ - setState: setInternalDraggingSizeState, - isPanelShown, - isDesktop, - }); const { navSize, rightPanelWidth, bottomPanelHeight } = internalDraggingSizeState.isDragging ? internalDraggingSizeState : managerLayoutState; @@ -123,6 +117,15 @@ const useLayoutSyncingState = ({ const customisedNavSize = api.getNavSizeWithCustomisations?.(navSize) ?? navSize; const customisedShowPanel = api.getShowPanelWithCustomisations?.(isPanelShown) ?? isPanelShown; + const { panelResizerRef, sidebarResizerRef, sidebarMaxWidth, panelMaxSize } = useDragging({ + setState: setInternalDraggingSizeState, + isDesktop, + navSize: customisedNavSize, + showPanel: customisedShowPanel, + rightPanelWidth, + panelPosition: managerLayoutState.panelPosition, + }); + return { navSize: customisedNavSize, rightPanelWidth, @@ -130,10 +133,10 @@ const useLayoutSyncingState = ({ panelPosition: managerLayoutState.panelPosition, panelResizerRef, sidebarResizerRef, + sidebarMaxWidth, + panelMaxSize, showPages: isPagesShown, - showPanel: - customisedShowPanel && - (managerLayoutState.panelPosition === 'right' ? rightPanelWidth > 0 : bottomPanelHeight > 0), + showPanel: customisedShowPanel, isDragging: internalDraggingSizeState.isDragging, }; }; @@ -153,6 +156,8 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s panelPosition, panelResizerRef, sidebarResizerRef, + sidebarMaxWidth, + panelMaxSize, showPages, showPanel, } = useLayoutSyncingState({ api, managerLayoutState, setManagerLayoutState, isDesktop, hasTab }); @@ -174,8 +179,11 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s > <> {isDesktop && ( - <SidebarContainer> - <Drag ref={sidebarResizerRef} /> + <SidebarContainer + navSize={navSize} + sidebarMaxWidth={sidebarMaxWidth} + sidebarResizerRef={sidebarResizerRef} + > {slots.slotSidebar} </SidebarContainer> )} @@ -193,14 +201,15 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s slotPages={slots.slotPages} /> - {isDesktop && showPanel && ( + {isDesktop && ( <PanelContainer bottomPanelHeight={bottomPanelHeight} rightPanelWidth={rightPanelWidth} + panelMaxSize={panelMaxSize} panelResizerRef={panelResizerRef} position={panelPosition} > - {slots.slotPanel} + {showPanel && slots.slotPanel} </PanelContainer> )} {isMobile && <Notifications />} @@ -241,10 +250,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/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx index 9ddce06d569e..98f39d028165 100644 --- a/code/core/src/manager/components/layout/PanelContainer.tsx +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -9,6 +9,7 @@ interface PanelContainerProps { children: React.ReactNode; bottomPanelHeight: number; rightPanelWidth: number; + panelMaxSize: number | undefined; panelResizerRef: React.Ref<HTMLDivElement>; position: API_Layout['panelPosition']; } @@ -31,7 +32,8 @@ const PanelSlot = styled.div({ * from the Accessibility Object Model when effectively collapsed. */ const PanelContainer = React.memo<PanelContainerProps>(function PanelContainer(props) { - const { children, bottomPanelHeight, rightPanelWidth, panelResizerRef, position } = props; + const { children, bottomPanelHeight, rightPanelWidth, panelMaxSize, panelResizerRef, position } = + props; const shouldHidePanelContent = position === 'bottom' ? bottomPanelHeight === 0 : rightPanelWidth === 0; @@ -39,10 +41,12 @@ const PanelContainer = React.memo<PanelContainerProps>(function PanelContainer(p return ( <Container position={position}> <Drag - orientation={position === 'bottom' ? 'horizontal' : 'vertical'} - overlapping={position === 'bottom' ? !!bottomPanelHeight : !!rightPanelWidth} - position={position === 'bottom' ? 'left' : 'right'} ref={panelResizerRef} + position={position} + overlapping={position === 'bottom' ? !!bottomPanelHeight : !!rightPanelWidth} + aria-label="Addon panel resize handle" + aria-valuenow={position === 'bottom' ? bottomPanelHeight : rightPanelWidth} + aria-valuemax={panelMaxSize} /> <PanelSlot // This ensures that the panel content is not reachable by keyboard or assistive 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..4fdab6fe2fc9 --- /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 { Drag } from './Drag'; + +interface SidebarContainerProps { + children: React.ReactNode; + navSize: number; + sidebarMaxWidth: number | undefined; + sidebarResizerRef: React.Ref<HTMLDivElement>; +} + +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<SidebarContainerProps>(function SidebarContainer(props) { + const { children, navSize, sidebarMaxWidth, sidebarResizerRef } = props; + + const shouldHideSidebarContent = navSize === 0; + + return ( + <Container> + <SidebarSlot + // This ensures that the sidebar content is not reachable by keyboard or assistive + // tech when actually collapsed. + hidden={shouldHideSidebarContent ? true : undefined} + aria-hidden={shouldHideSidebarContent ? true : undefined} + > + {children} + </SidebarSlot> + <Drag + ref={sidebarResizerRef} + position="left" + aria-label="Sidebar resize handle" + aria-valuenow={navSize} + aria-valuemax={sidebarMaxWidth} + /> + </Container> + ); +}); + +export { SidebarContainer }; diff --git a/code/core/src/manager/components/layout/useDragging.ts b/code/core/src/manager/components/layout/useDragging.ts index 1028f45df9f5..581b513c0b81 100644 --- a/code/core/src/manager/components/layout/useDragging.ts +++ b/code/core/src/manager/components/layout/useDragging.ts @@ -1,13 +1,24 @@ import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useRef } from 'react'; +import type { API_Layout } from 'storybook/internal/types'; + +import { + MINIMUM_CONTENT_WIDTH_PX, + MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX, + MINIMUM_HORIZONTAL_PANEL_WIDTH_PX, + MINIMUM_RIGHT_PANEL_WIDTH_PX, + MINIMUM_SIDEBAR_WIDTH_PX, + TOOLBAR_HEIGHT_PX, +} from '../../constants'; import type { LayoutState } from './Layout'; // the distance from the edge of the screen at which the panel/sidebar will snap to the edge 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 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 { @@ -19,18 +30,105 @@ function interpolate(relativeValue: number, min: number, max: number): number { return min + (max - min) * relativeValue; } +/** + * Computes the maximum width for the sidebar, accounting for the content minimum and the panel's + * estimated horizontal footprint. When the panel is at the bottom its minimum enforced width is + * reserved so the browser never squashes it below `MINIMUM_HORIZONTAL_PANEL_WIDTH_PX`. + */ +function computeSidebarMaxWidth( + panelPosition: API_Layout['panelPosition'], + rightPanelWidth: number, + showPanel: boolean +): number { + if (typeof window === 'undefined') { + return 0; + } + + const panelWidth = !showPanel + ? 0 + : panelPosition === 'right' + ? rightPanelWidth + : MINIMUM_HORIZONTAL_PANEL_WIDTH_PX; + return Math.max(window.innerWidth - MINIMUM_CONTENT_WIDTH_PX - panelWidth, 0); +} + +/** + * Computes the maximum size for the panel: + * + * - Bottom panel: `innerHeight` minus the toolbar so it cannot push the toolbar off-screen. + * - Right panel: `innerWidth` minus the content minimum and the sidebar. + */ +function computePanelMaxSize(panelPosition: API_Layout['panelPosition'], navSize: number): number { + if (typeof window === 'undefined') { + return 0; + } + + if (panelPosition === 'bottom') { + return Math.max(window.innerHeight - TOOLBAR_HEIGHT_PX, 0); + } + return Math.max(window.innerWidth - MINIMUM_CONTENT_WIDTH_PX - navSize, 0); +} + +/** + * Given the current layout state, a size key, a max size, and the key pressed, returns the next + * layout state with the resized panel/sidebar. All sidebar/panel-specific logic lives in the + * callers; this function is fully parameterised. + * + * @param sizeKey - The layout state key to resize. + * @param maxSize - The effective maximum size for the region. + * @param increaseKey - The key that grows the region. + * @param decreaseKey - The key that shrinks the region. + */ +function applyResizeKeyboard( + state: LayoutState, + sizeKey: 'navSize' | 'bottomPanelHeight' | 'rightPanelWidth', + key: string, + step: number, + minSize: number, + maxSize: number, + increaseKey: string, + decreaseKey: string +): LayoutState { + const currentSize = state[sizeKey]; + + switch (key) { + case increaseKey: + return { ...state, [sizeKey]: clamp(currentSize + step, minSize, maxSize) }; + case decreaseKey: + const effectivelyComputed = clamp(currentSize - step, 0, maxSize); + return { ...state, [sizeKey]: effectivelyComputed < minSize ? 0 : effectivelyComputed }; + case 'Home': + return { ...state, [sizeKey]: 0 }; + case 'End': + return { ...state, [sizeKey]: maxSize }; + default: + return state; + } +} + export function useDragging({ setState, - isPanelShown, + showPanel, isDesktop, + navSize, + rightPanelWidth, + panelPosition, }: { setState: Dispatch<SetStateAction<LayoutState>>; - isPanelShown: boolean; + showPanel: boolean; isDesktop: boolean; + navSize: number; + rightPanelWidth: number; + panelPosition: API_Layout['panelPosition']; }) { const panelResizerRef = useRef<HTMLDivElement>(null); const sidebarResizerRef = useRef<HTMLDivElement>(null); + // Compute current max sizes so callers can use them for aria attributes without duplicating logic. + // Evaluated at render time (from the same values the containers receive), so they stay in sync. + const sidebarMaxWidth = computeSidebarMaxWidth(panelPosition, rightPanelWidth, showPanel); + const panelMaxSize = computePanelMaxSize(panelPosition, navSize); + useEffect(() => { const panelResizer = panelResizerRef.current; const sidebarResizer = sidebarResizerRef.current; @@ -59,29 +157,40 @@ export function useDragging({ } }; - const onDragEnd = (e: MouseEvent) => { + const onDragEnd = () => { setState((state) => { if (draggedElement === sidebarResizer) { - if (state.navSize < SIDEBAR_MIN_WIDTH_PX && state.navSize > 0) { + if (state.navSize < MINIMUM_SIDEBAR_WIDTH_PX && state.navSize > 0) { // snap the sidebar back to its minimum width if it's smaller than the threshold return { ...state, isDragging: false, - navSize: SIDEBAR_MIN_WIDTH_PX, + navSize: MINIMUM_SIDEBAR_WIDTH_PX, }; } } if (draggedElement === panelResizer) { if ( state.panelPosition === 'right' && - state.rightPanelWidth < RIGHT_PANEL_MIN_WIDTH_PX && + state.rightPanelWidth < MINIMUM_RIGHT_PANEL_WIDTH_PX && state.rightPanelWidth > 0 ) { // snap the right panel back to its minimum width if it's smaller than the threshold return { ...state, isDragging: false, - rightPanelWidth: RIGHT_PANEL_MIN_WIDTH_PX, + rightPanelWidth: MINIMUM_RIGHT_PANEL_WIDTH_PX, + }; + } else if ( + state.panelPosition === 'bottom' && + state.bottomPanelHeight < MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX && + state.bottomPanelHeight > 0 + ) { + // snap the bottom panel back to its minimum height if it's smaller than the threshold + return { + ...state, + isDragging: false, + bottomPanelHeight: MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX, }; } } @@ -99,7 +208,7 @@ export function useDragging({ const onDrag = (e: MouseEvent) => { if (e.buttons === 0) { - onDragEnd(e); + onDragEnd(); return; } @@ -116,17 +225,20 @@ export function useDragging({ navSize: 0, }; } - if (sidebarDragX <= SIDEBAR_MIN_WIDTH_PX) { + if (sidebarDragX <= MINIMUM_SIDEBAR_WIDTH_PX) { // set sidebar width to a value in between the actual drag position and the min width, determined by the stiffness return { ...state, - navSize: interpolate(MIN_WIDTH_STIFFNESS, sidebarDragX, SIDEBAR_MIN_WIDTH_PX), + navSize: interpolate(STIFFNESS, sidebarDragX, MINIMUM_SIDEBAR_WIDTH_PX), }; } return { ...state, - // @ts-expect-error (non strict) - navSize: clamp(sidebarDragX, 0, e.view.innerWidth), + navSize: clamp( + sidebarDragX, + 0, + computeSidebarMaxWidth(state.panelPosition, state.rightPanelWidth, showPanel) + ), }; } if (draggedElement === panelResizer) { @@ -138,6 +250,10 @@ export function useDragging({ e.view.innerHeight - e.clientY : // @ts-expect-error (non strict) e.view.innerWidth - e.clientX; + const minimumSize = + state.panelPosition === 'bottom' + ? MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX + : MINIMUM_RIGHT_PANEL_WIDTH_PX; if (panelDragSize === state[sizeAxisState]) { return state; @@ -148,45 +264,88 @@ export function useDragging({ [sizeAxisState]: 0, }; } - if (state.panelPosition === 'right' && panelDragSize <= RIGHT_PANEL_MIN_WIDTH_PX) { - // set right panel width to a value in between the actual drag position and the min width, determined by the stiffness + // set panel width/height to a value in between the actual drag position and the min size, determined by the stiffness + if (panelDragSize <= minimumSize) { return { ...state, - [sizeAxisState]: interpolate( - MIN_WIDTH_STIFFNESS, - panelDragSize, - RIGHT_PANEL_MIN_WIDTH_PX - ), + [sizeAxisState]: interpolate(STIFFNESS, panelDragSize, minimumSize), }; } - const sizeAxisMax = - // @ts-expect-error (non strict) - state.panelPosition === 'bottom' ? e.view.innerHeight : e.view.innerWidth; return { ...state, - [sizeAxisState]: clamp(panelDragSize, 0, sizeAxisMax), + [sizeAxisState]: clamp( + panelDragSize, + 0, + computePanelMaxSize(state.panelPosition, state.navSize) + ), }; } return state; }); }; + const onSidebarKeyDown = (e: KeyboardEvent) => { + if (!RESIZE_KEYS.includes(e.key)) { + return; + } + e.preventDefault(); + const step = e.shiftKey ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER : KEYBOARD_STEP_PX; + setState((state) => + applyResizeKeyboard( + state, + 'navSize', + e.key, + step, + MINIMUM_SIDEBAR_WIDTH_PX, + computeSidebarMaxWidth(state.panelPosition, state.rightPanelWidth, showPanel), + 'ArrowRight', + 'ArrowLeft' + ) + ); + }; + + const onPanelKeyDown = (e: KeyboardEvent) => { + if (!RESIZE_KEYS.includes(e.key)) { + return; + } + e.preventDefault(); + const step = e.shiftKey ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER : KEYBOARD_STEP_PX; + setState((state) => + applyResizeKeyboard( + state, + state.panelPosition === 'bottom' ? 'bottomPanelHeight' : 'rightPanelWidth', + e.key, + step, + state.panelPosition === 'bottom' + ? MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX + : MINIMUM_RIGHT_PANEL_WIDTH_PX, + computePanelMaxSize(state.panelPosition, state.navSize), + state.panelPosition === 'bottom' ? 'ArrowUp' : 'ArrowLeft', + state.panelPosition === 'bottom' ? 'ArrowDown' : 'ArrowRight' + ) + ); + }; + 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'); }; }, [ // we need to rerun this effect when the panel is shown/hidden or when changing between mobile/desktop to re-attach the event listeners - isPanelShown, + showPanel, isDesktop, setState, ]); - return { panelResizerRef, sidebarResizerRef }; + return { panelResizerRef, sidebarResizerRef, sidebarMaxWidth, panelMaxSize }; } diff --git a/code/core/src/manager/components/panel/Panel.tsx b/code/core/src/manager/components/panel/Panel.tsx index 1ff1b5eb07ba..7682692dbacd 100644 --- a/code/core/src/manager/components/panel/Panel.tsx +++ b/code/core/src/manager/components/panel/Panel.tsx @@ -17,6 +17,7 @@ import { BottomBarIcon, CloseIcon, DocumentIcon, SidebarAltIcon } from '@storybo import type { State } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; +import { focusableUIElements } from '../../../manager-api/modules/layout'; import { useLandmark } from '../../hooks/useLandmark'; import { useLayout } from '../layout/LayoutProvider'; @@ -160,7 +161,7 @@ export const AddonPanel = React.memo<{ ); return ( - <Aside ref={asideRef} id="storybook-panel-region" {...landmarkProps}> + <Aside ref={asideRef} id={focusableUIElements.addonPanel} {...landmarkProps}> <h2 id="storybook-panel-heading" className="sb-sr-only"> Addon panel </h2> 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<FramesRendererProps> = ({ 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, 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..2c41eae8369f 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({ @@ -106,6 +114,7 @@ export const Short = meta.story({ }, parameters: { viewport: { options: customViewports }, + chromatic: { disableSnapshot: true }, }, render: () => <></>, }); @@ -116,6 +125,19 @@ export const Narrow = meta.story({ }, parameters: { viewport: { options: customViewports }, + chromatic: { disableSnapshot: true }, + }, + render: () => <></>, +}); + +export const Calculated = meta.story({ + globals: { + viewport: { value: 'calc' }, + }, + parameters: { + viewport: { options: customViewports }, + chromatic: { disableSnapshot: true }, }, render: () => <></>, + tags: ['!test', '!vitest'], // Vitest browser does not support calculated viewports }); diff --git a/code/core/src/manager/components/preview/Viewport.tsx b/code/core/src/manager/components/preview/Viewport.tsx index 78d0e5910878..4e52292d7f77 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 || !ny, + height: !nx || !ny, }, }; }, [width, height, scale]); @@ -311,6 +308,7 @@ export const Viewport = ({ value={width} minValue={0} setValue={(value) => resize(value, height)} + disabled={dimensions.locked.width} /> <ActionList.Button key="viewport-rotate" @@ -333,6 +331,7 @@ export const Viewport = ({ value={height} minValue={0} setValue={(value) => resize(width, value)} + disabled={dimensions.locked.height} /> {isCustom && lastSelectedOption && ( <ActionList.Button @@ -372,19 +371,25 @@ export const Viewport = ({ </> )} </div> - <DragHandle - ref={dragRefX} - isDefault={isDefault} - data-side="right" - data-value={dimensions.display.width} - /> - <DragHandle - ref={dragRefY} - isDefault={isDefault} - data-side="bottom" - data-value={dimensions.display.height} - /> - <DragHandle ref={dragRefXY} isDefault={isDefault} data-side="both" /> + {!dimensions.locked.width && ( + <DragHandle + ref={dragRefX} + isDefault={isDefault} + data-side="right" + data-value={dimensions.display.width} + /> + )} + {!dimensions.locked.height && ( + <DragHandle + ref={dragRefY} + isDefault={isDefault} + data-side="bottom" + data-value={dimensions.display.height} + /> + )} + {!dimensions.locked.width && !dimensions.locked.height && ( + <DragHandle ref={dragRefXY} isDefault={isDefault} data-side="both" /> + )} </FrameWrapper> </ViewportWrapper> ); 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: () => ( - <Consumer filter={menuMapper}> - {({ isVisible, toggle, singleStory }) => - !singleStory && - !isVisible && ( - <> - <Button - padding="small" - variant="ghost" - ariaLabel="Show sidebar" - key="menu" - onClick={toggle} - > - <MenuIcon /> - </Button> - <Separator /> - </> - ) - } - </Consumer> - ), + render: () => { + const animateLandmark = useRegionFocusAnimation(); + + return ( + <Consumer filter={menuMapper}> + {({ isVisible, showSidebar, singleStory }) => + !singleStory && + !isVisible && ( + <> + <Button + padding="small" + variant="ghost" + ariaLabel="Show sidebar" + id={focusableUIElements.showSidebar} + key="menu" + onClick={() => showSidebar()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + showSidebar(animateLandmark); + } + }} + > + <MenuIcon /> + </Button> + <Separator /> + </> + ) + } + </Consumer> + ); + }, }; diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index bf4e3f4db5b1..eaa7908c500a 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -33,13 +33,14 @@ const storyId = 'root-1-child-a2--grandchild-a1-1'; export const simpleData = { menu, index, storyId }; export const loadingData = { menu }; -const managerContext: any = { +const managerContext: any = (args: Meta<typeof Sidebar>['args']) => ({ state: { docsOptions: { defaultName: 'Docs', autodocs: 'tag', docsMode: false, }, + internal_index: args?.indexJson, }, api: { emit: fn().mockName('api::emit'), @@ -66,7 +67,7 @@ const managerContext: any = { }), applyQueryParams: fn().mockName('api::applyQueryParams'), }, -}; +}); const meta = { component: Sidebar, @@ -100,8 +101,8 @@ const meta = { isDevelopment: true, }, decorators: [ - (storyFn, { globals, title }) => ( - <ManagerContext.Provider value={managerContext}> + (storyFn, { args, globals, title }) => ( + <ManagerContext.Provider value={managerContext(args)}> <LayoutProvider forceDesktop={ globals.viewport?.value === 'desktop' || @@ -134,8 +135,8 @@ export default meta; type Story = StoryObj<typeof meta>; -const mobileLayoutDecorator: DecoratorFunction = (storyFn, { globals, title }) => ( - <ManagerContext.Provider value={managerContext}> +const mobileLayoutDecorator: DecoratorFunction = (storyFn, { args, globals, title }) => ( + <ManagerContext.Provider value={managerContext(args)}> <LayoutProvider forceDesktop={ globals.viewport?.value === 'desktop' || diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 6981f8701ec4..b2f1b068fc05 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'; @@ -10,6 +10,7 @@ import { PlusIcon } from '@storybook/icons'; import { type State, useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; +import { focusableUIElements } from '../../../manager-api/modules/layout'; import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; import { useLandmark } from '../../hooks/useLandmark'; import { useLayout } from '../layout/LayoutProvider'; @@ -130,16 +131,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<HTMLElement>(null); const { landmarkProps } = useLandmark( { 'aria-labelledby': 'global-site-h1', role: 'banner' }, @@ -150,7 +141,12 @@ export const Sidebar = React.memo(function Sidebar({ const skipLinkHref = isPagesShown ? '#main-content-wrapper' : '#storybook-preview-wrapper'; return ( - <Container className="container sidebar-container" ref={headerRef} {...landmarkProps}> + <Container + className="container sidebar-container" + id={focusableUIElements.sidebarRegion} + ref={headerRef} + {...landmarkProps} + > <h1 id="global-site-h1" className="sb-sr-only"> Storybook </h1> @@ -194,9 +190,7 @@ export const Sidebar = React.memo(function Sidebar({ </> ) } - searchFieldContent={ - indexJson && <TagsFilter api={api} indexJson={indexJson} tagPresets={tagPresets} /> - } + searchFieldContent={<TagsFilter />} {...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..35d51c4faf00 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -1,117 +1,318 @@ +import React, { useMemo, useState } from 'react'; + +import type { DocsIndexEntry, StoryIndex, StoryIndexEntry } from 'storybook/internal/types'; + +import { global } from '@storybook/global'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { findByRole, fn } from 'storybook/test'; +import { ManagerContext } from 'storybook/manager-api'; +import { expect, screen, waitFor } from 'storybook/test'; import { TagsFilter } from './TagsFilter'; +const getDefaultTagFilters = () => { + const tagOptions = global.TAGS_OPTIONS ?? {}; + + return Object.entries(tagOptions).reduce( + (acc, [tag, option]) => { + if (option.defaultFilterSelection === 'include') { + acc.included.push(tag); + } + if (option.defaultFilterSelection === 'exclude') { + acc.excluded.push(tag); + } + return acc; + }, + { included: [] as string[], excluded: [] as string[] } + ); +}; + +const createInitialState = (initialStoryState: Record<string, unknown> = {}) => { + const defaults = getDefaultTagFilters(); + + const defaultIncludedTagFilters = + (initialStoryState.defaultIncludedTagFilters as string[] | undefined) ?? defaults.included; + const defaultExcludedTagFilters = + (initialStoryState.defaultExcludedTagFilters as string[] | undefined) ?? defaults.excluded; + + return { + ...initialStoryState, + defaultIncludedTagFilters, + defaultExcludedTagFilters, + includedTagFilters: + (initialStoryState.includedTagFilters as string[] | undefined) ?? defaultIncludedTagFilters, + excludedTagFilters: + (initialStoryState.excludedTagFilters as string[] | undefined) ?? defaultExcludedTagFilters, + }; +}; + const meta = { component: TagsFilter, title: 'Sidebar/TagsFilter', tags: ['haha', 'this-is-a-very-long-tag-that-will-be-truncated-after-a-while'], - 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: {}, - 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, - }, + decorators: [ + (Story, { args, parameters }) => { + const [state, setState] = useState(() => + createInitialState(parameters?.initialStoryState as Record<string, unknown> | undefined) + ); + + const api = useMemo( + () => ({ + addTagFilters: (tags: string[], excluded: boolean) => { + setState((current: any) => { + const includedTagFilters = new Set(current.includedTagFilters ?? []); + const excludedTagFilters = new Set(current.excludedTagFilters ?? []); + + tags.forEach((tag) => { + if (excluded) { + includedTagFilters.delete(tag); + excludedTagFilters.add(tag); + } else { + includedTagFilters.add(tag); + excludedTagFilters.delete(tag); + } + }); + + return { + ...current, + includedTagFilters: Array.from(includedTagFilters), + excludedTagFilters: Array.from(excludedTagFilters), + }; + }); + }, + removeTagFilters: (tags: string[]) => { + setState((current: any) => ({ + ...current, + includedTagFilters: (current.includedTagFilters ?? []).filter( + (tag: string) => !tags.includes(tag) + ), + excludedTagFilters: (current.excludedTagFilters ?? []).filter( + (tag: string) => !tags.includes(tag) + ), + })); + }, + resetTagFilters: () => { + setState((current: any) => ({ + ...current, + includedTagFilters: current.defaultIncludedTagFilters ?? [], + excludedTagFilters: current.defaultExcludedTagFilters ?? [], + })); + }, + setAllTagFilters: (included: string[], excluded: string[]) => { + setState((current: any) => ({ + ...current, + includedTagFilters: included, + excludedTagFilters: excluded, + })); + }, + getDocsUrl: ({ subpath }: { subpath: string }) => + `https://storybook.js.org/docs/${subpath}`, + }), + [] + ); + + return ( + <ManagerContext.Provider value={{ api, state } as any}> + <Story args={{ ...args, api }} /> + </ManagerContext.Provider> + ); }, - }, + ], } satisfies Meta<typeof TagsFilter>; export default meta; type Story = StoryObj<typeof meta>; -export const Closed: Story = {}; +export const Closed: Story = { + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + '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, + }, + } as StoryIndex, + }, + }, +}; -export const ClosedWithSelection: Story = { - args: { - ...Closed.args, - tagPresets: { +export const ClosedWithDefaultTags: Story = { + ...Closed, + 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: { + internal_index: { + v: 6, + entries: { + '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, + }, + } as StoryIndex, + includedTagFilters: ['A', 'B'], }, }, }; 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: { + internal_index: { + v: 6, + entries: { + '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, + }, + } as StoryIndex, + 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; export const NoUserTags = { - ...Clear, - args: { - ...Clear.args, - indexJson: { - v: 6, - entries: { - 'c1-s1': { tags: ['dev', 'play-fn'], type: 'story' } as any, - 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as any, - 'c1-doc': { tags: [], type: 'docs' } as any, - }, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + '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, + }, + } as StoryIndex, }, }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + + const learnLink = await screen.findByRole('link', { name: 'Learn how to add tags' }); + + expect(learnLink).toBeInTheDocument(); + }, } satisfies Story; export const WithSelection = { ...ClosedWithSelection, - play: Clear.play, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + }, } satisfies Story; export const WithSelectionInverted = { ...Clear, - args: { - ...Clear.args, - tagPresets: { - A: { defaultFilterSelection: 'exclude' }, - B: { defaultFilterSelection: 'exclude' }, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + '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, + }, + } as StoryIndex, + excludedTagFilters: ['A', 'B'], }, }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + }, } satisfies Story; export const WithSelectionMixed = { - ...Clear, - args: { - ...Clear.args, - tagPresets: { - A: { defaultFilterSelection: 'include' }, - B: { defaultFilterSelection: 'exclude' }, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + '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, + }, + } as StoryIndex, + includedTagFilters: ['A'], + excludedTagFilters: ['B'], }, }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + }, } satisfies Story; export const Empty: Story = { - args: { - indexJson: { - v: 6, - entries: {}, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: {}, + } as StoryIndex, }, }, - 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 */ export const EmptyProduction: Story = { - args: { - ...Empty.args, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: {}, + } as StoryIndex, + }, }, - 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..516baebd8c0a --- /dev/null +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -0,0 +1,132 @@ +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 initStories } from '../../../manager-api/modules/stories'; +import { createTestingStore } from '../../../manager-api/test-utils/store'; + +/** Mock API wrapper that forces component updates when store state changes. */ +export class MockAPIWrapper<SubAPI, SubState> extends React.Component<{ + children: React.ReactNode; + args: Record<string, unknown>; + initFn: ModuleFn<SubAPI, SubState>; + initOptions?: Partial<ModuleArgs>; + initialStoryState?: Partial<State>; +}> { + api: ReturnType<typeof initStories>['api']; + store: ReturnType<typeof createTestingStore>; + channel: Channel; + mounted: boolean; + + constructor(props: { + children: React.ReactNode; + args: Record<string, unknown>; + initFn: ModuleFn<SubAPI, SubState>; + initOptions?: Partial<ModuleArgs>; + initialStoryState?: Partial<State>; + }) { + 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<API> = { + getConfig: () => ({}), + handleAPI: () => {}, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - TSC in CI fails to recognise this is the right Channel type. + channel: this.channel, + }; + + // Mock other submodules we depend on. + const fullAPI = { + experimental_setFilter: fn().mockName('API::experimental_setFilter'), + getRefs: fn().mockName('API::getRefs').mockReturnValue({}), + setRef: fn().mockName('API::setRef'), + updateRef: fn().mockName('API::updateRef'), + setOptions: fn().mockName('API::setOptions'), + } 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>(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 }) => ( + <MockAPIWrapper + args={args} + initFn={initStories} + initialStoryState={parameters?.initialStoryState} + initOptions={{ singleStory: false }} + > + <Story /> + </MockAPIWrapper> +); diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 6c8dd4667ba6..86a56a624cd8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -1,44 +1,25 @@ -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 { type API, type Combo, Consumer } from 'storybook/manager-api'; +import { styled } from 'storybook/theming'; -import { type Filter, type FilterFunction, TagsFilterPanel, groupByType } from './TagsFilterPanel'; +import { TagsFilterPanel } from './TagsFilterPanel'; -const TAGS_FILTER = 'tags-filter'; - -const BUILT_IN_TAGS = new Set<string>(Object.values(Tag)); - -const StyledButton = styled(Button)<{ isHighlighted: boolean }>(({ isHighlighted, theme }) => ({ +const StyledButton = styled(Button)<{ $isHighlighted: boolean }>(({ $isHighlighted, theme }) => ({ '&:focus-visible': { outlineOffset: 4, }, - ...(isHighlighted && { + ...($isHighlighted && { background: theme.background.hoverable, color: theme.color.secondary, }), })); -// Immutable set operations -const add = (set: Set<string>, id: string) => { - const copy = new Set(set); - copy.add(id); - return copy; -}; -const remove = (set: Set<string>, id: string) => { - const copy = new Set(set); - copy.delete(id); - return copy; -}; -const equal = (left: Set<string>, right: Set<string>) => - left.size === right.size && new Set([...left, ...right]).size === left.size; - const TagSelected = styled(Badge)(({ theme }) => ({ position: 'absolute', top: 7, @@ -57,157 +38,44 @@ const TagSelected = styled(Badge)(({ theme }) => ({ color: theme.color.inverseText, })); -export interface TagsFilterProps { +const tagsFilterMapper = ({ api, state }: Combo) => ({ + api, + indexJson: state.internal_index as StoryIndex | undefined, + activeFilterCount: + (state.includedTagFilters?.length ?? 0) + (state.excludedTagFilters?.length ?? 0), + defaultIncludedFilters: state.defaultIncludedTagFilters, + defaultExcludedFilters: state.defaultExcludedTagFilters, + includedFilters: state.includedTagFilters, + excludedFilters: state.excludedTagFilters, +}); + +interface TagsFilterInnerProps { api: API; indexJson: StoryIndex; - tagPresets: TagsOptions; + activeFilterCount: number; + defaultIncludedFilters: string[]; + defaultExcludedFilters: string[]; + includedFilters: string[]; + excludedFilters: string[]; } -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: <DocumentIcon color={color.gold} />, - ...withCount((entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded ? entry.type !== 'docs' : entry.type === 'docs' - ), - }, - _play: { - id: '_play', - type: 'built-in', - title: 'Play', - icon: <PlayHollowIcon color={color.seafoam} />, - ...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: <BeakerIcon color={color.green} />, - ...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<string>(), defaultExcluded: new Set<string>() } - ); - }, [tagPresets]); - - const [includedFilters, setIncludedFilters] = useState(new Set(defaultIncluded)); - const [excludedFilters, setExcludedFilters] = useState(new Set(defaultExcluded)); +const TagsFilterInner = ({ + api, + indexJson, + activeFilterCount, + defaultIncludedFilters, + defaultExcludedFilters, + includedFilters, + excludedFilters, +}: TagsFilterInnerProps) => { 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 handleToggleExpand = useCallback( (event: React.SyntheticEvent<Element, Event>): void => { event.preventDefault(); setExpanded(!expanded); }, - [expanded, setExpanded] + [expanded] ); return ( @@ -220,31 +88,56 @@ export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => { popover={() => ( <TagsFilterPanel api={api} - filtersById={filtersById} + indexJson={indexJson} + defaultIncludedFilters={defaultIncludedFilters} + defaultExcludedFilters={defaultExcludedFilters} includedFilters={includedFilters} excludedFilters={excludedFilters} - toggleFilter={toggleFilter} - setAllFilters={setAllFilters} - resetFilters={resetFilters} - isDefaultSelection={ - equal(includedFilters, defaultIncluded) && equal(excludedFilters, defaultExcluded) - } - hasDefaultSelection={defaultIncluded.size > 0 || defaultExcluded.size > 0} /> )} > <StyledButton key="tags" - ariaLabel="Tag filters" + ariaLabel={ + activeFilterCount + ? `${activeFilterCount} active tag ${activeFilterCount !== 1 ? 'filters' : 'filter'}` + : 'Tag filters' + } ariaDescription="Filter the items shown in a sidebar based on the tags applied to them." variant="ghost" padding="small" - isHighlighted={tagsActive} + $isHighlighted={activeFilterCount > 0} onClick={handleToggleExpand} > <FilterIcon /> - {includedFilters.size + excludedFilters.size > 0 && <TagSelected />} + {activeFilterCount > 0 && <TagSelected />} </StyledButton> </PopoverProvider> ); }; + +export const TagsFilter = () => ( + <Consumer filter={tagsFilterMapper}> + {({ + api, + indexJson, + activeFilterCount, + defaultIncludedFilters, + defaultExcludedFilters, + includedFilters, + excludedFilters, + }) => + indexJson ? ( + <TagsFilterInner + api={api} + indexJson={indexJson} + activeFilterCount={activeFilterCount} + defaultIncludedFilters={defaultIncludedFilters} + defaultExcludedFilters={defaultExcludedFilters} + includedFilters={includedFilters} + excludedFilters={excludedFilters} + /> + ) : null + } + </Consumer> +); diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index f87eb224b4e4..5640a94d1dee 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,79 +1,106 @@ -import { BeakerIcon, DocumentIcon, PlayHollowIcon } from '@storybook/icons'; +import type { DocsIndexEntry, StoryIndex, 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.story-helpers'; import { TagsFilterPanel } from './TagsFilterPanel'; -const builtInFilters = { - _docs: { - id: '_docs', - type: 'built-in', - title: 'Documentation', - icon: <DocumentIcon color={color.gold} />, - count: 8, - filterFn: fn(), - }, - _play: { - id: '_play', - type: 'built-in', - title: 'Play', - icon: <PlayHollowIcon color={color.seafoam} />, - count: 21, - filterFn: fn(), - }, - _test: { - id: '_test', - type: 'built-in', - title: 'Testing', - icon: <BeakerIcon color={color.green} />, - 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', + // Will provide api mock + decorators: [MockAPIDecorator], + tags: ['hoho'], 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, - }, - includedFilters: new Set(), - excludedFilters: new Set(), - resetFilters: fn(), - isDefaultSelection: true, - hasDefaultSelection: false, - api: { - getDocsUrl: () => 'https://storybook.js.org/docs/', - } as any, + api: {} as API, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + defaultExcludedFilters: [], + defaultIncludedFilters: [], + includedFilters: [], + excludedFilters: [], }, - tags: ['hoho'], } satisfies Meta<typeof TagsFilterPanel>; export default meta; @@ -84,7 +111,10 @@ export const Basic: Story = {}; export const BuiltInOnly: Story = { args: { - filtersById: builtInFilters, + indexJson: { + v: 6, + entries: getEntries(false), + } as StoryIndex, }, }; @@ -95,44 +125,65 @@ export const BuiltInOnly: Story = { */ export const BuiltInOnlyProduction: Story = { args: { - ...BuiltInOnly.args, + indexJson: { + v: 6, + entries: getEntries(false), + } as StoryIndex, }, }; export const Included: Story = { args: { - includedFilters: new Set(['tag1', '_play']), - isDefaultSelection: false, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], }, }; export const Excluded: Story = { args: { - excludedFilters: new Set(['tag1', '_play']), - isDefaultSelection: false, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + excludedFilters: ['tag1'], }, }; export const Mixed: Story = { args: { - includedFilters: new Set(['tag1', '_play']), - excludedFilters: new Set(['tag2', '_test']), - isDefaultSelection: false, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], + excludedFilters: ['tag2'], }, }; export const DefaultSelection: Story = { args: { - ...Mixed.args, - isDefaultSelection: true, - hasDefaultSelection: true, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], + excludedFilters: ['tag2'], + defaultIncludedFilters: ['tag1'], + defaultExcludedFilters: ['tag2'], }, }; export const DefaultSelectionModified: Story = { args: { - ...Mixed.args, - isDefaultSelection: false, - hasDefaultSelection: true, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1', 'tag2'], + defaultIncludedFilters: ['tag1'], + defaultExcludedFilters: ['tag2'], }, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index fa5ed4914059..cf172dd78a40 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -1,23 +1,33 @@ -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'; +import { BUILT_IN_FILTERS, USER_TAG_FILTER } from '../../../shared/constants/tags'; -export const groupByType = (filters: Filter[]) => +type Filter = { + id: string; + type: string; + title: string; + count: number; +}; + +const groupByType = (filters: Filter[]) => filters.filter(Boolean).reduce( (acc, filter) => { acc[filter.type] ??= []; @@ -40,61 +50,148 @@ 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<string>; - excludedFilters: Set<string>; - toggleFilter: (key: string, selected: boolean, excluded?: boolean) => void; - setAllFilters: (selected: boolean) => void; - resetFilters: () => void; - isDefaultSelection: boolean; - hasDefaultSelection: boolean; + indexJson: StoryIndex; + defaultIncludedFilters: string[]; + defaultExcludedFilters: string[]; + includedFilters: string[]; + excludedFilters: string[]; } +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; + +const getFilterFunction = (tag: Tag): FilterFunction | null => { + if (Object.hasOwn(BUILT_IN_FILTERS, tag)) { + return BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]; + } else { + return USER_TAG_FILTER(tag); + } +}; + export const TagsFilterPanel = ({ api, - filtersById, + indexJson, + defaultIncludedFilters, + defaultExcludedFilters, includedFilters, excludedFilters, - toggleFilter, - setAllFilters, - resetFilters, - isDefaultSelection, - hasDefaultSelection, }: TagsFilterPanelProps) => { const ref = useRef<HTMLDivElement>(null); - const renderLink = ({ - id, - type, - title, - icon, - count, - }: { - id: string; - type: string; - title: string; - icon?: React.ReactNode; - count: number; - }): Link | undefined => { + 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: <DocumentIcon color={color.gold} />, + count: getBuiltInCount(getFilterFunction('_docs')), + }, + _play: { + id: '_play', + type: 'built-in', + title: 'Play', + icon: <PlayHollowIcon color={color.seafoam} />, + count: getBuiltInCount(getFilterFunction('_play')), + }, + _test: { + id: '_test', + type: 'built-in', + title: 'Testing', + icon: <BeakerIcon color={color.green} />, + count: getBuiltInCount(getFilterFunction('_test')), + }, + }; + + return { ...userFilters, ...builtInFilters }; + }, [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, defaultIncludedFilters) && + equal(excludedFilters, defaultExcludedFilters) + ); + }, [includedFilters, excludedFilters, defaultIncludedFilters, defaultExcludedFilters]); + + const hasDefaultSelection = useMemo(() => { + return defaultIncludedFilters.length > 0 || defaultExcludedFilters.length > 0; + }, [defaultIncludedFilters, defaultExcludedFilters]); + + const builtInFilterIcons = useMemo( + () => ({ + _docs: <DocumentIcon color={color.gold} />, + _play: <PlayHollowIcon color={color.seafoam} />, + _test: <BeakerIcon color={color.green} />, + }), + [] + ); + + 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 +244,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 ( <Wrapper ref={ref}> @@ -179,7 +276,7 @@ export const TagsFilterPanel = ({ <ActionList.Button id="reset-filters" key="reset-filters" - onClick={resetFilters} + onClick={() => api.resetTagFilters()} ariaLabel="Reset filters" tooltip="Reset to default selection" disabled={isDefaultSelection} diff --git a/code/core/src/manager/constants.ts b/code/core/src/manager/constants.ts index 42747e87de4a..8eec2ff0ab83 100644 --- a/code/core/src/manager/constants.ts +++ b/code/core/src/manager/constants.ts @@ -1,3 +1,27 @@ export const BREAKPOINT = 600; export const MEDIA_DESKTOP_BREAKPOINT = `@media (min-width: ${BREAKPOINT}px)`; export const MOBILE_TRANSITION_DURATION = 300; + +/** Minimum width in pixels for the main content area in the layout grid. */ +export const MINIMUM_CONTENT_WIDTH_PX = 100; + +/** + * Upper bound on the minimum width that browsers will enforce for the addon panel in its horizontal + * layout. Use it to compute the max width of other items (e.g. sidebar). + */ +export const MINIMUM_HORIZONTAL_PANEL_WIDTH_PX = 360; + +/** Minimum height in pixels for the addon panel in the bottom position, beyond which it collapses. */ +export const MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX = 40; + +/** Minimum width in pixels for the sidebar, beyond which it collapses entirely. */ +export const MINIMUM_SIDEBAR_WIDTH_PX = 240; + +/** Minimum width in pixels for the addon panel in the right position, beyond which it collapses. */ +export const MINIMUM_RIGHT_PANEL_WIDTH_PX = 270; + +/** + * Height in pixels of the toolbar in the main content area. Used to compute the maximum height of + * the bottom panel so it does not push the toolbar out of view. + */ +export const TOOLBAR_HEIGHT_PX = 40; 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/preview-api/modules/preview-web/render/MdxDocsRender.test.ts b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.test.ts index 6526af86ac08..146dd04cd569 100644 --- a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.test.ts +++ b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Channel } from 'storybook/internal/channels'; +import { Tag } from 'storybook/internal/core-server'; import type { DocsIndexEntry, RenderContextCallbacks, Renderer } from 'storybook/internal/types'; import type { StoryStore } from '../../store'; @@ -17,6 +18,14 @@ const entry = { storiesImports: [], } as DocsIndexEntry; +const attachedEntry = { + ...entry, + id: 'meta--docs', + title: 'Meta', + storiesImports: ['./Meta.stories.ts'], + tags: [Tag.ATTACHED_MDX], +} as DocsIndexEntry; + const createGate = (): [Promise<any | undefined>, (_?: any) => void] => { let openGate = (_?: any) => {}; const gate = new Promise<any | undefined>((resolve) => { @@ -90,4 +99,74 @@ describe('attaching', () => { expect(context.storyById()).toEqual(story); }); + + it('pre-attaches the indexed CSF file for attached MDX docs', async () => { + const render = new MdxDocsRender( + new Channel({}), + store, + attachedEntry, + {} as RenderContextCallbacks<Renderer> + ); + await render.prepare(); + + const context = render.docsContext(vi.fn()); + + expect(context.storyById()).toEqual(story); + }); +}); + +describe('docs parameters', () => { + it('uses the attached CSF story docs parameters for attached MDX docs', async () => { + const renderPage = vi.fn(); + const renderer = { render: renderPage }; + const docsRenderer = vi.fn(async () => renderer); + const { story, csfFile, moduleExports } = csfFileParts(); + const attachedStory = { + ...story, + parameters: { + docs: { + components: { Canvas: 'OverrideCanvas' }, + renderer: docsRenderer, + }, + }, + }; + const store = { + loadEntry: () => ({ + entryExports: { ...moduleExports, default: () => null }, + csfFiles: [csfFile], + }), + componentStoriesFromCSFFile: () => [attachedStory], + storyFromCSFFile: () => attachedStory, + projectAnnotations: { + parameters: { + docs: { + components: { Canvas: 'ProjectCanvas' }, + renderer: vi.fn(), + }, + }, + }, + } as unknown as StoryStore<Renderer>; + + const render = new MdxDocsRender( + new Channel({}), + store, + attachedEntry, + {} as RenderContextCallbacks<Renderer> + ); + await render.prepare(); + + await render.renderToElement({} as Renderer['canvasElement'], vi.fn()); + + expect(docsRenderer).toHaveBeenCalled(); + expect(renderPage).toHaveBeenCalledWith( + expect.objectContaining({ + storyById: expect.any(Function), + }), + expect.objectContaining({ + components: { Canvas: 'OverrideCanvas' }, + page: expect.any(Function), + }), + expect.anything() + ); + }); }); diff --git a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts index 809f8c19fe71..0839702cd4b2 100644 --- a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts +++ b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts @@ -1,10 +1,11 @@ import type { Channel } from 'storybook/internal/channels'; import { DOCS_RENDERED } from 'storybook/internal/core-events'; import type { Renderer, StoryId } from 'storybook/internal/types'; -import type { CSFFile, ModuleExports } from 'storybook/internal/types'; +import type { CSFFile, ModuleExports, PreparedStory } from 'storybook/internal/types'; import type { IndexEntry } from 'storybook/internal/types'; import type { RenderContextCallbacks } from 'storybook/internal/types'; +import { Tag } from '../../../../shared/constants/tags'; import type { StoryStore } from '../../store'; import { DocsContext } from '../docs-context/DocsContext'; import type { DocsContextProps } from '../docs-context/DocsContextProps'; @@ -46,6 +47,10 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender public csfFiles?: CSFFile<TRenderer>[]; + public attachedCsfFile?: CSFFile<TRenderer>; + + public attachedStory?: PreparedStory<TRenderer>; + constructor( protected channel: Channel, protected store: StoryStore<TRenderer>, @@ -70,6 +75,20 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender this.csfFiles = csfFiles; this.exports = entryExports; + this.attachedCsfFile = undefined; + this.attachedStory = undefined; + + if (this.entry.tags?.includes(Tag.ATTACHED_MDX)) { + this.attachedCsfFile = csfFiles[0]; + + const primaryStoryId = this.attachedCsfFile && Object.keys(this.attachedCsfFile.stories)[0]; + if (this.attachedCsfFile && primaryStoryId) { + this.attachedStory = this.store.storyFromCSFFile({ + storyId: primaryStoryId, + csfFile: this.attachedCsfFile, + }); + } + } this.preparing = false; } @@ -87,14 +106,18 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender throw new Error('Cannot render docs before preparing'); } - // NOTE we do *not* attach any CSF file yet. We wait for `referenceMeta(..., true)` - // ie the CSF file is attached via `<Meta of={} />` - return new DocsContext<TRenderer>( + const docsContext = new DocsContext<TRenderer>( this.channel, this.store, renderStoryToElement, this.csfFiles ); + + if (this.attachedCsfFile) { + docsContext.attachCSFFile(this.attachedCsfFile); + } + + return docsContext; } async renderToElement( @@ -108,15 +131,16 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender const docsContext = this.docsContext(renderStoryToElement); const { docs } = this.store.projectAnnotations.parameters ?? ({} as { docs: any }); + const baseDocsParameter = this.attachedStory?.parameters?.docs ?? docs; - if (!docs) { + if (!baseDocsParameter) { throw new Error( `Cannot render a story in viewMode=docs if \`@storybook/addon-docs\` is not installed` ); } - const docsParameter = { ...docs, page: this.exports.default }; - const renderer = await docs.renderer(); + const docsParameter = { ...baseDocsParameter, page: this.exports.default }; + const renderer = await baseDocsParameter.renderer(); const { render } = renderer as { render: DocsRenderFunction<TRenderer> }; const renderDocs = async () => { try { diff --git a/code/core/src/shared/constants/tags.ts b/code/core/src/shared/constants/tags.ts index 5d5a6b11dc47..f17662efe0eb 100644 --- a/code/core/src/shared/constants/tags.ts +++ b/code/core/src/shared/constants/tags.ts @@ -1,3 +1,5 @@ +import type { API_PreparedIndexEntry } from '../../types'; + /** System tags used throughout Storybook for categorizing and filtering stories and docs entries. */ export const Tag = { /** Indicates that autodocs should be generated for this component */ @@ -23,3 +25,27 @@ export const Tag = { * system tags used by Storybook. */ export type Tag = string; + +/** + * Built-in story filters that extend beyond simple tag inclusion/exclusion. Those are used in the + * manager UI and in the manager API stories module. + */ +export 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(Tag.PLAY_FN) + : entry.type === 'story' && !!entry.tags?.includes(Tag.PLAY_FN), + _test: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded + ? entry.type !== 'story' || entry.subtype !== 'test' + : entry.type === 'story' && entry.subtype === 'test', +}; + +/** + * Logic to resolve whether a tag filters a given entry, based on whether the tag is excluded or + * included. Shared by the manager UI and manager API stories module. + */ +export const USER_TAG_FILTER = (tag: Tag) => (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); diff --git a/code/core/src/telemetry/detect-agent.test.ts b/code/core/src/telemetry/detect-agent.test.ts index 077ef55c23b2..e9c47587d3c2 100644 --- a/code/core/src/telemetry/detect-agent.test.ts +++ b/code/core/src/telemetry/detect-agent.test.ts @@ -1,95 +1,65 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { detectAgent } from './detect-agent'; describe('detectAgent', () => { - it('detects amp via AGENT=amp (highest precedence)', () => { - expect( - detectAgent({ - stdoutIsTTY: true, - env: { - AGENT: 'amp', - CLAUDECODE: '1', - GEMINI_CLI: '1', - CODEX_SANDBOX: '1', - CURSOR_AGENT: '1', - }, - }) - ).toEqual({ name: 'amp' }); + afterEach(() => { + vi.unstubAllEnvs(); + }); - expect( - detectAgent({ - stdoutIsTTY: true, - env: { - CLAUDECODE: '1', - GEMINI_CLI: '1', - CODEX_SANDBOX: '1', - CURSOR_AGENT: '1', - AGENT: 'something', - }, - }) - ).toEqual({ name: 'claude-code' }); + it('detects claude via CLAUDECODE', () => { + vi.stubEnv('CLAUDECODE', '1'); + expect(detectAgent()).toEqual({ name: 'claude' }); }); - it('detects Gemini CLI via GEMINI_CLI', () => { - expect(detectAgent({ stdoutIsTTY: true, env: { GEMINI_CLI: '1' } })).toEqual({ - name: 'gemini-cli', - }); + it('detects claude via CLAUDE_CODE', () => { + vi.stubEnv('CLAUDE_CODE', '1'); + expect(detectAgent()).toEqual({ name: 'claude' }); }); - it('detects OpenAI Codex via CODEX_SANDBOX', () => { - expect(detectAgent({ stdoutIsTTY: true, env: { CODEX_SANDBOX: '1' } })).toEqual({ - name: 'codex', - }); + it('detects gemini via GEMINI_CLI', () => { + vi.stubEnv('GEMINI_CLI', '1'); + expect(detectAgent()).toEqual({ name: 'gemini' }); }); - it('detects Cursor Agent via CURSOR_AGENT (even if AGENT is also set)', () => { - expect( - detectAgent({ stdoutIsTTY: true, env: { CURSOR_AGENT: '1', AGENT: 'something' } }) - ).toEqual({ - name: 'cursor', - }); + it('detects codex via CODEX_SANDBOX', () => { + vi.stubEnv('CODEX_SANDBOX', '1'); + expect(detectAgent()).toEqual({ name: 'codex' }); }); - it('treats generic AGENT as unknown', () => { - expect(detectAgent({ stdoutIsTTY: true, env: { AGENT: 'some-agent' } })).toEqual({ - name: 'unknown', - }); + it('detects codex via CODEX_THREAD_ID', () => { + vi.stubEnv('CODEX_THREAD_ID', '1'); + expect(detectAgent()).toEqual({ name: 'codex' }); }); - it('does not use heuristics when stdout is a TTY', () => { - expect(detectAgent({ stdoutIsTTY: true, env: { TERM: 'dumb' } })).toEqual(undefined); - expect(detectAgent({ stdoutIsTTY: true, env: { GIT_PAGER: 'cat' } })).toEqual(undefined); + it('detects cursor via CURSOR_AGENT', () => { + vi.stubEnv('CURSOR_AGENT', '1'); + expect(detectAgent()).toEqual({ name: 'cursor' }); }); - it('detects unknown agent via TERM=dumb when stdout is not a TTY', () => { - expect(detectAgent({ stdoutIsTTY: false, env: { TERM: 'dumb' } })).toEqual({ - name: 'unknown', - }); + it('detects opencode via OPENCODE', () => { + vi.stubEnv('OPENCODE', '1'); + expect(detectAgent()).toEqual({ name: 'opencode' }); }); - it('detects unknown agent via GIT_PAGER=cat when stdout is not a TTY', () => { - expect(detectAgent({ stdoutIsTTY: false, env: { GIT_PAGER: 'cat' } })).toEqual({ - name: 'unknown', - }); + it('detects explicit agent via AI_AGENT env var', () => { + vi.stubEnv('AI_AGENT', 'copilot'); + expect(detectAgent()).toEqual({ name: 'copilot' }); }); - it('returns isAgent=false when there are no signals', () => { - expect(detectAgent({ stdoutIsTTY: false, env: {} })).toEqual(undefined); + it('normalizes AI_AGENT to lowercase', () => { + vi.stubEnv('AI_AGENT', 'Copilot'); + expect(detectAgent()).toEqual({ name: 'copilot' }); }); - it('applies heuristics even when CI is set (no CI special-casing)', () => { - expect( - detectAgent({ - stdoutIsTTY: false, - env: { CI: 'true', TERM: 'dumb' }, - }) - ).toEqual({ name: 'unknown' }); + it('AI_AGENT takes precedence over other env vars', () => { + vi.stubEnv('AI_AGENT', 'copilot'); + vi.stubEnv('CLAUDECODE', '1'); + vi.stubEnv('GEMINI_CLI', '1'); + expect(detectAgent()).toEqual({ name: 'copilot' }); }); - it('still detects explicit agents in CI', () => { - expect(detectAgent({ stdoutIsTTY: false, env: { CI: 'true', CODEX_SANDBOX: '1' } })).toEqual({ - name: 'codex', - }); + it('returns undefined when there are no signals', () => { + expect(detectAgent()).toEqual(undefined); }); }); diff --git a/code/core/src/telemetry/detect-agent.ts b/code/core/src/telemetry/detect-agent.ts index f4cd4d11d4e4..ec596b77b611 100644 --- a/code/core/src/telemetry/detect-agent.ts +++ b/code/core/src/telemetry/detect-agent.ts @@ -1,90 +1,20 @@ -export type KnownAgentName = - | 'claude-code' - | 'gemini-cli' - | 'cursor' - | 'codex' - | 'opencode' - | 'amp' - | 'unknown'; +import { detectAgent as stdEnvDetectAgent } from 'std-env'; export type AgentInfo = { - name: KnownAgentName; + /** + * The name of the detected AI coding agent (e.g. `claude`, `gemini`, `codex`, `cursor`). Can be + * any value supported by std-env or explicitly set via the `AI_AGENT` environment variable. + */ + name: string; }; export type AgentDetection = AgentInfo | undefined; -type DetectAgentOptions = { - stdoutIsTTY: boolean; - env: NodeJS.ProcessEnv; -}; - -function detectExplicitAgent(env: NodeJS.ProcessEnv): AgentInfo | undefined { - // Amp - if (env.AGENT === 'amp') { - return { - name: 'amp', - }; - } - - // Claude Code - if (env.CLAUDECODE) { - return { - name: 'claude-code', - }; - } - - // Gemini CLI - if (env.GEMINI_CLI) { - return { - name: 'gemini-cli', - }; - } - - // OpenAI Codex - if (env.CODEX_SANDBOX) { - return { - name: 'codex', - }; - } - - // Cursor Agent (proposed / best-effort; Cursor often sets VSCode env vars too) - if (env.CURSOR_AGENT) { - return { - name: 'cursor', - }; - } - - // Generic "AGENT" marker (unknown implementation) - if (env.AGENT) { - return { name: 'unknown' }; - } - - return undefined; -} - -/** Detect whether Storybook CLI is likely being invoked by an AI agent. */ -export const detectAgent = (options: DetectAgentOptions): AgentDetection => { - const env = options.env; - - // 1) Explicit agent variables (strong signal; allow even in CI/TTY) - const explicit = detectExplicitAgent(env); - if (explicit) { - return explicit; - } - - const stdoutIsTTY = options.stdoutIsTTY; - - // 2) Behavioral / fingerprint heuristics (exclude CI to reduce false positives) - if (stdoutIsTTY) { +/** Detect whether Storybook CLI is likely being invoked by an AI agent, using std-env. */ +export const detectAgent = (): AgentDetection => { + const { name } = stdEnvDetectAgent(); + if (!name) { return undefined; } - - const isDumbTerm = env.TERM === 'dumb'; - const hasAgentPager = env.GIT_PAGER === 'cat'; - - if (isDumbTerm || hasAgentPager) { - return { name: 'unknown' }; - } - - return undefined; + return { name }; }; diff --git a/code/core/src/telemetry/telemetry.ts b/code/core/src/telemetry/telemetry.ts index cb5b903cd085..0ad3484ebcf7 100644 --- a/code/core/src/telemetry/telemetry.ts +++ b/code/core/src/telemetry/telemetry.ts @@ -51,7 +51,7 @@ const getOperatingSystem = (): 'Windows' | 'macOS' | 'Linux' | `Other: ${string} // by the app. currently: // - cliVersion const inCI = isCI(); -const agentDetection = detectAgent({ stdoutIsTTY: process.stdout.isTTY, env: process.env }); +const agentDetection = detectAgent(); const globalContext = { inCI, isTTY: process.stdout.isTTY, diff --git a/code/core/src/test/spy.ts b/code/core/src/test/spy.ts index 8e9537e5f091..673c800001cc 100644 --- a/code/core/src/test/spy.ts +++ b/code/core/src/test/spy.ts @@ -16,6 +16,19 @@ 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<MockInstance> = ((globalThis as any).__STORYBOOK_MODULE_MOCK_SPIES__ ??= + new Set<MockInstance>()); + type Listener = (mock: MockInstance, args: unknown[]) => void; const listeners = new Set<Listener>(); @@ -63,6 +76,7 @@ function listenWhenCalled(mock: MockInstance) { */ export function clearAllMocks() { mocks.forEach((spy) => spy.mockClear()); + moduleMockSpies.forEach((spy) => spy.mockClear()); } /** @@ -74,6 +88,7 @@ export function clearAllMocks() { */ export function resetAllMocks() { mocks.forEach((spy) => spy.mockReset()); + moduleMockSpies.forEach((spy) => spy.mockReset()); } /** @@ -82,6 +97,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/src/types/modules/api.ts b/code/core/src/types/modules/api.ts index 2d61616aff85..7e311e88cad4 100644 --- a/code/core/src/types/modules/api.ts +++ b/code/core/src/types/modules/api.ts @@ -5,7 +5,12 @@ 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 { StoryIndex } from './indexer'; @@ -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; diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index e5145c2d1efd..cc399717e7d2 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -507,7 +507,12 @@ export interface StorybookConfigRaw { /** Only show input controls in Angular */ angularFilterNonInputControls?: boolean; - experimentalComponentsManifest?: boolean; + /** + * Enable component manifest generation for MCP and other tooling integrations. + * + * @default true + */ + componentsManifest?: boolean; /** * Enables the new code example generation for React components. You can see those examples when diff --git a/code/core/src/viewport/useViewport.ts b/code/core/src/viewport/useViewport.ts index 7e1e3eea9b20..2ba9e2886d34 100644 --- a/code/core/src/viewport/useViewport.ts +++ b/code/core/src/viewport/useViewport.ts @@ -179,12 +179,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] ); diff --git a/code/core/template/stories/test/ClearModuleMocksMocking.api.ts b/code/core/template/stories/test/ClearModuleMocksMocking.api.ts new file mode 100644 index 000000000000..8dc03b90db0f --- /dev/null +++ b/code/core/template/stories/test/ClearModuleMocksMocking.api.ts @@ -0,0 +1,17 @@ +export type Data = { + userId: number; + id: number; + title: string; + body: string; +}; + +export const fetchData = async (): Promise<Data[]> => { + return Promise.resolve([ + { + userId: 1, + id: 1, + title: 'mocked title', + body: 'mocked body', + }, + ]); +}; diff --git a/code/core/template/stories/test/ClearModuleMocksMocking.stories.ts b/code/core/template/stories/test/ClearModuleMocksMocking.stories.ts new file mode 100644 index 000000000000..230dd6d65a79 --- /dev/null +++ b/code/core/template/stories/test/ClearModuleMocksMocking.stories.ts @@ -0,0 +1,51 @@ +// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. +import { global as globalThis } from '@storybook/global'; + +import { clearAllMocks, expect, waitFor } from 'storybook/test'; + +import { fetchData } from './ClearModuleMocksMocking.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: globalThis.__TEMPLATE_COMPONENTS__.Button, + args: { + label: 'Fetch Data', + onClick: () => { + fetchData(); + }, + }, + beforeEach: async () => { + clearAllMocks(); + }, +}; + +export default meta; + +export const First = { + args: {}, + play: async ({ canvas }: any) => { + const button = await canvas.getByRole('button'); + await button.click(); + await waitFor(() => { + expect(fetchData).toHaveBeenCalledTimes(1); + }); + }, +}; + +export const Second = { + args: {}, + play: async ({ canvas }: any) => { + const button = await canvas.getByRole('button'); + await button.click(); + await waitFor(() => { + expect(fetchData).toHaveBeenCalledTimes(1); + }); + }, +}; diff --git a/code/core/template/stories/test/ModuleAutoMocking.stories.ts b/code/core/template/stories/test/ModuleAutoMocking.stories.ts index 4d9c750437aa..587d6ab63a1c 100644 --- a/code/core/template/stories/test/ModuleAutoMocking.stories.ts +++ b/code/core/template/stories/test/ModuleAutoMocking.stories.ts @@ -1,7 +1,6 @@ import { global as globalThis } from '@storybook/global'; import { expect } from 'storybook/test'; -import { v4 } from 'uuid'; import { fn } from './ModuleAutoMocking.utils'; diff --git a/code/e2e-tests/tags.spec.ts b/code/e2e-tests/tags.spec.ts index dee950505223..f8b92a5b960e 100644 --- a/code/e2e-tests/tags.spec.ts +++ b/code/e2e-tests/tags.spec.ts @@ -157,11 +157,11 @@ test.describe('tags', () => { await expect(stories).toHaveCount(1); // Clear selection - await expect(tagFilterPopover.locator('#deselect-all')).toBeVisible(); - await tagFilterPopover.locator('#deselect-all').click(); + await expect(page.locator('#deselect-all')).toBeVisible(); + await page.locator('#deselect-all').click(); // Checkboxes are not selected anymore - await expect(tagFilterPopover.locator('input[type="checkbox"]:checked')).toHaveCount(0); + await expect(page.locator('input[type="checkbox"]:checked')).toHaveCount(0); }); }); }); diff --git a/code/e2e-tests/util.ts b/code/e2e-tests/util.ts index 8585626528cd..a6336f4f6f97 100644 --- a/code/e2e-tests/util.ts +++ b/code/e2e-tests/util.ts @@ -193,7 +193,9 @@ export class SbPage { } async openTagsFilter() { - const tagFiltersButton = this.page.locator('[aria-label="Tag filters"]'); + const tagFiltersButton = this.page + .locator('[aria-label*="active tag filter"]') + .or(this.page.locator('[aria-label="Tag filters"]')); // FIXME: we might want to strengthen this locator with an aria-label or testid on the dialog. const tooltip = this.page.locator('[role="dialog"]'); const isTooltipVisible = await tooltip.isVisible(); 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<NextImage.ImageProps>; builder?: BuilderOptions; }; @@ -71,6 +70,9 @@ export interface NextJsParameters { /** Next.js router configuration */ router?: Partial<NextRouter>; + + /** Next.js image props */ + image?: Partial<NextImage.ImageProps>; }; } diff --git a/code/frameworks/svelte-vite/package.json b/code/frameworks/svelte-vite/package.json index 57f432216851..b19594a3a056 100644 --- a/code/frameworks/svelte-vite/package.json +++ b/code/frameworks/svelte-vite/package.json @@ -67,7 +67,7 @@ "vite": "^7.0.4" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "@sveltejs/vite-plugin-svelte": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "storybook": "workspace:^", "svelte": "^5.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-ts/modules/Navigation.svelte b/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-ts/modules/Navigation.svelte index 24a37f3517f8..c66e745f6df7 100644 --- a/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-ts/modules/Navigation.svelte +++ b/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-ts/modules/Navigation.svelte @@ -1,37 +1,44 @@ <script> - import { goto, invalidate, invalidateAll, afterNavigate, replaceState, pushState } from '$app/navigation'; + import { + goto, + invalidate, + invalidateAll, + afterNavigate, + replaceState, + pushState, + } from '$app/navigation'; - export let afterNavigateFn; + const { afterNavigateFn } = $props(); - afterNavigate(afterNavigateFn); + afterNavigate(afterNavigateFn); </script> <button - on:click={() => { - goto('/storybook-goto'); - }}>goto</button + on:click={() => { + goto('/storybook-goto'); + }}>goto</button > <button - on:click={() => { - invalidate('/storybook-invalidate'); - }}>invalidate</button + on:click={() => { + invalidate('/storybook-invalidate'); + }}>invalidate</button > <button - on:click={() => { - invalidateAll(); - }}>invalidateAll</button + on:click={() => { + invalidateAll(); + }}>invalidateAll</button > <button - on:click={() => { - pushState('/storybook-push-state', {}); - }}>pushState</button + on:click={() => { + pushState('/storybook-push-state', {}); + }}>pushState</button > <button - on:click={() => { - replaceState('/storybook-replace-state', {}); - }}>replaceState</button + on:click={() => { + replaceState('/storybook-replace-state', {}); + }}>replaceState</button > diff --git a/code/frameworks/vue3-vite/src/types.ts b/code/frameworks/vue3-vite/src/types.ts index 559555f44bc6..e12564d80ac8 100644 --- a/code/frameworks/vue3-vite/src/types.ts +++ b/code/frameworks/vue3-vite/src/types.ts @@ -34,16 +34,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`; }; }; diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 338057932344..c5ba70dc24e7 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -96,6 +96,11 @@ export type Template = { editAddons?: (addons: string[]) => string[]; useCsfFactory?: boolean; }; + /** Additional CI steps in case this template has special needs during CI. */ + extraCiSteps?: { + // Some sandboxes (e.g. Angular) rely on Node 22.22.1 as minimum supported version and threfore it needs enforcing, even if the CI image comes with a different node version. + ensureMinNodeVersion?: boolean; + }; /** Additional options to pass to the initiate command when initializing Storybook. */ initOptions?: { builder?: SupportedBuilder; @@ -380,7 +385,6 @@ export const baseTemplates = { features: { developmentModeForBuild: true, experimentalTestSyntax: true, - experimentalComponentsManifest: true, }, }, }, @@ -639,6 +643,17 @@ export const baseTemplates = { // Remove smoke-test from the list once https://github.com/storybookjs/storybook/issues/19351 is fixed. skipTasks: ['smoke-test', 'bench'], }, + 'svelte-kit/skeleton-ts': { + name: 'SvelteKit Latest (Vite | TypeScript)', + script: + 'npx sv@latest create --template minimal --types ts --no-add-ons --no-install {{beforeDir}}', + expected: { + framework: '@storybook/sveltekit', + renderer: '@storybook/svelte', + builder: '@storybook/builder-vite', + }, + skipTasks: ['e2e-tests', 'bench'], + }, 'angular-cli/prerelease': { name: 'Angular CLI Prerelease (Webpack | TypeScript)', script: @@ -647,6 +662,9 @@ export const baseTemplates = { // extraDependencies: ['@standard-schema/spec@^1', '@angular/forms@next'], useCsfFactory: true, }, + extraCiSteps: { + ensureMinNodeVersion: true, + }, expected: { framework: '@storybook/angular', renderer: '@storybook/angular', @@ -662,6 +680,9 @@ export const baseTemplates = { extraDependencies: ['@angular/forms@latest'], useCsfFactory: true, }, + extraCiSteps: { + ensureMinNodeVersion: true, + }, expected: { framework: '@storybook/angular', renderer: '@storybook/angular', @@ -669,17 +690,6 @@ export const baseTemplates = { }, skipTasks: ['bench', 'vitest-integration'], }, - 'svelte-kit/skeleton-ts': { - name: 'SvelteKit Latest (Vite | TypeScript)', - script: - 'npx sv@latest create --template minimal --types ts --no-add-ons --no-install {{beforeDir}}', - expected: { - framework: '@storybook/sveltekit', - renderer: '@storybook/svelte', - builder: '@storybook/builder-vite', - }, - skipTasks: ['e2e-tests', 'bench'], - }, 'lit-vite/default-js': { name: 'Lit Latest (Vite | JavaScript)', script: diff --git a/code/package.json b/code/package.json index 4682f0f19485..e9ce1cecd44c 100644 --- a/code/package.json +++ b/code/package.json @@ -70,7 +70,7 @@ ], "dependencies": { "@chromatic-com/storybook": "^5.0.0", - "@playwright/test": "1.52.0", + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "workspace:*", "@storybook/addon-designs": "^11.0.3", "@storybook/addon-docs": "workspace:*", 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<typeof Primary>; + export default meta; + + export const Default = () => <Primary title="Primary title" />; + `, + ['./src/Primary/Primary.tsx']: dedent` + import React from 'react'; + + export interface PrimaryProps { + title: string; + } + + /** Primary component description */ + export const Primary = ({ title }: PrimaryProps) => <div>{title}</div>; + `, + ['./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<typeof OtherFile>; + export default meta; + + export const Default = () => <OtherFile label="Other file label" />; + `, + ['./src/OtherFile/OtherFile.tsx']: dedent` + import React from 'react'; + + export interface OtherFileProps { + label: string; + } + + /** Other file component description */ + export const OtherFile = ({ label }: OtherFileProps) => ( + <button type="button">{label}</button> + ); + `, + }, + '/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('<Primary'); +}); + test('stories are populated when meta has no explicit title', async () => { 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<string, IndexEntry>(); + + 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<typeof getComponents>, 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 => { diff --git a/docs/_snippets/nextjs-framework-options-next-config-path.md b/docs/_snippets/nextjs-framework-options-next-config-path.md index 5c81a45ede4a..5a9bc22b51f3 100644 --- a/docs/_snippets/nextjs-framework-options-next-config-path.md +++ b/docs/_snippets/nextjs-framework-options-next-config-path.md @@ -6,9 +6,6 @@ export default { framework: { name: '@storybook/your-framework', options: { - image: { - loading: 'eager', - }, nextConfigPath: path.resolve(process.cwd(), 'next.config.js'), }, }, @@ -26,9 +23,6 @@ const config: StorybookConfig = { framework: { name: '@storybook/your-framework', options: { - image: { - loading: 'eager', - }, nextConfigPath: path.resolve(process.cwd(), 'next.config.js'), }, }, @@ -48,9 +42,6 @@ export default defineMain({ framework: { name: '@storybook/your-framework', options: { - image: { - loading: 'eager', - }, nextConfigPath: path.resolve(process.cwd(), 'next.config.js'), }, }, @@ -70,9 +61,6 @@ export default defineMain({ framework: { name: '@storybook/your-framework', options: { - image: { - loading: 'eager', - }, nextConfigPath: path.resolve(process.cwd(), 'next.config.js'), }, }, diff --git a/docs/api/main-config/main-config-features.mdx b/docs/api/main-config/main-config-features.mdx index 63c0452c95ba..6f7d63370219 100644 --- a/docs/api/main-config/main-config-features.mdx +++ b/docs/api/main-config/main-config-features.mdx @@ -26,6 +26,7 @@ Type: legacyDecoratorFileOrder?: boolean; measure?: boolean; outline?: boolean; + sidebarOnboardingChecklist?: boolean; toolbars?: boolean; viewport?: boolean; } @@ -47,6 +48,7 @@ Type: legacyDecoratorFileOrder?: boolean; measure?: boolean; outline?: boolean; + sidebarOnboardingChecklist?: boolean; toolbars?: boolean; viewport?: boolean; } @@ -67,6 +69,7 @@ Type: legacyDecoratorFileOrder?: boolean; measure?: boolean; outline?: boolean; + sidebarOnboardingChecklist?: boolean; toolbars?: boolean; viewport?: boolean; } @@ -80,6 +83,8 @@ Enables Storybook's additional features. Type: `boolean` +Default: `true` + Enable the [Actions](../../essentials/actions.mdx) feature. <If renderer="angular"> @@ -98,6 +103,8 @@ Filter non-input controls in Angular. Type: `boolean` +Default: `true` + Filter args with a "target" on the type from the render function. {/* prettier-ignore-start */} @@ -110,6 +117,8 @@ Filter args with a "target" on the type from the render function. Type: `boolean` +Default: `true` + Enable the [Backgrounds](../../essentials/backgrounds.mdx) feature. <If renderer="react"> @@ -130,6 +139,8 @@ Generate [manifests](../../ai/manifests.mdx), used by the [MCP server](../../ai/ Type: `boolean` +Default: `true` + Enable the [Controls](../../essentials/controls.mdx) feature. ## `developmentModeForBuild` @@ -178,12 +189,16 @@ Enable the [experimental `.test` method with the CSF Next format](../csf/csf-nex Type: `boolean` +Default: `true` + Enable the [Highlight](../../essentials/highlight.mdx) feature. ## `interactions` Type: `boolean` +Default: `true` + Enable the [Interactions](../../writing-tests/interaction-testing.mdx#debugging-interaction-tests) feature. ## `legacyDecoratorFileOrder` @@ -202,22 +217,30 @@ Apply decorators from preview.js before decorators from addons or frameworks. [M Type: `boolean` +Default: `true` + Enable the [Measure](../../essentials/measure-and-outline.mdx#measure) feature. ## `outline` Type: `boolean` +Default: `true` + Enable the [Outline](../../essentials/measure-and-outline.mdx#outline) feature. -## `toolbars` +## `sidebarOnboardingChecklist` Type: `boolean` -Enable the [Toolbars](../../essentials/toolbars.mdx) feature. +Default: `true` + +Enable the onboarding checklist sidebar widget. ## `viewport` Type: `boolean` +Default: `true` + Enable the [Viewport](../../essentials/viewport.mdx) feature. diff --git a/docs/get-started/frameworks/nextjs.mdx b/docs/get-started/frameworks/nextjs.mdx index 25da9d0b4952..25d4befbf7f8 100644 --- a/docs/get-started/frameworks/nextjs.mdx +++ b/docs/get-started/frameworks/nextjs.mdx @@ -867,12 +867,6 @@ Type: `Record<string, any>` 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` @@ -883,6 +877,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` @@ -927,4 +927,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. - diff --git a/docs/writing-tests/in-ci.mdx b/docs/writing-tests/in-ci.mdx index eba10a08cce2..0bd8aa72b12e 100644 --- a/docs/writing-tests/in-ci.mdx +++ b/docs/writing-tests/in-ci.mdx @@ -58,7 +58,7 @@ jobs: container: # Make sure to grab the latest version of the Playwright image # https://playwright.dev/docs/docker#pull-the-image - image: mcr.microsoft.com/playwright:v1.52.0-noble + image: mcr.microsoft.com/playwright:v1.58.2-noble steps: - uses: actions/checkout@v4 @@ -98,7 +98,7 @@ Test: stage: UI_Tests # Make sure to grab the latest version of the Playwright image # https://playwright.dev/docs/docker#pull-the-image - image: mcr.microsoft.com/playwright:v1.52.0-noble + image: mcr.microsoft.com/playwright:v1.58.2-noble script: - npm run test-storybook ``` @@ -125,7 +125,7 @@ pipelines: name: "Run Tests" # Make sure to grab the latest version of the Playwright image # https://playwright.dev/docs/docker#pull-the-image - image: mcr.microsoft.com/playwright:v1.52.0-noble + image: mcr.microsoft.com/playwright:v1.58.2-noble caches: - npm - node @@ -149,7 +149,7 @@ executors: docker: # Make sure to grab the latest version of the Playwright image # https://playwright.dev/docs/docker#pull-the-image - - image: mcr.microsoft.com/playwright:v1.52.0-noble + - image: mcr.microsoft.com/playwright:v1.58.2-noble working_directory: ~/repo jobs: @@ -222,7 +222,7 @@ pipeline { * Make sure to grab the latest version of the Playwright image * https://playwright.dev/docs/docker#pull-the-image */ - image 'mcr.microsoft.com/playwright:v1.52.0-noble' + image 'mcr.microsoft.com/playwright:v1.58.2-noble' reuseNode true } } @@ -257,7 +257,7 @@ stages: displayName: "Storybook tests" # Make sure to grab the latest version of the Playwright image # https://playwright.dev/docs/docker#pull-the-image - container: mcr.microsoft.com/playwright:v1.52.0-noble + container: mcr.microsoft.com/playwright:v1.58.2-noble variables: npm_config_cache: $(Pipeline.Workspace)/.npm steps: @@ -308,7 +308,7 @@ jobs: test: runs-on: ubuntu-latest container: - image: mcr.microsoft.com/playwright:v1.52.0-noble + image: mcr.microsoft.com/playwright:v1.58.2-noble + # 👇 Only run on successful deployments + if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success' steps: diff --git a/docs/writing-tests/index.mdx b/docs/writing-tests/index.mdx index 9d1f693ad93a..099008cb5f9a 100644 --- a/docs/writing-tests/index.mdx +++ b/docs/writing-tests/index.mdx @@ -90,7 +90,7 @@ Next, add a new CI workflow. container: # Make sure to grab the latest version of the Playwright image # https://playwright.dev/docs/docker#pull-the-image - image: mcr.microsoft.com/playwright:v1.52.0-noble + image: mcr.microsoft.com/playwright:v1.58.2-noble steps: - uses: actions/checkout@v4 diff --git a/package.json b/package.json index d6e3c288464f..00526f3ff9e2 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,13 @@ "vite-ecosystem-ci:before-test": "./scripts/ecosystem-ci/before-test.sh react-vite/default-ts", "vite-ecosystem-ci:build": "./scripts/ecosystem-ci/build.sh react-vite/default-ts", "vite-ecosystem-ci:test": "./scripts/ecosystem-ci/test.sh react-vite/default-ts", - "prepare": "husky" + "postinstall": "husky" }, "resolutions": { "@babel/runtime": "latest", "@babel/traverse": "latest", "@babel/types": "^7.28.4", - "@playwright/test": "1.52.0", + "@playwright/test": "1.58.2", "@testing-library/user-event@npm:^14.4.0": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch", "@testing-library/user-event@npm:^14.6.1": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch", "@types/babel__traverse@npm:*": "patch:@types/babel__traverse@npm%3A7.20.6#~/.yarn/patches/@types-babel__traverse-npm-7.20.6-fac4243243.patch", @@ -51,8 +51,8 @@ "@vitest/expect@npm:3.2.4": "patch:@vitest/expect@npm%3A3.2.4#~/.yarn/patches/@vitest-expect-npm-3.2.4-97c526d5cc.patch", "aria-query@5.3.0": "^5.3.0", "esbuild": "^0.27.0", - "playwright": "1.52.0", - "playwright-core": "1.52.0", + "playwright": "1.58.2", + "playwright-core": "1.58.2", "react": "^18.2.0", "serialize-javascript": "^3.1.0", "type-fest": "~2.19", @@ -60,7 +60,7 @@ }, "devDependencies": { "@nx/workspace": "^22.1.3", - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.58.2", "@types/kill-port": "^2.0.3", "http-server": "^14.1.1", "husky": "^9.1.7", diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index fc1ebec3fbb0..40dd08beec29 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -1,6 +1,7 @@ import { join } from 'path'; import * as sandboxTemplates from '../../code/lib/cli-storybook/src/sandbox-templates'; +import { type TemplateKey } from '../../code/lib/cli-storybook/src/sandbox-templates'; import { build_linux } from './common-jobs'; import { LINUX_ROOT_DIR, SANDBOX_DIR, WINDOWS_ROOT_DIR, WORKING_DIR } from './utils/constants'; import { @@ -17,6 +18,23 @@ import { import { defineJob, defineNoOpJob, isWorkflowOrAbove } from './utils/types'; import type { JobOrNoOpJob, Workflow } from './utils/types'; +function getSandboxSetupSteps(template: string) { + const extraSteps = []; + const templateData = sandboxTemplates.allTemplates[template as TemplateKey]; + + if (templateData.extraCiSteps?.ensureMinNodeVersion) { + extraSteps.push({ + 'node/install': { + 'install-yarn': true, + // Currently using Node 22.22.1 as minimum supported version for Angular sandboxes + 'node-version': '22.22.1', + }, + }); + } + + return extraSteps; +} + function defineSandboxJob_build({ directory, name, @@ -36,6 +54,7 @@ function defineSandboxJob_build({ class: 'medium+', }, steps: [ + ...getSandboxSetupSteps(template), ...workflow.restoreLinux(), { run: { @@ -75,6 +94,7 @@ function defineSandboxJob_dev({ class: 'medium', }, steps: [ + ...getSandboxSetupSteps(template), ...workflow.restoreLinux(), ...(options.e2e ? [ @@ -134,6 +154,7 @@ export function defineSandboxFlow<Key extends string>(key: Key) { class: 'large', }, steps: [ + ...getSandboxSetupSteps(key), ...workflow.restoreLinux(), verdaccio.start(), { @@ -223,6 +244,7 @@ export function defineSandboxFlow<Key extends string>(key: Key) { class: 'medium', }, steps: [ + ...getSandboxSetupSteps(key), 'checkout', // we need the full git history for chromatic workspace.attach(), cache.attach(CACHE_KEYS()), @@ -254,6 +276,7 @@ export function defineSandboxFlow<Key extends string>(key: Key) { class: 'medium', }, steps: [ + ...getSandboxSetupSteps(key), ...workflow.restoreLinux(), { run: { @@ -274,6 +297,7 @@ export function defineSandboxFlow<Key extends string>(key: Key) { class: 'medium+', }, steps: [ + ...getSandboxSetupSteps(key), ...workflow.restoreLinux(), { run: { @@ -310,6 +334,7 @@ export function defineSandboxFlow<Key extends string>(key: Key) { class: 'medium', }, steps: [ + ...getSandboxSetupSteps(key), ...workflow.restoreLinux(), { run: { @@ -359,6 +384,7 @@ export function defineSandboxTestRunner(sandbox: ReturnType<typeof defineSandbox class: 'medium', }, steps: [ + ...getSandboxSetupSteps(sandbox.name), ...workflow.restoreLinux(), { run: { diff --git a/scripts/ci/utils/executors.ts b/scripts/ci/utils/executors.ts index a342eac36c30..4644297c9a7b 100644 --- a/scripts/ci/utils/executors.ts +++ b/scripts/ci/utils/executors.ts @@ -7,7 +7,7 @@ export const executors = { environment: { NODE_OPTIONS: '--max_old_space_size=6144', }, - image: 'cimg/node:22.15.0-browsers', + image: 'cimg/node:22.22.1-browsers', }, ], parameters: { @@ -27,7 +27,7 @@ export const executors = { environment: { NODE_OPTIONS: '--max_old_space_size=6144', }, - image: 'cimg/node:22.15.0', + image: 'cimg/node:22.22.1', }, ], parameters: { @@ -47,7 +47,7 @@ export const executors = { environment: { NODE_OPTIONS: '--max_old_space_size=6144', }, - image: 'mcr.microsoft.com/playwright:v1.52.0-noble', + image: 'mcr.microsoft.com/playwright:v1.58.2-noble', }, ], parameters: { diff --git a/scripts/package.json b/scripts/package.json index e5db1e082858..d0e330c65af0 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -138,8 +138,8 @@ "p-limit": "^7.2.0", "p-retry": "^7.1.0", "picocolors": "^1.1.0", - "playwright": "1.52.0", - "playwright-core": "1.52.0", + "playwright": "1.58.2", + "playwright-core": "1.58.2", "polka": "^1.0.0-next.28", "prettier": "^3.7.1", "prettier-plugin-brace-style": "^0.8.1", diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index c5dd92921d9e..2c94b638f201 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -844,6 +844,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/ClearModuleMocksMocking.api.ts', { spy: true });", "sb.mock(import('lodash-es'));", "sb.mock(import('lodash-es/add'));", "sb.mock(import('lodash-es/sum'));", diff --git a/scripts/utils/yarn.ts b/scripts/utils/yarn.ts index 7630ca403324..1ce193d5f614 100644 --- a/scripts/utils/yarn.ts +++ b/scripts/utils/yarn.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; // TODO -- should we generate this file a second time outside of CLI? import storybookVersions from '../../code/core/src/common/versions'; +import { allTemplates } from '../../code/lib/cli-storybook/src/sandbox-templates'; import type { AllTemplatesKey } from '../../code/lib/cli-storybook/src/sandbox-templates'; import { exec } from './exec'; @@ -37,9 +38,9 @@ export const addPackageResolutions = async ({ cwd, dryRun }: YarnOptions) => { ...packageJson.resolutions, ...storybookVersions, // this is for our CI test, ensure we use the same version as docker image, it should match version specified in `./code/package.json` and `.circleci/config.yml` - playwright: '1.52.0', - 'playwright-core': '1.52.0', - '@playwright/test': '1.52.0', + playwright: '1.58.2', + 'playwright-core': '1.58.2', + '@playwright/test': '1.58.2', }; await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)); }; @@ -80,8 +81,9 @@ export const installYarn2 = async ({ cwd, dryRun, debug }: YarnOptions) => { ); }; -export const isViteSandbox = (key?: AllTemplatesKey) => - !key || key.includes('vite') || key.includes('svelte-kit'); +export const isViteSandbox = (key?: AllTemplatesKey) => { + return allTemplates[key as AllTemplatesKey]?.expected.builder === '@storybook/builder-vite'; +}; export const addWorkaroundResolutions = async ({ cwd, @@ -100,30 +102,38 @@ export const addWorkaroundResolutions = async ({ let additionalResolutions = {}; - if (isViteSandbox(key)) { - // Override vite to v8 beta for vite-based sandboxes to test Vite 8 compatibility - additionalResolutions = { - vite: '8.0.0-beta.18', - }; - } - // add additional resolutions for React 19 if (['nextjs/default-ts', 'nextjs/prerelease', 'react-native-web-vite/expo-ts'].includes(key)) { additionalResolutions = { react: '^19.0.0', 'react-dom': '^19.0.0', }; - } else if (key === 'react-webpack/prerelease-ts') { + } + + if (key === 'react-webpack/prerelease-ts') { additionalResolutions = { + ...additionalResolutions, react: packageJson.dependencies.react, 'react-dom': packageJson.dependencies['react-dom'], }; - } else if (key === 'react-rsbuild/default-ts') { + } + + if (key === 'react-rsbuild/default-ts') { additionalResolutions = { + ...additionalResolutions, 'react-docgen': '^8.0.2', }; } + if (key === 'react-native-web-vite/expo-ts') { + additionalResolutions = { + ...additionalResolutions, + // The expo sandbox started to break in beta 5, yet to investigate the root cause + // in the meantime, we downgrade to the version where things worked. + vite: '8.0.0-beta.4', + }; + } + packageJson.resolutions = { ...packageJson.resolutions, '@testing-library/dom': '^9.3.4', diff --git a/test-storybooks/portable-stories-kitchen-sink/react-vitest-3/package.json b/test-storybooks/portable-stories-kitchen-sink/react-vitest-3/package.json index ebaa7cd0f087..0a6d58169e9d 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react-vitest-3/package.json +++ b/test-storybooks/portable-stories-kitchen-sink/react-vitest-3/package.json @@ -12,7 +12,7 @@ "vitest": "SKIP_FAIL_ON_PURPOSE=true vitest run" }, "resolutions": { - "@playwright/test": "1.52.0", + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "file:../../../code/addons/a11y", "@storybook/addon-docs": "file:../../../code/addons/docs", "@storybook/addon-jest": "file:../../../code/addons/jest", @@ -47,7 +47,7 @@ "@storybook/web-components": "file:../../../code/renderers/web-components", "@storybook/web-components-vite": "file:../../../code/frameworks/web-components-vite", "eslint-plugin-storybook": "file:../../../code/lib/eslint-plugin", - "playwright": "1.52.0", + "playwright": "1.58.2", "storybook": "portal:../../../code/core" }, "dependencies": { @@ -55,7 +55,7 @@ "react-dom": "^18.0.0" }, "devDependencies": { - "@playwright/test": "1.52.0", + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "^8.0.0", "@storybook/addon-vitest": "^8.0.0", "@storybook/react": "^8.0.0", diff --git a/test-storybooks/portable-stories-kitchen-sink/react/package.json b/test-storybooks/portable-stories-kitchen-sink/react/package.json index 24835d2fb05d..59991571370c 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/package.json +++ b/test-storybooks/portable-stories-kitchen-sink/react/package.json @@ -16,7 +16,7 @@ "vitest": "SKIP_FAIL_ON_PURPOSE=true vitest run" }, "resolutions": { - "@playwright/test": "1.52.0", + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "file:../../../code/addons/a11y", "@storybook/addon-docs": "file:../../../code/addons/docs", "@storybook/addon-links": "file:../../../code/addons/links", @@ -50,7 +50,7 @@ "@storybook/web-components": "file:../../../code/renderers/web-components", "@storybook/web-components-vite": "file:../../../code/frameworks/web-components-vite", "eslint-plugin-storybook": "file:../../../code/lib/eslint-plugin", - "playwright": "1.52.0", + "playwright": "1.58.2", "storybook": "portal:../../../code/core" }, "dependencies": { @@ -58,8 +58,8 @@ "react-dom": "^18.0.0" }, "devDependencies": { - "@playwright/experimental-ct-react": "1.52.0", - "@playwright/test": "1.52.0", + "@playwright/experimental-ct-react": "1.58.2", + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "^10.0.0", "@storybook/addon-vitest": "^10.0.0", "@storybook/react": "^10.0.0", diff --git a/test-storybooks/portable-stories-kitchen-sink/svelte/package.json b/test-storybooks/portable-stories-kitchen-sink/svelte/package.json index e351a866665f..5f41275af50f 100644 --- a/test-storybooks/portable-stories-kitchen-sink/svelte/package.json +++ b/test-storybooks/portable-stories-kitchen-sink/svelte/package.json @@ -14,7 +14,7 @@ "vitest": "vitest" }, "resolutions": { - "@playwright/test": "1.52.0", + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "portal:../../../code/addons/a11y", "@storybook/addon-docs": "portal:../../../code/addons/docs", "@storybook/addon-links": "portal:../../../code/addons/links", @@ -46,11 +46,11 @@ "@storybook/vue3-vite": "portal:../../../code/frameworks/vue3-vite", "@storybook/web-components": "portal:../../../code/renderers/web-components", "@storybook/web-components-vite": "portal:../../../code/frameworks/web-components-vite", - "playwright": "1.52.0", + "playwright": "1.58.2", "storybook": "portal:../../../code/core" }, "devDependencies": { - "@playwright/experimental-ct-svelte": "1.52.0", + "@playwright/experimental-ct-svelte": "1.58.2", "@storybook/svelte": "9.0.0-alpha.0", "@storybook/svelte-vite": "9.0.0-alpha.0", "@sveltejs/vite-plugin-svelte": "^6.2.0", diff --git a/test-storybooks/portable-stories-kitchen-sink/vue3/package.json b/test-storybooks/portable-stories-kitchen-sink/vue3/package.json index fe78b5c5f255..8f7bf135e7de 100644 --- a/test-storybooks/portable-stories-kitchen-sink/vue3/package.json +++ b/test-storybooks/portable-stories-kitchen-sink/vue3/package.json @@ -15,7 +15,7 @@ "vitest": "echo 'Vitest tests are implemented in the renderer directory instead!'" }, "resolutions": { - "@playwright/test": "1.52.0", + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "file:../../../code/addons/a11y", "@storybook/addon-docs": "file:../../../code/addons/docs", "@storybook/addon-links": "file:../../../code/addons/links", @@ -48,14 +48,14 @@ "@storybook/web-components": "file:../../../code/renderers/web-components", "@storybook/web-components-vite": "file:../../../code/frameworks/web-components-vite", "eslint-plugin-storybook": "file:../../../code/lib/eslint-plugin", - "playwright": "1.52.0", + "playwright": "1.58.2", "storybook": "file:../../../code/core" }, "dependencies": { "vue": "^3.4.19" }, "devDependencies": { - "@playwright/experimental-ct-vue": "1.52.0", + "@playwright/experimental-ct-vue": "1.58.2", "@storybook/vue3": "^8.0.0", "@storybook/vue3-vite": "^8.0.0", "@testing-library/jest-dom": "^6.6.3", diff --git a/test-storybooks/yarn-pnp/package.json b/test-storybooks/yarn-pnp/package.json index 9cc1347e1290..2f935ee231e6 100644 --- a/test-storybooks/yarn-pnp/package.json +++ b/test-storybooks/yarn-pnp/package.json @@ -11,7 +11,7 @@ "storybook": "storybook dev -p 6006" }, "resolutions": { - "@playwright/test": "1.52.0", + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "file:../../code/addons/a11y", "@storybook/addon-docs": "file:../../code/addons/docs", "@storybook/addon-links": "file:../../code/addons/links", @@ -45,7 +45,7 @@ "@storybook/web-components": "file:../../code/renderers/web-components", "@storybook/web-components-vite": "file:../../code/frameworks/web-components-vite", "eslint-plugin-storybook": "file:../../code/lib/eslint-plugin", - "playwright": "1.52.0", + "playwright": "1.58.2", "storybook": "file:../../code/core" }, "dependencies": { diff --git a/yarn.lock b/yarn.lock index 493339d5f83e..728a4a89bdc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4970,14 +4970,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:1.52.0": - version: 1.52.0 - resolution: "@playwright/test@npm:1.52.0" +"@playwright/test@npm:1.58.2": + version: 1.58.2 + resolution: "@playwright/test@npm:1.58.2" dependencies: - playwright: "npm:1.52.0" + playwright: "npm:1.58.2" bin: playwright: cli.js - checksum: 10c0/1c428b421593eb4f79b7c99783a389c3ab3526c9051ec772749f4fca61414dfa9f2344eba846faac5f238084aa96c836364a91d81d3034ac54924f239a93e247 + checksum: 10c0/2164c03ad97c3653ff02e8818a71f3b2bbc344ac07924c9d8e31cd57505d6d37596015a41f51396b3ed8de6840f59143eaa9c21bf65515963da20740119811da languageName: node linkType: hard @@ -7915,7 +7915,7 @@ __metadata: resolution: "@storybook/code@workspace:code" dependencies: "@chromatic-com/storybook": "npm:^5.0.0" - "@playwright/test": "npm:1.52.0" + "@playwright/test": "npm:1.58.2" "@storybook/addon-a11y": "workspace:*" "@storybook/addon-designs": "npm:^11.0.3" "@storybook/addon-docs": "workspace:*" @@ -8453,7 +8453,7 @@ __metadata: resolution: "@storybook/root@workspace:." dependencies: "@nx/workspace": "npm:^22.1.3" - "@playwright/test": "npm:^1.52.0" + "@playwright/test": "npm:^1.58.2" "@types/kill-port": "npm:^2.0.3" http-server: "npm:^14.1.1" husky: "npm:^9.1.7" @@ -8556,8 +8556,8 @@ __metadata: p-limit: "npm:^7.2.0" p-retry: "npm:^7.1.0" picocolors: "npm:^1.1.0" - playwright: "npm:1.52.0" - playwright-core: "npm:1.52.0" + playwright: "npm:1.58.2" + playwright-core: "npm:1.58.2" polka: "npm:^1.0.0-next.28" prettier: "npm:^3.7.1" prettier-plugin-brace-style: "npm:^0.8.1" @@ -8647,7 +8647,7 @@ __metadata: typescript: "npm:^5.9.3" vite: "npm:^7.0.4" peerDependencies: - "@sveltejs/vite-plugin-svelte": ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + "@sveltejs/vite-plugin-svelte": ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 storybook: "workspace:^" svelte: ^5.0.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -24590,27 +24590,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.52.0": - version: 1.52.0 - resolution: "playwright-core@npm:1.52.0" +"playwright-core@npm:1.58.2": + version: 1.58.2 + resolution: "playwright-core@npm:1.58.2" bin: playwright-core: cli.js - checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f + checksum: 10c0/5aa15b2b764e6ffe738293a09081a6f7023847a0dbf4cd05fe10eed2e25450d321baf7482f938f2d2eb330291e197fa23e57b29a5b552b89927ceb791266225b languageName: node linkType: hard -"playwright@npm:1.52.0": - version: 1.52.0 - resolution: "playwright@npm:1.52.0" +"playwright@npm:1.58.2": + version: 1.58.2 + resolution: "playwright@npm:1.58.2" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.52.0" + playwright-core: "npm:1.58.2" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579 + checksum: 10c0/d060d9b7cc124bd8b5dffebaab5e84f6b34654a553758fe7b19cc598dfbee93f6ecfbdc1832b40a6380ae04eade86ef3285ba03aa0b136799e83402246dc0727 languageName: node linkType: hard @@ -28255,6 +28255,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^4.0.0": + version: 4.0.0 + resolution: "std-env@npm:4.0.0" + checksum: 10c0/63b1716eae27947adde49e21b7225a0f75fb2c3d410273ae9de8333c07c7d5fc7a0628ae4c8af6b4b49b4274ed46c2bf118ed69b64f1261c9d8213d76ed1c16c + languageName: node + linkType: hard + "steno@npm:^0.4.1": version: 0.4.4 resolution: "steno@npm:0.4.4" @@ -28422,6 +28429,7 @@ __metadata: sirv: "npm:^2.0.4" slash: "npm:^5.0.0" source-map: "npm:^0.7.4" + std-env: "npm:^4.0.0" store2: "npm:^2.14.2" strip-ansi: "npm:^7.1.0" strip-json-comments: "npm:^5.0.1"