diff --git a/apps/public-docsite-v9-headless/.storybook/preview.js b/apps/public-docsite-v9-headless/.storybook/preview.js
index 6264e041abcb63..a3ebd40ef31ecb 100644
--- a/apps/public-docsite-v9-headless/.storybook/preview.js
+++ b/apps/public-docsite-v9-headless/.storybook/preview.js
@@ -1,6 +1,7 @@
import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless';
import * as rootPreview from '../../../.storybook/preview';
+import { tailwindSandboxTemplate } from './tailwind-sandbox-template';
polyfillBodyAndObserve();
@@ -19,6 +20,10 @@ export const parameters = {
order: ['Introduction', 'Headless Components'],
},
},
+ exportToSandbox: {
+ ...rootPreview.parameters.exportToSandbox,
+ ...tailwindSandboxTemplate,
+ },
};
export const tags = ['autodocs'];
diff --git a/apps/public-docsite-v9-headless/.storybook/tailwind-sandbox-template.js b/apps/public-docsite-v9-headless/.storybook/tailwind-sandbox-template.js
new file mode 100644
index 00000000000000..6204703ca107f8
--- /dev/null
+++ b/apps/public-docsite-v9-headless/.storybook/tailwind-sandbox-template.js
@@ -0,0 +1,32 @@
+const sandboxApp = `import { Provider } from '@fluentui/react-headless-components-preview';
+import { Example } from './example';
+
+const App = () => (
+
+
+
+);
+
+export default App;
+`;
+
+const tailwindSandboxTemplate = {
+ devDependencies: {
+ tailwindcss: '^4.0.0',
+ '@tailwindcss/vite': '^4.0.0',
+ },
+ transformFiles: (/** @type {Record} */ files) => ({
+ ...files,
+ 'src/index.css': '@import "tailwindcss";\n',
+ 'src/App.tsx': sandboxApp,
+ 'src/index.tsx': `import './index.css';\n${files['src/index.tsx']}`,
+ 'vite.config.ts': files['vite.config.ts']
+ .replace(
+ "import react from '@vitejs/plugin-react'",
+ "import react from '@vitejs/plugin-react'\nimport tailwindcss from '@tailwindcss/vite'",
+ )
+ .replace('plugins: [react()]', 'plugins: [react(), tailwindcss()]'),
+ }),
+};
+
+module.exports = { tailwindSandboxTemplate };
diff --git a/change/@fluentui-react-storybook-addon-export-to-sandbox-73929f6e-c56d-49f5-82cb-3efa58fde4fe.json b/change/@fluentui-react-storybook-addon-export-to-sandbox-73929f6e-c56d-49f5-82cb-3efa58fde4fe.json
new file mode 100644
index 00000000000000..856f6cb13608f1
--- /dev/null
+++ b/change/@fluentui-react-storybook-addon-export-to-sandbox-73929f6e-c56d-49f5-82cb-3efa58fde4fe.json
@@ -0,0 +1,7 @@
+{
+ "type": "minor",
+ "comment": "feat: add template extension params",
+ "packageName": "@fluentui/react-storybook-addon-export-to-sandbox",
+ "email": "vgenaev@gmail.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts
index 6a8974d09330f5..d9ee74588310c4 100644
--- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts
+++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts
@@ -4,11 +4,25 @@
*
* only pure API definitions of addon are allowed to live here, that are used both internal and for external storybook `Parameter` type extensions
*/
+
+export interface SandboxContext {
+ provider: 'codesandbox-cloud' | 'codesandbox-browser' | 'stackblitz-cloud';
+ bundler: 'vite' | 'cra';
+ storyExportToken: string;
+ storyFile: string;
+ dependencies: Record;
+ requiredDependencies: Record;
+ optionalDependencies: Record;
+ devDependencies: Record;
+}
+
interface ParametersConfig {
optionalDependencies?: Record;
requiredDependencies?: Record;
+ devDependencies?: Record;
provider: 'codesandbox-cloud' | 'codesandbox-browser' | 'stackblitz-cloud';
bundler: 'vite' | 'cra';
+ transformFiles?: (files: Record, ctx: SandboxContext) => Record;
}
export interface ParametersExtension {
diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.spec.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.spec.ts
index ad4bd11f82de0f..4937e8b8cb4a09 100644
--- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.spec.ts
+++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.spec.ts
@@ -12,6 +12,9 @@ describe(`sabdbox-scaffold`, () => {
`,
description: 'react sandbox demo',
title: 'react sandbox',
+ requiredDependencies: {},
+ optionalDependencies: {},
+ devDependencies: {},
};
describe(`cra`, () => {
it(`should generate scaffold for codesandbox-browser`, () => {
@@ -48,7 +51,7 @@ describe(`sabdbox-scaffold`, () => {
}",
"public/index.html": "",
"src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components';
- import { DefaultTitle as Example } from './example';
+ import { Example } from './example';
const App = () => {
return (
@@ -64,7 +67,9 @@ describe(`sabdbox-scaffold`, () => {
import { Text } from '@proj/react-components';
export const Default = () => This is an example of the Text component's usage.;
- ",
+
+ export { DefaultTitle as Example };
+ ",
"src/index.tsx": "import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
@@ -163,7 +168,7 @@ describe(`sabdbox-scaffold`, () => {
}",
"public/index.html": "",
"src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components';
- import { DefaultTitle as Example } from './example';
+ import { Example } from './example';
const App = () => {
return (
@@ -179,7 +184,9 @@ describe(`sabdbox-scaffold`, () => {
import { Text } from '@proj/react-components';
export const Default = () => This is an example of the Text component's usage.;
- ",
+
+ export { DefaultTitle as Example };
+ ",
"src/index.tsx": "import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
@@ -244,7 +251,7 @@ describe(`sabdbox-scaffold`, () => {
}",
"public/index.html": "",
"src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components';
- import { DefaultTitle as Example } from './example';
+ import { Example } from './example';
const App = () => {
return (
@@ -260,7 +267,9 @@ describe(`sabdbox-scaffold`, () => {
import { Text } from '@proj/react-components';
export const Default = () => This is an example of the Text component's usage.;
- ",
+
+ export { DefaultTitle as Example };
+ ",
"src/index.tsx": "import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
@@ -380,7 +389,7 @@ describe(`sabdbox-scaffold`, () => {
}
}",
"src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components';
- import { DefaultTitle as Example } from './example';
+ import { Example } from './example';
const App = () => {
return (
@@ -396,7 +405,9 @@ describe(`sabdbox-scaffold`, () => {
import { Text } from '@proj/react-components';
export const Default = () => This is an example of the Text component's usage.;
- ",
+
+ export { DefaultTitle as Example };
+ ",
"src/index.tsx": "import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
@@ -505,7 +516,7 @@ describe(`sabdbox-scaffold`, () => {
}
}",
"src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components';
- import { DefaultTitle as Example } from './example';
+ import { Example } from './example';
const App = () => {
return (
@@ -521,7 +532,9 @@ describe(`sabdbox-scaffold`, () => {
import { Text } from '@proj/react-components';
export const Default = () => This is an example of the Text component's usage.;
- ",
+
+ export { DefaultTitle as Example };
+ ",
"src/index.tsx": "import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
@@ -587,4 +600,143 @@ describe(`sabdbox-scaffold`, () => {
`);
});
});
+
+ describe(`extension params`, () => {
+ it(`should re-export the story as Example from generated example.tsx`, () => {
+ const actual = scaffold.vite({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ ...config,
+ });
+
+ expect(actual['src/example.tsx']).toContain(`export { DefaultTitle as Example };`);
+ });
+
+ it(`should keep FluentProvider in App.tsx by default`, () => {
+ const actual = scaffold.vite({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ ...config,
+ });
+
+ expect(actual['src/App.tsx']).toContain(
+ "import { FluentProvider, webLightTheme } from '@fluentui/react-components';",
+ );
+ expect(actual['src/App.tsx']).toContain("import { Example } from './example';");
+ });
+
+ it(`should merge devDependencies into Vite package.json`, () => {
+ const actual = scaffold.vite({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ ...config,
+ devDependencies: { tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' },
+ });
+
+ const pkg = JSON.parse(actual['package.json']);
+ expect(pkg.devDependencies.tailwindcss).toBe('^4.0.0');
+ expect(pkg.devDependencies['@tailwindcss/vite']).toBe('^4.0.0');
+ expect(pkg.devDependencies.vite).toBe('^5.0.0');
+ expect(pkg.dependencies.tailwindcss).toBeUndefined();
+ });
+
+ it(`should merge devDependencies into CRA package.json`, () => {
+ const actual = scaffold.cra({
+ provider: 'stackblitz-cloud',
+ bundler: 'cra',
+ ...config,
+ devDependencies: { tailwindcss: '^4.0.0' },
+ });
+
+ const pkg = JSON.parse(actual['package.json']);
+ expect(pkg.devDependencies.tailwindcss).toBe('^4.0.0');
+ expect(pkg.devDependencies['react-scripts']).toBe('^5.0.0');
+ });
+ });
+
+ describe(`transformFiles`, () => {
+ it(`should patch an existing generated file`, () => {
+ const actual = scaffold.vite({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ ...config,
+ transformFiles: files => ({
+ ...files,
+ 'vite.config.ts': files['vite.config.ts'].replace('react()', 'react(), tailwindcss()'),
+ }),
+ });
+
+ expect(actual['vite.config.ts']).toContain('plugins: [react(), tailwindcss()]');
+ });
+
+ it(`should add a new file`, () => {
+ const actual = scaffold.vite({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ ...config,
+ transformFiles: files => ({ ...files, 'extra.txt': 'hello' }),
+ });
+
+ expect(actual['extra.txt']).toBe('hello');
+ expect(actual['vite.config.ts']).toBeDefined();
+ });
+
+ it(`should drop a file when the returned map omits its key`, () => {
+ const actual = scaffold.vite({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ ...config,
+ transformFiles: files => {
+ const { 'index.html': _dropped, ...rest } = files;
+ return rest;
+ },
+ });
+
+ expect(actual['index.html']).toBeUndefined();
+ expect(actual['src/App.tsx']).toBeDefined();
+ });
+
+ it(`should expose context to the callback`, () => {
+ let captured: unknown;
+ scaffold.vite({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ ...config,
+ requiredDependencies: { 'some-pkg': '^1.0.0' },
+ optionalDependencies: { react: '^18' },
+ devDependencies: { tailwindcss: '^4.0.0' },
+ transformFiles: (files, ctx) => {
+ captured = ctx;
+ return files;
+ },
+ });
+
+ expect(captured).toEqual({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ storyExportToken: 'DefaultTitle',
+ storyFile: config.storyFile,
+ dependencies: {},
+ requiredDependencies: { 'some-pkg': '^1.0.0' },
+ optionalDependencies: { react: '^18' },
+ devDependencies: { tailwindcss: '^4.0.0' },
+ });
+ });
+
+ it(`should see provider-specific extras in the input map (.stackblitzrc)`, () => {
+ let capturedKeys: string[] = [];
+ scaffold.vite({
+ provider: 'stackblitz-cloud',
+ bundler: 'vite',
+ ...config,
+ transformFiles: files => {
+ capturedKeys = Object.keys(files);
+ return files;
+ },
+ });
+
+ expect(capturedKeys).toContain('.stackblitzrc');
+ expect(capturedKeys).toContain('vite.config.ts');
+ });
+ });
});
diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.ts
index 6bacb6f1267990..c58d3201664654 100644
--- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.ts
+++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.ts
@@ -1,5 +1,6 @@
import dedent from 'dedent';
+import type { SandboxContext } from './public-types';
import type { Data } from './sandbox-utils';
import { serializeJson } from './utils';
@@ -11,7 +12,7 @@ export const scaffold = {
throw new Error('vite is not supported on codesandbox-browser');
}
- const base = {
+ const base: Record = {
'index.html': Vite.getHTML(),
'src/App.tsx': Vite.getApp(data),
'src/index.tsx': Vite.getRootIndex(),
@@ -27,10 +28,10 @@ export const scaffold = {
if (data.provider === 'codesandbox-cloud') {
Object.assign(base, getCodesandboxConfig('vite'));
}
- return base;
+ return applyTransform(base, data);
},
cra: (data: Data): Record => {
- const base = {
+ const base: Record = {
'public/index.html': CRA.getHTML(),
'src/App.tsx': CRA.getApp(data),
'src/index.tsx': CRA.getRootIndex(),
@@ -45,10 +46,27 @@ export const scaffold = {
Object.assign(base, getCodesandboxConfig('cra'));
}
- return base;
+ return applyTransform(base, data);
},
};
+function applyTransform(base: Record, data: Data): Record {
+ if (!data.transformFiles) {
+ return base;
+ }
+ const ctx: SandboxContext = {
+ provider: data.provider,
+ bundler: data.bundler,
+ storyExportToken: data.storyExportToken,
+ storyFile: data.storyFile,
+ dependencies: data.dependencies,
+ requiredDependencies: data.requiredDependencies,
+ optionalDependencies: data.optionalDependencies,
+ devDependencies: data.devDependencies,
+ };
+ return data.transformFiles(base, ctx);
+}
+
const Vite = {
getHTML: () => dedent`
@@ -137,6 +155,7 @@ const Vite = {
...commonDevDeps,
'@vitejs/plugin-react': '^4.2.0',
vite: '^5.0.0',
+ ...data.devDependencies,
},
});
},
@@ -167,6 +186,7 @@ const CRA = {
...commonDevDeps,
'react-scripts': '^5.0.0',
'@babel/plugin-proposal-private-property-in-object': 'latest',
+ ...data.devDependencies,
},
scripts: {
start: 'react-scripts start',
@@ -246,13 +266,13 @@ function getIndex() {
}
function getExample(demoData: Data) {
- return demoData.storyFile;
+ return `${demoData.storyFile}\nexport { ${demoData.storyExportToken} as Example };\n`;
}
-function getApp(data: Data) {
- const code = dedent`
+function getApp(_data: Data) {
+ return dedent`
import { FluentProvider, webLightTheme } from '@fluentui/react-components';
- import { ${data.storyExportToken} as Example } from './example';
+ import { Example } from './example';
const App = () => {
return (
@@ -264,6 +284,4 @@ function getApp(data: Data) {
export default App;
`;
-
- return code;
}
diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.spec.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.spec.ts
index 4f67cc6554eef7..6dd13dfb973312 100644
--- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.spec.ts
+++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.spec.ts
@@ -61,6 +61,9 @@ describe(`sabdbox-utils`, () => {
storyExportToken: 'DefaultTitle',
storyFile: context.parameters.fullSource,
title: 'FluentUI React v9',
+ requiredDependencies: {},
+ optionalDependencies: {},
+ devDependencies: {},
});
});
});
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 a0c9093f84a300..8f3076e348c00e 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
@@ -46,7 +46,7 @@ export function prepareSandboxContainers(context: StoryContext) {
});
}
-const addonConfigDefaults = { requiredDependencies: {}, optionalDependencies: {} };
+const addonConfigDefaults = { requiredDependencies: {}, optionalDependencies: {}, devDependencies: {} };
export type Data = Pick, 'provider' | 'bundler'> & {
storyFile: string;
// use originalStoryFn because users can override the `storyName` property.
@@ -59,6 +59,10 @@ export type Data = Pick, 'provider' | 'bundler'> & {
dependencies: Record;
title: string;
description: string;
+ requiredDependencies: Record;
+ optionalDependencies: Record;
+ devDependencies: Record;
+ transformFiles?: NonNullable;
};
export function prepareData(context: StoryContext): Data | null {
@@ -66,7 +70,7 @@ export function prepareData(context: StoryContext): Data | null {
throw new Error('exportToSandbox config parameter cannot be empty');
}
- const addonConfig: Required = {
+ const addonConfig: ParametersConfig & typeof addonConfigDefaults = {
...addonConfigDefaults,
...context.parameters.exportToSandbox,
};
@@ -97,7 +101,7 @@ export function prepareData(context: StoryContext): Data | null {
throw new Error('issues processing story export token');
}
- const demoData = {
+ const demoData: Data = {
storyFile,
storyExportToken,
provider,
@@ -105,6 +109,10 @@ export function prepareData(context: StoryContext): Data | null {
dependencies,
title,
description,
+ requiredDependencies: addonConfig.requiredDependencies,
+ optionalDependencies: addonConfig.optionalDependencies,
+ devDependencies: addonConfig.devDependencies,
+ transformFiles: addonConfig.transformFiles,
};
return demoData;