diff --git a/.circleci/config.yml b/.circleci/config.yml index b34288f775cb..1cd268cbbe1b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -47,6 +47,19 @@ executors: environment: NODE_OPTIONS: --max_old_space_size=6144 resource_class: <> + sb_playwright_component_testing: + parameters: + class: + description: The Resource class + type: enum + enum: ["small", "medium", "medium+", "large", "xlarge"] + default: "small" + working_directory: /tmp/storybook + docker: + - image: mcr.microsoft.com/playwright:v1.42.1-jammy + environment: + NODE_OPTIONS: --max_old_space_size=6144 + resource_class: <> orbs: git-shallow-clone: guitarrapc/git-shallow-clone@2.5.0 @@ -565,7 +578,39 @@ jobs: STORYBOOK_INIT_EMPTY_TYPE: << parameters.template >> STORYBOOK_DISABLE_TELEMETRY: true - report-workflow-on-failure - + test-portable-stories: + parameters: + directory: + type: string + executor: + name: sb_playwright_component_testing + class: medium + steps: + - git-shallow-clone/checkout_advanced: + clone_options: "--depth 1 --verbose" + - attach_workspace: + at: . + - run: + name: Install dependencies + command: yarn install + working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >> + - run: + name: Run Jest tests + command: yarn jest + working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >> + - run: + name: Run Vitest tests + command: yarn vitest + working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >> + - run: + name: Run Playwright CT tests + command: yarn playwright + working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >> + - run: + name: Run Cypress CT tests + command: yarn cypress + working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >> + - report-workflow-on-failure workflows: docs: when: @@ -624,6 +669,12 @@ workflows: parallelism: 5 requires: - build-sandboxes + - test-portable-stories: + requires: + - build + matrix: + parameters: + directory: ["react", "vue3", "nextjs", "svelte"] # TODO: reenable once we find out the source of flakyness # - test-runner-dev: # requires: @@ -676,6 +727,12 @@ workflows: parallelism: 14 requires: - build-sandboxes + - test-portable-stories: + requires: + - build + matrix: + parameters: + directory: ["react", "vue3", "nextjs", "svelte"] - bench: parallelism: 5 requires: @@ -733,7 +790,12 @@ workflows: parallelism: 30 requires: - build-sandboxes - + - test-portable-stories: + requires: + - build + matrix: + parameters: + directory: ["react", "vue3", "nextjs", "svelte"] - test-empty-init: requires: - build diff --git a/.github/workflows/canary-release-pr.yml b/.github/workflows/canary-release-pr.yml index 659765318fe5..557a0331fe2d 100644 --- a/.github/workflows/canary-release-pr.yml +++ b/.github/workflows/canary-release-pr.yml @@ -49,18 +49,18 @@ jobs: echo "timestamp=$(date +%s)" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: ${{ steps.info.outputs.isFork == 'true' && steps.info.outputs.repository || null }} ref: ${{ steps.info.outputs.sha }} token: ${{ secrets.GH_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" - name: Cache dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.yarn/berry/cache diff --git a/.github/workflows/cron-weekly.yml b/.github/workflows/cron-weekly.yml index 898d10ace803..07026c97fb8a 100644 --- a/.github/workflows/cron-weekly.yml +++ b/.github/workflows/cron-weekly.yml @@ -2,21 +2,21 @@ name: Markdown Links Check # runs every monday at 9 am on: schedule: - - cron: '0 9 * * 1' + - cron: "0 9 * * 1" jobs: check-links: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: gaurav-nelson/github-action-markdown-link-check@v1 # checks all markdown files from important folders including all subfolders with: # only show errors that occur instead of successful links + errors - use-quiet-mode: 'yes' + use-quiet-mode: "yes" # output full HTTP info for broken links - use-verbose-mode: 'yes' - config-file: '.github/workflows/markdown-link-check-config.json' + use-verbose-mode: "yes" + config-file: ".github/workflows/markdown-link-check-config.json" # Notify to Discord channel on failure - name: Send Discord Notification if: failure() # Only run this step if previous steps failed diff --git a/.github/workflows/danger-js.yml b/.github/workflows/danger-js.yml index eddb5dee1fe7..a9ef5d65affe 100644 --- a/.github/workflows/danger-js.yml +++ b/.github/workflows/danger-js.yml @@ -21,10 +21,10 @@ jobs: name: Danger JS runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' + node-version-file: ".nvmrc" - name: Danger JS uses: danger/danger-js@11.2.6 env: diff --git a/.github/workflows/handle-release-branches.yml b/.github/workflows/handle-release-branches.yml index e1eb20e97adb..84cebf0aee54 100644 --- a/.github/workflows/handle-release-branches.yml +++ b/.github/workflows/handle-release-branches.yml @@ -23,7 +23,7 @@ jobs: if: ${{ needs.branch-checks.outputs.is-latest-branch == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: curl -X POST "https://api.netlify.com/build_hooks/${{ secrets.FRONTPAGE_HOOK }}" @@ -32,7 +32,7 @@ jobs: if: ${{ needs.branch-checks.outputs.is-next-branch == 'true' || needs.branch-checks.outputs.is-release-branch == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: next path: next @@ -54,7 +54,7 @@ jobs: if: ${{ needs.branch-checks.outputs.is-next-branch == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -88,7 +88,8 @@ jobs: request-create-frontpage-branch: if: ${{ always() }} - needs: [branch-checks, next-release-branch-check, create-next-release-branch] + needs: + [branch-checks, next-release-branch-check, create-next-release-branch] runs-on: ubuntu-latest steps: - if: ${{ needs.branch-checks.outputs.is-actionable-branch == 'true' && needs.branch-checks.outputs.is-latest-branch == 'false' && needs.next-release-branch-check.outputs.check == 'false' }} diff --git a/.github/workflows/prepare-non-patch-release.yml b/.github/workflows/prepare-non-patch-release.yml index 0b4163251bdd..3cbf8f8b1fc9 100644 --- a/.github/workflows/prepare-non-patch-release.yml +++ b/.github/workflows/prepare-non-patch-release.yml @@ -8,9 +8,9 @@ on: workflow_dispatch: inputs: release-type: - description: 'Which release type to use for bumping the version' + description: "Which release type to use for bumping the version" required: true - default: 'prerelease' + default: "prerelease" type: choice options: - prerelease @@ -43,7 +43,7 @@ jobs: working-directory: scripts steps: - name: Checkout next - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: next # this needs to be set to a high enough number that it will contain the last version tag @@ -52,12 +52,12 @@ jobs: token: ${{ secrets.GH_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' + node-version-file: ".nvmrc" - name: Cache dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.yarn/berry/cache @@ -123,7 +123,7 @@ jobs: run: | yarn release:write-changelog ${{ steps.bump-version.outputs.next-version }} --verbose - - name: 'Commit changes to branch: version-non-patch-from-${{ steps.bump-version.outputs.current-version }}' + - name: "Commit changes to branch: version-non-patch-from-${{ steps.bump-version.outputs.current-version }}" working-directory: . run: | git config --global user.name 'storybook-bot' @@ -180,4 +180,4 @@ jobs: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} uses: Ilshidur/action-discord@master with: - args: 'The GitHub Action for preparing the release pull request bumping from v${{ steps.bump-version.outputs.current-version }} to v${{ steps.bump-version.outputs.next-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' + args: "The GitHub Action for preparing the release pull request bumping from v${{ steps.bump-version.outputs.current-version }} to v${{ steps.bump-version.outputs.next-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/prepare-patch-release.yml b/.github/workflows/prepare-patch-release.yml index 91be3acf6f92..f66258c0d836 100644 --- a/.github/workflows/prepare-patch-release.yml +++ b/.github/workflows/prepare-patch-release.yml @@ -25,18 +25,18 @@ jobs: working-directory: scripts steps: - name: Checkout main - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: main token: ${{ secrets.GH_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" - name: Cache dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.yarn/berry/cache diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd9c892e3310..cc88ce6182fd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,18 +37,18 @@ jobs: gh run watch ${{ github.run_id }} - name: Checkout ${{ github.ref_name }} - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 100 token: ${{ secrets.GH_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' + node-version-file: ".nvmrc" - name: Cache dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.yarn/berry/cache @@ -197,4 +197,4 @@ jobs: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} uses: Ilshidur/action-discord@master with: - args: 'The GitHub Action for publishing version ${{ steps.version.outputs.current-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' + args: "The GitHub Action for publishing version ${{ steps.version.outputs.current-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 1418c69695f3..29afdd8ce196 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,18 +1,18 @@ -name: 'Close stale issues that need reproduction or more info from OP' +name: "Close stale issues that need reproduction or more info from OP" on: schedule: - - cron: '30 1 * * *' + - cron: "30 1 * * *" jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: stale-issue-message: "Hi there! Thank you for opening this issue, but it has been marked as `stale` because we need more information to move forward. Could you please provide us with the requested reproduction or additional information that could help us better understand the problem? We'd love to resolve this issue, but we can't do it without your help!" close-issue-message: "I'm afraid we need to close this issue for now, since we can't take any action without the requested reproduction or additional information. But please don't hesitate to open a new issue if the problem persists – we're always happy to help. Thanks so much for your understanding." - any-of-labels: 'needs reproduction,needs more info' - exempt-issue-labels: 'needs triage' - labels-to-add-when-unstale: 'needs triage' + any-of-labels: "needs reproduction,needs more info" + exempt-issue-labels: "needs triage" + labels-to-add-when-unstale: "needs triage" days-before-stale: 21 days-before-pr-stale: -1 diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index dbb4f498ab6e..103bb8196fa4 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -16,11 +16,11 @@ jobs: os: [windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set node version - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' + node-version-file: ".nvmrc" - name: install and compile run: yarn task --task compile --start-from=auto --no-link - name: test diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1978abc0d9..9826390c44b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 8.0.1 + +- Controls: Fix type summary when table.type unset - [#26283](https://github.com/storybookjs/storybook/pull/26283), thanks @shilman! +- Core: Fix addon bundling script - [#26145](https://github.com/storybookjs/storybook/pull/26145), thanks @ndelangen! +- Core: Fix fail to load `main.ts` error message - [#26035](https://github.com/storybookjs/storybook/pull/26035), thanks @ndelangen! +- Maintenance: Fix performance regressions - [#26411](https://github.com/storybookjs/storybook/pull/26411), thanks @kasperpeulen! + ## 8.0.0 #### Storybook 8.0 is here diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 3474586b1d3f..21d52c2627ff 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,13 @@ +## 8.1.0-alpha.3 + +- Addon Docs: Fix [Object object] displayName in some JSX components - [#26566](https://github.com/storybookjs/storybook/pull/26566), thanks @yannbf! +- CLI: Introduce package manager fallback for initializing Storybook in an empty directory with yarn1 - [#26500](https://github.com/storybookjs/storybook/pull/26500), thanks @valentinpalkovic! +- CSF: Make sure loaders/decorators can be used as array - [#26514](https://github.com/storybookjs/storybook/pull/26514), thanks @kasperpeulen! +- Controls: Fix disable condition in ArgControl component - [#26567](https://github.com/storybookjs/storybook/pull/26567), thanks @valentinpalkovic! +- Portable stories: Introduce experimental Playwright CT API and Support for more renderers - [#26063](https://github.com/storybookjs/storybook/pull/26063), thanks @yannbf! +- UI: Fix theming of elements inside bars - [#26527](https://github.com/storybookjs/storybook/pull/26527), thanks @valentinpalkovic! +- UI: Improve empty state of addon panel - [#26481](https://github.com/storybookjs/storybook/pull/26481), thanks @yannbf! + ## 8.1.0-alpha.2 - CLI: Automigrate improve upgrade storybook related packages - [#26497](https://github.com/storybookjs/storybook/pull/26497), thanks @ndelangen! diff --git a/MIGRATION.md b/MIGRATION.md index 561a9a61b899..2d6f1d834ecf 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -510,8 +510,8 @@ For migrating to CSF, see: [`storyStoreV6` and `storiesOf` is deprecated](#story In Storybook 7, these packages existed for backwards compatibility, but were marked as deprecated: - `@storybook/addons` - this package has been split into 2 packages: `@storybook/preview-api` and `@storybook/manager-api`, see more here: [New Addons API](#new-addons-api). -- `@storybook/channel-postmessage` - this package has been merged into `@storybook/channel`. -- `@storybook/channel-websocket` - this package has been merged into `@storybook/channel`. +- `@storybook/channel-postmessage` - this package has been merged into `@storybook/channels`. +- `@storybook/channel-websocket` - this package has been merged into `@storybook/channels`. - `@storybook/client-api` - this package has been merged into `@storybook/preview-api`. - `@storybook/core-client` - this package has been merged into `@storybook/preview-api`. - `@storybook/preview-web` - this package has been merged into `@storybook/preview-api`. @@ -554,7 +554,7 @@ export default defineConfig({ ```ts import { defineConfig } from "vite"; -import svelte from "@sveltejs/vite-plugin-svelte"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; export default defineConfig({ plugins: [svelte()], diff --git a/code/.yarn/patches/@testing-library-svelte-npm-4.1.0-34b7037bc0.patch b/code/.yarn/patches/@testing-library-svelte-npm-4.1.0-34b7037bc0.patch new file mode 100644 index 000000000000..212dfcc7d0ea --- /dev/null +++ b/code/.yarn/patches/@testing-library-svelte-npm-4.1.0-34b7037bc0.patch @@ -0,0 +1,97 @@ +diff --git a/package.json b/package.json +index 195dac9ee7d42fdb76bb22dc37580fa0bffd4680..980ad42f41a06023f9f7e370fd382c9217c24be5 100644 +--- a/package.json ++++ b/package.json +@@ -55,7 +55,7 @@ + "contributors:generate": "all-contributors generate" + }, + "peerDependencies": { +- "svelte": "^3 || ^4" ++ "svelte": "^3 || ^4 || ^5" + }, + "dependencies": { + "@testing-library/dom": "^9.3.1" +diff --git a/src/pure.js b/src/pure.js +index 6d4943412448c9f310f007ca7dab9d04cef90d0d..d62f4aebeb1b23ccc3c3d82aadd67075c6507c0e 100644 +--- a/src/pure.js ++++ b/src/pure.js +@@ -3,7 +3,7 @@ import { + getQueriesForElement, + prettyDOM + } from '@testing-library/dom' +-import { tick } from 'svelte' ++import { tick, mount, unmount } from 'svelte' + + const containerCache = new Set() + const componentCache = new Set() +@@ -54,40 +54,34 @@ const render = ( + return { props: options } + } + +- let component = new ComponentConstructor({ ++ let component = mount(ComponentConstructor, { + target, +- ...checkProps(options) ++ ...checkProps(options), ++ ondestroy: () => componentCache.delete(component) + }) + + containerCache.add({ container, target, component }) + componentCache.add(component) + +- component.$$.on_destroy.push(() => { +- componentCache.delete(component) +- }) +- + return { + container, + component, + debug: (el = container) => console.log(prettyDOM(el)), + rerender: (options) => { +- if (componentCache.has(component)) component.$destroy() ++ if (componentCache.has(component)) unmount(component) + + // eslint-disable-next-line no-new + component = new ComponentConstructor({ + target, +- ...checkProps(options) ++ ...checkProps(options), ++ ondestroy: () => componentCache.delete(component) + }) + + containerCache.add({ container, target, component }) + componentCache.add(component) +- +- component.$$.on_destroy.push(() => { +- componentCache.delete(component) +- }) + }, + unmount: () => { +- if (componentCache.has(component)) component.$destroy() ++ if (componentCache.has(component)) unmount(component) + }, + ...getQueriesForElement(container, queries) + } +@@ -96,7 +90,7 @@ const render = ( + const cleanupAtContainer = (cached) => { + const { target, component } = cached + +- if (componentCache.has(component)) component.$destroy() ++ if (componentCache.has(component)) unmount(component) + + if (target.parentNode === document.body) { + document.body.removeChild(target) +@@ -109,9 +103,10 @@ const cleanup = () => { + Array.from(containerCache.keys()).forEach(cleanupAtContainer) + } + +-const act = async (fn) => { +- if (fn) { +- await fn() ++const act = (fn) => { ++ const value = fn && fn() ++ if (value !== undefined && typeof value.then === 'function') { ++ return value.then(() => tick()) + } + return tick() + } diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json index 2dc509c9a535..e1148efa3066 100644 --- a/code/addons/a11y/package.json +++ b/code/addons/a11y/package.json @@ -32,8 +32,12 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", - "./preview": "./dist/preview.js", "./register": "./dist/manager.js", "./package.json": "./package.json" }, diff --git a/code/addons/a11y/src/components/Report/index.tsx b/code/addons/a11y/src/components/Report/index.tsx index d231cc4cf0e2..83bcb1705d32 100644 --- a/code/addons/a11y/src/components/Report/index.tsx +++ b/code/addons/a11y/src/components/Report/index.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import React, { Fragment } from 'react'; -import { Placeholder } from '@storybook/components'; +import { EmptyTabContent } from '@storybook/components'; import type { Result } from 'axe-core'; import { Item } from './Item'; @@ -18,7 +18,7 @@ export const Report: FC = ({ items, empty, type }) => ( {items && items.length ? ( items.map((item) => ) ) : ( - {empty} + )} ); diff --git a/code/addons/actions/package.json b/code/addons/actions/package.json index 3daab0e6523b..2e3772547dfd 100644 --- a/code/addons/actions/package.json +++ b/code/addons/actions/package.json @@ -33,8 +33,12 @@ "require": "./dist/decorator.js", "import": "./dist/decorator.mjs" }, + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", - "./preview": "./dist/preview.js", "./register.js": "./dist/manager.js", "./package.json": "./package.json" }, diff --git a/code/addons/backgrounds/package.json b/code/addons/backgrounds/package.json index e1628ec59e9c..ab4c5ad3051c 100644 --- a/code/addons/backgrounds/package.json +++ b/code/addons/backgrounds/package.json @@ -32,8 +32,12 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", - "./preview": "./dist/preview.js", "./register": "./dist/manager.js", "./package.json": "./package.json" }, diff --git a/code/addons/essentials/package.json b/code/addons/essentials/package.json index bd5e7c1d3042..f2122af1de0d 100644 --- a/code/addons/essentials/package.json +++ b/code/addons/essentials/package.json @@ -28,22 +28,50 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, - "./actions/preview": "./dist/actions/preview.js", + "./actions/preview": { + "types": "./dist/actions/preview.d.ts", + "import": "./dist/actions/preview.mjs", + "require": "./dist/actions/preview.js" + }, "./actions/manager": "./dist/actions/manager.js", - "./backgrounds/preview": "./dist/backgrounds/preview.js", + "./backgrounds/preview": { + "types": "./dist/backgrounds/preview.d.ts", + "import": "./dist/backgrounds/preview.mjs", + "require": "./dist/backgrounds/preview.js" + }, "./backgrounds/manager": "./dist/backgrounds/manager.js", "./controls/manager": "./dist/controls/manager.js", - "./docs/preview": "./dist/docs/preview.js", + "./docs/preview": { + "types": "./dist/docs/preview.d.ts", + "import": "./dist/docs/preview.mjs", + "require": "./dist/docs/preview.js" + }, "./docs/preset": "./dist/docs/preset.js", "./docs/mdx-react-shim": "./dist/docs/mdx-react-shim.js", - "./highlight/preview": "./dist/highlight/preview.js", - "./measure/preview": "./dist/measure/preview.js", + "./highlight/preview": { + "types": "./dist/highlight/preview.d.ts", + "import": "./dist/highlight/preview.mjs", + "require": "./dist/highlight/preview.js" + }, + "./measure/preview": { + "types": "./dist/measure/preview.d.ts", + "import": "./dist/measure/preview.mjs", + "require": "./dist/measure/preview.js" + }, "./measure/manager": "./dist/measure/manager.js", - "./outline/preview": "./dist/outline/preview.js", + "./outline/preview": { + "types": "./dist/outline/preview.d.ts", + "import": "./dist/outline/preview.mjs", + "require": "./dist/outline/preview.js" + }, "./outline/manager": "./dist/outline/manager.js", "./toolbars/manager": "./dist/toolbars/manager.js", "./viewport/manager": "./dist/viewport/manager.js", - "./viewport/preview": "./dist/viewport/preview.js", + "./viewport/preview": { + "types": "./dist/viewport/preview.d.ts", + "import": "./dist/viewport/preview.mjs", + "require": "./dist/viewport/preview.js" + }, "./package.json": "./package.json" }, "main": "dist/index.js", diff --git a/code/addons/essentials/src/measure/preview.ts b/code/addons/essentials/src/measure/preview.ts index 647ef4345a6d..c34063ac4ca4 100644 --- a/code/addons/essentials/src/measure/preview.ts +++ b/code/addons/essentials/src/measure/preview.ts @@ -1,2 +1 @@ -// @ts-expect-error (no types needed for this) export * from '@storybook/addon-measure/preview'; diff --git a/code/addons/highlight/package.json b/code/addons/highlight/package.json index 4a5d8848e411..6e0383f755a3 100644 --- a/code/addons/highlight/package.json +++ b/code/addons/highlight/package.json @@ -30,7 +30,11 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, - "./preview": "./dist/preview.js", + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./package.json": "./package.json" }, "main": "dist/index.js", diff --git a/code/addons/highlight/src/preview.ts b/code/addons/highlight/src/preview.ts index 1948f7b39d97..794417ef0d9d 100644 --- a/code/addons/highlight/src/preview.ts +++ b/code/addons/highlight/src/preview.ts @@ -8,18 +8,12 @@ const { document } = global; type OutlineStyle = 'dotted' | 'dashed' | 'solid' | 'double'; -export const highlightStyle = (color = '#FF4785', style: OutlineStyle = 'dashed') => ` +const highlightStyle = (color = '#FF4785', style: OutlineStyle = 'dashed') => ` outline: 2px ${style} ${color}; outline-offset: 2px; box-shadow: 0 0 0 6px rgba(255,255,255,0.6); `; -export const highlightObject = (color: string) => ({ - outline: `2px dashed ${color}`, - outlineOffset: 2, - boxShadow: '0 0 0 6px rgba(255,255,255,0.6)', -}); - interface HighlightInfo { /** html selector of the element */ elements: string[]; diff --git a/code/addons/interactions/package.json b/code/addons/interactions/package.json index f39a3065e103..9b6c22d96f90 100644 --- a/code/addons/interactions/package.json +++ b/code/addons/interactions/package.json @@ -28,8 +28,12 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", - "./preview": "./dist/preview.js", "./preset": "./dist/preset.js", "./register.js": "./dist/manager.js", "./package.json": "./package.json" diff --git a/code/addons/interactions/src/components/EmptyState.tsx b/code/addons/interactions/src/components/EmptyState.tsx index d4fa62c144a4..0cb5ecba69e2 100644 --- a/code/addons/interactions/src/components/EmptyState.tsx +++ b/code/addons/interactions/src/components/EmptyState.tsx @@ -1,43 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Link } from '@storybook/components'; +import { Link, EmptyTabContent } from '@storybook/components'; import { DocumentIcon, VideoIcon } from '@storybook/icons'; -import { Consumer, useStorybookApi } from '@storybook/manager-api'; +import { useStorybookApi } from '@storybook/manager-api'; import { styled } from '@storybook/theming'; import { DOCUMENTATION_LINK, TUTORIAL_VIDEO_LINK } from '../constants'; -const Wrapper = styled.div(({ theme }) => ({ - height: '100%', - display: 'flex', - padding: 0, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'column', - gap: 15, - background: theme.background.content, -})); - -const Content = styled.div({ - display: 'flex', - flexDirection: 'column', - gap: 4, - maxWidth: 415, -}); - -const Title = styled.div(({ theme }) => ({ - fontWeight: theme.typography.weight.bold, - fontSize: theme.typography.size.s2 - 1, - textAlign: 'center', - color: theme.textColor, -})); - -const Description = styled.div(({ theme }) => ({ - fontWeight: theme.typography.weight.regular, - fontSize: theme.typography.size.s2 - 1, - textAlign: 'center', - color: theme.textMutedColor, -})); - const Links = styled.div(({ theme }) => ({ display: 'flex', fontSize: theme.typography.size.s2 - 1, @@ -73,27 +41,25 @@ export const Empty = () => { if (isLoading) return null; return ( - - - Interaction testing - + Interaction tests allow you to verify the functional aspects of UIs. Write a play function for your story and you'll see it run here. - - - - - Watch 8m video - - - - {({ state }) => ( - - Read docs - - )} - - - + + } + footer={ + + + Watch 8m video + + + + Read docs + + + } + /> ); }; diff --git a/code/addons/links/package.json b/code/addons/links/package.json index 4effdb357a7c..d5269011dc6b 100644 --- a/code/addons/links/package.json +++ b/code/addons/links/package.json @@ -33,8 +33,12 @@ "require": "./dist/react/index.js", "import": "./dist/react/index.mjs" }, + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", - "./preview": "./dist/preview.js", "./register": "./dist/manager.js", "./package.json": "./package.json" }, diff --git a/code/addons/measure/package.json b/code/addons/measure/package.json index 71616e30727c..2e5c905ba64b 100644 --- a/code/addons/measure/package.json +++ b/code/addons/measure/package.json @@ -31,8 +31,12 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", - "./preview": "./dist/preview.js", "./register": "./dist/manager.js", "./package.json": "./package.json" }, diff --git a/code/addons/outline/package.json b/code/addons/outline/package.json index 96f83f0b4b15..07fb23746bce 100644 --- a/code/addons/outline/package.json +++ b/code/addons/outline/package.json @@ -34,8 +34,12 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", - "./preview": "./dist/preview.js", "./register": "./dist/manager.js", "./package.json": "./package.json" }, diff --git a/code/addons/themes/package.json b/code/addons/themes/package.json index 2724c1b9450b..a55582e17c84 100644 --- a/code/addons/themes/package.json +++ b/code/addons/themes/package.json @@ -33,8 +33,12 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", - "./preview": "./dist/preview.js", "./package.json": "./package.json", "./postinstall": "./postinstall.js" }, diff --git a/code/addons/viewport/package.json b/code/addons/viewport/package.json index eecdb09fb296..45bf6471994f 100644 --- a/code/addons/viewport/package.json +++ b/code/addons/viewport/package.json @@ -29,7 +29,11 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, - "./preview": "./dist/preview.js", + "./preview": { + "types": "./dist/preview.d.ts", + "require": "./dist/preview.js", + "import": "./dist/preview.mjs" + }, "./manager": "./dist/manager.js", "./package.json": "./package.json" }, diff --git a/code/e2e-tests/tags.spec.ts b/code/e2e-tests/tags.spec.ts index 37fb76fb814c..8302a9a6a338 100644 --- a/code/e2e-tests/tags.spec.ts +++ b/code/e2e-tests/tags.spec.ts @@ -9,7 +9,9 @@ test.describe('tags', () => { await new SbPage(page).waitUntilLoaded(); }); - test('should correctly filter dev-only, docs-only, test-only stories', async ({ page }) => { + test('@flaky: should correctly filter dev-only, docs-only, test-only stories', async ({ + page, + }) => { const sbPage = new SbPage(page); await sbPage.navigateToStory('lib/preview-api/tags', 'docs'); diff --git a/code/frameworks/nextjs/src/index.ts b/code/frameworks/nextjs/src/index.ts index fcb073fefcd6..a904f93ec89d 100644 --- a/code/frameworks/nextjs/src/index.ts +++ b/code/frameworks/nextjs/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './portable-stories'; diff --git a/code/frameworks/nextjs/src/portable-stories.ts b/code/frameworks/nextjs/src/portable-stories.ts new file mode 100644 index 000000000000..01948d524c2c --- /dev/null +++ b/code/frameworks/nextjs/src/portable-stories.ts @@ -0,0 +1,126 @@ +import { + composeStory as originalComposeStory, + composeStories as originalComposeStories, + setProjectAnnotations as originalSetProjectAnnotations, + composeConfigs, +} from '@storybook/preview-api'; +import type { + Args, + ProjectAnnotations, + StoryAnnotationsOrFn, + Store_CSFExports, + StoriesWithPartialProps, +} from '@storybook/types'; + +// ! ATTENTION: This needs to be a relative import so it gets prebundled. This is to avoid ESM issues in Nextjs + Jest setups +import { INTERNAL_DEFAULT_PROJECT_ANNOTATIONS as reactAnnotations } from '../../../renderers/react/src/portable-stories'; +import * as nextJsAnnotations from './preview'; + +import type { ReactRenderer, Meta } from '@storybook/react'; + +/** Function that sets the globalConfig of your storybook. The global config is the preview module of your .storybook folder. + * + * It should be run a single time, so that your global config (e.g. decorators) is applied to your stories when using `composeStories` or `composeStory`. + * + * Example: + *```jsx + * // setup.js (for jest) + * import { setProjectAnnotations } from '@storybook/nextjs'; + * import projectAnnotations from './.storybook/preview'; + * + * setProjectAnnotations(projectAnnotations); + *``` + * + * @param projectAnnotations - e.g. (import projectAnnotations from '../.storybook/preview') + */ +export function setProjectAnnotations( + projectAnnotations: ProjectAnnotations | ProjectAnnotations[] +) { + originalSetProjectAnnotations(projectAnnotations); +} + +// This will not be necessary once we have auto preset loading +const defaultProjectAnnotations: ProjectAnnotations = composeConfigs([ + reactAnnotations, + nextJsAnnotations, +]); + +/** + * Function that will receive a story along with meta (e.g. a default export from a .stories file) + * and optionally projectAnnotations e.g. (import * from '../.storybook/preview) + * and will return a composed component that has all args/parameters/decorators/etc combined and applied to it. + * + * + * It's very useful for reusing a story in scenarios outside of Storybook like unit testing. + * + * Example: + *```jsx + * import { render } from '@testing-library/react'; + * import { composeStory } from '@storybook/nextjs'; + * import Meta, { Primary as PrimaryStory } from './Button.stories'; + * + * const Primary = composeStory(PrimaryStory, Meta); + * + * test('renders primary button with Hello World', () => { + * const { getByText } = render(Hello world); + * expect(getByText(/Hello world/i)).not.toBeNull(); + * }); + *``` + * + * @param story + * @param componentAnnotations - e.g. (import Meta from './Button.stories') + * @param [projectAnnotations] - e.g. (import * as projectAnnotations from '../.storybook/preview') this can be applied automatically if you use `setProjectAnnotations` in your setup files. + * @param [exportsName] - in case your story does not contain a name and you want it to have a name. + */ +export function composeStory( + story: StoryAnnotationsOrFn, + componentAnnotations: Meta, + projectAnnotations?: ProjectAnnotations, + exportsName?: string +) { + return originalComposeStory( + story as StoryAnnotationsOrFn, + componentAnnotations, + projectAnnotations, + defaultProjectAnnotations, + exportsName + ); +} + +/** + * Function that will receive a stories import (e.g. `import * as stories from './Button.stories'`) + * and optionally projectAnnotations (e.g. `import * from '../.storybook/preview`) + * and will return an object containing all the stories passed, but now as a composed component that has all args/parameters/decorators/etc combined and applied to it. + * + * + * It's very useful for reusing stories in scenarios outside of Storybook like unit testing. + * + * Example: + *```jsx + * import { render } from '@testing-library/react'; + * import { composeStories } from '@storybook/nextjs'; + * import * as stories from './Button.stories'; + * + * const { Primary, Secondary } = composeStories(stories); + * + * test('renders primary button with Hello World', () => { + * const { getByText } = render(Hello world); + * expect(getByText(/Hello world/i)).not.toBeNull(); + * }); + *``` + * + * @param csfExports - e.g. (import * as stories from './Button.stories') + * @param [projectAnnotations] - e.g. (import * as projectAnnotations from '../.storybook/preview') this can be applied automatically if you use `setProjectAnnotations` in your setup files. + */ +export function composeStories>( + csfExports: TModule, + projectAnnotations?: ProjectAnnotations +) { + // @ts-expect-error (Converted from ts-ignore) + const composedStories = originalComposeStories(csfExports, projectAnnotations, composeStory); + + return composedStories as unknown as Omit< + StoriesWithPartialProps, + keyof Store_CSFExports + >; +} diff --git a/code/lib/cli/src/initiate.ts b/code/lib/cli/src/initiate.ts index 730e4ce8dead..4d83666780b2 100644 --- a/code/lib/cli/src/initiate.ts +++ b/code/lib/cli/src/initiate.ts @@ -238,7 +238,7 @@ export async function doInitiate(options: CommandOptions): Promise< > { const { packageManager: pkgMgr } = options; - const packageManager = JsPackageManagerFactory.getPackageManager({ + let packageManager = JsPackageManagerFactory.getPackageManager({ force: pkgMgr, }); @@ -272,6 +272,13 @@ export async function doInitiate(options: CommandOptions): Promise< // Check if the current directory is empty. if (options.force !== true && currentDirectoryIsEmpty(packageManager.type)) { + // Initializing Storybook in an empty directory with yarn1 + // will very likely fail due to different kind of hoisting issues + // which doesn't get fixed anymore in yarn1. + // We will fallback to npm in this case. + if (packageManager.type === 'yarn1') { + packageManager = JsPackageManagerFactory.getPackageManager({ force: 'npm' }); + } // Prompt the user to create a new project from our list. await scaffoldNewProject(packageManager.type, options); diff --git a/code/lib/instrumenter/src/instrumenter.ts b/code/lib/instrumenter/src/instrumenter.ts index 3eeb6ea86ed8..57c812109e89 100644 --- a/code/lib/instrumenter/src/instrumenter.ts +++ b/code/lib/instrumenter/src/instrumenter.ts @@ -102,7 +102,7 @@ export class Instrumenter { // Restore state from the parent window in case the iframe was reloaded. // @ts-expect-error (TS doesn't know about this global variable) - this.state = global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {}; + this.state = global.window?.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {}; // When called from `start`, isDebugging will be true. const resetState = ({ @@ -242,8 +242,10 @@ export class Instrumenter { const patch = typeof update === 'function' ? update(state) : update; this.state = { ...this.state, [storyId]: { ...state, ...patch } }; // Track state on the parent window so we can reload the iframe without losing state. - // @ts-expect-error (TS doesn't know about this global variable) - global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state; + if (global.window?.parent) { + // @ts-expect-error fix this later in d.ts file + global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state; + } } cleanup() { @@ -259,8 +261,10 @@ export class Instrumenter { ); const payload: SyncPayload = { controlStates: controlsDisabled, logItems: [] }; this.channel.emit(EVENTS.SYNC, payload); - // @ts-expect-error (TS doesn't know about this global variable) - global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state; + if (global.window?.parent) { + // @ts-expect-error fix this later in d.ts file + global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state; + } } getLog(storyId: string): LogItem[] { @@ -426,7 +430,7 @@ export class Instrumenter { const { flags, source } = value; return { __regexp__: { flags, source } }; } - if (value instanceof global.window.HTMLElement) { + if (value instanceof global.window?.HTMLElement) { const { prefix, localName, id, classList, innerText } = value; const classNames = Array.from(classList); return { __element__: { prefix, localName, id, classNames, innerText } }; @@ -640,23 +644,23 @@ export function instrument>( let forceInstrument = false; let skipInstrument = false; - if (global.window.location?.search?.includes('instrument=true')) { + if (global.window?.location?.search?.includes('instrument=true')) { forceInstrument = true; - } else if (global.window.location?.search?.includes('instrument=false')) { + } else if (global.window?.location?.search?.includes('instrument=false')) { skipInstrument = true; } // Don't do any instrumentation if not loaded in an iframe unless it's forced - instrumentation can also be skipped. - if ((global.window.parent === global.window && !forceInstrument) || skipInstrument) { + if ((global.window?.parent === global.window && !forceInstrument) || skipInstrument) { return obj; } // Only create an instance if we don't have one (singleton) yet. - if (!global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__) { + if (global.window && !global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__) { global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__ = new Instrumenter(); } - const instrumenter: Instrumenter = global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__; + const instrumenter: Instrumenter = global.window?.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__; return instrumenter.instrument(obj, options); } catch (e) { // Access to the parent window might fail due to CORS restrictions. diff --git a/code/lib/preview-api/src/index.ts b/code/lib/preview-api/src/index.ts index e47cdaa0a0dd..779cfeba1557 100644 --- a/code/lib/preview-api/src/index.ts +++ b/code/lib/preview-api/src/index.ts @@ -62,6 +62,8 @@ export { sortStoriesV7, } from './store'; +export { createPlaywrightTest } from './modules/store/csf/portable-stories'; + export type { PropDescriptor } from './store'; /** diff --git a/code/lib/preview-api/src/modules/store/csf/composeConfigs.test.ts b/code/lib/preview-api/src/modules/store/csf/composeConfigs.test.ts index 238885f44ba3..147038a5a8d2 100644 --- a/code/lib/preview-api/src/modules/store/csf/composeConfigs.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/composeConfigs.test.ts @@ -176,6 +176,34 @@ describe('composeConfigs', () => { }); }); + it('allows single array to be written without array', () => { + expect( + composeConfigs([ + { + argsEnhancers: ['1', '2'], + argTypesEnhancers: ['1', '2'], + loaders: '1', + }, + { + argsEnhancers: '3', + argTypesEnhancers: '3', + loaders: ['2', '3'], + }, + ]) + ).toEqual({ + parameters: {}, + decorators: [], + args: {}, + argsEnhancers: ['1', '2', '3'], + argTypes: {}, + argTypesEnhancers: ['1', '2', '3'], + globals: {}, + globalTypes: {}, + loaders: ['1', '2', '3'], + runStep: expect.any(Function), + }); + }); + it('combines decorators in reverse file order', () => { expect( composeConfigs([ diff --git a/code/lib/preview-api/src/modules/store/csf/composeConfigs.ts b/code/lib/preview-api/src/modules/store/csf/composeConfigs.ts index 862f8cbcd501..e5785a6a3f01 100644 --- a/code/lib/preview-api/src/modules/store/csf/composeConfigs.ts +++ b/code/lib/preview-api/src/modules/store/csf/composeConfigs.ts @@ -3,6 +3,7 @@ import { global } from '@storybook/global'; import { combineParameters } from '../parameters'; import { composeStepRunners } from './stepRunners'; +import { normalizeArrays } from './normalizeArrays'; export function getField( moduleExportList: ModuleExports[], @@ -16,10 +17,10 @@ export function getArrayField( field: string, options: { reverseFileOrder?: boolean } = {} ): TFieldType[] { - return getField(moduleExportList, field).reduce( - (a: any, b: any) => (options.reverseFileOrder ? [...b, ...a] : [...a, ...b]), - [] - ); + return getField(moduleExportList, field).reduce((prev: any, cur: any) => { + const normalized = normalizeArrays(cur); + return options.reverseFileOrder ? [...normalized, ...prev] : [...prev, ...normalized]; + }, []); } export function getObjectField>( diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index 47465eacc8e5..57e8fcda9a2b 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -1,5 +1,7 @@ +/* eslint-disable no-underscore-dangle */ /* eslint-disable @typescript-eslint/naming-convention */ import { isExportStory } from '@storybook/csf'; +import dedent from 'ts-dedent'; import type { Renderer, Args, @@ -161,3 +163,68 @@ export function composeStories( return composedStories; } + +type WrappedStoryRef = { __pw_type: 'jsx' | 'importRef' }; +type UnwrappedJSXStoryRef = { + __pw_type: 'jsx'; + type: ComposedStoryFn; +}; +type UnwrappedImportStoryRef = ComposedStoryFn; + +declare global { + function __pwUnwrapObject( + storyRef: WrappedStoryRef + ): Promise; +} + +export function createPlaywrightTest( + baseTest: TFixture +): TFixture { + return baseTest.extend({ + mount: async ({ mount, page }: any, use: any) => { + await use(async (storyRef: WrappedStoryRef, ...restArgs: any) => { + // Playwright CT deals with JSX import references differently than normal imports + // and we can currently only handle JSX import references + if ( + !('__pw_type' in storyRef) || + ('__pw_type' in storyRef && storyRef.__pw_type !== 'jsx') + ) { + // eslint-disable-next-line local-rules/no-uncategorized-errors + throw new Error(dedent` + Portable stories in Playwright CT only work when referencing JSX elements. + Please use JSX format for your components such as: + + instead of: + await mount(MyComponent, { props: { foo: 'bar' } }) + + do: + await mount() + + More info: https://storybook.js.org/docs/api/portable-stories-playwright + `); + } + + await page.evaluate(async (wrappedStoryRef: WrappedStoryRef) => { + const unwrappedStoryRef = await globalThis.__pwUnwrapObject?.(wrappedStoryRef); + const story = + '__pw_type' in unwrappedStoryRef ? unwrappedStoryRef.type : unwrappedStoryRef; + return story?.load?.(); + }, storyRef); + + // mount the story + const mountResult = await mount(storyRef, ...restArgs); + + // play the story in the browser + await page.evaluate(async (wrappedStoryRef: WrappedStoryRef) => { + const unwrappedStoryRef = await globalThis.__pwUnwrapObject?.(wrappedStoryRef); + const story = + '__pw_type' in unwrappedStoryRef ? unwrappedStoryRef.type : unwrappedStoryRef; + const canvasElement = document.querySelector('#root'); + return story?.play?.({ canvasElement }); + }, storyRef); + + return mountResult; + }); + }, + }); +} diff --git a/code/lib/theming/src/themes/dark.ts b/code/lib/theming/src/themes/dark.ts index 4cb8f19f29bd..173e735ef366 100644 --- a/code/lib/theming/src/themes/dark.ts +++ b/code/lib/theming/src/themes/dark.ts @@ -25,7 +25,7 @@ const theme: ThemeVars = { textMutedColor: '#798186', // Toolbar default and active colors - barTextColor: '#798186', + barTextColor: color.mediumdark, barHoverColor: color.secondary, barSelectedColor: color.secondary, barBg: '#292C2E', diff --git a/code/package.json b/code/package.json index b7f7f7d2bf3d..b4361f2f68cc 100644 --- a/code/package.json +++ b/code/package.json @@ -291,5 +291,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "8.1.0-alpha.3" } diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index a73eb90118ce..9a46313c53ff 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -26,6 +26,12 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./experimental-playwright": { + "types": "./dist/playwright.d.ts", + "node": "./dist/playwright.js", + "require": "./dist/playwright.js", + "import": "./dist/playwright.mjs" + }, "./preset": "./preset.js", "./dist/entry-preview.mjs": "./dist/entry-preview.mjs", "./dist/entry-preview-docs.mjs": "./dist/entry-preview-docs.mjs", @@ -101,7 +107,8 @@ "./src/preset.ts", "./src/entry-preview.ts", "./src/entry-preview-docs.ts", - "./src/entry-preview-rsc.tsx" + "./src/entry-preview-rsc.tsx", + "./src/playwright.ts" ], "platform": "browser" }, diff --git a/code/renderers/react/src/__test__/Button.stories.tsx b/code/renderers/react/src/__test__/Button.stories.tsx index 277f92ddde1f..fc78c1f27d63 100644 --- a/code/renderers/react/src/__test__/Button.stories.tsx +++ b/code/renderers/react/src/__test__/Button.stories.tsx @@ -4,6 +4,8 @@ import type { StoryFn as CSF2Story, StoryObj as CSF3Story, Meta } from '..'; import type { ButtonProps } from './Button'; import { Button } from './Button'; +import type { HandlerFunction } from '@storybook/addon-actions'; +import { action } from '@storybook/addon-actions'; const meta = { title: 'Example/Button', @@ -124,3 +126,35 @@ export const LoaderStory: CSF3Story<{ mockFn: (val: string) => string }> = { expect(mockFn).toHaveBeenCalledWith('render'); }, }; + +export const WithActionArg: CSF3Story<{ someActionArg: HandlerFunction }> = { + args: { + someActionArg: action('some-action-arg'), + }, + render: (args) => { + args.someActionArg('in render'); + return ( + diff --git a/code/renderers/svelte/src/__test__/composeStories/CustomRenderComponent.svelte b/code/renderers/svelte/src/__test__/composeStories/CustomRenderComponent.svelte new file mode 100644 index 000000000000..2619c6cfc849 --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/CustomRenderComponent.svelte @@ -0,0 +1,11 @@ + + +
+

