From 54d8c5f4f9d120e7a8d7e54d9c673924ed67a191 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 16 Sep 2026 12:07:42 +0300 Subject: [PATCH 01/21] feat(storybook): add @fluentui/react-storybook-addon-playground with in-browser TSX playground (#36728) Co-authored-by: Cursor --- .github/CODEOWNERS | 1 + apps/public-docsite-v9-headless/package.json | 1 + apps/public-docsite-v9-headless/project.json | 16 +- ...-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json | 6 + ...-99650265-91f2-458b-bfdb-cf5dda68c498.json | 6 + .../stories/.storybook/css.d.ts | 1 + .../stories/.storybook/main.js | 47 +- .../stories/.storybook/playground.setup.tsx | 52 ++ .../stories/.storybook/preview.js | 1 + .../stories/.storybook/tsconfig.json | 2 +- .../stories/project.json | 20 +- .../src/sandbox-utils.ts | 3 +- .../.babelrc.json | 3 + .../react-storybook-addon-playground/.swcrc | 35 + .../react-storybook-addon-playground/LICENSE | 15 + .../README.md | 108 +++ .../config/api-extractor.json | 4 + .../config/tests.js | 1 + .../eslint.config.js | 22 + .../react-storybook-addon-playground.api.md | 117 ++++ .../jest.config.js | 28 + .../package.json | 77 ++ .../preset.js | 38 + .../project.json | 32 + .../withOpenInPlaygroundButton.spec.ts | 183 +++++ .../decorators/withOpenInPlaygroundButton.ts | 85 +++ .../src/defaultSetup.tsx | 73 ++ .../src/index.ts | 7 + .../src/playground/Editor.styles.ts | 10 + .../src/playground/Editor.tsx | 182 +++++ .../src/playground/Playground.styles.ts | 303 ++++++++ .../src/playground/Playground.tsx | 660 ++++++++++++++++++ .../src/playground/Preview.styles.ts | 27 + .../src/playground/Preview.tsx | 110 +++ .../src/playground/compiler.ts | 87 +++ .../src/playground/cssModules.spec.ts | 78 +++ .../src/playground/cssModules.ts | 86 +++ .../src/playground/editorTheme.ts | 129 ++++ .../src/playground/formatter.spec.ts | 34 + .../src/playground/formatter.ts | 78 +++ .../src/playground/main.tsx | 42 ++ .../src/playground/monaco.ts | 48 ++ .../src/playground/playground.html | 26 + .../src/playground/runner.spec.ts | 49 ++ .../src/playground/runner.ts | 51 ++ .../src/playground/runtime.ts | 59 ++ .../src/playground/sandbox.ts | 189 +++++ .../src/playground/shortcuts.spec.ts | 23 + .../src/playground/shortcuts.ts | 23 + .../src/playground/themes.ts | 21 + .../src/playground/typings.ts | 34 + .../src/playground/useMediaQuery.ts | 24 + .../src/playground/useSplitPane.spec.ts | 70 ++ .../src/playground/useSplitPane.ts | 110 +++ .../src/preset/preset.ts | 1 + .../src/preset/preview.ts | 5 + .../src/public-types.ts | 44 ++ .../src/setup.ts | 39 ++ .../src/styles.css | 29 + .../src/types.ts | 17 + .../src/url.spec.ts | 91 +++ .../src/url.ts | 117 ++++ .../src/webpack.spec.ts | 65 ++ .../src/webpack.ts | 304 ++++++++ .../tools/collect-typings.js | 461 ++++++++++++ .../tools/collect-typings.spec.js | 237 +++++++ .../tsconfig.json | 26 + .../tsconfig.lib.json | 15 + .../tsconfig.playground.json | 11 + .../tsconfig.spec.json | 9 + .../webpack.playground.config.js | 123 ++++ syncpack.config.js | 6 + tsconfig.base.all.json | 9 +- tsconfig.base.json | 3 + yarn.lock | 26 +- 75 files changed, 5163 insertions(+), 12 deletions(-) create mode 100644 change/@fluentui-react-storybook-addon-export-to-sandbox-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json create mode 100644 change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/css.d.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/.babelrc.json create mode 100644 packages/react-components/react-storybook-addon-playground/.swcrc create mode 100644 packages/react-components/react-storybook-addon-playground/LICENSE create mode 100644 packages/react-components/react-storybook-addon-playground/README.md create mode 100644 packages/react-components/react-storybook-addon-playground/config/api-extractor.json create mode 100644 packages/react-components/react-storybook-addon-playground/config/tests.js create mode 100644 packages/react-components/react-storybook-addon-playground/eslint.config.js create mode 100644 packages/react-components/react-storybook-addon-playground/etc/react-storybook-addon-playground.api.md create mode 100644 packages/react-components/react-storybook-addon-playground/jest.config.js create mode 100644 packages/react-components/react-storybook-addon-playground/package.json create mode 100644 packages/react-components/react-storybook-addon-playground/preset.js create mode 100644 packages/react-components/react-storybook-addon-playground/project.json create mode 100644 packages/react-components/react-storybook-addon-playground/src/decorators/withOpenInPlaygroundButton.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/decorators/withOpenInPlaygroundButton.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/defaultSetup.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/index.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Editor.styles.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Editor.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Playground.styles.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Playground.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Preview.styles.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Preview.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/compiler.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/cssModules.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/cssModules.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/editorTheme.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/formatter.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/formatter.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/main.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/monaco.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/playground.html create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/runner.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/runner.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/runtime.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/sandbox.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/shortcuts.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/shortcuts.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/themes.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/typings.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/useMediaQuery.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/useSplitPane.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/useSplitPane.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/preset/preset.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/preset/preview.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/public-types.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/setup.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/styles.css create mode 100644 packages/react-components/react-storybook-addon-playground/src/types.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/url.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/url.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/webpack.ts create mode 100644 packages/react-components/react-storybook-addon-playground/tools/collect-typings.js create mode 100644 packages/react-components/react-storybook-addon-playground/tools/collect-typings.spec.js create mode 100644 packages/react-components/react-storybook-addon-playground/tsconfig.json create mode 100644 packages/react-components/react-storybook-addon-playground/tsconfig.lib.json create mode 100644 packages/react-components/react-storybook-addon-playground/tsconfig.playground.json create mode 100644 packages/react-components/react-storybook-addon-playground/tsconfig.spec.json create mode 100644 packages/react-components/react-storybook-addon-playground/webpack.playground.config.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 90627caa29ee9..94bdae50b6ef3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -288,6 +288,7 @@ packages/react-components/react-breadcrumb/stories @microsoft/cxe-prg packages/react-components/react-drawer/library @microsoft/cxe-prg @mainframev packages/react-components/react-drawer/stories @microsoft/cxe-prg @mainframev packages/react-components/react-storybook-addon-export-to-sandbox @microsoft/fluentui-react-build +packages/react-components/react-storybook-addon-playground @microsoft/fluentui-react-build packages/react-components/babel-preset-storybook-full-source @microsoft/fluentui-react-build packages/react-components/react-jsx-runtime @microsoft/teams-prg packages/react-components/react-toast/library @microsoft/teams-prg diff --git a/apps/public-docsite-v9-headless/package.json b/apps/public-docsite-v9-headless/package.json index 8e5736f87ea10..524d1e28e385b 100644 --- a/apps/public-docsite-v9-headless/package.json +++ b/apps/public-docsite-v9-headless/package.json @@ -16,6 +16,7 @@ "@fluentui/react-headless-components-preview": "*", "@fluentui/react-storybook-addon": "*", "@fluentui/react-storybook-addon-export-to-sandbox": "*", + "@fluentui/react-storybook-addon-playground": "*", "react": "19.2.0", "react-dom": "19.2.0", "tslib": "^2.1.0" diff --git a/apps/public-docsite-v9-headless/project.json b/apps/public-docsite-v9-headless/project.json index d48c59b15dd70..0c887704c318d 100644 --- a/apps/public-docsite-v9-headless/project.json +++ b/apps/public-docsite-v9-headless/project.json @@ -8,7 +8,13 @@ "build-storybook": { "dependsOn": [ { - "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox", + "react-storybook-addon-playground", + "storybook-llms-extractor" + ], "target": "build" } ] @@ -16,7 +22,13 @@ "build-storybook:docsite": { "dependsOn": [ { - "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox", + "react-storybook-addon-playground", + "storybook-llms-extractor" + ], "target": "build" } ], diff --git a/change/@fluentui-react-storybook-addon-export-to-sandbox-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json b/change/@fluentui-react-storybook-addon-export-to-sandbox-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json new file mode 100644 index 0000000000000..f7429b73b2d13 --- /dev/null +++ b/change/@fluentui-react-storybook-addon-export-to-sandbox-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: ignore buttons added by the playground addon when locating the Show code toggle", + "packageName": "@fluentui/react-storybook-addon-export-to-sandbox", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json b/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json new file mode 100644 index 0000000000000..a3ff4ddc7a388 --- /dev/null +++ b/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "feat: add @fluentui/react-storybook-addon-playground package scaffold", + "packageName": "@fluentui/react-storybook-addon-playground", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/css.d.ts b/packages/react-components/react-headless-components-preview/stories/.storybook/css.d.ts new file mode 100644 index 0000000000000..35306c6fc9aed --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/css.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/main.js b/packages/react-components/react-headless-components-preview/stories/.storybook/main.js index 8d95fb49d632f..5565529ee6a52 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/main.js +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/main.js @@ -7,23 +7,55 @@ const { processBabelLoaderOptions, } = require('@fluentui/scripts-storybook'); const { registerCssModuleRules } = require('./css-modules-webpack'); +const headlessPackageJson = require('../../library/package.json'); const repoRoot = path.resolve(__dirname, '../../../../..'); const tsConfigPath = path.resolve(repoRoot, 'tsconfig.base.json'); +const HEADLESS_PACKAGE_NAME = '@fluentui/react-headless-components-preview'; /** + * Headless Storybook replaces the root sandbox addon with a CSS-modules-aware one, and registers + * the playground here only (not on the shared v9 Storybook). + * * @param {string | { name?: string }} addon */ -function isNotExportToSandboxAddon(addon) { +function isNotRootSandboxOrPlaygroundAddon(addon) { const name = typeof addon === 'string' ? addon : addon?.name ?? ''; - return !name.includes('react-storybook-addon-export-to-sandbox'); + return ( + !name.includes('react-storybook-addon-export-to-sandbox') && !name.includes('react-storybook-addon-playground') + ); +} + +/** + * Maps every published subpath of the headless package (plus react-icons) into the playground allowlist. + * Skips `.` — the barrel is empty, and Storybook only has a tsconfig path for `package/*` (source), + * not the package root (built `lib/index.js`, which CI `build-storybook` does not produce). + * + * @returns {Record} + */ +function getHeadlessPlaygroundModules() { + /** @type {Record} */ + const modules = { + '@fluentui/react-icons': '@fluentui/react-icons', + }; + + for (const exportPath of Object.keys(headlessPackageJson.exports ?? {})) { + if (exportPath === '.' || exportPath === './package.json') { + continue; + } + + const specifier = `${HEADLESS_PACKAGE_NAME}${exportPath.slice(1)}`; + modules[specifier] = specifier; + } + + return modules; } module.exports = /** @type {Omit} */ ({ ...rootMain, stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], addons: [ - ...rootMain.addons.filter(isNotExportToSandboxAddon), + ...rootMain.addons.filter(isNotRootSandboxOrPlaygroundAddon), loadWorkspaceAddon('@fluentui/react-storybook-addon-export-to-sandbox', { tsConfigPath, /** @type {import('../../../react-storybook-addon-export-to-sandbox/src/index').PresetConfig} */ @@ -37,6 +69,15 @@ module.exports = /** @type {Omit { const localConfig = /** @type {any} */ ({ ...rootMain.webpackFinal(config, options) }); diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx b/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx new file mode 100644 index 0000000000000..ec08ef5656a26 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; +import { Provider } from '@fluentui/react-headless-components-preview/provider'; +import { definePlaygroundSetup } from '@fluentui/react-storybook-addon-playground/setup'; + +import './tokens.css'; + +polyfillBodyAndObserve(); + +type HeadlessPlaygroundTheme = 'light' | 'dark'; + +const defaultCode = `import * as React from 'react'; +import { Button } from '@fluentui/react-headless-components-preview/button'; + +export default () => { + const [count, setCount] = React.useState(0); + + return ( + + ); +}; +`; + +export default definePlaygroundSetup({ + title: 'Fluent UI Playground', + subtitle: 'Headless', + defaultCode, + themes: [ + { id: 'light', label: 'Light', value: 'light', dark: false }, + { id: 'dark', label: 'Dark', value: 'dark', dark: true }, + ], + render: ({ Component, theme }) => ( + +
+ +
+
+ ), +}); diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js b/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js index a16824d5be686..1ad92661b8e71 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js @@ -1,4 +1,5 @@ import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; +import '../../../react-storybook-addon-playground/src/styles.css'; import * as rootPreview from '../../../../../.storybook/preview'; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json b/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json index 94fa013fc1872..4911d72ee49f5 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json @@ -7,5 +7,5 @@ "moduleResolution": "bundler", "types": ["static-assets", "environment"] }, - "include": ["*.js"] + "include": ["*.js", "*.tsx", "*.d.ts"] } diff --git a/packages/react-components/react-headless-components-preview/stories/project.json b/packages/react-components/react-headless-components-preview/stories/project.json index 0effce2bfe286..fc52b7e599414 100644 --- a/packages/react-components/react-headless-components-preview/stories/project.json +++ b/packages/react-components/react-headless-components-preview/stories/project.json @@ -4,5 +4,23 @@ "projectType": "library", "sourceRoot": "packages/react-components/react-headless-components-preview/stories/src", "tags": ["vNext", "platform:web", "type:stories", "react-headless"], - "implicitDependencies": [] + "implicitDependencies": [], + "targets": { + "storybook": { + "dependsOn": [ + { + "projects": ["react-headless-components-preview", "react-storybook-addon-playground"], + "target": "build" + } + ] + }, + "build-storybook": { + "dependsOn": [ + { + "projects": ["react-headless-components-preview", "react-storybook-addon-playground"], + "target": "build" + } + ] + } + } } diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts index 1c118531da63d..84066e72ba1ad 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts @@ -26,8 +26,9 @@ export function prepareSandboxContainers(context: StoryContext) { } return Array.from(rootElements).map(rootElement => { + // Exclude buttons added by this addon and by @fluentui/react-storybook-addon-playground const showCodeButton = rootElement.querySelector( - '.docblock-code-toggle:not(.with-code-sandbox-button):not(.with-open-in-new-tab-button)', + '.docblock-code-toggle:not(.with-code-sandbox-button):not(.with-open-in-new-tab-button):not(.with-open-in-playground-button)', ); const container = showCodeButton?.parentElement; diff --git a/packages/react-components/react-storybook-addon-playground/.babelrc.json b/packages/react-components/react-storybook-addon-playground/.babelrc.json new file mode 100644 index 0000000000000..38bf1f7a945cf --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/.babelrc.json @@ -0,0 +1,3 @@ +{ + "plugins": ["annotate-pure-calls", "@babel/transform-react-pure-annotations"] +} diff --git a/packages/react-components/react-storybook-addon-playground/.swcrc b/packages/react-components/react-storybook-addon-playground/.swcrc new file mode 100644 index 0000000000000..30f79f5975e21 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/.swcrc @@ -0,0 +1,35 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "exclude": [ + "/testing", + "/**/*.cy.ts", + "/**/*.cy.tsx", + "/**/*.spec.ts", + "/**/*.spec.tsx", + "/**/*.test.ts", + "/**/*.test.tsx", + "/**/src/playground/**" + ], + "jsc": { + "baseUrl": ".", + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + }, + "externalHelpers": true, + "transform": { + "react": { + "runtime": "classic", + "useSpread": true + }, + "optimizer": { + "simplify": true + } + }, + "target": "es2019" + }, + "minify": false, + "sourceMaps": true +} diff --git a/packages/react-components/react-storybook-addon-playground/LICENSE b/packages/react-components/react-storybook-addon-playground/LICENSE new file mode 100644 index 0000000000000..3e7475939fea6 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-storybook-addon-playground + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/react-components/react-storybook-addon-playground/README.md b/packages/react-components/react-storybook-addon-playground/README.md new file mode 100644 index 0000000000000..d98c42c279c15 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/README.md @@ -0,0 +1,108 @@ +# @fluentui/react-storybook-addon-playground + +**Configurable Storybook TSX playground for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)** + +This addon serves a prebuilt Monaco playground shell and adds an **Open in Playground** button next to "Show code" in +Storybook Docs mode. The consumer's Storybook Webpack build produces a separate runtime for React, configured packages, +an optional setup module, and Monaco declarations. User code runs inside a sandboxed preview iframe. + +## Features + +- Consumer-controlled import map (`options.modules`) eagerly compiled into the sandbox runtime by Storybook Webpack +- Private registry and workspace packages without a runtime CDN +- Build-time declaration collection for Monaco IntelliSense (from `modules` + optional `typings`) +- Optional TSX `setup` module for branding, themes and provider/render behavior (Fluent defaults when omitted) +- Sandboxed preview (`sandbox="allow-scripts"`), Prettier formatting, error reporting and shareable URL state + +## Installation + +```sh +yarn add @fluentui/react-storybook-addon-playground +``` + +## Usage + +```js +// .storybook/main.js +const path = require('path'); + +module.exports = { + addons: [ + { + name: '@fluentui/react-storybook-addon-playground', + options: { + modules: { + '@fluentui/react-components': '@fluentui/react-components', + '@fluentui/react-components/unstable': '@fluentui/react-components/unstable', + '@fluentui/react-icons': '@fluentui/react-icons', + }, + setup: path.resolve(__dirname, './playground.setup.tsx'), // optional + // typings: ['@fluentui/react-theme'], // optional declaration-only entries + }, + }, + ], +}; +``` + +```js +// .storybook/preview.js +import '@fluentui/react-storybook-addon-playground/styles.css'; +``` + +`modules` maps public imports accepted by the editor to package requests resolved by the consumer's Webpack +configuration. React runtime entries are provided automatically. `typings` adds declaration-only entries. + +The optional setup module default-exports a value created with `definePlaygroundSetup`: + +```tsx +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { definePlaygroundSetup } from '@fluentui/react-storybook-addon-playground/setup'; + +export default definePlaygroundSetup({ + title: 'Fluent UI Playground', + defaultCode: `import { Button } from '@fluentui/react-components'; + +export default () => ;`, + themes: [{ id: 'web-light', label: 'Web Light', value: webLightTheme }], + render: ({ Component, theme }) => ( + + + + ), +}); +``` + +When `setup` is omitted, the addon's built-in Fluent UI default setup is used. + +### Story source + +The button reads the story source from `parameters.fullSource`, which is injected at build time by +`@fluentui/babel-preset-storybook-full-source` (registered by `@fluentui/react-storybook-addon-export-to-sandbox`). +Other consumers can populate the same parameter with their own Storybook source transform. The button is not rendered +when `parameters.fullSource` is unavailable. + +### Disabling per story + +```ts +export const MyStory = () => ; +`; + +describe('url', () => { + describe('encodeCode / decodeCode', () => { + it('round trips source code', () => { + expect(decodeCode(encodeCode(sampleCode))).toBe(sampleCode); + }); + + it('produces a URL safe payload', () => { + const encoded = encodeCode(sampleCode); + + expect(encoded).toMatch(/^[A-Za-z0-9+\-$]+$/); + }); + + it('tolerates "+" being decoded as a space by URLSearchParams', () => { + const encoded = encodeCode(sampleCode); + + expect(encoded).toContain('+'); + expect(decodeCode(encoded.replace(/\+/g, ' '))).toBe(sampleCode); + }); + + it('returns null for empty or invalid payloads', () => { + expect(decodeCode('')).toBeNull(); + expect(decodeCode('not-a-valid-payload!!!')).toBeNull(); + }); + }); + + describe('createCodeHash / decodeCodeFromHash', () => { + it('creates a hash with the code param', () => { + const hash = createCodeHash(sampleCode); + + expect(hash.startsWith(`#${CODE_HASH_PARAM}=`)).toBe(true); + expect(decodeCodeFromHash(hash)).toBe(sampleCode); + }); + + it('accepts hash with and without the leading "#"', () => { + const hash = createCodeHash(sampleCode); + + expect(decodeCodeFromHash(hash.slice(1))).toBe(sampleCode); + }); + + it('ignores unrelated hash params', () => { + expect(decodeCodeFromHash('')).toBeNull(); + expect(decodeCodeFromHash('#foo=bar')).toBeNull(); + expect(decodeCodeFromHash(`#foo=bar&${createCodeHash(sampleCode).slice(1)}`)).toBe(sampleCode); + }); + }); + + describe('createPlaygroundUrl', () => { + it('defaults to the playground shell path', () => { + const url = createPlaygroundUrl(sampleCode); + + expect(url.startsWith(`./${PLAYGROUND_PATH}#${CODE_HASH_PARAM}=`)).toBe(true); + }); + + it('supports a custom base url', () => { + const url = createPlaygroundUrl(sampleCode, 'https://example.com/storybook/playground.html'); + const [base, hash] = url.split('#'); + + expect(base).toBe('https://example.com/storybook/playground.html'); + expect(decodeCodeFromHash(`#${hash}`)).toBe(sampleCode); + }); + + it('encodes CSS modules next to the source so the sandbox can apply them', () => { + const cssModules = [{ name: 'button.module.css', source: '.root { color: red; }' }]; + const url = createPlaygroundUrl(sampleCode, undefined, cssModules); + const hash = `#${url.split('#')[1]}`; + + expect(hash).toContain(`${CSS_HASH_PARAM}=`); + expect(decodePlaygroundStateFromHash(hash)).toEqual({ code: sampleCode, cssModules }); + expect(createPlaygroundHash({ code: sampleCode, cssModules }).startsWith('#')).toBe(true); + }); + }); +}); diff --git a/packages/react-components/react-storybook-addon-playground/src/url.ts b/packages/react-components/react-storybook-addon-playground/src/url.ts new file mode 100644 index 0000000000000..d5e6f865d938e --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/url.ts @@ -0,0 +1,117 @@ +import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; + +export const CODE_HASH_PARAM = 'code'; +export const CSS_HASH_PARAM = 'css'; +/** Relative to Storybook's `iframe.html`: shell under `/playground/app`, runtime under `/playground/runtime`. */ +export const PLAYGROUND_PATH = 'playground/app/playground.html'; + +export interface CssModuleSource { + name: string; + source: string; +} + +export interface PlaygroundUrlState { + code: string; + cssModules: CssModuleSource[]; +} + +/** + * Compresses source code into a URL safe string. + */ +export function encodeCode(code: string): string { + return compressToEncodedURIComponent(code); +} + +/** + * Inverse of {@link encodeCode}. Returns `null` when the payload is missing or cannot be decoded. + */ +export function decodeCode(encoded: string): string | null { + if (!encoded) { + return null; + } + + const decoded = decompressFromEncodedURIComponent(encoded); + + return typeof decoded === 'string' && decoded.length > 0 ? decoded : null; +} + +function parseCssModulesPayload(encoded: string | null): CssModuleSource[] { + if (!encoded) { + return []; + } + + const json = decodeCode(encoded); + if (!json) { + return []; + } + + try { + const parsed: unknown = JSON.parse(json); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter( + (entry): entry is CssModuleSource => + Boolean(entry) && typeof entry.name === 'string' && typeof entry.source === 'string', + ); + } catch { + return []; + } +} + +/** + * Reads the playground payload from a `location.hash` value (`#code=...&css=...`). + */ +export function decodePlaygroundStateFromHash(hash: string): PlaygroundUrlState | null { + const params = new URLSearchParams(hash.replace(/^#/, '')); + const encoded = params.get(CODE_HASH_PARAM); + const code = encoded ? decodeCode(encoded) : null; + + if (!code) { + return null; + } + + return { code, cssModules: parseCssModulesPayload(params.get(CSS_HASH_PARAM)) }; +} + +/** + * Reads the code payload from a `location.hash` value (`#code=...`). + */ +export function decodeCodeFromHash(hash: string): string | null { + return decodePlaygroundStateFromHash(hash)?.code ?? null; +} + +/** + * Creates a `location.hash` value for provided playground state. + */ +export function createPlaygroundHash(state: { code: string; cssModules?: readonly CssModuleSource[] }): string { + let hash = `#${CODE_HASH_PARAM}=${encodeCode(state.code)}`; + + if (state.cssModules && state.cssModules.length > 0) { + hash += `&${CSS_HASH_PARAM}=${encodeCode(JSON.stringify(state.cssModules))}`; + } + + return hash; +} + +/** + * Creates a `location.hash` value for provided code. + */ +export function createCodeHash(code: string): string { + return createPlaygroundHash({ code }); +} + +/** + * Creates the playground URL for provided code (and optional CSS modules from the story). + * + * The URL is relative to the Storybook preview (`iframe.html`), which lives next to the playground static dirs in the + * Storybook root, so it also works when Storybook is deployed under a sub path. + */ +export function createPlaygroundUrl( + code: string, + baseUrl = `./${PLAYGROUND_PATH}`, + cssModules?: readonly CssModuleSource[], +): string { + return `${baseUrl}${createPlaygroundHash({ code, cssModules })}`; +} diff --git a/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts b/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts new file mode 100644 index 0000000000000..d527fcb7b0342 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts @@ -0,0 +1,65 @@ +import { buildRuntimeEntrySource, filterRuntimeEntryAssets } from './webpack'; + +describe('filterRuntimeEntryAssets', () => { + it('removes playground-only assets from Storybook HTML and preserves shared chunks', () => { + const entrypoints = new Map([ + [ + 'main', + { + getFiles: () => ['runtime~main.js', 'shared.js', 'main.js', 'shared.css'], + }, + ], + [ + 'playground-runtime', + { + getFiles: () => [ + 'runtime~playground-runtime.js', + 'react-dom-client.js', + 'shared.js', + 'playground-runtime.js', + 'shared.css', + 'playground.css', + ], + }, + ], + ]); + const data = { + assets: { + js: [ + 'runtime~main.js', + 'shared.js', + 'main.js', + 'runtime~playground-runtime.js', + 'react-dom-client.js', + 'playground-runtime.js', + ], + css: ['shared.css', 'playground.css'], + }, + }; + + expect(filterRuntimeEntryAssets(entrypoints, data)).toEqual({ + assets: { + js: ['runtime~main.js', 'shared.js', 'main.js'], + css: ['shared.css'], + }, + }); + }); +}); + +describe('buildRuntimeEntrySource', () => { + it('eagerly imports configured modules instead of using dynamic import()', () => { + const source = buildRuntimeEntrySource({ + modules: { + '@fluentui/react-components': '@fluentui/react-components', + '@fluentui/react-icons': '@fluentui/react-icons', + }, + setup: '/abs/playground.setup.tsx', + }); + + expect(source).toContain('import * as __pg_mod_0 from "@fluentui/react-components";'); + expect(source).toContain('import * as __pg_mod_1 from "@fluentui/react-icons";'); + expect(source).toContain('"@fluentui/react-components": () => Promise.resolve(__pg_mod_0)'); + expect(source).toContain('"@fluentui/react-icons": () => Promise.resolve(__pg_mod_1)'); + expect(source).not.toMatch(/\bimport\(/); + }); +}); diff --git a/packages/react-components/react-storybook-addon-playground/src/webpack.ts b/packages/react-components/react-storybook-addon-playground/src/webpack.ts new file mode 100644 index 0000000000000..df2a17e8edbf0 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/webpack.ts @@ -0,0 +1,304 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { PresetConfig } from './public-types'; + +type WebpackFinalFn = NonNullable; +type WebpackFinalConfig = Parameters[0]; +type WebpackFinalOptions = Parameters[1]; +type CollectTypingsResult = { + files: Record; + sources: string[]; + missing: string[]; +}; +type HtmlAssetsData = { + assets: { + js: string[]; + css: string[]; + }; +}; +type EntrypointFiles = { + getFiles(): string[]; +}; +type HtmlWebpackPluginConstructor = { + getHooks(compilation: import('webpack').Compilation): { + beforeAssetTagGeneration: { + tap(name: string, callback: (data: HtmlAssetsData) => HtmlAssetsData): void; + }; + }; +}; + +export const ENTRY_NAME = 'playground-runtime'; +export const REGISTER_CALLBACK = '__FLUENTUI_PLAYGROUND_REGISTER_V1__'; + +const addonFilePattern = /react-storybook-addon-playground\/[a-z/]+.[jt]s$/; +const defaultOptions: PresetConfig = { + modules: {}, +}; + +const { collectTypings } = require('../tools/collect-typings') as { + collectTypings(options: { packageRoot: string; entries: string[]; typescriptVersion: string }): CollectTypingsResult; +}; + +/** + * Storybook preset hook: emits a separate Webpack entry for the playground runtime (configured modules + setup), + * collects Monaco typings for those modules, and writes `playground/runtime/manifest.json`. + */ +export function webpackFinal(config: WebpackFinalConfig, options: WebpackFinalOptions): WebpackFinalConfig { + const addonOptions = getAddonOptions(options); + const runtimeEntry = writeRuntimeEntry(addonOptions, options.configDir ?? process.cwd()); + const typings = collectConfiguredTypings(addonOptions, options); + const originalEntry = config.entry; + + config.entry = async (): Promise => { + const entry = typeof originalEntry === 'function' ? await originalEntry() : originalEntry; + const normalizedEntry: import('webpack').EntryObject = + !entry || typeof entry === 'string' || Array.isArray(entry) ? { main: entry ?? [] } : entry; + + return { + ...normalizedEntry, + [ENTRY_NAME]: runtimeEntry, + }; + }; + + config.plugins = config.plugins ?? []; + config.plugins.push(new ExcludeRuntimeEntryFromHtmlPlugin()); + config.plugins.push(new PlaygroundRuntimeManifestPlugin(addonOptions, typings)); + + return config; +} + +/** + * Storybook injects every Webpack entry into `iframe.html` by default. The playground runtime must only load inside + * the sandboxed preview iframe. + */ +class ExcludeRuntimeEntryFromHtmlPlugin { + public apply(compiler: import('webpack').Compiler): void { + const pluginName = 'ExcludePlaygroundRuntimeFromHtmlPlugin'; + const htmlPlugin = compiler.options.plugins.find( + plugin => plugin && plugin.constructor.name === 'HtmlWebpackPlugin', + ); + const htmlPluginConstructor = htmlPlugin?.constructor as unknown as HtmlWebpackPluginConstructor | undefined; + + if (!htmlPluginConstructor?.getHooks) { + return; + } + + compiler.hooks.compilation.tap(pluginName, compilation => { + htmlPluginConstructor.getHooks(compilation).beforeAssetTagGeneration.tap(pluginName, data => { + return filterRuntimeEntryAssets(compilation.entrypoints, data); + }); + }); + } +} + +export function filterRuntimeEntryAssets( + entrypoints: ReadonlyMap, + data: HtmlAssetsData, +): HtmlAssetsData { + const runtimeFiles = new Set(entrypoints.get(ENTRY_NAME)?.getFiles() ?? []); + const otherEntryFiles = new Set( + Array.from(entrypoints.entries()).flatMap(([name, entrypoint]) => + name === ENTRY_NAME ? [] : entrypoint.getFiles(), + ), + ); + const exclusiveRuntimeFiles = Array.from(runtimeFiles).filter(file => !otherEntryFiles.has(file)); + + data.assets.js = data.assets.js.filter(asset => !matchesAnyAsset(asset, exclusiveRuntimeFiles)); + data.assets.css = data.assets.css.filter(asset => !matchesAnyAsset(asset, exclusiveRuntimeFiles)); + + return data; +} + +function matchesAnyAsset(assetUrl: string, assetFiles: string[]): boolean { + const normalizedUrl = assetUrl.split(/[?#]/, 1)[0].replace(/\\/g, '/'); + + return assetFiles.some(file => { + const normalizedFile = file.replace(/\\/g, '/'); + return normalizedUrl === normalizedFile || normalizedUrl.endsWith(`/${normalizedFile}`); + }); +} + +function getAddonOptions(options: WebpackFinalOptions): PresetConfig { + const presetRegistration = options.presetsList?.find(preset => addonFilePattern.test(preset.name)); + const addonOptions = presetRegistration?.options ?? {}; + + return { ...defaultOptions, ...addonOptions }; +} + +function getDefaultSetupPath(): string { + // Compiled next to this file (`lib-commonjs/webpack.js` → `lib-commonjs/defaultSetup.js`) + return require.resolve('./defaultSetup'); +} + +/** + * Builds the playground runtime entry source. + * + * Configured modules are **static** imports (not `import()`). Dynamic imports become async chunks that Storybook's + * webpack lazy-compilation serves from a separate origin — which fails inside `sandbox="allow-scripts"` (opaque + * origin) iframes. Static imports stay on the entrypoint graph and ship via the manifest `