I am a custom render function

+
diff --git a/code/renderers/svelte/src/__test__/composeStories/InputFilledStoryComponent.svelte b/code/renderers/svelte/src/__test__/composeStories/InputFilledStoryComponent.svelte new file mode 100644 index 000000000000..e01457d58a17 --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/InputFilledStoryComponent.svelte @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/renderers/svelte/src/__test__/composeStories/LoaderStoryComponent.svelte b/code/renderers/svelte/src/__test__/composeStories/LoaderStoryComponent.svelte new file mode 100644 index 000000000000..5295f9ea991e --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/LoaderStoryComponent.svelte @@ -0,0 +1,15 @@ + + +
+
{loaded.value}
+
{String(data)}
+
\ No newline at end of file diff --git a/code/renderers/svelte/src/__test__/composeStories/StoryWithLocaleComponent.svelte b/code/renderers/svelte/src/__test__/composeStories/StoryWithLocaleComponent.svelte new file mode 100644 index 000000000000..fc71b19231e8 --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/StoryWithLocaleComponent.svelte @@ -0,0 +1,11 @@ + + +
+

locale: {locale}

+
\ No newline at end of file diff --git a/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories.test.ts.snap b/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories.test.ts.snap new file mode 100644 index 000000000000..3143b671b0c5 --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories.test.ts.snap @@ -0,0 +1,180 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Renders CSF2Secondary story 1`] = ` + +
+ + + + + +
+ +`; + +exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = ` + +
+
+ + + +
+ + + + + + +
+ +`; + +exports[`Renders CSF3Button story 1`] = ` + +
+ + + + + +
+ +`; + +exports[`Renders CSF3ButtonWithRender story 1`] = ` + +
+
+

+ I am a custom render function +

+ + + +
+ + + + +
+ +`; + +exports[`Renders CSF3InputFieldFilled story 1`] = ` + +
+ + + + + +
+ +`; + +exports[`Renders CSF3Primary story 1`] = ` + +
+ + + + + +
+ +`; + +exports[`Renders LoaderStory story 1`] = ` + +
+
+
+ loaded data +
+ +
+ mockFn return value +
+
+ + + + +
+ +`; + +exports[`Renders NewStory story 1`] = ` + +
+
+ + + +
+ + + + + + +
+ +`; diff --git a/code/renderers/svelte/src/__test__/composeStories/portable-stories.test.ts b/code/renderers/svelte/src/__test__/composeStories/portable-stories.test.ts new file mode 100644 index 000000000000..f678ff522142 --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/portable-stories.test.ts @@ -0,0 +1,187 @@ +/// ; +import { it, expect, vi, describe, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +// import '@testing-library/svelte/vitest'; +import { expectTypeOf } from 'expect-type'; +import type { Meta } from '../..'; +import * as stories from './Button.stories'; +// import type Button from './Button.svelte'; +import type Button from './Button.svelte'; +import { composeStories, composeStory, setProjectAnnotations } from '../../portable-stories'; + +// example with composeStories, returns an object with all stories composed with args/decorators +const { CSF3Primary, LoaderStory } = composeStories(stories); + +// example with composeStory, returns a single story composed with args/decorators +const Secondary = composeStory(stories.CSF2Secondary, stories.default); +describe('renders', () => { + afterEach(() => { + cleanup(); + }); + + it('renders primary button with custom props via composeStory', () => { + // We unfortunately can't do the following: + // render(CSF3Primary.Component, { ...CSF3Primary.props, label: 'Hello world' }); + // Because the props will be passed to the first decorator of the story instead + // of the actual component of the story. This is because of our current PreviewRender structure + + const Composed = composeStory( + { + ...stories.CSF3Primary, + args: { ...stories.CSF3Primary.args, label: 'Hello world' }, + }, + stories.default + ); + + render(Composed.Component, Composed.props); + const buttonElement = screen.getByText(/Hello world/i); + expect(buttonElement).not.toBeNull(); + }); + + it('reuses args from composed story', () => { + render(Secondary.Component, Secondary.props); + const buttonElement = screen.getByRole('button'); + expect(buttonElement.textContent).toMatch(Secondary.args.label); + }); + + // TODO TypeError: component.$on is not a function - Potentially only works in Svelte 4 + it.skip('onclick handler is called', async () => { + const onClickSpy = vi.fn(); + const { component } = render(Secondary.Component, { ...Secondary.props, onClick: onClickSpy }); + component.$on('click', onClickSpy); + const buttonElement = screen.getByRole('button'); + buttonElement.click(); + expect(onClickSpy).toHaveBeenCalled(); + }); + + it('reuses args from composeStories', () => { + const { getByText } = render(CSF3Primary.Component, CSF3Primary.props); + const buttonElement = getByText(/foo/i); + expect(buttonElement).not.toBeNull(); + }); + + it('should call and compose loaders data', async () => { + await LoaderStory.load(); + const { getByTestId } = render(LoaderStory.Component, LoaderStory.props); + expect(getByTestId('spy-data').textContent).toEqual('mockFn return value'); + expect(getByTestId('loaded-data').textContent).toEqual('loaded data'); + // spy assertions happen in the play function and should work + await LoaderStory.play!(); + }); +}); + +describe('projectAnnotations', () => { + afterEach(() => { + cleanup(); + }); + + it('renders with default projectAnnotations', () => { + setProjectAnnotations([ + { + parameters: { injected: true }, + globalTypes: { + locale: { defaultValue: 'en' }, + }, + }, + ]); + const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default); + const { getByText } = render(WithEnglishText.Component, WithEnglishText.props); + const buttonElement = getByText('Hello!'); + expect(buttonElement).not.toBeNull(); + expect(WithEnglishText.parameters?.injected).toBe(true); + }); + + it('renders with custom projectAnnotations via composeStory params', () => { + const WithPortugueseText = composeStory(stories.CSF2StoryWithLocale, stories.default, { + globals: { locale: 'pt' }, + }); + const { getByText } = render(WithPortugueseText.Component, WithPortugueseText.props); + const buttonElement = getByText('Olá!'); + expect(buttonElement).not.toBeNull(); + }); +}); + +describe('CSF3', () => { + afterEach(() => { + cleanup(); + }); + + it('renders with inferred globalRender', () => { + const Primary = composeStory(stories.CSF3Button, stories.default); + render(Primary.Component, Primary.props); + const buttonElement = screen.getByText(/foo/i); + expect(buttonElement).not.toBeNull(); + }); + + it('renders with custom render function', () => { + const Primary = composeStory(stories.CSF3ButtonWithRender, stories.default); + + render(Primary.Component, Primary.props); + expect(screen.getByTestId('custom-render')).not.toBeNull(); + }); + + it('renders with play function without canvas element', async () => { + const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default); + + render(CSF3InputFieldFilled.Component, CSF3InputFieldFilled.props); + + await CSF3InputFieldFilled.play!(); + + const input = screen.getByTestId('input') as HTMLInputElement; + expect(input.value).toEqual('Hello world!'); + }); + + it('renders with play function with canvas element', async () => { + const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default); + + const { container } = render(CSF3InputFieldFilled.Component, CSF3InputFieldFilled.props); + + await CSF3InputFieldFilled.play!({ canvasElement: container }); + + const input = screen.getByTestId('input') as HTMLInputElement; + expect(input.value).toEqual('Hello world!'); + }); +}); + +describe('ComposeStories types', () => { + // this file tests Typescript types that's why there are no assertions + it('Should support typescript operators', () => { + type ComposeStoriesParam = Parameters[0]; + + expectTypeOf({ + ...stories, + default: stories.default as Meta, + }).toMatchTypeOf(); + + expectTypeOf({ + ...stories, + + /** + * Types of property 'argTypes' are incompatible. + * Type '{ backgroundColor: { control: string; }; size: { control: { type: string; }; options: string[]; }; }' + * has no properties in common with type 'Partial>'. + */ + // @ts-expect-error fix this later + default: stories.default satisfies Meta, + }).toMatchTypeOf(); + }); +}); + +// Batch snapshot testing +const testCases = Object.values(composeStories(stories)).map( + (Story) => [Story.storyName, Story] as [string, typeof Story] +); +it.each(testCases)('Renders %s story', async (_storyName, Story) => { + cleanup(); + + if (_storyName === 'CSF2StoryWithLocale') { + return; + } + + await Story.load(); + + const { container } = await render(Story.Component, Story.props); + + await Story.play?.({ canvasElement: container }); + expect(container).toMatchSnapshot(); +}); diff --git a/code/renderers/svelte/src/components/AddStorybookIdDecorator.svelte b/code/renderers/svelte/src/components/AddStorybookIdDecorator.svelte new file mode 100644 index 000000000000..3c59f352273c --- /dev/null +++ b/code/renderers/svelte/src/components/AddStorybookIdDecorator.svelte @@ -0,0 +1,7 @@ + + +
+ +
\ No newline at end of file diff --git a/code/renderers/svelte/src/globals.ts b/code/renderers/svelte/src/globals.ts index a9adc16b0908..ce0d0501bab2 100644 --- a/code/renderers/svelte/src/globals.ts +++ b/code/renderers/svelte/src/globals.ts @@ -1,5 +1 @@ -import { global } from '@storybook/global'; - -const { window: globalWindow } = global; - -globalWindow.STORYBOOK_ENV = 'svelte'; +globalThis.STORYBOOK_ENV = 'svelte'; diff --git a/code/renderers/svelte/src/index.ts b/code/renderers/svelte/src/index.ts index 145fbf2612ea..258466105826 100644 --- a/code/renderers/svelte/src/index.ts +++ b/code/renderers/svelte/src/index.ts @@ -3,6 +3,7 @@ import './globals'; export * from './public-types'; +export * from './portable-stories'; // optimization: stop HMR propagation in webpack if (typeof module !== 'undefined') module?.hot?.decline(); diff --git a/code/renderers/svelte/src/playwright.ts b/code/renderers/svelte/src/playwright.ts new file mode 100644 index 000000000000..d1538f820e23 --- /dev/null +++ b/code/renderers/svelte/src/playwright.ts @@ -0,0 +1 @@ +export { createPlaywrightTest as createTest } from '@storybook/preview-api'; diff --git a/code/renderers/svelte/src/portable-stories.ts b/code/renderers/svelte/src/portable-stories.ts new file mode 100644 index 000000000000..abf9ca79b2e2 --- /dev/null +++ b/code/renderers/svelte/src/portable-stories.ts @@ -0,0 +1,175 @@ +import { + composeStory as originalComposeStory, + composeStories as originalComposeStories, + setProjectAnnotations as originalSetProjectAnnotations, +} from '@storybook/preview-api'; +import type { + Args, + ProjectAnnotations, + StoryAnnotationsOrFn, + Store_CSFExports, + StoriesWithPartialProps, + ComposedStoryFn, +} from '@storybook/types'; + +import * as svelteProjectAnnotations from './entry-preview'; +import type { Meta } from './public-types'; +import type { SvelteRenderer } from './types'; +import PreviewRender from '@storybook/svelte/internal/PreviewRender.svelte'; +// @ts-expect-error Don't know why TS doesn't pick up the types export here +import { createSvelte5Props } from '@storybook/svelte/internal/createSvelte5Props'; +import { IS_SVELTE_V4 } from './utils'; + +type ComposedStory = ComposedStoryFn & { + Component: typeof PreviewRender; + // these props current refer to the props of PReviewRender, not the user's component's + props: any; +}; + +type MapToComposed = { + [K in keyof TModule]: TModule[K] extends StoryAnnotationsOrFn< + SvelteRenderer, + infer TArgs extends Args + > + ? ComposedStory + : never; +}; + +/** Function that sets the globalConfig of your storybook. The global config is the preview module of your .storybook folder. + * + * It should be run a single time, so that your global config (e.g. decorators) is applied to your stories when using `composeStories` or `composeStory`. + * + * Example: + *```jsx + * // setup.js (for jest) + * import { setProjectAnnotations } from '@storybook/svelte'; + * import projectAnnotations from './.storybook/preview'; + * + * setProjectAnnotations(projectAnnotations); + *``` + * + * @param projectAnnotations - e.g. (import projectAnnotations from '../.storybook/preview') + */ +export function setProjectAnnotations( + projectAnnotations: ProjectAnnotations | ProjectAnnotations[] +) { + originalSetProjectAnnotations(projectAnnotations); +} + +// This will not be necessary once we have auto preset loading +export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations = + svelteProjectAnnotations; + +/** + * Function that will receive a story along with meta (e.g. a default export from a .stories file) + * and optionally projectAnnotations e.g. (import * from '../.storybook/preview) + * and will return a composed component that has all args/parameters/decorators/etc combined and applied to it. + * + * + * It's very useful for reusing a story in scenarios outside of Storybook like unit testing. + * + * Example: + *```jsx + * import { render } from '@testing-library/svelte'; + * import { composeStory } from '@storybook/svelte'; + * import Meta, { Primary as PrimaryStory } from './Button.stories'; + * + * const Primary = composeStory(PrimaryStory, Meta); + * + * test('renders primary button with Hello World', () => { + * const { getByText } = render(Primary, { label: 'Hello world' }); + * expect(getByText(/Hello world/i)).not.toBeNull(); + * }); + *``` + * + * @param story + * @param componentAnnotations - e.g. (import Meta from './Button.stories') + * @param [projectAnnotations] - e.g. (import * as projectAnnotations from '../.storybook/preview') this can be applied automatically if you use `setProjectAnnotations` in your setup files. + * @param [exportsName] - in case your story does not contain a name and you want it to have a name. + */ +export function composeStory( + story: StoryAnnotationsOrFn, + componentAnnotations: Meta, + projectAnnotations?: ProjectAnnotations, + exportsName?: string +) { + const composedStory = originalComposeStory( + story as StoryAnnotationsOrFn, + // @ts-expect-error Fix this later: Type 'Partial<{ [x: string]: any; }>' is not assignable to type 'Partial>' + componentAnnotations, + projectAnnotations, + INTERNAL_DEFAULT_PROJECT_ANNOTATIONS, + exportsName + ); + + let props = { + storyFn: composedStory, + storyContext: { ...composedStory }, + name: composedStory.storyName, + title: composedStory.id, + showError: () => {}, + }; + + // In Svelte >= 5, we make the props reactive + if (!IS_SVELTE_V4) { + props = createSvelte5Props(props); + } + /** TODO: figure out the situation here. + * Currently, we construct props to render the PreviewRender, a "story wrapper" that + * allows to render the story and its decorators correctly. However, the props + * from the user's component can't be overwritten in tests e.g. + * render(Primary.Component, { label: 'Hello world' }) + * + * In fact, the props that the user has access to are the props for PreviewRender, + * which should be an internal detail instead. + * + * Ideally, we should create a Svelte component with pre-configured props, so users + * can do something like: + * render(Primary) instead of render(Primary.Component, Primary.props) + * */ + const renderable = { + Component: PreviewRender, + props, + }; + Object.assign(renderable, composedStory); + + return renderable as ComposedStory; +} + +/** + * Function that will receive a stories import (e.g. `import * as stories from './Button.stories'`) + * and optionally projectAnnotations (e.g. `import * from '../.storybook/preview`) + * and will return an object containing all the stories passed, but now as a composed component that has all args/parameters/decorators/etc combined and applied to it. + * + * + * It's very useful for reusing stories in scenarios outside of Storybook like unit testing. + * + * Example: + *```jsx + * import { render } from '@testing-library/svelte'; + * import { composeStories } from '@storybook/svelte'; + * import * as stories from './Button.stories'; + * + * const { Primary, Secondary } = composeStories(stories); + * + * test('renders primary button with Hello World', () => { + * const { getByText } = render(Primary, { label: 'Hello world' }); + * expect(getByText(/Hello world/i)).not.toBeNull(); + * }); + *``` + * + * @param csfExports - e.g. (import * as stories from './Button.stories') + * @param [projectAnnotations] - e.g. (import * as projectAnnotations from '../.storybook/preview') this can be applied automatically if you use `setProjectAnnotations` in your setup files. + */ +export function composeStories>( + csfExports: TModule, + projectAnnotations?: ProjectAnnotations +) { + // @ts-expect-error (Converted from ts-ignore) + const composedStories = originalComposeStories(csfExports, projectAnnotations, composeStory); + + return composedStories as unknown as Omit< + MapToComposed>, + keyof Store_CSFExports + >; +} diff --git a/code/renderers/svelte/src/render.ts b/code/renderers/svelte/src/render.ts index 28552379bf90..2219e21b3760 100644 --- a/code/renderers/svelte/src/render.ts +++ b/code/renderers/svelte/src/render.ts @@ -11,11 +11,8 @@ import { createSvelte5Props } from '@storybook/svelte/internal/createSvelte5Prop import { addons } from '@storybook/preview-api'; import * as svelte from 'svelte'; -import { VERSION as SVELTE_VERSION } from 'svelte/compiler'; - import type { SvelteRenderer } from './types'; - -const IS_SVELTE_V4 = Number(SVELTE_VERSION[0]) <= 4; +import { IS_SVELTE_V4 } from './utils'; export function renderToCanvas( renderContext: RenderContext, diff --git a/code/renderers/svelte/src/utils.ts b/code/renderers/svelte/src/utils.ts new file mode 100644 index 000000000000..09bf34cab42e --- /dev/null +++ b/code/renderers/svelte/src/utils.ts @@ -0,0 +1,3 @@ +import { VERSION as SVELTE_VERSION } from 'svelte/compiler'; + +export const IS_SVELTE_V4 = Number(SVELTE_VERSION[0]) <= 4; diff --git a/code/renderers/svelte/vitest.config.ts b/code/renderers/svelte/vitest.config.ts index edd280537aec..93b7542fd9f1 100644 --- a/code/renderers/svelte/vitest.config.ts +++ b/code/renderers/svelte/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig( test: { environment: 'jsdom', name: __dirname.split(sep).slice(-2).join(posix.sep), + // setupFiles: ['./vitest-setup.ts'], }, plugins: [ // eslint-disable-next-line import/no-unresolved diff --git a/code/renderers/vue3/package.json b/code/renderers/vue3/package.json index 56434484dd96..44224370c977 100644 --- a/code/renderers/vue3/package.json +++ b/code/renderers/vue3/package.json @@ -26,6 +26,12 @@ "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./experimental-playwright": { + "types": "./dist/playwright.d.ts", + "node": "./dist/playwright.js", + "require": "./dist/playwright.js", + "import": "./dist/playwright.mjs" + }, "./preset": "./preset.js", "./dist/entry-preview.mjs": "./dist/entry-preview.mjs", "./dist/entry-preview-docs.mjs": "./dist/entry-preview-docs.mjs", @@ -80,7 +86,8 @@ "./src/index.ts", "./src/preset.ts", "./src/entry-preview.ts", - "./src/entry-preview-docs.ts" + "./src/entry-preview-docs.ts", + "./src/playwright.ts" ], "platform": "browser" }, diff --git a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts index 7491a376e07c..84e34a189f31 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts +++ b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts @@ -66,6 +66,7 @@ describe('projectAnnotations', () => { const { getByText } = render(WithEnglishText); const buttonElement = getByText('Hello!'); expect(buttonElement).toBeInTheDocument(); + expect(WithEnglishText.parameters?.injected).toBe(true); }); it('renders with custom projectAnnotations via composeStory params', () => { @@ -76,12 +77,6 @@ describe('projectAnnotations', () => { const buttonElement = getByText('Olá!'); expect(buttonElement).toBeInTheDocument(); }); - - it('renders with custom projectAnnotations via setProjectAnnotations', () => { - setProjectAnnotations([{ parameters: { injected: true } }]); - const Story = composeStory(stories.CSF2StoryWithLocale, stories.default); - expect(Story.parameters?.injected).toBe(true); - }); }); describe('CSF3', () => { diff --git a/code/renderers/vue3/src/playwright.ts b/code/renderers/vue3/src/playwright.ts new file mode 100644 index 000000000000..d1538f820e23 --- /dev/null +++ b/code/renderers/vue3/src/playwright.ts @@ -0,0 +1 @@ +export { createPlaywrightTest as createTest } from '@storybook/preview-api'; diff --git a/code/renderers/vue3/src/portable-stories.ts b/code/renderers/vue3/src/portable-stories.ts index 043f15ff46b3..562228aa4d42 100644 --- a/code/renderers/vue3/src/portable-stories.ts +++ b/code/renderers/vue3/src/portable-stories.ts @@ -17,6 +17,14 @@ import * as defaultProjectAnnotations from './entry-preview'; import type { Meta } from './public-types'; import type { VueRenderer } from './types'; +type JSXAble = TElement & { + new (...args: any[]): any; + $props: any; +}; +type MapToJSXAble = { + [K in keyof T]: JSXAble; +}; + /** Function that sets the globalConfig of your Storybook. The global config is the preview module of your .storybook folder. * * It should be run a single time, so that your global config (e.g. decorators) is applied to your stories when using `composeStories` or `composeStory`. @@ -87,7 +95,7 @@ export function composeStory( // typing this as newable means TS allows it to be used as a JSX element // TODO: we should do the same for composeStories as well - return renderable as unknown as typeof composedStory & { new (...args: any[]): any }; + return renderable as unknown as JSXAble; } /** @@ -122,8 +130,7 @@ export function composeStories, - keyof Store_CSFExports + return composedStories as unknown as MapToJSXAble< + Omit, keyof Store_CSFExports> >; } diff --git a/code/ui/.storybook/main.ts b/code/ui/.storybook/main.ts index dd01824a38e8..4025715324d0 100644 --- a/code/ui/.storybook/main.ts +++ b/code/ui/.storybook/main.ts @@ -52,6 +52,7 @@ const config: StorybookConfig = { '@storybook/addon-interactions', '@storybook/addon-storysource', '@storybook/addon-designs', + '@storybook/addon-a11y', '@chromatic-com/storybook', ], build: { diff --git a/code/ui/blocks/src/components/ArgsTable/ArgControl.tsx b/code/ui/blocks/src/components/ArgsTable/ArgControl.tsx index 6d51fe88d318..ed8ee973d11e 100644 --- a/code/ui/blocks/src/components/ArgsTable/ArgControl.tsx +++ b/code/ui/blocks/src/components/ArgsTable/ArgControl.tsx @@ -65,8 +65,8 @@ export const ArgControl: FC = ({ row, arg, updateArgs, isHovere const onBlur = useCallback(() => setFocused(false), []); const onFocus = useCallback(() => setFocused(true), []); - if (!control || control.disabled) { - const canBeSetup = control?.disabled !== true && row?.type?.name !== 'function'; + if (!control || control.disable) { + const canBeSetup = control?.disable !== true && row?.type?.name !== 'function'; return isHovered && canBeSetup ? ( ({ - color: theme.barTextColor, margin: '-4px -12px -4px 0', })); diff --git a/code/ui/blocks/src/components/ArgsTable/Empty.tsx b/code/ui/blocks/src/components/ArgsTable/Empty.tsx index c4269a605f95..2f2c1bb40445 100644 --- a/code/ui/blocks/src/components/ArgsTable/Empty.tsx +++ b/code/ui/blocks/src/components/ArgsTable/Empty.tsx @@ -1,8 +1,8 @@ import type { FC } from 'react'; import React, { useEffect, useState } from 'react'; import { styled } from '@storybook/theming'; -import { Link } from '@storybook/components'; -import { DocumentIcon, SupportIcon, VideoIcon } from '@storybook/icons'; +import { Link, EmptyTabContent } from '@storybook/components'; +import { DocumentIcon, VideoIcon } from '@storybook/icons'; interface EmptyProps { inAddonPanel?: boolean; @@ -22,27 +22,6 @@ const Wrapper = styled.div<{ inAddonPanel?: boolean }>(({ inAddonPanel, theme }) boxShadow: 'rgba(0, 0, 0, 0.10) 0 1px 3px 0', })); -const Content = styled.div({ - display: 'flex', - flexDirection: 'column', - gap: 4, - maxWidth: 415, -}); - -const Title = styled.div(({ theme }) => ({ - fontWeight: theme.typography.weight.bold, - fontSize: theme.typography.size.s2 - 1, - textAlign: 'center', - color: theme.textColor, -})); - -const Description = styled.div(({ theme }) => ({ - fontWeight: theme.typography.weight.regular, - fontSize: theme.typography.size.s2 - 1, - textAlign: 'center', - color: theme.textMutedColor, -})); - const Links = styled.div(({ theme }) => ({ display: 'flex', fontSize: theme.typography.size.s2 - 1, @@ -73,39 +52,47 @@ export const Empty: FC = ({ inAddonPanel }) => { return ( - - - {inAddonPanel + <EmptyTabContent + title={ + inAddonPanel ? 'Interactive story playground' - : "Args table with interactive controls couldn't be auto-generated"} - - - Controls give you an easy to use interface to test your components. Set your story args - and you'll see controls appearing here automatically. - - - - {inAddonPanel && ( + : "Args table with interactive controls couldn't be auto-generated" + } + description={ <> - - Watch 5m video - - - - Read docs - + Controls give you an easy to use interface to test your components. Set your story args + and you'll see controls appearing here automatically. - )} - {!inAddonPanel && ( - - Learn how to set that up - - )} - + } + footer={ + + {inAddonPanel && ( + <> + + Watch 5m video + + + + Read docs + + + )} + {!inAddonPanel && ( + + Learn how to set that up + + )} + + } + /> ); }; diff --git a/code/ui/components/src/components/Button/Button.tsx b/code/ui/components/src/components/Button/Button.tsx index 37b64a786493..6e77f668b8df 100644 --- a/code/ui/components/src/components/Button/Button.tsx +++ b/code/ui/components/src/components/Button/Button.tsx @@ -170,6 +170,36 @@ const StyledButton = styled('button', { if (variant === 'ghost' && active) return theme.background.hoverable; return 'transparent'; })(), + ...(variant === 'ghost' + ? { + // This is a hack to apply bar styles to the button as soon as it is part of a bar + // It is a temporary solution until we have implemented Theming 2.0. + '.sb-bar &': { + background: (() => { + if (active) return transparentize(0.9, theme.barTextColor); + return 'transparent'; + })(), + color: (() => { + if (active) return theme.barSelectedColor; + return theme.barTextColor; + })(), + '&:hover': { + color: theme.barHoverColor, + background: transparentize(0.86, theme.barHoverColor), + }, + + '&:active': { + color: theme.barSelectedColor, + background: transparentize(0.9, theme.barSelectedColor), + }, + + '&:focus': { + boxShadow: `${rgba(theme.barHoverColor, 1)} 0 0 0 1px inset`, + outline: 'none', + }, + }, + } + : {}), color: (() => { if (variant === 'solid') return theme.color.lightest; if (variant === 'outline') return theme.input.color; diff --git a/code/ui/components/src/components/bar/bar.tsx b/code/ui/components/src/components/bar/bar.tsx index 27c595c65a8a..9320e2a97045 100644 --- a/code/ui/components/src/components/bar/bar.tsx +++ b/code/ui/components/src/components/bar/bar.tsx @@ -93,10 +93,10 @@ export interface FlexBarProps extends ComponentProps { backgroundColor?: string; } -export const FlexBar = ({ children, backgroundColor, ...rest }: FlexBarProps) => { +export const FlexBar = ({ children, backgroundColor, className, ...rest }: FlexBarProps) => { const [left, right] = Children.toArray(children); return ( - + {left} diff --git a/code/ui/components/src/components/bar/button.tsx b/code/ui/components/src/components/bar/button.tsx index 68ce83036b59..db838d581336 100644 --- a/code/ui/components/src/components/bar/button.tsx +++ b/code/ui/components/src/components/bar/button.tsx @@ -108,7 +108,7 @@ export const TabButton = styled(ButtonOrLink, { shouldForwardProp: isPropValid } '&:focus': { outline: '0 none', - borderBottomColor: theme.color.secondary, + borderBottomColor: theme.barSelectedColor, }, }), ({ active, textColor, theme }) => @@ -120,6 +120,9 @@ export const TabButton = styled(ButtonOrLink, { shouldForwardProp: isPropValid } : { color: textColor || theme.barTextColor, borderBottomColor: 'transparent', + '&:hover': { + color: theme.barHoverColor, + }, } ); TabButton.displayName = 'TabButton'; diff --git a/code/ui/components/src/components/tabs/EmptyTabContent.stories.tsx b/code/ui/components/src/components/tabs/EmptyTabContent.stories.tsx new file mode 100644 index 000000000000..3ef2da755f34 --- /dev/null +++ b/code/ui/components/src/components/tabs/EmptyTabContent.stories.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { EmptyTabContent } from './EmptyTabContent'; +import { DocumentIcon } from '@storybook/icons'; +import { Link } from '@storybook/components'; +import type { Meta, StoryObj } from '@storybook/react'; + +export default { + component: EmptyTabContent, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +type Story = StoryObj; + +export const OnlyTitle: Story = { + args: { + title: 'Nothing found', + }, +}; + +export const TitleAndDescription: Story = { + args: { + title: 'Nothing found', + description: 'Sorry, there is nothing to display here.', + }, +}; + +export const TitleAndFooter: Story = { + args: { + title: 'Nothing found', + footer: ( + + See the docs + + ), + }, +}; + +export const TitleDescriptionAndFooter: Story = { + args: { + title: 'Nothing found', + description: 'Sorry, there is nothing to display here.', + footer: ( + + See the docs + + ), + }, +}; diff --git a/code/ui/components/src/components/tabs/EmptyTabContent.tsx b/code/ui/components/src/components/tabs/EmptyTabContent.tsx new file mode 100644 index 000000000000..eec65f6183d7 --- /dev/null +++ b/code/ui/components/src/components/tabs/EmptyTabContent.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { styled } from '@storybook/theming'; + +const Wrapper = styled.div(({ theme }) => ({ + height: '100%', + display: 'flex', + padding: 30, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: 15, + background: theme.background.content, +})); + +const Content = styled.div({ + display: 'flex', + flexDirection: 'column', + gap: 4, + maxWidth: 415, +}); + +const Title = styled.div(({ theme }) => ({ + fontWeight: theme.typography.weight.bold, + fontSize: theme.typography.size.s2 - 1, + textAlign: 'center', + color: theme.textColor, +})); + +const Description = styled.div(({ theme }) => ({ + fontWeight: theme.typography.weight.regular, + fontSize: theme.typography.size.s2 - 1, + textAlign: 'center', + color: theme.textMutedColor, +})); + +interface Props { + title: React.ReactNode; + description?: React.ReactNode; + footer?: React.ReactNode; +} + +export const EmptyTabContent = ({ title, description, footer }: Props) => { + return ( + + + {title} + {description && {description}} + + {footer} + + ); +}; diff --git a/code/ui/components/src/components/tabs/tabs.hooks.tsx b/code/ui/components/src/components/tabs/tabs.hooks.tsx index f5fbd7272fe6..d932b974b81e 100644 --- a/code/ui/components/src/components/tabs/tabs.hooks.tsx +++ b/code/ui/components/src/components/tabs/tabs.hooks.tsx @@ -22,11 +22,14 @@ const CollapseIcon = styled.span<{ isActive: boolean }>(({ theme, isActive }) => const AddonButton = styled(TabButton)<{ preActive: boolean }>(({ active, theme, preActive }) => { return ` - color: ${preActive || active ? theme.color.secondary : theme.color.mediumdark}; + color: ${preActive || active ? theme.barSelectedColor : theme.barTextColor}; + .addon-collapsible-icon { + color: ${preActive || active ? theme.barSelectedColor : theme.barTextColor}; + } &:hover { - color: ${theme.color.secondary}; + color: ${theme.barHoverColor}; .addon-collapsible-icon { - color: ${theme.color.secondary}; + color: ${theme.barHoverColor}; } } `; diff --git a/code/ui/components/src/components/tabs/tabs.stories.tsx b/code/ui/components/src/components/tabs/tabs.stories.tsx index 46a332a87f1f..a3c40fd8a9d9 100644 --- a/code/ui/components/src/components/tabs/tabs.stories.tsx +++ b/code/ui/components/src/components/tabs/tabs.stories.tsx @@ -1,9 +1,9 @@ import { expect } from '@storybook/test'; -import React, { Fragment } from 'react'; +import React from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryObj } from '@storybook/react'; import { within, fireEvent, waitFor, screen, userEvent, findByText } from '@storybook/test'; -import { CPUIcon, MemoryIcon } from '@storybook/icons'; +import { BottomBarIcon, CloseIcon } from '@storybook/icons'; import { Tabs, TabsState, TabWrapper } from './tabs'; import type { ChildrenList } from './tabs.helpers'; import { IconButton } from '../IconButton/IconButton'; @@ -260,7 +260,27 @@ export const StatelessBordered = { ), } satisfies Story; +const AddonTools = () => ( +
+ + + + + + +
+); + export const StatelessWithTools = { + args: { + tools: , + }, render: (args) => ( - - - - - - - - } {...args} > {content} ), -} satisfies Story; +} satisfies StoryObj; export const StatelessAbsolute = { parameters: { @@ -303,7 +313,7 @@ export const StatelessAbsolute = { {content} ), -} satisfies Story; +} satisfies StoryObj; export const StatelessAbsoluteBordered = { parameters: { @@ -323,9 +333,13 @@ export const StatelessAbsoluteBordered = { {content} ), -} satisfies Story; +} satisfies StoryObj; -export const StatelessEmpty = { +export const StatelessEmptyWithTools = { + args: { + ...StatelessWithTools.args, + showToolsWhenEmpty: true, + }, parameters: { layout: 'fullscreen', }, @@ -340,4 +354,25 @@ export const StatelessEmpty = { {...args} /> ), -} satisfies Story; +} satisfies StoryObj; + +export const StatelessWithCustomEmpty = { + args: { + ...StatelessEmptyWithTools.args, + emptyState:
I am custom!
, + }, + parameters: { + layout: 'fullscreen', + }, + render: (args) => ( + + ), +} satisfies StoryObj; diff --git a/code/ui/components/src/components/tabs/tabs.tsx b/code/ui/components/src/components/tabs/tabs.tsx index 0e3484eab4ba..5b0cbb2b5612 100644 --- a/code/ui/components/src/components/tabs/tabs.tsx +++ b/code/ui/components/src/components/tabs/tabs.tsx @@ -1,14 +1,14 @@ import type { FC, PropsWithChildren, ReactElement, ReactNode, SyntheticEvent } from 'react'; -import React, { useMemo, Component, Fragment, memo } from 'react'; +import React, { useMemo, Component, memo } from 'react'; import { styled } from '@storybook/theming'; import { sanitize } from '@storybook/csf'; import type { Addon_RenderOptions } from '@storybook/types'; -import { Placeholder } from '../placeholder/placeholder'; import { TabButton } from '../bar/button'; import { FlexBar } from '../bar/bar'; import { childrenToList, VisuallyHidden } from './tabs.helpers'; import { useList } from './tabs.hooks'; +import { EmptyTabContent } from './EmptyTabContent'; const ignoreSsrWarning = '/* emotion-disable-server-rendering-unsafe-selector-warning-please-do-not-use-this-the-warning-exists-for-a-reason */'; @@ -119,6 +119,8 @@ export interface TabsProps { }>[]; id?: string; tools?: ReactNode; + showToolsWhenEmpty?: boolean; + emptyState?: ReactNode; selected?: string; actions?: { onSelect: (id: string) => void; @@ -140,6 +142,8 @@ export const Tabs: FC = memo( backgroundColor, id: htmlId, menuName, + emptyState, + showToolsWhenEmpty, }) => { const idList = childrenToList(children) .map((i) => i.id) @@ -157,7 +161,13 @@ export const Tabs: FC = memo( const { visibleList, tabBarRef, tabRefs, AddonTab } = useList(list); - return list.length ? ( + const EmptyContent = emptyState ?? ; + + if (!showToolsWhenEmpty && list.length === 0) { + return EmptyContent; + } + + return ( @@ -190,15 +200,13 @@ export const Tabs: FC = memo( {tools} - {list.map(({ id, active, render }) => { - return React.createElement(render, { key: id, active }, null); - })} + {list.length + ? list.map(({ id, active, render }) => { + return React.createElement(render, { key: id, active }, null); + }) + : EmptyContent} - ) : ( - - Nothing found - ); } ); diff --git a/code/ui/components/src/index.ts b/code/ui/components/src/index.ts index f43d08b5d8f5..ffe4a08d699f 100644 --- a/code/ui/components/src/index.ts +++ b/code/ui/components/src/index.ts @@ -66,6 +66,7 @@ export { default as ListItem } from './components/tooltip/ListItem'; // Toolbar and subcomponents export { Tabs, TabsState, TabBar, TabWrapper } from './components/tabs/tabs'; +export { EmptyTabContent } from './components/tabs/EmptyTabContent'; export { IconButtonSkeleton, TabButton } from './components/bar/button'; export { Separator, interleaveSeparators } from './components/bar/separator'; export { Bar, FlexBar } from './components/bar/bar'; diff --git a/code/ui/manager/src/components/mobile/navigation/MobileNavigation.tsx b/code/ui/manager/src/components/mobile/navigation/MobileNavigation.tsx index 47b7587019f8..9ecee759fb06 100644 --- a/code/ui/manager/src/components/mobile/navigation/MobileNavigation.tsx +++ b/code/ui/manager/src/components/mobile/navigation/MobileNavigation.tsx @@ -45,7 +45,7 @@ export const MobileNavigation: FC = ({ menu, panel, showP {isMobilePanelOpen ? ( {panel} ) : ( -