diff --git a/apps/public-docsite-v9-headless/.storybook/main.js b/apps/public-docsite-v9-headless/.storybook/main.js index f1f764f65e3c0d..47cf45ef365590 100644 --- a/apps/public-docsite-v9-headless/.storybook/main.js +++ b/apps/public-docsite-v9-headless/.storybook/main.js @@ -1,23 +1,14 @@ -const rootMain = require('../../../.storybook/main'); +const headlessMain = require('../../../packages/react-components/react-headless-components-preview/stories/.storybook/main'); module.exports = /** @type {Omit} */ ({ - ...rootMain, + ...headlessMain, stories: [ - ...rootMain.stories, - // docsite stories - '../src/**/*.mdx', - '../src/**/index.stories.@(ts|tsx)', + ...headlessMain.stories, // headless package stories '../../../packages/react-components/react-headless-components-preview/stories/src/**/index.stories.@(ts|tsx)', ], staticDirs: ['../public'], - addons: [...rootMain.addons], build: { previewUrl: process.env.DEPLOY_PATH, }, - webpackFinal: (config, options) => { - const localConfig = /** @type config */ ({ ...rootMain.webpackFinal(config, options) }); - - return localConfig; - }, }); diff --git a/apps/public-docsite-v9-headless/.storybook/manager-head.html b/apps/public-docsite-v9-headless/.storybook/manager-head.html index cca30d3b13bd02..11beec81c25d49 100644 --- a/apps/public-docsite-v9-headless/.storybook/manager-head.html +++ b/apps/public-docsite-v9-headless/.storybook/manager-head.html @@ -5,61 +5,33 @@ + + + + + - - diff --git a/apps/public-docsite-v9-headless/.storybook/preview.js b/apps/public-docsite-v9-headless/.storybook/preview.js index 920889c0e46fb8..3cdce74a824fb4 100644 --- a/apps/public-docsite-v9-headless/.storybook/preview.js +++ b/apps/public-docsite-v9-headless/.storybook/preview.js @@ -1,29 +1,16 @@ -import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; +import * as headlessPreview from '../../../packages/react-components/react-headless-components-preview/stories/.storybook/preview'; -import * as rootPreview from '../../../.storybook/preview'; -import { tailwindSandboxTemplate } from './tailwind-sandbox-template'; +export const decorators = [...headlessPreview.decorators]; -polyfillBodyAndObserve(); - -/** @type {typeof rootPreview.decorators} */ -export const decorators = [...rootPreview.decorators]; - -/** @type {typeof rootPreview.parameters} */ +/** @type {typeof headlessPreview.parameters} */ export const parameters = { - ...rootPreview.parameters, - docs: { - ...rootPreview.parameters.docs, - }, + ...headlessPreview.parameters, options: { storySort: { method: 'alphabetical', order: ['Introduction', 'Headless Components'], }, }, - exportToSandbox: { - ...rootPreview.parameters.exportToSandbox, - ...tailwindSandboxTemplate, - }, reactStorybookAddon: { docs: { argTable: { diff --git a/apps/public-docsite-v9-headless/.storybook/theme.js b/apps/public-docsite-v9-headless/.storybook/theme.js index 4a00dce65f7882..d483408a3609c9 100644 --- a/apps/public-docsite-v9-headless/.storybook/theme.js +++ b/apps/public-docsite-v9-headless/.storybook/theme.js @@ -1,40 +1 @@ -import { create } from 'storybook/theming'; - -/** - * Theming and branding the storybook to fluent. Taken from https://storybook.js.org/docs/react/configure/theming - */ -const theme = create({ - base: 'light', - - // Storybook-specific color palette - colorPrimary: 'rgba(255, 255, 255, .4)', - colorSecondary: '#0078d4', - - // UI - appBg: '#ffffff', - appContentBg: '#ffffff', - appBorderColor: '#e0e0e0', // use msft gray - appBorderRadius: 4, - - // Fonts - fontBase: - '"Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;', - fontCode: 'monospace', - - // Text colors - textColor: '#11100f', - textInverseColor: '#0078d4', // use msft primary blue default - - // Toolbar default and active colors - barSelectedColor: '#0078d4', // use msft primary blue default - - // Form colors - inputBorderRadius: 4, - - // Use the fluent branding for the upper left image - brandTitle: 'Fluent UI Headless Components', - brandUrl: - 'https://github.com/microsoft/fluentui/tree/master/packages/react-components/react-headless-components-preview', -}); - -export default theme; +export { default } from '../../../packages/react-components/react-headless-components-preview/stories/.storybook/theme'; diff --git a/apps/public-docsite-v9-headless/project.json b/apps/public-docsite-v9-headless/project.json index f231a9a1891f67..d48c59b15dd703 100644 --- a/apps/public-docsite-v9-headless/project.json +++ b/apps/public-docsite-v9-headless/project.json @@ -19,7 +19,8 @@ "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], "target": "build" } - ] + ], + "inputs": ["default", "{workspaceRoot}/.storybook/**", "{projectRoot}/.storybook/**"] } } } diff --git a/change/@fluentui-babel-preset-storybook-full-source-533c6664-8c70-4ba3-9465-119e1f33b61c.json b/change/@fluentui-babel-preset-storybook-full-source-533c6664-8c70-4ba3-9465-119e1f33b61c.json new file mode 100644 index 00000000000000..a9b188549b6901 --- /dev/null +++ b/change/@fluentui-babel-preset-storybook-full-source-533c6664-8c70-4ba3-9465-119e1f33b61c.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add css-modules support as opt in via 'cssModules' config", + "packageName": "@fluentui/babel-preset-storybook-full-source", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-storybook-addon-export-to-sandbox-0a90a8e4-2429-4858-8a15-c3a7d7555692.json b/change/@fluentui-react-storybook-addon-export-to-sandbox-0a90a8e4-2429-4858-8a15-c3a7d7555692.json new file mode 100644 index 00000000000000..cf64ad05506a83 --- /dev/null +++ b/change/@fluentui-react-storybook-addon-export-to-sandbox-0a90a8e4-2429-4858-8a15-c3a7d7555692.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add css-modules support as opt in via 'cssModules' preset config", + "packageName": "@fluentui/react-storybook-addon-export-to-sandbox", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/babel-preset-storybook-full-source/README.md b/packages/react-components/babel-preset-storybook-full-source/README.md index 5267a310b95d20..279ba6b29dc16c 100644 --- a/packages/react-components/babel-preset-storybook-full-source/README.md +++ b/packages/react-components/babel-preset-storybook-full-source/README.md @@ -18,7 +18,8 @@ To use this Babel preset, add it to your Babel configuration: - **Removes Storybook specific assignments**: Avoids issues with undefined stories and unnecessary clutter. - **Collects and modifies import declarations**: Ensures valid single-file code examples. -- **Adds the `context.parameters.fullSource` property**: Includes the full source code of the story in Storybook. +- **Adds the `context.parameters.fullSource` property**: post-processed, single-file source for the "Open in Sandbox" flow. +- **CSS module support** (opt-in via `cssModules` option): when enabled, reads `*.module.css` files from disk and injects `context.parameters.cssModuleSources` with `{ cssModules, tokensSource }` entries for the sandbox addon and docs panel. Set `cssModules: true` to enable, or `cssModules: { tokensFilePath: '...' }` to also inject a tokens CSS file as `tokensSource`. ## Note diff --git a/packages/react-components/babel-preset-storybook-full-source/etc/babel-preset-storybook-full-source.api.md b/packages/react-components/babel-preset-storybook-full-source/etc/babel-preset-storybook-full-source.api.md index b224fa709bf0d5..9a5d9239071e6c 100644 --- a/packages/react-components/babel-preset-storybook-full-source/etc/babel-preset-storybook-full-source.api.md +++ b/packages/react-components/babel-preset-storybook-full-source/etc/babel-preset-storybook-full-source.api.md @@ -7,7 +7,10 @@ import * as Babel from '@babel/core'; // @public (undocumented) -export type BabelPluginOptions = Record; +export interface BabelPluginOptions { + cssModules?: boolean | CssModulesConfig; + importMappings: Record; +} // @public function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOptions): Babel.PluginObj; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/code.js new file mode 100644 index 00000000000000..630ed920ec326f --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/code.js @@ -0,0 +1,4 @@ +import * as React from 'react'; +import styles from './example.module.css'; + +export const Default = () => ; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/example.module.css b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/example.module.css new file mode 100644 index 00000000000000..be1cfa94393c4a --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/example.module.css @@ -0,0 +1,3 @@ +.root { + color: var(--text); +} diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/output.js new file mode 100644 index 00000000000000..7ab69d9112f0f4 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/output.js @@ -0,0 +1,22 @@ +import * as React from 'react'; +import styles from './example.module.css'; +export const Default = () => + /*#__PURE__*/ React.createElement( + Button, + { + className: styles.root, + }, + 'Click me', + ); +Default.parameters = {}; +Default.parameters.fullSource = + 'import * as React from "react";\nimport styles from "./styles/example.module.css";\n\nexport const Default = () => ;\n'; +Default.parameters.cssModuleSources = Object.assign({}, Default.parameters.cssModuleSources, { + cssModules: [ + { + name: 'example.module.css', + source: '.root {\n color: var(--text);\n}\n', + }, + ], + tokensSource: ':root {\n --text: #242424;\n --space-4: 16px;\n}\n', +}); diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/tokens.css b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/tokens.css new file mode 100644 index 00000000000000..97d82cd3d30137 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/tokens.css @@ -0,0 +1,4 @@ +:root { + --text: #242424; + --space-4: 16px; +} diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/code.js new file mode 100644 index 00000000000000..630ed920ec326f --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/code.js @@ -0,0 +1,4 @@ +import * as React from 'react'; +import styles from './example.module.css'; + +export const Default = () => ; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/example.module.css b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/example.module.css new file mode 100644 index 00000000000000..7ae317c9fc34d2 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/example.module.css @@ -0,0 +1,4 @@ +.root { + color: var(--text); + padding: var(--space-4); +} diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/output.js new file mode 100644 index 00000000000000..ca06e9a1357539 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/output.js @@ -0,0 +1,21 @@ +import * as React from 'react'; +import styles from './example.module.css'; +export const Default = () => + /*#__PURE__*/ React.createElement( + Button, + { + className: styles.root, + }, + 'Click me', + ); +Default.parameters = {}; +Default.parameters.fullSource = + 'import * as React from "react";\nimport styles from "./styles/example.module.css";\n\nexport const Default = () => ;\n'; +Default.parameters.cssModuleSources = Object.assign({}, Default.parameters.cssModuleSources, { + cssModules: [ + { + name: 'example.module.css', + source: '.root {\n color: var(--text);\n padding: var(--space-4);\n}\n', + }, + ], +}); diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts index 114a93224c5a2d..43af7003784c60 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts @@ -11,9 +11,32 @@ pluginTester({ }, fixtures: fixturesDir, pluginOptions: { - '@fluentui/react-button': defaultDependencyReplace, - '@fluentui/react-menu': defaultDependencyReplace, - '@fluentui/react-link': defaultDependencyReplace, + importMappings: { + '@fluentui/react-button': defaultDependencyReplace, + '@fluentui/react-menu': defaultDependencyReplace, + '@fluentui/react-link': defaultDependencyReplace, + }, + cssModules: true, + }, + pluginName: PLUGIN_NAME, + plugin, +}); + +pluginTester({ + babelOptions: { + presets: ['@babel/preset-react'], + }, + fixtures: path.join(__dirname, '__fixtures__/storybook-stories-fullsource-with-tokens'), + pluginOptions: { + importMappings: { + '@fluentui/react-button': defaultDependencyReplace, + }, + cssModules: { + tokensFilePath: path.join( + __dirname, + '__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/tokens.css', + ), + }, }, pluginName: PLUGIN_NAME, plugin, diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts index e3134b7d63ee2e..b9e3046fac49fa 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts @@ -1,6 +1,7 @@ import * as Babel from '@babel/core'; import * as prettier from 'prettier'; import * as fs from 'fs'; +import * as nodePath from 'path'; import { modifyImportsPlugin } from './modifyImports'; import { removeStorybookParameters } from './removeStorybookParameters'; @@ -23,6 +24,8 @@ export const PLUGIN_NAME = 'storybook-stories-fullsource'; */ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOptions): Babel.PluginObj { const { types: t } = babel; + const cssModulesConfig = typeof options.cssModules === 'object' ? options.cssModules : undefined; + const cssModulesEnabled = Boolean(options.cssModules); let storyName: string; let parametersAssignment: Babel.NodePath | undefined; @@ -50,6 +53,51 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption ); }; + /** + * Builds an AST expression that merges auto-detected CSS module data into + * `Story.parameters.cssModuleSources`: + * + * Story.parameters.cssModuleSources = Object.assign({}, Story.parameters.cssModuleSources, { + * cssModules: [{ name: '…', source: '…' }, …], + * tokensSource: '…', + * }); + */ + const createCssModuleSourcesAssignment = (data: { + cssModules?: Array<{ name: string; source: string }>; + tokensSource?: string; + }) => { + const storyParametersCssModuleSources = t.memberExpression( + t.memberExpression(t.identifier(storyName), t.identifier('parameters')), + t.identifier('cssModuleSources'), + ); + + const properties: Babel.types.ObjectProperty[] = []; + + if (data.cssModules && data.cssModules.length > 0) { + const modulesArray = t.arrayExpression( + data.cssModules.map(m => + t.objectExpression([ + t.objectProperty(t.identifier('name'), t.stringLiteral(m.name)), + t.objectProperty(t.identifier('source'), t.stringLiteral(m.source)), + ]), + ), + ); + properties.push(t.objectProperty(t.identifier('cssModules'), modulesArray)); + } + + if (data.tokensSource) { + properties.push(t.objectProperty(t.identifier('tokensSource'), t.stringLiteral(data.tokensSource))); + } + + const mergedObject = t.callExpression(t.memberExpression(t.identifier('Object'), t.identifier('assign')), [ + t.objectExpression([]), + storyParametersCssModuleSources, + t.objectExpression(properties), + ]); + + return t.expressionStatement(t.assignmentExpression('=', storyParametersCssModuleSources, mergedObject)); + }; + return { name: PLUGIN_NAME, visitor: { @@ -116,6 +164,19 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption } path.pushContainer('body', createFullSourceAssignmentExpression(code)); + + // Auto-detect CSS module imports and inject their source as parameters. + // This removes the need for manual `?raw` imports + `withCssModuleSource()` calls. + if (cssModulesEnabled) { + const cssModules = collectCssModuleImports(path, t, state.filename); + const tokensSource = cssModulesConfig?.tokensFilePath + ? fs.readFileSync(cssModulesConfig.tokensFilePath, 'utf-8') + : undefined; + + if (cssModules.length > 0 || tokensSource) { + path.pushContainer('body', createCssModuleSourcesAssignment({ cssModules, tokensSource })); + } + } }, }, }, @@ -131,3 +192,42 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption function isComponentLikeName(name: string) { return name.charAt(0) === name.charAt(0).toUpperCase(); } + +/** + * Walks the program's import declarations looking for `*.module.css` imports + * (excluding `?raw` query imports). For each match, resolves the file on disk + * and returns `{ name, source }` pairs. + */ +function collectCssModuleImports( + programPath: Babel.NodePath, + t: typeof Babel.types, + filename: string, +): Array<{ name: string; source: string }> { + const dir = nodePath.dirname(filename); + const seen = new Set(); + const result: Array<{ name: string; source: string }> = []; + + for (const node of programPath.node.body) { + if (!t.isImportDeclaration(node)) { + continue; + } + const src = node.source.value; + // Match relative *.module.css imports but skip ?raw query imports + if (!/\.module\.css$/.test(src) || src.includes('?')) { + continue; + } + const resolved = nodePath.resolve(dir, src); + if (seen.has(resolved)) { + continue; + } + seen.add(resolved); + try { + const source = fs.readFileSync(resolved, 'utf-8'); + result.push({ name: nodePath.basename(resolved), source }); + } catch { + // CSS file not found — skip silently (it may be handled by webpack aliases) + } + } + + return result; +} diff --git a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.test.ts b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.test.ts index 4ef9ee11c33af0..cf7f5089307e4d 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.test.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.test.ts @@ -15,10 +15,12 @@ describe(PLUGIN_NAME, () => { pluginTester({ fixtures: fixturesDir, pluginOptions: { - '@fluentui/react-button': defaultDependencyReplace, - '@fluentui/react-menu': defaultDependencyReplace, - '@fluentui/react-link': defaultDependencyReplace, - '@fluentui/react-unstable-component': { replace: '@fluentui/react-components/unstable' }, + importMappings: { + '@fluentui/react-button': defaultDependencyReplace, + '@fluentui/react-menu': defaultDependencyReplace, + '@fluentui/react-link': defaultDependencyReplace, + '@fluentui/react-unstable-component': { replace: '@fluentui/react-components/unstable' }, + }, }, pluginName: PLUGIN_NAME, plugin, diff --git a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts index e6d370c3c07a3e..03f8348eb73726 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts @@ -17,6 +17,8 @@ export const PLUGIN_NAME = 'storybook-stories-modifyImports'; */ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOptions): Babel.PluginObj { const { types: t } = babel; + const { importMappings } = options; + const cssModulesEnabled = Boolean(options.cssModules); return { name: PLUGIN_NAME, @@ -27,8 +29,8 @@ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOpt parserOptions.plugins.push('typescript'); }, pre() { - this.imports = Object.keys(options).reduce((acc, cur) => { - acc[options[cur].replace] = []; + this.imports = Object.keys(importMappings).reduce((acc, cur) => { + acc[importMappings[cur].replace] = []; return acc; }, {} as PluginState['imports']); }, @@ -53,6 +55,16 @@ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOpt const isRelativeImportToIndexBarrel = importSource.value.endsWith('./index'); if (isRelativeImport && !isRelativeImportToIndexBarrel) { + // When cssModules is enabled, preserve *.module.css imports — rewrite path + // to ./styles/ so the displayed source matches the Stackblitz sandbox layout. + if (cssModulesEnabled) { + const cssModuleMatch = importSource.value.match(/([^/]+\.module\.css)$/); + if (cssModuleMatch) { + path.node.source = t.stringLiteral(`./styles/${cssModuleMatch[1]}`); + return; + } + } + if (process.env.NODE_ENV !== 'production') { console.warn( [ @@ -78,14 +90,14 @@ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOpt } } - if (t.isLiteral(path.node.source) && options[importSource.value]) { + if (t.isLiteral(path.node.source) && importMappings[importSource.value]) { path.node.specifiers.forEach(specifier => { if ( t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported) && t.isIdentifier(specifier.local) ) { - pluginState.imports[options[importSource.value].replace].push(specifier.imported.name); + pluginState.imports[importMappings[importSource.value].replace].push(specifier.imported.name); } }); diff --git a/packages/react-components/babel-preset-storybook-full-source/src/types.ts b/packages/react-components/babel-preset-storybook-full-source/src/types.ts index 9d5522415fcb2d..683bd284e055da 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/types.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/types.ts @@ -1,4 +1,4 @@ -export interface DependencyEntry { +interface DependencyEntry { /** * Replaces the dependency with another * @default \@fluentui/react-components @@ -6,4 +6,25 @@ export interface DependencyEntry { replace: string; } -export type BabelPluginOptions = Record; +interface CssModulesConfig { + /** + * Absolute path to the tokens CSS file. When provided, the plugin reads this + * file at build time and injects its content as `Story.parameters.cssModuleSources.tokensSource`. + */ + tokensFilePath?: string; +} + +export interface BabelPluginOptions { + /** Map of package names to their replacement config (used by `modifyImportsPlugin`). */ + importMappings: Record; + + /** + * When `true` (or a config object), the plugin will: + * - Preserve `*.module.css` imports (rewriting paths to `./styles/`) + * - Auto-detect CSS module files on disk and inject `Story.parameters.cssModuleSources.cssModules` + * - If `tokensFilePath` is provided, inject `Story.parameters.cssModuleSources.tokensSource` + * + * @default false + */ + cssModules?: boolean | CssModulesConfig; +} diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/HeadlessDocsPage.tsx b/packages/react-components/react-headless-components-preview/stories/.storybook/HeadlessDocsPage.tsx new file mode 100644 index 00000000000000..a7535d328dbc0b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/HeadlessDocsPage.tsx @@ -0,0 +1,136 @@ +/** + * `HeadlessDocsPage` — replaces Storybook's autodocs page so we can render a + * **tabbed** "Show code" panel under each story (TSX + each CSS Module the + * story uses). The deployed Fluent docs page (`FluentDocsPage`) hard-wires + * `` / `` blocks whose Source can't be made multi-language, + * so we re-implement the same layout (Title / Subtitle / Description / + * primary canvas + source / ArgTypes / Stories heading / each story canvas + + * source) and swap the source block for our own ``. The order + * mirrors `packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx` + * so the page matches what's deployed at storybooks.fluentui.dev/headless. + * + */ +import * as React from 'react'; + +import { + Anchor, + ArgTypes, + Canvas, + Description, + DocsContext, + HeaderMdx, + Subtitle, + Title, +} from '@storybook/addon-docs/blocks'; + +import { HeadlessSourcePanel } from './HeadlessSourcePanel'; + +const dividerStyle: React.CSSProperties = { + height: 1, + backgroundColor: '#e1dfdd', + border: 0, + margin: '48px 0', +}; + +const storiesHeadingStyle: React.CSSProperties = { + fontSize: 11, + fontWeight: 700, + lineHeight: '16px', + letterSpacing: '0.35em', + textTransform: 'uppercase', + color: '#666666', + border: 0, + margin: '56px 0 12px', +}; + +const nameToHash = (name: string) => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + +const disclaimerStyle: React.CSSProperties = { + margin: '20px 0 0', + padding: '18px 22px', + border: '1px solid #e1dfdd', + borderLeft: '4px solid #9b1f5a', + borderRadius: 6, + background: '#fdf6f9', + color: '#3c3c3c', + fontSize: 19, + lineHeight: 1.55, +}; + +const disclaimerNoteStyle: React.CSSProperties = { + marginTop: 12, + paddingTop: 12, + borderTop: '1px dashed #e1c2d2', + fontSize: 19, + lineHeight: 1.55, + color: '#3c3c3c', +}; + +export const HeadlessDocsPage: React.FC = () => { + const docsContext = React.useContext(DocsContext); + const stories = docsContext.componentStories(); + + const primaryStory = stories[0]; + const remainingStories = stories.slice(1); + + return ( +
+ {/* + The `@fluentui/react-storybook-addon-export-to-sandbox` decorator looks + for `.docblock-code-toggle` inside `.docs-story` of each story to anchor + its "Open in Stackblitz" button. We keep Canvas's default sourceState + ('hidden') so the native "Show code" toggle is rendered there too — + the Stackblitz button sits next to it inside the canvas footer (see + `HeadlessSourcePanel` for how its clicks drive our tabbed panel). + */} + + <Subtitle /> + <Description /> + <aside style={disclaimerStyle} role="note"> + <div> + <strong>Heads up:</strong> headless components ship without default styles. The CSS shown in these stories is + provided purely as a demonstration of one possible look. + </div> + <div style={disclaimerNoteStyle}> + <strong>Preview:</strong> these controls are in preview and their APIs are subject to change. + </div> + </aside> + + {primaryStory && ( + <> + <hr style={dividerStyle} /> + <HeaderMdx as="h3" id={nameToHash(primaryStory.name)}> + {primaryStory.name} + </HeaderMdx> + <Anchor storyId={primaryStory.id}> + <Canvas of={primaryStory.moduleExport} /> + <HeadlessSourcePanel of={primaryStory.moduleExport} /> + </Anchor> + </> + )} + + {/* Component-level props table (mirrors what FluentDocsPage renders). */} + <ArgTypes /> + + {remainingStories.length > 0 && ( + <> + <h2 style={storiesHeadingStyle}>Stories</h2> + {remainingStories.map(story => ( + <Anchor key={story.id} storyId={story.id}> + <HeaderMdx as="h3" id={nameToHash(story.name)}> + {story.name} + </HeaderMdx> + <Description of={story.moduleExport} /> + <Canvas of={story.moduleExport} /> + <HeadlessSourcePanel of={story.moduleExport} /> + </Anchor> + ))} + </> + )} + </div> + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/HeadlessSourcePanel.tsx b/packages/react-components/react-headless-components-preview/stories/.storybook/HeadlessSourcePanel.tsx new file mode 100644 index 00000000000000..1fc3c531df5380 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/HeadlessSourcePanel.tsx @@ -0,0 +1,281 @@ +/** + * `HeadlessSourcePanel` — a docs block that renders the "Show code" panel for a + * headless story with **tabs**: one for the story TSX, one per CSS Module + * referenced by the story's meta. Replaces Storybook's built-in single-blob + * Source block (which can't show two languages side-by-side). + * + * The tabbed panel is driven by Storybook's native "Show code" toggle that + * Canvas renders inside its footer (alongside the "Open in Stackblitz" button + * injected by `@fluentui/react-storybook-addon-export-to-sandbox`). We listen + * to that toggle's clicks via a click handler on its DOM node and mirror its + * open/closed state into local React state — keeping the UX of two buttons + * sitting together in the canvas footer (matching the deployed Fluent docs) + * while still showing the multi-language tabbed panel below the canvas card. + * + * Wired up by `HeadlessDocsPage`. The story's TSX comes from + * `parameters.fullSource` (injected by the babel-preset-storybook-full-source + * plugin at build time); the CSS comes from `parameters.cssModuleSources.cssModules` (also auto-detected + * by the same babel plugin from `*.module.css` imports). + * + * Styled via Storybook's `styled` (emotion) so the panel inherits the + * active SB theme tokens and stays consistent with the rest of the docs chrome. + */ +/* eslint-disable @nx/workspace-no-restricted-globals -- Storybook docs block running in the manager iframe; uses DOM APIs to bridge to the native Canvas toggle that lives outside React. */ +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +// Storybook's docs blocks live behind a deep import. +import { useOf } from '@storybook/addon-docs/blocks'; +// `SyntaxHighlighter` is part of Storybook's internal UI kit and already +// matches the rest of the docs chrome — reusing it keeps the panel visually +// consistent with everything else Storybook renders. +import { SyntaxHighlighter } from 'storybook/internal/components'; +import { styled } from 'storybook/theming'; + +/** A CSS Module file surfaced as a tab in the "Show code" panel. */ +interface CssModule { + name: string; + source: string; +} + +/** Shape consumed via `story.parameters.cssModuleSources`. */ +interface HeadlessSourceParameters { + cssModules?: CssModule[]; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyProps = Record<string, any>; + +interface HeadlessSourcePanelProps { + /** Reference to the story being rendered (`story.moduleExport`). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + of: any; +} + +const ACTIVE_TAB_FG = '#9b1f5a'; + +const PanelContainer = styled.div(({ theme }) => ({ + // Blend into the canvas card: no own border/radius, just a top divider and + // breathing room below the action bar (Show code / Open in Stackblitz). + marginTop: 16, + borderTop: `1px solid ${theme.appBorderColor}`, + background: theme.background.content, +})); + +const TabBar = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'stretch', + background: theme.background.app, + borderBottom: `1px solid ${theme.appBorderColor}`, +})); + +const TabButton = styled.button<{ active: boolean }>(({ active, theme }) => ({ + appearance: 'none', + border: 0, + background: 'transparent', + padding: '10px 14px', + font: 'inherit', + fontSize: 12, + fontWeight: active ? 700 : 500, + color: active ? ACTIVE_TAB_FG : theme.color.mediumdark, + cursor: 'pointer', + borderBottom: `2px solid ${active ? ACTIVE_TAB_FG : 'transparent'}`, + marginBottom: -1, + whiteSpace: 'nowrap', +})); + +/** + * Subscribe to the native "Show code" toggle that Canvas renders inside the + * `.docs-story` element for `storyId`. Returns the current open/closed state. + * The selectors mirror those used by `react-storybook-addon-export-to-sandbox` + * to find the same button (supports both Storybook < 10 and >= 10 anchor IDs). + */ +function useNativeToggleState(storyId: string): boolean { + const [expanded, setExpanded] = React.useState(false); + + React.useEffect(() => { + const selector = [ + `#anchor--${storyId} .docs-story .docblock-code-toggle:not(.with-code-sandbox-button)`, + `#anchor--primary--${storyId} .docs-story .docblock-code-toggle:not(.with-code-sandbox-button)`, + ].join(', '); + + let cleanups: Array<() => void> = []; + let cancelled = false; + + const attach = () => { + if (cancelled) { + return true; + } + const button = document.querySelector<HTMLButtonElement>(selector); + if (!button) { + return false; + } + const onClick = () => { + // Native toggle has no aria-expanded — flip our mirror on every click. + setExpanded(prev => !prev); + }; + button.addEventListener('click', onClick); + cleanups.push(() => button.removeEventListener('click', onClick)); + return true; + }; + + if (!attach()) { + // Canvas mounts asynchronously; poll briefly for the toggle to appear. + const interval = window.setInterval(() => { + if (attach()) { + window.clearInterval(interval); + } + }, 100); + cleanups.push(() => window.clearInterval(interval)); + } + + return () => { + cancelled = true; + cleanups.forEach(fn => fn()); + cleanups = []; + }; + }, [storyId]); + + return expanded; +} + +/** + * Find the canvas card (`.sbdocs-preview`) for `storyId` and append (once) a + * portal target div as its last child. Returns the element when ready so + * `HeadlessSourcePanel` can render its tabbed panel **inside** the same bordered card + * as the story preview, rather than as a detached block below it. + */ +function useCanvasPortalTarget(storyId: string): HTMLElement | null { + const [target, setTarget] = React.useState<HTMLElement | null>(null); + + React.useEffect(() => { + const anchorSelector = [`#anchor--${storyId}`, `#anchor--primary--${storyId}`].join(', '); + let cancelled = false; + let interval: number | undefined; + let portalEl: HTMLDivElement | null = null; + + const attach = () => { + if (cancelled) { + return true; + } + const anchor = document.querySelector<HTMLElement>(anchorSelector); + const card = anchor?.querySelector<HTMLElement>('.sbdocs-preview'); + if (!card) { + return false; + } + // Look for an existing target so multiple mounts of `HeadlessSourcePanel` (in + // dev / fast-refresh) reuse the same node. + let existing = card.querySelector<HTMLDivElement>(':scope > .headless-source-portal'); + if (!existing) { + existing = document.createElement('div'); + existing.className = 'headless-source-portal'; + // Storybook's `.sbdocs-preview > div` global rules paint a near-black + // background and drop shadow on direct children — explicitly reset + // both so the canvas card colour shows through behind our inset, + // rounded panel. + existing.style.background = 'transparent'; + existing.style.boxShadow = 'none'; + card.appendChild(existing); + } + portalEl = existing; + setTarget(existing); + return true; + }; + + if (!attach()) { + interval = window.setInterval(() => { + if (attach()) { + window.clearInterval(interval!); + interval = undefined; + } + }, 100); + } + + return () => { + cancelled = true; + if (interval !== undefined) { + window.clearInterval(interval); + } + if (portalEl && portalEl.parentElement) { + portalEl.parentElement.removeChild(portalEl); + } + }; + }, [storyId]); + + return target; +} + +export const HeadlessSourcePanel: React.FC<HeadlessSourcePanelProps> = ({ of }) => { + const { story } = useOf(of || 'story', ['story']) as { story: AnyProps }; + const expanded = useNativeToggleState(story.id); + const portalTarget = useCanvasPortalTarget(story.id); + const [activeTabId, setActiveTabId] = React.useState<string>('story-tsx'); + + // `fullSource` is injected at build time by `babel-preset-storybook-full-source`. + // It already contains cleaned imports (CSS module paths rewritten to `./styles/…`). + const tsxCode: string = typeof story.parameters?.fullSource === 'string' ? story.parameters.fullSource : ''; + const tsxLanguage = 'tsx' as const; + const allCssModules: CssModule[] = + (story.parameters?.cssModuleSources as HeadlessSourceParameters | undefined)?.cssModules ?? []; + + // The meta typically registers every CSS module a component touches across + // all stories so the Stackblitz sandbox can bundle them. For the per-story + // tab strip we only want the modules actually referenced in the displayed + // TSX — match by basename in import strings (e.g. `./styles/dialog.module.css` + // after `cleanStorySource`, or `./dialog.module.css?raw`). + const referencedBasenames = new Set(Array.from(tsxCode.matchAll(/([a-z][a-z0-9-]*\.module\.css)/gi), m => m[1])); + const cssModules = referencedBasenames.size + ? allCssModules.filter(m => referencedBasenames.has(m.name)) + : allCssModules; + + if (!expanded || !portalTarget) { + return null; + } + if (!tsxCode && cssModules.length === 0) { + return null; + } + + type Tab = { id: string; label: string; code: string; language: 'tsx' | 'css' }; + const tabs: Tab[] = [ + { id: 'story-tsx', label: 'Story.tsx', code: tsxCode, language: tsxLanguage }, + ...cssModules.map((m, i) => ({ id: `css-${i}`, label: m.name, code: m.source.trim(), language: 'css' as const })), + ]; + const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0]; + + return createPortal( + <PanelContainer className="sb-unstyled"> + {tabs.length > 1 && ( + <TabBar role="tablist" aria-label="Source code"> + {tabs.map(tab => ( + <TabButton + key={tab.id} + type="button" + role="tab" + aria-selected={tab.id === activeTab.id} + active={tab.id === activeTab.id} + onClick={() => setActiveTabId(tab.id)} + > + {tab.label} + </TabButton> + ))} + </TabBar> + )} + <div role="tabpanel"> + <SyntaxHighlighter + // `key` forces a fresh mount per tab so the highlighter resets its + // scroll position and copy button state between languages. + key={activeTab.id} + language={activeTab.language} + copyable + bordered={false} + padded + format={false} + showLineNumbers={false} + > + {activeTab.code} + </SyntaxHighlighter> + </div> + </PanelContainer>, + portalTarget, + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/css-modules-webpack.js b/packages/react-components/react-headless-components-preview/stories/.storybook/css-modules-webpack.js new file mode 100644 index 00000000000000..eae026b7f25eac --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/css-modules-webpack.js @@ -0,0 +1,54 @@ +/** + * Enables CSS Modules with debuggable class names in a Storybook webpack config. + * + * css-loader v5+ auto-detects `*.module.css` files via `modules.auto: true` (its default). + * This helper finds Storybook's built-in `\.css$` rule and sets a human-readable `localIdentName`. + * + * @param {{ config: import('webpack').Configuration }} options + */ +function registerCssModuleRules({ config }) { + /** + * @param {string} detail + * @returns {never} + */ + const fail = detail => { + throw new Error( + `registerCssModuleRules: ${detail}. Storybook's internal webpack config may have changed — please update this helper.`, + ); + }; + + const rules = config.module?.rules ?? []; + + for (const rule of rules) { + if (!rule || typeof rule !== 'object') continue; + if (!(rule.test instanceof RegExp) || rule.test.source !== /\.css$/.source) continue; + + const loaders = Array.isArray(rule.use) ? rule.use : []; + const cssLoaderEntry = loaders.find( + entry => + typeof entry === 'object' && + entry !== null && + 'loader' in entry && + /\bcss-loader\b/.test(/** @type {string} */ (entry.loader)), + ); + + if (!cssLoaderEntry || typeof cssLoaderEntry !== 'object' || !('options' in cssLoaderEntry)) { + fail('found the .css$ rule but it no longer contains a css-loader entry'); + } + + /** @type {{ options?: string | Record<string, unknown> }} */ + const loader = cssLoaderEntry; + + loader.options = { + ...(typeof loader.options === 'object' ? loader.options : {}), + modules: { auto: true, localIdentName: '[name]__[local]--[hash:base64:5]' }, + }; + return; + } + + fail('could not find the default .css$ webpack rule'); +} + +module.exports = { + registerCssModuleRules, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/headless-docs-page.css b/packages/react-components/react-headless-components-preview/stories/.storybook/headless-docs-page.css new file mode 100644 index 00000000000000..3e65ab063698b2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/headless-docs-page.css @@ -0,0 +1,47 @@ +/* + Loaded once from .storybook/preview.js. Drives the docs-page chrome around + the canvas card so HeadlessSourcePanel can portal its tabbed code panel into + the same bordered preview. +*/ + +.headless-docs-page .sbdocs-preview > *:not(.docs-story):not(.headless-source-portal) { + display: none !important; +} + +.headless-docs-page .sbdocs-preview:has(> .headless-source-portal:not(:empty)) { + height: auto !important; +} + +/* + Reset Storybook's default `.docs-story + div > div:last-child` chrome — + it paints a near-black background (selector specificity (0,2,2)) for the + legacy single-blob Source block. Our portaled HeadlessSourcePanel renders + its own light surface in the same DOM position, but emotion class names + alone can't beat that specificity. `!important` here is the simplest way + to win; alternatives like &&& chains or inline styles negate the value of + the panel's themed `styled` components. +*/ +.headless-source-portal > div { + background: var(--bg-elev) !important; + box-shadow: none !important; + border-radius: 0 !important; + right: auto !important; +} + +/* + Force the magenta accent for the "Show code" / "Open in Stackblitz" hover & + focus underlines. Storybook's ActionBar paints the underline via an inset + box-shadow driven by `theme.color.secondary`, and the + `@fluentui/react-storybook-addon-export-to-sandbox` styles hard-code a blue + underline on the Stackblitz button — both are overridden here so the canvas + action buttons match the rest of the headless docs accent. +*/ +.headless-docs-page .sbdocs-preview .docblock-code-toggle:hover, +.headless-docs-page .sbdocs-preview .docblock-code-toggle:focus, +.headless-docs-page .sbdocs-preview .docblock-code-toggle.docblock-code-toggle--expanded, +.headless-docs-page .docs-story .with-code-sandbox-button:hover, +.headless-docs-page .docs-story .with-code-sandbox-button:focus { + outline: none !important; + box-shadow: #9b1f5a 0 -3px 0 0 inset !important; + color: #9b1f5a !important; +} 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 67905c6bfe15f2..8d95fb49d632ff 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 @@ -1,13 +1,47 @@ +const path = require('path'); + const rootMain = require('../../../../../.storybook/main'); +const { + loadWorkspaceAddon, + getImportMappingsForExportToSandboxAddon, + processBabelLoaderOptions, +} = require('@fluentui/scripts-storybook'); +const { registerCssModuleRules } = require('./css-modules-webpack'); + +const repoRoot = path.resolve(__dirname, '../../../../..'); +const tsConfigPath = path.resolve(repoRoot, 'tsconfig.base.json'); + +/** + * @param {string | { name?: string }} addon + */ +function isNotExportToSandboxAddon(addon) { + const name = typeof addon === 'string' ? addon : addon?.name ?? ''; + return !name.includes('react-storybook-addon-export-to-sandbox'); +} module.exports = /** @type {Omit<import('../../../../../.storybook/main'), 'typescript'|'babel'>} */ ({ ...rootMain, stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], - addons: [...rootMain.addons], + addons: [ + ...rootMain.addons.filter(isNotExportToSandboxAddon), + loadWorkspaceAddon('@fluentui/react-storybook-addon-export-to-sandbox', { + tsConfigPath, + /** @type {import('../../../react-storybook-addon-export-to-sandbox/src/index').PresetConfig} */ + options: { + importMappings: getImportMappingsForExportToSandboxAddon(), + babelLoaderOptionsUpdater: processBabelLoaderOptions, + cssModules: { tokensFilePath: path.resolve(__dirname, 'tokens.css') }, + webpackRule: { + test: /\.stories\.tsx$/, + include: /stories/, + }, + }, + }), + ], webpackFinal: (config, options) => { - const localConfig = { ...rootMain.webpackFinal(config, options) }; + const localConfig = /** @type {any} */ ({ ...rootMain.webpackFinal(config, options) }); - // add your own webpack tweaks if needed + registerCssModuleRules({ config: localConfig }); return localConfig; }, diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/preview-head.html b/packages/react-components/react-headless-components-preview/stories/.storybook/preview-head.html index 670a7917313c64..8505581d0e0d37 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/preview-head.html +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/preview-head.html @@ -1,5 +1,8 @@ -<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> -<style type="text/tailwindcss"> +<!-- + Story canvas head (per-package storybook). Mirrors the docsite at + apps/public-docsite-v9-headless/.storybook/preview-head.html. +--> +<style> :root { interpolate-size: allow-keywords; } 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 c9e29de4bb968b..a16824d5be6867 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 @@ -2,12 +2,26 @@ import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowles import * as rootPreview from '../../../../../.storybook/preview'; +// Design tokens — loaded once for every story. Defines :root (light) and +// [data-theme="dark"] CSS custom properties consumed by all *.module.css files. +import './tokens.css'; + +// Custom docs page chrome and the tabbed source panel for CSS modules +import './headless-docs-page.css'; +import { HeadlessDocsPage } from './HeadlessDocsPage'; + polyfillBodyAndObserve(); /** @type {typeof rootPreview.decorators} */ export const decorators = [...rootPreview.decorators]; /** @type {typeof rootPreview.parameters} */ -export const parameters = { ...rootPreview.parameters }; +export const parameters = { + ...rootPreview.parameters, + docs: { + ...rootPreview.parameters.docs, + page: HeadlessDocsPage, + }, +}; export const tags = ['autodocs']; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/theme.js b/packages/react-components/react-headless-components-preview/stories/.storybook/theme.js new file mode 100644 index 00000000000000..2d88b7f13bb96a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/theme.js @@ -0,0 +1,55 @@ +import { create } from 'storybook/theming'; + +/** + * Custom Storybook chrome for the headless components docsite. + * + * Values mirror the light-mode tokens in `tokens.css`. The Storybook + * theme builds at compile time and cannot read CSS custom properties, so the + * palette is inlined here. Update this file alongside `tokens.css` if + * the design tokens shift. + */ +const theme = create({ + base: 'light', + + // Storybook color palette + colorPrimary: '#9b1f5a', // matches --accent + colorSecondary: '#9b1f5a', + + // UI surfaces + appBg: '#f7f7f8', // --bg-soft + appContentBg: '#ffffff', // --bg + appPreviewBg: '#ffffff', + appBorderColor: '#e4e4e7', // --border + appBorderRadius: 12, // --radius-lg + + // Fonts + fontBase: '"Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif', + fontCode: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + + // Text + textColor: '#0a0a0a', // --text + textInverseColor: '#ffffff', // --text-on-accent + textMutedColor: '#52525b', // --text-muted + + // Toolbar + barTextColor: '#52525b', + barHoverColor: '#9b1f5a', + barSelectedColor: '#9b1f5a', + barBg: '#ffffff', + + // Form controls + buttonBg: '#ffffff', + buttonBorder: '#e4e4e7', + booleanBg: '#f2f2f4', // --surface-muted + booleanSelectedBg: '#9b1f5a', + inputBg: '#ffffff', + inputBorder: '#e4e4e7', + inputTextColor: '#0a0a0a', + inputBorderRadius: 8, // --radius-md + + brandTitle: 'Fluent UI Headless Components', + brandUrl: + 'https://github.com/microsoft/fluentui/tree/master/packages/react-components/react-headless-components-preview', +}); + +export default theme; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/tokens.css b/packages/react-components/react-headless-components-preview/stories/.storybook/tokens.css new file mode 100644 index 00000000000000..24a88d06ceb8bc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/tokens.css @@ -0,0 +1,209 @@ +/* ------------------------------------------------------------------ + * Design tokens + * + * Mapped from FOUNDATIONS pages of the design Figma file: + * - color algorithm/primitives/generics + * - elevation + * - gap & padding generics + * - stroke primitives/generics + * - border radius generic + * ----------------------------------------------------------------*/ +:root { + /* surface */ + --bg: #ffffff; + --bg-soft: #f7f7f8; + --bg-elev: #ffffff; + --bg-elev-2: #fafafa; + --surface-muted: #f2f2f4; + --surface-sunken: #ededf0; + + /* line */ + --border: #e4e4e7; + --border-strong: #d4d4d8; + --border-stronger: #a1a1aa; + + /* ink */ + --text: #0a0a0a; + --text-muted: #52525b; + --text-soft: #71717a; + --text-faint: #a1a1aa; + --text-on-accent: #ffffff; + + /* accent — primary action is rich the brand magenta on white (Figma --prmt-color-red-45) */ + --accent: #9b1f5a; + --accent-strong: #7a1a4a; + --accent-soft: #fff1f3; + --accent-contrast: #ffffff; + + /* brand — signature magenta, used sparingly for hot states */ + --brand: #a81f6a; + --brand-strong: #7a1a4a; + --brand-soft: #fff1f3; + + /* status (subtle pastels per Message Bar spec) */ + --success: #2e7d32; + --success-soft: #e8f5e9; + --warning: #b56e00; + --warning-soft: #fff4dc; + --danger: #c62828; + --danger-soft: #fdecea; + --info: #0d47a1; + --info-soft: #eaf2fb; + + /* elevation (light) — formula N=2E, R=elevation index */ + --shadow-1: 0 0 0 1px rgba(0, 0, 0, 0.02), 0 2px 2px rgba(0, 0, 0, 0.03); + --shadow-2: 0 0 0 1px rgba(0, 0, 0, 0.02), 0 4px 6px rgba(0, 0, 0, 0.04); + --shadow-3: 0 1px 0 rgba(0, 0, 0, 0.02), 0 8px 12px rgba(0, 0, 0, 0.06); + --shadow-4: 0 1px 0 rgba(0, 0, 0, 0.02), 0 16px 24px rgba(0, 0, 0, 0.08); + --shadow-5: 0 1px 0 rgba(0, 0, 0, 0.03), 0 20px 40px rgba(0, 0, 0, 0.1); + --shadow-6: 0 1px 0 rgba(0, 0, 0, 0.04), 0 32px 64px rgba(0, 0, 0, 0.16); + + /* legacy aliases */ + --shadow-sm: var(--shadow-1); + --shadow-md: var(--shadow-3); + --shadow-lg: var(--shadow-5); + + /* radius — atomic / composite / layout */ + --radius-xs: 4px; /* badges, small chips */ + --radius-sm: 6px; + --radius-md: 8px; /* buttons (when not pill), small inputs */ + --radius-lg: 12px; /* composite */ + --radius-xl: 16px; + --radius-2xl: 20px; /* cards, dialogs */ + --radius-3xl: 24px; + --radius-pill: 999px; + + /* stroke widths */ + --stroke-thin: 1px; + --stroke-thick: 2px; + --stroke-thicker: 3px; + + /* spacing — atomic, composite, layout */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + + /* type ramp (web) — derived from "Typography primitives - web" */ + --font-sans: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --font-display: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; + + --tracking-display: -0.03em; + --tracking-heading: -0.02em; + --tracking-tight: -0.01em; + + /* motion */ + --ease-standard: cubic-bezier(0.2, 0.7, 0.3, 1); + --ease-emphasized: cubic-bezier(0.32, 0.72, 0, 1); + --duration-fast: 120ms; + --duration-medium: 200ms; + --duration-slow: 320ms; +} + +[data-theme='dark'] { + --bg: #09090b; + --bg-soft: #0e0e10; + --bg-elev: #131316; + --bg-elev-2: #18181b; + --surface-muted: #1f1f23; + --surface-sunken: #0e0e10; + + --border: #26262a; + --border-strong: #3a3a40; + --border-stronger: #52525b; + + --text: #fafafa; + --text-muted: #a1a1aa; + --text-soft: #71717a; + --text-faint: #52525b; + --text-on-accent: #ffffff; + + --accent: #ec4899; + --accent-strong: #db2777; + --accent-soft: #3b1525; + --accent-contrast: #ffffff; + + --brand: #f472b6; + --brand-strong: #ec4899; + --brand-soft: #3b1525; + + --success: #4ade80; + --success-soft: #14361f; + --warning: #fbbf24; + --warning-soft: #3a2a08; + --danger: #f87171; + --danger-soft: #3a1414; + --info: #60a5fa; + --info-soft: #11243f; + + /* dark elevation: opacity values double per spec */ + --shadow-1: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 2px 2px rgba(0, 0, 0, 0.06); + --shadow-2: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 4px 6px rgba(0, 0, 0, 0.08); + --shadow-3: 0 1px 0 rgba(255, 255, 255, 0.04), 0 8px 12px rgba(0, 0, 0, 0.45); + --shadow-4: 0 1px 0 rgba(255, 255, 255, 0.04), 0 16px 24px rgba(0, 0, 0, 0.5); + --shadow-5: 0 1px 0 rgba(255, 255, 255, 0.06), 0 20px 40px rgba(0, 0, 0, 0.55); + --shadow-6: 0 1px 0 rgba(255, 255, 255, 0.08), 0 32px 64px rgba(0, 0, 0, 0.72); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'cv11', 'ss01', 'ss02'; + font-size: 14px; + line-height: 1.45; + letter-spacing: var(--tracking-tight); + transition: background-color var(--duration-medium) var(--ease-standard), + color var(--duration-medium) var(--ease-standard); +} + +a { + color: inherit; +} + +code { + font-family: var(--font-mono); + font-size: 0.86em; + background: var(--surface-muted); + padding: 0.1em 0.4em; + border-radius: var(--radius-xs); + letter-spacing: 0; +} + +button, +input, +textarea, +select { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; +} + +::selection { + background: var(--accent); + color: var(--accent-contrast); +} diff --git a/packages/react-components/react-headless-components-preview/stories/README.md b/packages/react-components/react-headless-components-preview/stories/README.md index 4ed8b62d2d8778..30edaef2b08741 100644 --- a/packages/react-components/react-headless-components-preview/stories/README.md +++ b/packages/react-components/react-headless-components-preview/stories/README.md @@ -1,17 +1,184 @@ # @fluentui/react-headless-components-preview-stories -Storybook stories for packages/react-components/react-headless-components-preview +Storybook stories for [`@fluentui/react-headless-components-preview`](../library). + +These stories double as the visual reference for the "Design system" design language: the +headless components stay unstyled in `library/`, all visual concerns live in CSS +Modules, and the stories pull both together. +`.storybook/tokens.css` is imported once in `.storybook/preview.js` and defines +`:root` (light) and `[data-theme="dark"]` (dark) CSS variables for every story. ## Usage -To include within storybook specify stories globs: +To include these stories in a Storybook composition, specify the stories globs: -\`\`\`js +```js module.exports = { -stories: ['../packages/react-components/react-headless-components-preview/stories/src/**/*.mdx', '../packages/react-components/react-headless-components-preview/stories/src/**/index.stories.@(ts|tsx)'], -} -\`\`\` + stories: [ + '../packages/react-components/react-headless-components-preview/stories/src/**/*.mdx', + '../packages/react-components/react-headless-components-preview/stories/src/**/index.stories.@(ts|tsx)', + ], +}; +``` ## API -no public API available +No public API — this package only ships stories. + +--- + +## Authoring a new component story + +### 1 · The pattern at a glance + +For each new component: + +1. Create the headless component under `library/src/components/<Name>/` (out of + scope for this guide). +2. Add a CSS Module at `stories/src/<Component>/<name>.module.css` driven entirely by + `var(--…)` from `.storybook/tokens.css`. **Do not hardcode colors, sizes, or + typography.** +3. Add a stories folder at `stories/src/<Name>/` containing: + - `<Name>Description.md` — short MDX-friendly markdown component description. + - `<Name>Default.stories.tsx` (and any extra variant `*.stories.tsx`). + - `index.stories.tsx` — meta export with `title`, `component`, and docs + description (see §3). + +The component itself stays unstyled in `library/`. All visual concerns live in +the CSS Module, and stories pull both together. + +### 2 · Story file boilerplate + +```tsx +import * as React from 'react'; +import { MyComponent } from '@fluentui/react-headless-components-preview/my-component'; +import styles from './my-component.module.css'; + +export const Default = (): React.ReactNode => <MyComponent className={styles.root} />; +``` + +- No inline styles, no Tailwind, no Griffel. Tokens come from `.storybook/tokens.css`. +- Every CSS value must resolve through a `var(--…)` token. + +### 3 · Show code wiring (`index.stories.tsx`) + +The docsite's "Show code" panel is fully automatic — no manual wiring needed: + +- **Story TSX** and **CSS Module sources** are injected at build time by + `@fluentui/babel-preset-storybook-full-source`. Just `import` your + `*.module.css` file and the plugin handles the rest. + +```tsx +import { MyComponent } from '@fluentui/react-headless-components-preview/my-component'; + +import descriptionMd from './MyComponentDescription.md'; +import classes from './my-component.module.css'; + +export { Default } from './MyComponentDefault.stories'; + +export default { + title: 'Headless Components/MyComponent', + component: MyComponent, + parameters: { + docs: { + description: { component: descriptionMd }, + }, + }, +}; +``` + +If a story uses multiple CSS modules (e.g. `Field` stories nest `Input`), just +import them all — the Babel plugin collects every `*.module.css` import it +finds. + +### 4 · Token tiers + +| Tier | Variables (selected) | +| --------- | -------------------------------------------------------------------------------------- | +| Surface | `--bg`, `--bg-soft`, `--bg-elev`, `--bg-elev-2`, `--surface-muted`, `--surface-sunken` | +| Line | `--border`, `--border-strong`, `--border-stronger` | +| Ink | `--text`, `--text-muted`, `--text-soft`, `--text-faint`, `--text-on-accent` | +| Accent | `--accent`, `--accent-strong`, `--accent-soft`, `--accent-contrast` | +| Brand | `--brand`, `--brand-strong`, `--brand-soft` (signature magenta — hot states only) | +| Status | `--success`, `--warning`, `--danger`, `--info` (each with a `-soft` companion) | +| Elevation | `--shadow-1` … `--shadow-6` (dark mode doubles opacity) | +| Radius | `--radius-xs` 4 px → `--radius-3xl` 24 px, `--radius-pill` 999 px | +| Stroke | `--stroke-thin/thick/thicker` (1 / 2 / 3 px) | +| Spacing | `--space-1` … `--space-16` on a 4 px grid | +| Type | `--font-sans` (Segoe UI), `--font-mono`, `--font-display` | +| Motion | `--ease-standard`, `--ease-emphasized`, `--duration-fast/medium/slow` | + +Read the file directly when in doubt: `.storybook/tokens.css`. + +### 5 · Visual language conventions + +- **Monochrome by default.** Primary action is the dark accent; everything else + lives on a neutral gray ramp. +- **Pill-shaped controls.** Buttons, toggle buttons, message bars, badges, the + tab segmented control, switch — all `--radius-pill`. +- **Generous radii on surfaces.** Cards, panels, dialogs use `--radius-2xl` + (20 px) or `--radius-xl` (16 px). +- **Subtle elevation.** Default surfaces are flat; only floating overlays use + `--shadow-3` or higher. +- **Magenta is reserved.** `--brand` shows up only for input validation errors, + the focus halo on chat-input, and the danger button. Don't use it as a + generic accent. + +### 6 · Headless / icon gotchas + +These are the things that took time to discover. Keep them in mind: + +- **Headless Divider has no internal line element** — render the line via + `::before` and `::after` on the root. The headless component only renders + `<root><wrapper>{children}</wrapper></root>`. +- **The chat-input pattern is just an `Input`** — not a separate component. The + `[+]` / mic / send arrangement comes from `contentBefore` / `contentAfter`. +- **Some Fluent icon names that look obvious do not exist.** Examples: + `ProgressRingDotsRegular`, `ShimmerRegular`, `WaveformRegular`, + `LoaderRegular`. Real equivalents: `DataBarHorizontalRegular`, `BoxRegular`, + `MicPulseRegular`, `SpinnerIosRegular`. Verify against + `node_modules/@fluentui/react-icons/lib/icons/chunk-*.d.ts` before using. +- **Hidden-input pattern.** Checkbox / Switch / Radio / Slider all position the + real `<input>` absolutely with `opacity: 0` over their visual indicator. The + CSS targets `.input:checked + .indicator` etc. Don't replace the native input + — accessibility depends on it. +- **Slider exposes `--fui-Slider--progress`.** Use it for both the rail fill + width and the thumb position. Don't compute it yourself. +- **`data-disabled` vs `data-disabled-focusable`.** The headless components + emit both. Style them the same; the difference is keyboard reachability, not + visual. +- **Disabled focus rings.** Don't suppress them — focus-visible stays on + disabled-focusable so screen-reader users still see context. + +### 7 · Verification before opening a PR + +- [ ] No inline styles, no Tailwind, no Griffel — only CSS Modules + the + headless component. +- [ ] All colors / sizes / typography come through `var(--…)`. Search the diff + for raw `#` and `rgb(` to confirm. +- [ ] The story renders in both `data-theme="light"` and `data-theme="dark"` + without manual overrides. +- [ ] `yarn nx run react-headless-components-preview-stories:build-storybook` + succeeds (this is the build PR previews run; see + `.github/workflows/pr-website-deploy.yml`). +- [ ] `yarn nx run public-docsite-v9-headless:build-storybook` succeeds (the + deployed docsite; see `.github/workflows/docsite-publish-ghpages.yml`). +- [ ] Open the story in a browser and verify focus rings and disabled states + visually — these are the most-likely-to-regress areas. +- [ ] The "Show code" panel shows both the JSX and the CSS Module source. + +### 8 · Where things live + +| Path | Purpose | +| ---------------------------------------------------- | ------------------------------------------------------------------- | +| `.storybook/tokens.css` | CSS custom properties, light + dark. Imported once in `preview.js`. | +| `stories/src/<Component>/<name>.module.css` | Per-component scoped styles. | +| `stories/src/<Name>/<Name>Default.stories.tsx` | Default story body using CSS Module classes. | +| `stories/src/<Name>/<Name>Description.md` | Component description shown in the Docs panel. | +| `stories/src/<Name>/index.stories.tsx` | Meta + component docs description. | +| `stories/.storybook/css-modules-webpack.js` | Source-of-truth webpack wiring for `*.module.css`. | +| `stories/.storybook/main.js` | Per-package storybook (consumes the shared webpack module). | +| `stories/.storybook/HeadlessDocsPage.tsx` | Custom docs page wired into `parameters.docs.page`. | +| `stories/.storybook/HeadlessSourcePanel.tsx` | Tabbed "Show code" panel (TSX + each referenced CSS Module). | +| `apps/public-docsite-v9-headless/.storybook/main.js` | Deployed docsite config (re-exports from the stories storybook). | +| `typings/static-assets/index.d.ts` | Ambient `*.module.css` declaration (workspace-wide). | diff --git a/packages/react-components/react-headless-components-preview/stories/src/Accordion/AccordionCollapsible.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Accordion/AccordionCollapsible.stories.tsx index 3d2e689daf0b16..f35de098c055c9 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Accordion/AccordionCollapsible.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Accordion/AccordionCollapsible.stories.tsx @@ -7,28 +7,25 @@ import { } from '@fluentui/react-headless-components-preview/accordion'; import { ChevronRightRegular } from '@fluentui/react-icons'; +import styles from './accordion.module.css'; const items = [ - { value: 'item-1', header: 'Accordion Header 1', panel: 'Accordion Panel 1' }, - { value: 'item-2', header: 'Accordion Header 2', panel: 'Accordion Panel 2' }, - { value: 'item-3', header: 'Accordion Header 3', panel: 'Accordion Panel 3' }, + { value: 'item-1', header: 'Section one', panel: 'All items can be collapsed.' }, + { value: 'item-2', header: 'Section two', panel: 'Click an open item to close it.' }, + { value: 'item-3', header: 'Section three', panel: 'Only one item open at a time.' }, ]; export const Collapsible = (): React.ReactNode => ( - <Accordion className="flex w-full max-w-96 flex-col justify-center text-gray-900" collapsible> + <Accordion className={`${styles.accordion} ${styles.demo}`} collapsible> {items.map(item => ( - <AccordionItem className="group border-b border-gray-200 last:border-b-0" key={item.value} value={item.value}> + <AccordionItem className={styles.item} key={item.value} value={item.value}> <AccordionHeader - button={{ - className: - 'border-none relative flex w-full items-baseline gap-3 py-2 px-3 text-left font-semibold hover:bg-gray-50 focus-visible:z-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - }} - expandIcon={<ChevronRightRegular className="group-data-[open]:rotate-90 transition-transform" />} + className={styles.header} + button={{ className: styles.headerBtn }} + expandIcon={<ChevronRightRegular className={styles.expandIcon} aria-hidden />} > - {item.header} + <span className={styles.label}>{item.header}</span> </AccordionHeader> - <AccordionPanel className="group-data-[open]:h-max overflow-hidden text-base text-gray-600 transition-[height] ease-out h-0"> - <div className="p-3">{item.panel}</div> - </AccordionPanel> + <AccordionPanel className={styles.panel}>{item.panel}</AccordionPanel> </AccordionItem> ))} </Accordion> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Accordion/AccordionDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Accordion/AccordionDefault.stories.tsx index 5ce3de96bb758f..9d1fe0af86a398 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Accordion/AccordionDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Accordion/AccordionDefault.stories.tsx @@ -7,28 +7,37 @@ import { } from '@fluentui/react-headless-components-preview/accordion'; import { ChevronRightRegular } from '@fluentui/react-icons'; +import styles from './accordion.module.css'; const items = [ - { value: 'item-1', header: 'Accordion Header 1', panel: 'Accordion Panel 1' }, - { value: 'item-2', header: 'Accordion Header 2', panel: 'Accordion Panel 2' }, - { value: 'item-3', header: 'Accordion Header 3', panel: 'Accordion Panel 3' }, + { + value: 'overview', + header: 'Overview', + panel: 'A short summary of what this section is about. The design system favours generous radii and quiet borders.', + }, + { + value: 'details', + header: 'Details', + panel: 'Deeper details rendered inside the panel. The reveal animation is driven by data-open.', + }, + { + value: 'extras', + header: 'Extras', + panel: 'Supporting content. The expand icon rotates 90° when the item opens.', + }, ]; export const Default = (): React.ReactNode => ( - <Accordion className="flex w-full max-w-96 flex-col justify-center text-gray-900"> + <Accordion className={`${styles.accordion} ${styles.demo}`}> {items.map(item => ( - <AccordionItem className="group border-b border-gray-200 last:border-b-0" key={item.value} value={item.value}> + <AccordionItem className={styles.item} key={item.value} value={item.value}> <AccordionHeader - button={{ - className: - 'border-none relative flex w-full items-baseline gap-3 py-2 px-3 text-left font-semibold hover:bg-gray-50 focus:outline-none focus-visible:z-1 focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - }} - expandIcon={<ChevronRightRegular className="group-data-[open]:rotate-90 transition-transform" />} + className={styles.header} + button={{ className: styles.headerBtn }} + expandIcon={<ChevronRightRegular className={styles.expandIcon} aria-hidden />} > - {item.header} + <span className={styles.label}>{item.header}</span> </AccordionHeader> - <AccordionPanel className="group-data-[open]:h-max overflow-hidden text-base text-gray-600 transition-[height] ease-out h-0"> - <div className="p-3">{item.panel}</div> - </AccordionPanel> + <AccordionPanel className={styles.panel}>{item.panel}</AccordionPanel> </AccordionItem> ))} </Accordion> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Accordion/accordion.module.css b/packages/react-components/react-headless-components-preview/stories/src/Accordion/accordion.module.css new file mode 100644 index 00000000000000..0231ec8bc2b030 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Accordion/accordion.module.css @@ -0,0 +1,85 @@ +.accordion { + display: flex; + flex-direction: column; + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elev); + overflow: hidden; +} + +.item { + border-bottom: 1px solid var(--border); +} + +.item:last-child { + border-bottom: none; +} + +.header { + margin: 0; +} + +.headerBtn { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: transparent; + border: none; + font-size: 13.5px; + font-weight: 500; + color: var(--text); + cursor: pointer; + text-align: left; + transition: background var(--duration-fast) var(--ease-standard); +} + +.headerBtn:hover { + background: var(--surface-muted); +} + +.headerBtn:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--accent); +} + +.expandIcon { + width: 14px; + height: 14px; + color: var(--text-muted); + transition: transform var(--duration-medium) var(--ease-emphasized); + flex-shrink: 0; +} + +.item[data-open] .expandIcon { + transform: rotate(90deg); + color: var(--text); +} + +.panel { + padding: 0 18px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.65; + max-height: 0; + overflow: hidden; + transition: max-height var(--duration-medium) var(--ease-emphasized), + padding var(--duration-medium) var(--ease-emphasized); +} + +.item[data-open] .panel { + max-height: 320px; + padding: 0 18px 16px; +} + +.label { + flex: 1; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + max-width: 480px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Accordion/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Accordion/index.stories.tsx index 6b9e2b454ff981..a67b98651d01b9 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Accordion/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Accordion/index.stories.tsx @@ -6,7 +6,6 @@ import { } from '@fluentui/react-headless-components-preview/accordion'; import descriptionMd from './AccordionDescription.md'; - export { Default } from './AccordionDefault.stories'; export { Collapsible } from './AccordionCollapsible.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Avatar/AvatarDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Avatar/AvatarDefault.stories.tsx index 111ffb3e402a21..a6898d3c5deb23 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Avatar/AvatarDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Avatar/AvatarDefault.stories.tsx @@ -1,21 +1,42 @@ import * as React from 'react'; import { Avatar } from '@fluentui/react-headless-components-preview/avatar'; +import styles from './avatar.module.css'; export const Default = (): React.ReactNode => ( - <div className="flex items-center gap-4 flex-wrap"> - <Avatar - name="Alice Johnson" - className="inline-flex items-center justify-center rounded-full text-sm font-semibold text-white select-none overflow-hidden shrink-0 size-10 bg-gray-900" - /> + <div className={styles.demo}> + <div className={styles.demoRow}> + <Avatar name="Alice Johnson" className={`${styles.avatar} ${styles.size32}`} /> + <Avatar name="Bilal Ahmad" className={`${styles.avatar} ${styles.size40} ${styles.tone1}`} /> + <Avatar name="Carlos Diaz" className={`${styles.avatar} ${styles.size56} ${styles.tone2}`} /> + <Avatar name="Dina Rivera" className={`${styles.avatar} ${styles.size72} ${styles.tone4}`} /> + </div> <Avatar - className="size-10 rounded-full overflow-hidden relative" + className={`${styles.avatar} ${styles.size56}`} name="Katri Athokas" - initials={{ className: 'absolute inset-0 flex items-center justify-center' }} + initials={{ className: styles.initials }} image={{ - className: 'absolute inset-0 object-cover', + className: styles.image, src: 'https://fabricweb.azureedge.net/fabric-website/assets/images/avatar/KatriAthokas.jpg', }} /> + + <div className={styles.stack}> + {['Alice', 'Bilal', 'Carlos', 'Dina'].map((name, i) => ( + <Avatar + key={name} + name={name} + className={`${styles.avatar} ${styles.size40} ${styles[`tone${(i % 4) + 1}` as 'tone1']}`} + /> + ))} + </div> + + <div className={styles.row}> + <Avatar name="Eve Park" className={`${styles.avatar} ${styles.size40} ${styles.tone3}`} /> + <div className={styles.meta}> + <span className={styles.metaName}>Eve Park</span> + <span className={styles.metaSub}>Product designer · Online</span> + </div> + </div> </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Avatar/avatar.module.css b/packages/react-components/react-headless-components-preview/stories/src/Avatar/avatar.module.css new file mode 100644 index 00000000000000..265d685083b8a6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Avatar/avatar.module.css @@ -0,0 +1,126 @@ +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--accent); + color: var(--accent-contrast); + font-weight: 600; + position: relative; + overflow: hidden; + flex-shrink: 0; + user-select: none; + letter-spacing: 0; +} + +.size32 { + width: 32px; + height: 32px; + font-size: 12px; +} + +.size40 { + width: 40px; + height: 40px; + font-size: 14px; +} + +.size56 { + width: 56px; + height: 56px; + font-size: 19px; +} + +.size72 { + width: 72px; + height: 72px; + font-size: 24px; +} + +.image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.initials { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* Tones — neutral grays + a single brand-pink accent so the group isn't monotonous */ +.tone1 { + background: #44403c; +} + +.tone2 { + background: #525252; +} + +.tone3 { + background: #57534e; +} + +.tone4 { + background: var(--brand); +} + +.stack { + display: inline-flex; +} + +.stack > * { + margin-left: -8px; + border: 2px solid var(--bg-elev); +} + +.stack > *:first-child { + margin-left: 0; +} + +.row { + display: flex; + align-items: center; + gap: 12px; +} + +.meta { + display: flex; + flex-direction: column; +} + +.metaName { + font-weight: 600; + color: var(--text); + font-size: 13.5px; +} + +.metaSub { + color: var(--text-muted); + font-size: 12.5px; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + display: flex; + + flex-direction: column; + + gap: 24px; +} + +.demoRow { + display: flex; + + align-items: center; + + gap: 12px; + + flex-wrap: wrap; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Avatar/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Avatar/index.stories.tsx index e85e9cae3f8b5d..fabf13de0d5f84 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Avatar/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Avatar/index.stories.tsx @@ -1,7 +1,6 @@ import { Avatar } from '@fluentui/react-headless-components-preview/avatar'; import descriptionMd from './AvatarDescription.md'; - export { Default } from './AvatarDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDefault.stories.tsx index cad735c00cd8d0..8f6715cd7655c6 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDefault.stories.tsx @@ -1,22 +1,27 @@ import * as React from 'react'; import { Badge } from '@fluentui/react-headless-components-preview/badge'; +import styles from './badge.module.css'; export const Default = (): React.ReactNode => ( - <div className="flex items-center gap-3 flex-wrap"> - <Badge className="inline-flex items-center rounded-full bg-gray-900 px-2.5 py-0.5 text-xs font-medium text-white"> - New - </Badge> - <Badge className="inline-flex items-center rounded-full bg-green-500 px-2.5 py-0.5 text-xs font-medium text-white"> + <div className={styles.demo}> + <Badge className={styles.badge}>Default</Badge> + <Badge className={`${styles.badge} ${styles.solid}`}>Solid</Badge> + <Badge className={`${styles.badge} ${styles.success}`}> + <span className={styles.dot} /> Success </Badge> - <Badge className="inline-flex items-center rounded-full bg-orange-500 px-2.5 py-0.5 text-xs font-medium text-white"> + <Badge className={`${styles.badge} ${styles.warning}`}> + <span className={styles.dot} /> Warning </Badge> - <Badge className="inline-flex items-center rounded-full bg-red-500 px-2.5 py-0.5 text-xs font-medium text-white"> + <Badge className={`${styles.badge} ${styles.danger}`}> + <span className={styles.dot} /> Error </Badge> - <Badge className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white"> - 9 + <Badge className={`${styles.badge} ${styles.info}`}> + <span className={styles.dot} /> + Info </Badge> + <Badge className={`${styles.badge} ${styles.counter}`}>9</Badge> </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Badge/badge.module.css b/packages/react-components/react-headless-components-preview/stories/src/Badge/badge.module.css new file mode 100644 index 00000000000000..2ffe77fef34ee5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Badge/badge.module.css @@ -0,0 +1,86 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 8px; + border-radius: var(--radius-pill); + background: var(--surface-muted); + color: var(--text); + border: 1px solid var(--border); + font-size: 11.5px; + font-weight: 500; + letter-spacing: 0; +} + +.solid { + background: var(--accent); + color: var(--accent-contrast); + border-color: transparent; +} + +.success { + background: var(--success-soft); + color: var(--success); + border-color: transparent; +} + +.warning { + background: var(--warning-soft); + color: var(--warning); + border-color: transparent; +} + +.danger { + background: var(--brand-soft); + color: var(--brand); + border-color: transparent; +} + +.info { + background: var(--info-soft); + color: var(--info); + border-color: transparent; +} + +.accent { + background: var(--surface-muted); + color: var(--text); + border-color: var(--border); +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + display: inline-block; +} + +.icon { + width: 12px; + height: 12px; +} + +.counter { + height: 18px; + min-width: 18px; + padding: 0 6px; + font-size: 10.5px; + font-weight: 600; + background: var(--brand); + color: white; + border-color: transparent; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + display: flex; + + align-items: center; + + gap: 12px; + + flex-wrap: wrap; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Badge/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Badge/index.stories.tsx index 1c8df0c4e5bee0..4fc5def4b0aa65 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Badge/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Badge/index.stories.tsx @@ -1,7 +1,6 @@ import { Badge } from '@fluentui/react-headless-components-preview/badge'; import descriptionMd from './BadgeDescription.md'; - export { Default } from './BadgeDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/BreadcrumbDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/BreadcrumbDefault.stories.tsx index b16fba2e3fc7c1..a0a68daa8ad447 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/BreadcrumbDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/BreadcrumbDefault.stories.tsx @@ -7,32 +7,23 @@ import { } from '@fluentui/react-headless-components-preview/breadcrumb'; import { ChevronRightRegular } from '@fluentui/react-icons'; -const linkClass = - 'text-gray-500 hover:text-gray-900 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 rounded transition-colors'; - +import styles from './breadcrumb.module.css'; export const Default = (): React.ReactNode => ( - <Breadcrumb - aria-label="Navigation" - className="flex items-center" - list={{ className: 'flex items-center gap-1 list-none m-0 p-0 text-sm' }} - > - <BreadcrumbItem className="flex items-center"> - <BreadcrumbButton className={linkClass}>Home</BreadcrumbButton> + <Breadcrumb aria-label="Navigation" className={styles.crumb} list={{ className: styles.list }}> + <BreadcrumbItem className={styles.item}> + <BreadcrumbButton className={styles.btn}>Home</BreadcrumbButton> </BreadcrumbItem> - <BreadcrumbDivider className="flex items-center text-gray-400"> - <ChevronRightRegular className="h-4 w-4" /> + <BreadcrumbDivider className={styles.divider}> + <ChevronRightRegular aria-hidden /> </BreadcrumbDivider> - <BreadcrumbItem className="flex items-center"> - <BreadcrumbButton className={linkClass}>Settings</BreadcrumbButton> + <BreadcrumbItem className={styles.item}> + <BreadcrumbButton className={styles.btn}>Settings</BreadcrumbButton> </BreadcrumbItem> - <BreadcrumbDivider className="flex items-center text-gray-400"> - <ChevronRightRegular className="h-4 w-4" /> + <BreadcrumbDivider className={styles.divider}> + <ChevronRightRegular aria-hidden /> </BreadcrumbDivider> - <BreadcrumbItem className="flex items-center"> - <BreadcrumbButton - current - className="font-medium text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 rounded data-[current]:cursor-default" - > + <BreadcrumbItem className={styles.item}> + <BreadcrumbButton current className={styles.btn}> Profile </BreadcrumbButton> </BreadcrumbItem> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/breadcrumb.module.css b/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/breadcrumb.module.css new file mode 100644 index 00000000000000..aea22063ad8183 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/breadcrumb.module.css @@ -0,0 +1,63 @@ +.crumb { + display: flex; + align-items: center; +} + +.list { + display: flex; + align-items: center; + gap: 2px; + list-style: none; + margin: 0; + padding: 0; + font-size: 13px; +} + +.item { + display: flex; + align-items: center; +} + +.btn { + background: none; + border: none; + padding: 4px 8px; + border-radius: var(--radius-md); + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); +} + +.btn:hover { + background: var(--surface-muted); + color: var(--text); +} + +.btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.btn[data-current] { + color: var(--text); + font-weight: 600; + cursor: default; +} + +.btn[data-current]:hover { + background: transparent; +} + +.divider { + display: inline-flex; + align-items: center; + color: var(--text-faint); + list-style: none; +} + +.divider svg { + width: 12px; + height: 12px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/index.stories.tsx index 8f6fc3e0155114..eff2b479f922e0 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/index.stories.tsx @@ -6,7 +6,6 @@ import { } from '@fluentui/react-headless-components-preview/breadcrumb'; import descriptionMd from './BreadcrumbDescription.md'; - export { Default } from './BreadcrumbDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Button/ButtonDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Button/ButtonDefault.stories.tsx index 319d2bf6b0554a..76192faaf98d6a 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Button/ButtonDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Button/ButtonDefault.stories.tsx @@ -1,19 +1,49 @@ import * as React from 'react'; import { Button } from '@fluentui/react-headless-components-preview/button'; +import { AddRegular } from '@fluentui/react-icons'; -const classes = { - button: - 'flex items-center justify-center h-10 px-4 m-0 border border-transparent rounded-md bg-gray-900 font-inherit text-base font-medium leading-6 text-white select-none cursor-pointer hover:bg-gray-800 hover:data-[disabled]:bg-gray-900 active:bg-gray-700 active:shadow-[inset_0_1px_3px_rgba(0,0,0,0.2)] active:data-[disabled]:bg-gray-900 active:data-[disabled]:shadow-none focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed', -}; - +import styles from './button.module.css'; export const Default = (): React.ReactNode => ( - <div className="flex gap-4"> - <Button className={classes.button}>Button</Button> - <Button className={classes.button} disabled> - Button disabled - </Button> - <Button className={classes.button} disabled disabledFocusable> - Button disabled focusable - </Button> + <div className={styles.demo}> + <div className={styles.demoRow}> + <Button className={styles.button}>Primary</Button> + <Button className={`${styles.button} ${styles.secondary}`}>Secondary</Button> + <Button className={`${styles.button} ${styles.subtle}`}>Subtle</Button> + <Button className={`${styles.button} ${styles.outline}`}>Outline</Button> + </div> + + <div className={styles.demoRow}> + <Button className={`${styles.button} ${styles.small}`}>Small</Button> + <Button className={styles.button}>Medium</Button> + <Button className={`${styles.button} ${styles.large}`}>Large</Button> + </div> + + <div className={styles.demoRow}> + <Button + className={styles.button} + icon={{ children: <AddRegular className={styles.icon} aria-hidden />, className: styles.icon }} + > + New project + </Button> + <Button + className={styles.button} + aria-label="Add" + icon={{ children: <AddRegular className={styles.icon} aria-hidden />, className: styles.icon }} + /> + <Button + className={`${styles.button} ${styles.secondary} ${styles.small} ${styles.iconOnlySmall}`} + aria-label="Add" + icon={{ children: <AddRegular className={styles.icon} aria-hidden />, className: styles.icon }} + /> + </div> + + <div className={styles.demoRow}> + <Button className={styles.button} disabled> + Disabled + </Button> + <Button className={styles.button} disabled disabledFocusable> + Disabled focusable + </Button> + </div> </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Button/button.module.css b/packages/react-components/react-headless-components-preview/stories/src/Button/button.module.css new file mode 100644 index 00000000000000..3040ff354df2f9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Button/button.module.css @@ -0,0 +1,149 @@ +/* button — pill-shaped, monochrome */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: var(--radius-pill); + border: 1px solid transparent; + background: var(--accent); + color: var(--accent-contrast); + font-size: 13px; + font-weight: 500; + letter-spacing: 0; + cursor: pointer; + user-select: none; + text-decoration: none; + transition: background-color var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), + transform 80ms var(--ease-standard); +} + +.button:hover { + background: var(--accent-strong); +} + +.button:active { + transform: scale(0.98); +} + +.button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.button[data-disabled], +.button[data-disabled-focusable] { + opacity: 0.4; + cursor: not-allowed; +} + +.button[data-disabled]:hover, +.button[data-disabled-focusable]:hover { + background: var(--accent); +} + +/* Secondary — light gray pill */ +.secondary { + background: var(--surface-muted); + color: var(--text); +} + +.secondary:hover { + background: var(--surface-sunken); +} + +.secondary[data-disabled]:hover, +.secondary[data-disabled-focusable]:hover { + background: var(--surface-muted); +} + +/* Subtle — text-only, no chrome until hover */ +.subtle { + background: transparent; + color: var(--text); +} + +.subtle:hover { + background: var(--surface-muted); +} + +.subtle[data-disabled]:hover, +.subtle[data-disabled-focusable]:hover { + background: transparent; +} + +/* Outline — bordered transparent */ +.outline { + background: transparent; + color: var(--text); + border-color: var(--border-strong); +} + +.outline:hover { + background: var(--surface-muted); + border-color: var(--text); +} + +/* Sizes */ +.small { + height: 26px; + padding: 0 10px; + font-size: 12px; +} + +.large { + height: 40px; + padding: 0 18px; + font-size: 14px; +} + +/* Icon-only — perfectly circular */ +.button[data-icon-only] { + width: 32px; + padding: 0; +} + +.iconOnlySmall[data-icon-only] { + width: 26px; + height: 26px; +} + +.iconOnlyLarge[data-icon-only] { + width: 40px; + height: 40px; +} + +.icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.large .icon, +.iconOnlyLarge .icon { + width: 16px; + height: 16px; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + display: flex; + + flex-direction: column; + + gap: 16px; +} + +.demoRow { + display: flex; + + gap: 12px; + + align-items: center; + + flex-wrap: wrap; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Button/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Button/index.stories.tsx index a2b39f2c0d2bfc..b8e9ad54c2e8e7 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Button/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Button/index.stories.tsx @@ -1,7 +1,6 @@ import { Button } from '@fluentui/react-headless-components-preview/button'; import descriptionMd from './ButtonDescription.md'; - export { Default } from './ButtonDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDefault.stories.tsx index 945601e50de4b4..6dfd6dd971b04e 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Card/CardDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDefault.stories.tsx @@ -2,72 +2,50 @@ import * as React from 'react'; import { Card, CardHeader, CardPreview, CardFooter } from '@fluentui/react-headless-components-preview/card'; import { MoreHorizontalRegular, ShareRegular, ArrowReplyRegular } from '@fluentui/react-icons'; -const classes = { - card: - 'flex flex-col gap-3 w-80 p-3 bg-white rounded-lg border border-gray-200 shadow-sm ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - preview: 'flex items-center justify-center bg-gray-100 rounded-md overflow-hidden -mx-3 -mt-3', - previewImage: 'block w-full h-40 object-cover', - header: 'flex items-center gap-3', - headerImage: 'flex h-10 w-10 rounded-md overflow-hidden bg-gray-100', - headerImg: 'h-full w-full object-cover', - headerTitle: 'text-sm font-semibold text-gray-900 leading-tight', - headerDescription: 'text-xs text-gray-500 leading-tight', - headerAction: 'ml-auto flex items-center', - iconButton: - 'inline-flex items-center justify-center h-8 w-8 rounded-md text-gray-600 ' + - 'hover:bg-gray-100 active:bg-gray-200 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - body: 'text-sm text-gray-700 leading-snug', - footer: 'flex items-center gap-2 pt-1', - footerButton: - 'inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm text-gray-700 border border-gray-200 ' + - 'hover:bg-gray-100 active:bg-gray-200 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', -}; +import styles from './card.module.css'; export const Default = (): React.ReactNode => ( - <Card className={classes.card}> - <CardPreview className={classes.preview}> + <Card className={styles.card}> + <CardPreview className={styles.preview}> <img - className={classes.previewImage} + className={styles.previewImage} src="https://fabricweb.azureedge.net/fabric-website/assets/images/wireframe/image.png" alt="Preview" /> </CardPreview> <CardHeader - className={classes.header} + className={styles.header} image={ - <div className={classes.headerImage}> + <div className={styles.headerImage}> <img - className={classes.headerImg} + className={styles.headerImg} src="https://fabricweb.azureedge.net/fabric-website/assets/images/wireframe/square-image.png" alt="" /> </div> } - header={<div className={classes.headerTitle}>App Name</div>} - description={<div className={classes.headerDescription}>Developer</div>} + header={<div className={styles.headerTitle}>App Name</div>} + description={<div className={styles.headerDescription}>Developer</div>} action={ - <div className={classes.headerAction}> - <button type="button" aria-label="More options" className={classes.iconButton}> + <div className={styles.headerAction}> + <button type="button" aria-label="More options" className={styles.iconButton}> <MoreHorizontalRegular /> </button> </div> } /> - <div className={classes.body}> + <div className={styles.body}> Donut chocolate bar oat cake. Dragée tiramisu lollipop bear claw. Marshmallow pastry jujubes toffee sugar plum. </div> - <CardFooter className={classes.footer}> - <button type="button" className={classes.footerButton}> + <CardFooter className={styles.footer}> + <button type="button" className={styles.footerButton}> <ArrowReplyRegular aria-hidden /> Reply </button> - <button type="button" className={classes.footerButton}> + <button type="button" className={styles.footerButton}> <ShareRegular aria-hidden /> Share </button> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardDisabled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDisabled.stories.tsx index 85f59ac289ab34..8107a80077440d 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Card/CardDisabled.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDisabled.stories.tsx @@ -1,44 +1,33 @@ import * as React from 'react'; import { Card, CardHeader, CardPreview } from '@fluentui/react-headless-components-preview/card'; -const classes = { - card: - 'relative flex flex-col gap-3 w-80 p-3 bg-white rounded-lg border border-gray-200 shadow-sm ' + - 'aria-disabled:opacity-50 aria-disabled:cursor-not-allowed', - checkbox: 'absolute top-3 left-3 h-4 w-4 accent-blue-600 disabled:cursor-not-allowed', - preview: 'flex items-center justify-center bg-gray-100 rounded-md overflow-hidden -mx-3 -mt-3', - previewImage: 'block w-full h-40 object-cover', - header: 'flex items-center gap-3 pl-6', - headerTitle: 'text-sm font-semibold text-gray-900 leading-tight', - headerDescription: 'text-xs text-gray-500 leading-tight', - body: 'text-sm text-gray-700 leading-snug', -}; +import styles from './card.module.css'; export const Disabled = (): React.ReactNode => ( <Card - className={classes.card} + className={`${styles.card} ${styles.cardSelectable}`} disabled selected onSelectionChange={() => { /* no-op */ }} - checkbox={{ className: classes.checkbox, 'aria-label': 'Select card' }} + checkbox={{ className: styles.checkbox, 'aria-label': 'Select card' }} > - <CardPreview className={classes.preview}> + <CardPreview className={styles.preview}> <img - className={classes.previewImage} + className={styles.previewImage} src="https://fabricweb.azureedge.net/fabric-website/assets/images/wireframe/image.png" alt="Preview" /> </CardPreview> <CardHeader - className={classes.header} - header={<div className={classes.headerTitle}>Disabled card</div>} - description={<div className={classes.headerDescription}>Selection is locked</div>} + className={`${styles.header} ${styles.headerWithSelect}`} + header={<div className={styles.headerTitle}>Disabled card</div>} + description={<div className={styles.headerDescription}>Selection is locked</div>} /> - <div className={classes.body}> + <div className={styles.body}> A disabled card sets `aria-disabled="true"` on the root and short-circuits selection toggling. </div> </Card> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx index dc6dfc5a0f00ef..b759a2ea3f0820 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx @@ -3,65 +3,41 @@ import type { CardOnSelectionChangeEvent } from '@fluentui/react-headless-compon import { Card, CardHeader, CardPreview } from '@fluentui/react-headless-components-preview/card'; import { MoreHorizontalRegular } from '@fluentui/react-icons'; -const classes = { - card: - 'relative flex flex-col gap-3 w-80 p-3 bg-white rounded-lg border border-gray-200 shadow-sm cursor-pointer ' + - 'hover:bg-gray-50 transition-colors ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 ' + - 'data-[selected]:border-blue-500 data-[selected]:ring-2 data-[selected]:ring-blue-500 ' + - 'aria-disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:hover:bg-white', - checkbox: - 'absolute top-3 left-3 h-4 w-4 cursor-pointer accent-blue-600 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - preview: 'flex items-center justify-center bg-gray-100 rounded-md overflow-hidden -mx-3 -mt-3', - previewImage: 'block w-full h-40 object-cover', - header: 'flex items-center gap-3 pl-6', - headerImage: 'flex h-10 w-10 rounded-md overflow-hidden bg-gray-100', - headerImg: 'h-full w-full object-cover', - headerTitle: 'text-sm font-semibold text-gray-900 leading-tight', - headerDescription: 'text-xs text-gray-500 leading-tight', - headerAction: 'ml-auto flex items-center', - iconButton: - 'inline-flex items-center justify-center h-8 w-8 rounded-md text-gray-600 ' + - 'hover:bg-gray-100 active:bg-gray-200 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - body: 'text-sm text-gray-700 leading-snug', - status: 'text-xs text-gray-500', -}; +import styles from './card.module.css'; const CardContent = ({ title }: { title: string }): React.ReactElement => ( <> - <CardPreview className={classes.preview}> + <CardPreview className={styles.preview}> <img - className={classes.previewImage} + className={styles.previewImage} src="https://fabricweb.azureedge.net/fabric-website/assets/images/wireframe/image.png" alt="Preview" /> </CardPreview> <CardHeader - className={classes.header} + className={`${styles.header} ${styles.headerWithSelect}`} image={ - <div className={classes.headerImage}> + <div className={styles.headerImage}> <img - className={classes.headerImg} + className={styles.headerImg} src="https://fabricweb.azureedge.net/fabric-website/assets/images/wireframe/square-image.png" alt="" /> </div> } - header={<div className={classes.headerTitle}>{title}</div>} - description={<div className={classes.headerDescription}>Developer</div>} + header={<div className={styles.headerTitle}>{title}</div>} + description={<div className={styles.headerDescription}>Developer</div>} action={ - <div className={classes.headerAction}> - <button type="button" aria-label="More options" className={classes.iconButton}> + <div className={styles.headerAction}> + <button type="button" aria-label="More options" className={styles.iconButton}> <MoreHorizontalRegular /> </button> </div> } /> - <div className={classes.body}> + <div className={styles.body}> Donut chocolate bar oat cake. Dragée tiramisu lollipop bear claw. Marshmallow pastry jujubes toffee sugar plum. </div> </> @@ -75,17 +51,17 @@ export const Selectable = (): React.ReactNode => { }; return ( - <div className="flex flex-col gap-3"> + <div className={styles.list}> <Card - className={classes.card} + className={`${styles.card} ${styles.cardSelectable}`} selected={selected} onSelectionChange={onSelectionChange} - checkbox={{ className: classes.checkbox, 'aria-label': 'Select card' }} + checkbox={{ className: styles.checkbox, 'aria-label': 'Select card' }} > <CardContent title="Selectable card" /> </Card> - <p className={classes.status}>Selected: {String(selected)}</p> + <p className={styles.status}>Selected: {String(selected)}</p> </div> ); }; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/card.module.css b/packages/react-components/react-headless-components-preview/stories/src/Card/card.module.css new file mode 100644 index 00000000000000..1fb057600a10c3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/card.module.css @@ -0,0 +1,206 @@ +.card { + display: flex; + flex-direction: column; + gap: var(--space-3); + width: 320px; + padding: var(--space-3); + background: var(--bg-elev); + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + position: relative; + /* + Clip children to the card's rounded shape — without this, the preview's + negative margins push its square top corners past the rounded card border + on selected/disabled variants. Border + box-shadow render outside the + overflow box and stay intact. + */ + overflow: hidden; +} + +.card:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.cardSelectable { + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease; +} + +.cardSelectable:hover { + background: var(--bg-soft); +} + +.cardSelectable[data-selected] { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.cardSelectable[aria-disabled='true'] { + opacity: 0.5; + cursor: not-allowed; +} + +.cardSelectable[aria-disabled='true']:hover { + background: var(--bg-elev); +} + +.preview { + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-muted); + border-radius: var(--radius-md); + overflow: hidden; + margin: calc(-1 * var(--space-3)) calc(-1 * var(--space-3)) 0; +} + +.previewImage { + display: block; + width: 100%; + height: 160px; + object-fit: cover; +} + +.header { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.headerWithSelect { + padding-left: var(--space-6); +} + +.headerImage { + display: flex; + height: 40px; + width: 40px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--surface-muted); + flex-shrink: 0; +} + +.headerImg { + height: 100%; + width: 100%; + object-fit: cover; +} + +.headerTitle { + font-size: 13.5px; + font-weight: 600; + color: var(--text); + line-height: 1.2; +} + +.headerDescription { + font-size: 12px; + color: var(--text-muted); + line-height: 1.2; +} + +.headerAction { + margin-left: auto; + display: flex; + align-items: center; +} + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + width: 32px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.iconButton:hover { + background: var(--surface-muted); + color: var(--text); +} + +.iconButton:active { + background: var(--surface-sunken); +} + +.iconButton:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.body { + font-size: 13.5px; + color: var(--text-muted); + line-height: 1.4; +} + +.footer { + display: flex; + align-items: center; + gap: var(--space-2); + padding-top: var(--space-1); +} + +.footerButton { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 var(--space-3); + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elev); + color: var(--text); + font-size: 13px; + cursor: pointer; +} + +.footerButton:hover { + background: var(--surface-muted); +} + +.footerButton:active { + background: var(--surface-sunken); +} + +.footerButton:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.checkbox { + position: absolute; + top: var(--space-3); + left: var(--space-3); + height: 16px; + width: 16px; + cursor: pointer; + accent-color: var(--accent); +} + +.checkbox:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.checkbox:disabled { + cursor: not-allowed; +} + +.list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.status { + font-size: 12px; + color: var(--text-muted); +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/index.stories.tsx index 0ec1d869929e99..ca0505ef173940 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Card/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/index.stories.tsx @@ -1,7 +1,6 @@ import { Card, CardHeader, CardPreview, CardFooter } from '@fluentui/react-headless-components-preview/card'; import descriptionMd from './CardDescription.md'; - export { Default } from './CardDefault.stories'; export { Selectable } from './CardSelectable.stories'; export { Disabled } from './CardDisabled.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Checkbox/CheckboxDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Checkbox/CheckboxDefault.stories.tsx index 6011effb9d2ace..cd6f76bd35749f 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Checkbox/CheckboxDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Checkbox/CheckboxDefault.stories.tsx @@ -2,15 +2,37 @@ import * as React from 'react'; import { Checkbox } from '@fluentui/react-headless-components-preview/checkbox'; import { CheckmarkRegular } from '@fluentui/react-icons'; +import styles from './checkbox.module.css'; export const Default = (): React.ReactNode => ( - <Checkbox - label="Default Checkbox" - className="flex items-center gap-2 relative" - indicator={{ - className: - 'border border-black rounded size-5 flex items-center justify-center peer-checked:bg-black transition-colors text-transparent peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-black peer-focus-visible:ring-offset-2', - children: <CheckmarkRegular className="size-4" />, - }} - input={{ className: 'absolute size-5 opacity-0 peer z-1' }} - /> + <div className={styles.list}> + <Checkbox + label={{ children: 'Send me updates', className: styles.label }} + className={styles.row} + input={{ className: styles.input }} + indicator={{ + className: styles.indicator, + children: <CheckmarkRegular className={styles.iconCheck} aria-hidden />, + }} + /> + <Checkbox + defaultChecked + label={{ children: 'Subscribe to newsletter', className: styles.label }} + className={styles.row} + input={{ className: styles.input }} + indicator={{ + className: styles.indicator, + children: <CheckmarkRegular className={styles.iconCheck} aria-hidden />, + }} + /> + <Checkbox + disabled + label={{ children: 'Disabled option', className: styles.label }} + className={styles.row} + input={{ className: styles.input }} + indicator={{ + className: styles.indicator, + children: <CheckmarkRegular className={styles.iconCheck} aria-hidden />, + }} + /> + </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Checkbox/checkbox.module.css b/packages/react-components/react-headless-components-preview/stories/src/Checkbox/checkbox.module.css new file mode 100644 index 00000000000000..78aeb19019d33a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Checkbox/checkbox.module.css @@ -0,0 +1,77 @@ +.row { + position: relative; + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; + font-size: 13.5px; + color: var(--text); + padding: 4px 0; +} + +.input { + position: absolute; + inset: 0; + width: 18px; + height: 18px; + opacity: 0; + cursor: pointer; + z-index: 1; +} + +.indicator { + width: 18px; + height: 18px; + border-radius: var(--radius-xs); + border: 1.5px solid var(--border-stronger); + background: var(--bg-elev); + display: inline-flex; + align-items: center; + justify-content: center; + color: transparent; + flex-shrink: 0; + transition: background var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); +} + +.row:hover .indicator { + border-color: var(--text); +} + +.input:checked + .indicator, +.input[data-state='mixed'] + .indicator { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); +} + +.input:focus-visible + .indicator { + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.input:disabled + .indicator { + background: var(--surface-muted); + border-color: var(--border); +} + +.label { + color: var(--text); +} + +.row[data-disabled] { + opacity: 0.4; + cursor: not-allowed; +} + +.iconCheck { + width: 12px; + height: 12px; + stroke-width: 2.5; +} + +.list { + display: flex; + flex-direction: column; + gap: 6px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Checkbox/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Checkbox/index.stories.tsx index 004d27b1c58db8..bd41f10e2e73da 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Checkbox/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Checkbox/index.stories.tsx @@ -1,7 +1,6 @@ import { Checkbox } from '@fluentui/react-headless-components-preview/checkbox'; import descriptionMd from './CheckboxDescription.md'; - export { Default } from './CheckboxDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogAlert.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogAlert.stories.tsx index 664cd8b849c831..2fe172fb3a76f4 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogAlert.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogAlert.stories.tsx @@ -8,6 +8,7 @@ import { DialogTrigger, } from '@fluentui/react-headless-components-preview/dialog'; +import styles from './dialog.module.css'; /** * An alert dialog uses `modalType="alert"`, which sets `role="alertdialog"` on the surface. * It is intended for critical messages that require the user to make a decision before proceeding. @@ -15,43 +16,35 @@ import { * Unlike a regular modal: * - Clicking the backdrop does NOT dismiss the alert dialog (only action buttons can). * - Screen readers announce it as an alert, giving it higher urgency. - * - * The user must explicitly choose "Delete" or "Cancel" — there is no escape hatch. */ -export const Alert = (): React.ReactNode => { - return ( - <Dialog modalType="alert"> - <DialogTrigger> - <button - type="button" - className="rounded bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-50 hover:bg-zinc-800" - > - Delete item - </button> - </DialogTrigger> +export const Alert = (): React.ReactNode => ( + <Dialog modalType="alert"> + <DialogTrigger> + <button type="button" className={`${styles.btn} ${styles.danger}`}> + Delete item + </button> + </DialogTrigger> - <DialogSurface className="fixed inset-0 m-auto w-full max-w-[400px] rounded-lg border border-zinc-200 bg-white p-0 shadow-lg"> - <DialogBody className="px-4 py-3 text-sm text-zinc-700"> - <DialogTitle className="mb-3 mt-0 text-lg font-semibold text-zinc-900">Delete item?</DialogTitle> - <p className="m-0">This action is permanent and cannot be undone. The item will be deleted immediately.</p> - </DialogBody> + <DialogSurface className={`${styles.surface} ${styles.alertSurface}`}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Delete item?</DialogTitle> + <p className={styles.copy}> + This action is permanent and cannot be undone. The item will be deleted immediately. + </p> + </DialogBody> - <DialogActions className="flex justify-end gap-2 px-4 pb-4"> - <DialogTrigger action="close"> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> - Cancel - </button> - </DialogTrigger> - <DialogTrigger action="close"> - <button - type="button" - className="rounded bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-50 hover:bg-zinc-800" - > - Delete - </button> - </DialogTrigger> - </DialogActions> - </DialogSurface> - </Dialog> - ); -}; + <DialogActions className={styles.actions}> + <DialogTrigger action="close"> + <button type="button" className={styles.btn}> + Cancel + </button> + </DialogTrigger> + <DialogTrigger action="close"> + <button type="button" className={`${styles.btn} ${styles.danger}`}> + Delete + </button> + </DialogTrigger> + </DialogActions> + </DialogSurface> + </Dialog> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogControlled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogControlled.stories.tsx index 8d8a713597c6c3..52f2d0069a96c8 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogControlled.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogControlled.stories.tsx @@ -8,14 +8,11 @@ import { DialogTrigger, } from '@fluentui/react-headless-components-preview/dialog'; +import styles from './dialog.module.css'; /** - * In controlled mode the parent component owns the open state. + * In controlled mode the parent owns the open state. * Pass `open` and `onOpenChange` together — `onOpenChange` fires for every - * dismiss gesture (Escape, backdrop click, trigger click) so the parent can - * decide whether to actually close. - * - * This example blocks closing until the user ticks a checkbox, - * demonstrating how to veto a close by calling `event.preventDefault()`. + * dismiss gesture (Escape, backdrop click, trigger click). */ export const Controlled = (): React.ReactNode => { const [open, setOpen] = React.useState(false); @@ -23,30 +20,28 @@ export const Controlled = (): React.ReactNode => { return ( <Dialog open={open} onOpenChange={(_, data) => setOpen(data.open)}> <DialogTrigger> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Open controlled dialog </button> </DialogTrigger> - <DialogSurface className="fixed inset-0 m-auto w-full max-w-[480px] rounded-lg border border-zinc-200 bg-white p-0 shadow-lg"> - <DialogBody className="px-4 py-3 text-sm text-zinc-700"> - <DialogTitle className="mb-3 mt-0 text-lg font-semibold text-zinc-900">Dialog title</DialogTitle> - Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam exercitationem cumque repellendus eaque est - dolor eius expedita nulla ullam? Tenetur reprehenderit aut voluptatum impedit voluptates in natus iure cumque - eaque? + <DialogSurface className={styles.surface}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Dialog title</DialogTitle> + <p className={styles.copy}> + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam exercitationem cumque repellendus eaque + est dolor eius expedita nulla ullam. + </p> </DialogBody> - <DialogActions className="flex justify-end gap-2 px-4 pb-4"> - <button - type="button" - className="rounded bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-50 hover:bg-zinc-800" - > - Do Something - </button> + <DialogActions className={styles.actions}> <DialogTrigger action="close"> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Close </button> </DialogTrigger> + <button type="button" className={`${styles.btn} ${styles.primary}`}> + Do something + </button> </DialogActions> </DialogSurface> </Dialog> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogDefault.stories.tsx index a97481e3e73d82..3bf385cbaea470 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogDefault.stories.tsx @@ -8,31 +8,29 @@ import { DialogTrigger, } from '@fluentui/react-headless-components-preview/dialog'; +import styles from './dialog.module.css'; export const Default = (): React.ReactNode => ( <Dialog> <DialogTrigger> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Open dialog </button> </DialogTrigger> - <DialogSurface className="fixed inset-0 m-auto w-full max-w-[480px] rounded-lg border border-zinc-200 bg-white p-0 shadow-lg backdrop:bg-black/50"> - <DialogBody className="p-4 text-sm text-zinc-700"> - <DialogTitle className="mb-3 mt-0 text-lg font-semibold text-zinc-900">Confirm action</DialogTitle> - <p className="m-0">Are you sure you want to proceed? This action cannot be undone.</p> + <DialogSurface className={styles.surface}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Confirm action</DialogTitle> + <p className={styles.copy}>Are you sure you want to proceed? This action cannot be undone.</p> </DialogBody> - <DialogActions className="flex justify-end gap-2 px-4 pb-4"> + <DialogActions className={styles.actions}> <DialogTrigger action="close"> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Cancel </button> </DialogTrigger> <DialogTrigger action="close"> - <button - type="button" - className="rounded bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-50 hover:bg-zinc-800" - > + <button type="button" className={`${styles.btn} ${styles.primary}`}> Confirm </button> </DialogTrigger> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogKeepMounted.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogKeepMounted.stories.tsx index cf830c845c0e02..2cace5adaa3b6b 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogKeepMounted.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogKeepMounted.stories.tsx @@ -7,50 +7,44 @@ import { DialogTitle, DialogTrigger, } from '@fluentui/react-headless-components-preview/dialog'; +import { Textarea } from '@fluentui/react-headless-components-preview/textarea'; +import styles from './dialog.module.css'; +import textareaStyles from '../Textarea/textarea.module.css'; /** - * By default, `DialogSurface` is unmounted from the DOM when the dialog closes - * (`unmountOnClose={true}`), which resets any state inside it. - * - * Set `unmountOnClose={false}` to keep the dialog in the DOM at all times. - * The native `<dialog>` element manages its own visibility via `show()`/`close()`, - * so the dialog is hidden without being removed. Any state inside (e.g. form values) - * is preserved across open/close cycles. - * - * Type something in the input, close the dialog, then reopen it — the value persists. + * `unmountOnClose={false}` keeps the dialog in the DOM at all times. The native + * `<dialog>` element manages its own visibility via `show()`/`close()`, so any + * state inside (e.g. form values) is preserved across open/close cycles. */ export const KeepMounted = (): React.ReactNode => ( <Dialog unmountOnClose={false}> <DialogTrigger> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Open dialog (state preserved) </button> </DialogTrigger> - <DialogSurface className="fixed inset-0 m-auto w-full max-w-[480px] rounded-lg border border-zinc-200 bg-white p-0 shadow-lg"> - <DialogBody className="px-4 py-3"> - <DialogTitle className="mb-3 mt-0 text-lg font-semibold text-zinc-900">Draft message</DialogTitle> - <p className="mt-0 mb-2 text-sm text-zinc-700"> + <DialogSurface className={styles.surface}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Draft message</DialogTitle> + <p className={`${styles.copy} ${styles.demoSpacer}`}> Close and reopen — your draft is preserved (<code>unmountOnClose=false</code>). </p> - <textarea - className="w-full rounded border border-zinc-200 px-3 py-2 text-sm outline-none focus:border-zinc-950" + <Textarea rows={4} placeholder="Type your message…" - defaultValue="" + className={textareaStyles.wrap} + textarea={{ className: textareaStyles.textarea }} /> </DialogBody> - <DialogActions className="flex justify-end gap-2 px-4 pb-4"> + <DialogActions className={styles.actions}> <DialogTrigger action="close"> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Save draft </button> </DialogTrigger> - <button - type="button" - className="rounded bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-50 hover:bg-zinc-800" - > + <button type="button" className={`${styles.btn} ${styles.primary}`}> Send </button> </DialogActions> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNested.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNested.stories.tsx index b336753db7f2f6..1969becdb6cc28 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNested.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNested.stories.tsx @@ -8,46 +8,43 @@ import { DialogTrigger, } from '@fluentui/react-headless-components-preview/dialog'; +import styles from './dialog.module.css'; /** * Dialogs can be nested. The inner `Dialog` detects that it is inside a parent - * `DialogContext` and sets `isNestedDialog=true` automatically. - * - * Each dialog manages its own open state independently. Pressing Escape closes - * only the innermost open dialog — propagation is stopped so the outer dialog - * stays open. + * `DialogContext` and sets `isNestedDialog=true` automatically. Each dialog + * manages its own open state independently. */ export const Nested = (): React.ReactNode => ( <Dialog> <DialogTrigger> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Open outer dialog </button> </DialogTrigger> - <DialogSurface className="fixed inset-0 m-auto w-full max-w-[480px] rounded-lg border border-zinc-200 bg-white p-0 shadow-lg"> - <DialogBody className="px-4 py-3 text-sm text-zinc-700"> - <DialogTitle className="mb-3 mt-0 text-lg font-semibold text-zinc-900">Outer dialog</DialogTitle> - <p className="mt-0 mb-3">This is the outer dialog. Open the inner dialog to see nesting in action.</p> + <DialogSurface className={styles.surface}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Outer dialog</DialogTitle> + <p className={`${styles.copy} ${styles.demoSpacer}`}> + This is the outer dialog. Open the inner dialog to see nesting in action. + </p> - {/* Inner dialog lives inside the outer dialog's body */} <Dialog> <DialogTrigger> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Open inner dialog </button> </DialogTrigger> - <DialogSurface className="absolute m-auto w-full max-w-[360px] rounded-lg border border-zinc-300 bg-white p-0 shadow-xl"> - <DialogBody className="px-4 py-3 text-sm text-zinc-700"> - <DialogTitle className="mb-3 mt-0 text-base font-semibold text-zinc-900">Inner dialog</DialogTitle> - <p className="m-0"> - This is the inner dialog. Press Escape — only this dialog closes; the outer stays open. - </p> + <DialogSurface className={`${styles.surface} ${styles.alertSurface}`}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Inner dialog</DialogTitle> + <p className={styles.copy}>Press Escape — only this dialog closes; the outer stays open.</p> </DialogBody> - <DialogActions className="flex justify-end px-4 pb-4"> + <DialogActions className={styles.actions}> <DialogTrigger action="close"> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Close inner </button> </DialogTrigger> @@ -56,9 +53,9 @@ export const Nested = (): React.ReactNode => ( </Dialog> </DialogBody> - <DialogActions className="flex justify-end px-4 pb-4"> + <DialogActions className={styles.actions}> <DialogTrigger action="close"> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Close outer </button> </DialogTrigger> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNoTrigger.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNoTrigger.stories.tsx index 891947b6845fc4..1f6ad50d8a0a0c 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNoTrigger.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNoTrigger.stories.tsx @@ -8,12 +8,11 @@ import { } from '@fluentui/react-headless-components-preview/dialog'; import type { DialogOpenChangeData } from '@fluentui/react-headless-components-preview/dialog'; +import styles from './dialog.module.css'; /** * `DialogTrigger` is optional. When the open state is managed entirely by the * parent (e.g. opened by a network event, a timeout, or a button outside the * Dialog tree), omit `DialogTrigger` and pass only `DialogSurface` as children. - * - * Use `open` + `onOpenChange` for full control. */ export const NoTrigger = (): React.ReactNode => { const [open, setOpen] = React.useState(false); @@ -23,42 +22,29 @@ export const NoTrigger = (): React.ReactNode => { }; return ( - <div className="flex flex-col gap-3"> - <div className="flex gap-2"> - <button - type="button" - className="rounded bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-50 hover:bg-zinc-800" - onClick={() => setOpen(true)} - > + <div className={styles.demoCol}> + <div className={styles.row}> + <button type="button" className={`${styles.btn} ${styles.primary}`} onClick={() => setOpen(true)}> Open </button> - <button - type="button" - className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100" - onClick={() => setOpen(false)} - > + <button type="button" className={styles.btn} onClick={() => setOpen(false)}> Close </button> - <span className="self-center text-sm text-zinc-500">open: {String(open)}</span> + <span className={styles.demoNote}>open: {String(open)}</span> </div> - {/* No DialogTrigger — Dialog receives only DialogSurface as child */} <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogSurface className="fixed inset-0 m-auto w-full max-w-[420px] rounded-lg border border-zinc-200 bg-white p-0 shadow-lg"> - <DialogBody className="px-4 py-3 text-sm text-zinc-700"> - <DialogTitle className="mb-3 mt-0 text-lg font-semibold text-zinc-900">Programmatic open</DialogTitle> - <p className="m-0"> + <DialogSurface className={styles.surface}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Programmatic open</DialogTitle> + <p className={styles.copy}> This dialog has no <code>DialogTrigger</code>. It was opened by the buttons above. Close it with Escape, the backdrop, or the Close button. </p> </DialogBody> - <DialogActions className="flex justify-end px-4 pb-4"> - <button - type="button" - className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100" - onClick={() => setOpen(false)} - > + <DialogActions className={styles.actions}> + <button type="button" className={styles.btn} onClick={() => setOpen(false)}> Close </button> </DialogActions> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNonModal.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNonModal.stories.tsx index c113f8922e78ab..7053930f161807 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNonModal.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogNonModal.stories.tsx @@ -7,33 +7,35 @@ import { DialogTitle, DialogTrigger, } from '@fluentui/react-headless-components-preview/dialog'; +import { Input } from '@fluentui/react-headless-components-preview/input'; +import styles from './dialog.module.css'; +import inputStyles from '../Input/input.module.css'; /** * A non-modal dialog does not dim the background and does not trap focus. * Users can still interact with the rest of the page while it is open. - * There is no backdrop — only the dialog surface itself is rendered. */ export const NonModal = (): React.ReactNode => ( - <div className="flex gap-4 items-start"> + <div className={styles.row}> <Dialog modalType="non-modal"> <DialogTrigger> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Open non-modal dialog </button> </DialogTrigger> - <DialogSurface className="fixed inset-0 m-auto w-72 rounded-lg border border-zinc-200 bg-white p-0 shadow-lg"> - <DialogBody className="px-4 py-3 text-sm text-zinc-700"> - <DialogTitle className="mb-3 mt-0 text-base font-semibold text-zinc-900">Non-modal</DialogTitle> - <p className="m-0"> + <DialogSurface className={`${styles.surface} ${styles.alertSurface}`}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Non-modal</DialogTitle> + <p className={styles.copy}> You can still interact with the page behind this dialog. Focus is not trapped and the background is not dimmed. </p> </DialogBody> - <DialogActions className="flex justify-end gap-2 px-4 pb-4"> + <DialogActions className={styles.actions}> <DialogTrigger action="close"> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> + <button type="button" className={styles.btn}> Close </button> </DialogTrigger> @@ -41,10 +43,10 @@ export const NonModal = (): React.ReactNode => ( </DialogSurface> </Dialog> - <input - type="text" + <Input placeholder="Type here while dialog is open…" - className="rounded border border-zinc-200 px-3 py-1.5 text-sm outline-none focus:border-zinc-950" + className={inputStyles.wrap} + input={{ className: inputStyles.input }} /> </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogWithCloseButton.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogWithCloseButton.stories.tsx index f70b59ed8be977..dc64444bb1990d 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogWithCloseButton.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogWithCloseButton.stories.tsx @@ -7,62 +7,60 @@ import { DialogTitle, DialogTrigger, } from '@fluentui/react-headless-components-preview/dialog'; +import { Checkbox } from '@fluentui/react-headless-components-preview/checkbox'; +import { CheckmarkRegular } from '@fluentui/react-icons'; +import styles from './dialog.module.css'; +import checkboxStyles from '../Checkbox/checkbox.module.css'; /** * Use `DialogTrigger` with `action="close"` to wire up a close button anywhere - * inside the dialog — including the "X in the top-right corner" UX pattern. - * It defaults to `type="button"` and calls `onOpenChange` when clicked. + * inside the dialog. It defaults to `type="button"` and calls `onOpenChange` + * when clicked. */ export const WithCloseButton = (): React.ReactNode => ( <Dialog> <DialogTrigger> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> - Open dialog + <button type="button" className={styles.btn}> + Open settings </button> </DialogTrigger> - <DialogSurface className="fixed inset-0 m-auto w-full max-w-[480px] rounded-lg border border-zinc-200 bg-white p-0 shadow-lg"> - <DialogBody className="px-4 py-3 text-sm text-zinc-700"> - <DialogTitle className="mb-3 mt-0 text-lg font-semibold text-zinc-900">Settings</DialogTitle> - <p className="mt-0 mb-3">Update your preferences below.</p> - <div className="flex flex-col gap-3"> - <label className="flex cursor-pointer items-center gap-2"> - <input type="checkbox" className="h-4 w-4 accent-zinc-900" defaultChecked /> - Email notifications - </label> - <label className="flex cursor-pointer items-center gap-2"> - <input type="checkbox" className="h-4 w-4 accent-zinc-900" /> - SMS notifications - </label> - <label className="flex cursor-pointer items-center gap-2"> - <input type="checkbox" className="h-4 w-4 accent-zinc-900" defaultChecked /> - Weekly digest - </label> + <DialogSurface className={styles.surface}> + <DialogBody className={styles.body}> + <DialogTitle className={styles.title}>Settings</DialogTitle> + <p className={`${styles.copy} ${styles.demoSpacerLg}`}>Update your preferences below.</p> + <div className={checkboxStyles.list}> + {[ + { label: 'Email notifications', defaultChecked: true }, + { label: 'SMS notifications', defaultChecked: false }, + { label: 'Weekly digest', defaultChecked: true }, + ].map(opt => ( + <Checkbox + key={opt.label} + defaultChecked={opt.defaultChecked} + label={{ children: opt.label, className: checkboxStyles.label }} + className={checkboxStyles.row} + input={{ className: checkboxStyles.input }} + indicator={{ + className: checkboxStyles.indicator, + children: <CheckmarkRegular className={checkboxStyles.iconCheck} aria-hidden />, + }} + /> + ))} </div> </DialogBody> - <DialogActions className="flex justify-end gap-2 px-4 pb-4"> + <DialogActions className={styles.actions}> <DialogTrigger action="close"> - <button - type="button" - aria-label="Close" - className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100" - > - Close + <button type="button" className={styles.btn}> + Cancel </button> </DialogTrigger> <DialogTrigger action="close"> - <button type="button" className="rounded px-3 py-1.5 text-sm border border-zinc-200 hover:bg-zinc-100"> - Cancel + <button type="button" className={`${styles.btn} ${styles.primary}`}> + Save </button> </DialogTrigger> - <button - type="button" - className="rounded bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-50 hover:bg-zinc-800" - onClick={() => alert('Settings saved!')} - > - Save - </button> </DialogActions> </DialogSurface> </Dialog> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/dialog.module.css b/packages/react-components/react-headless-components-preview/stories/src/Dialog/dialog.module.css new file mode 100644 index 00000000000000..7b9866ba8ddd65 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/dialog.module.css @@ -0,0 +1,121 @@ +.surface { + position: fixed; + inset: 0; + margin: auto; + width: min(96vw, 480px); + max-height: 90vh; + border: 1px solid var(--border); + border-radius: var(--radius-2xl); + background: var(--bg-elev); + color: var(--text); + padding: 0; + overflow: hidden; + box-shadow: var(--shadow-5); +} + +.surface::backdrop { + background: rgba(10, 10, 10, 0.4); + backdrop-filter: blur(2px); +} + +.body { + padding: 24px 24px 8px; + overflow-y: auto; +} + +.title { + margin: 0 0 8px; + font-family: var(--font-display); + font-size: 18px; + font-weight: 700; + letter-spacing: var(--tracking-heading); +} + +.copy { + margin: 0; + color: var(--text-muted); + font-size: 13.5px; + line-height: 1.6; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px 20px; +} + +.alertSurface { + width: min(94vw, 400px); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 14px; + border-radius: var(--radius-pill); + border: none; + background: var(--surface-muted); + color: var(--text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard); +} + +.btn:hover { + background: var(--surface-sunken); +} + +.primary { + background: var(--accent); + color: var(--accent-contrast); +} + +.primary:hover { + background: var(--accent-strong); +} + +.danger { + background: var(--brand); + color: white; +} + +.danger:hover { + background: var(--brand-strong); +} + +.row { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +/* Demo helpers (used by Storybook examples) */ + +.demoSpacer { + margin-bottom: 12px; +} + +.demoSpacerLg { + margin-bottom: 16px; +} + +.demoCol { + display: flex; + + flex-direction: column; + + gap: 12px; +} + +.demoNote { + align-self: center; + + color: var(--text-muted); + + font-size: 13px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/index.stories.tsx index 5639d2ada27e99..0adc7e687f13d6 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/index.stories.tsx @@ -8,7 +8,6 @@ import { } from '@fluentui/react-headless-components-preview/dialog'; import descriptionMd from './DialogDescription.md'; - export { Default } from './DialogDefault.stories'; export { NonModal } from './DialogNonModal.stories'; export { Alert } from './DialogAlert.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Divider/DividerDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Divider/DividerDefault.stories.tsx index cd591d6a07af9a..a69d0849c7bcb2 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Divider/DividerDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Divider/DividerDefault.stories.tsx @@ -1,10 +1,23 @@ import * as React from 'react'; import { Divider } from '@fluentui/react-headless-components-preview/divider'; +import styles from './divider.module.css'; export const Default = (): React.ReactNode => ( - <div className="flex flex-col max-w-48 w-full gap-2 *:my-0"> - <p>Content above the divider</p> - <Divider className="h-px bg-gray-300" /> - <p>Content below the divider</p> + <div className={styles.column}> + <p className={styles.section}>Content above</p> + <Divider className={styles.divider}> + <span className={styles.label}>Or</span> + </Divider> + <p className={styles.section}>Content below</p> + + <Divider className={`${styles.divider} ${styles.start}`}> + <span className={styles.label}>Section</span> + </Divider> + + <Divider className={`${styles.divider} ${styles.end}`}> + <span className={styles.label}>End</span> + </Divider> + + <Divider className={styles.horizontal} /> </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Divider/DividerVertical.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Divider/DividerVertical.stories.tsx index a947b326f2dd7b..b1c8277aa58cb9 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Divider/DividerVertical.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Divider/DividerVertical.stories.tsx @@ -1,10 +1,51 @@ import * as React from 'react'; import { Divider } from '@fluentui/react-headless-components-preview/divider'; +import { CircleRegular } from '@fluentui/react-icons'; +import styles from './divider.module.css'; export const Vertical = (): React.ReactNode => ( - <div className="flex items-center h-4 gap-4"> - <a href="#">Link 1</a> - <Divider className="w-px h-full bg-gray-300" vertical /> - <a href="#">Link 2</a> + <div className={styles.verticalGroup}> + <div className={styles.verticalCol}> + <span className={styles.verticalCaption}>No text</span> + <div className={styles.verticalLineWrap}> + <Divider className={styles.dividerVertical} vertical /> + </div> + </div> + + <div className={styles.verticalCol}> + <span className={styles.verticalCaption}>Center</span> + <div className={styles.verticalLineWrap}> + <Divider className={styles.dividerVertical} vertical> + <span className={styles.content}> + <CircleRegular aria-hidden /> + Text + </span> + </Divider> + </div> + </div> + + <div className={styles.verticalCol}> + <span className={styles.verticalCaption}>Start</span> + <div className={styles.verticalLineWrap}> + <Divider className={`${styles.dividerVertical} ${styles.start}`} vertical> + <span className={styles.content}> + <CircleRegular aria-hidden /> + Text + </span> + </Divider> + </div> + </div> + + <div className={styles.verticalCol}> + <span className={styles.verticalCaption}>End</span> + <div className={styles.verticalLineWrap}> + <Divider className={`${styles.dividerVertical} ${styles.end}`} vertical> + <span className={styles.content}> + <CircleRegular aria-hidden /> + Text + </span> + </Divider> + </div> + </div> </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Divider/divider.module.css b/packages/react-components/react-headless-components-preview/stories/src/Divider/divider.module.css new file mode 100644 index 00000000000000..baac32ad6b70da --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Divider/divider.module.css @@ -0,0 +1,181 @@ +/* Headless Divider renders <root><wrapper>{children}</wrapper></root>. + The line itself comes from ::before / ::after on the root. */ + +.divider { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + color: var(--text-faint); +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +/* Start: short 8 px stub before content, full line after. */ +.divider.start::before { + flex: 0 0 8px; +} + +/* End: full line before content, short 8 px stub after. */ +.divider.end::after { + flex: 0 0 8px; +} + +.label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); + white-space: nowrap; +} + +.label::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + border: 1.5px solid var(--border-stronger); +} + +/* Sentence-case content (icon + text) used inside dividers. */ +.content { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; +} + +.content svg { + width: 14px; + height: 14px; +} + +/* Vertical: stack lines top/bottom of label, swap orientation. */ +.dividerVertical { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + height: 100%; + width: auto; + min-width: 80px; +} + +.dividerVertical::before, +.dividerVertical::after { + content: ''; + flex: 1; + width: 1px; + height: auto; + min-height: 8px; + background: var(--border); +} + +/* Start: 8 px stub above content, full line below. */ +.dividerVertical.start::before { + flex: 0 0 8px; +} + +/* End: full line above content, 8 px stub below. */ +.dividerVertical.end::after { + flex: 0 0 8px; +} + +/* Plain hairline (no label) */ +.horizontal { + width: 100%; + height: 1px; + background: var(--border); +} + +.vertical { + width: 1px; + background: var(--border); +} + +.row { + display: flex; + align-items: center; + gap: 16px; + height: 24px; + font-size: 13px; + color: var(--text-muted); +} + +.column { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; +} + +.section { + font-size: 13px; + color: var(--text); +} + +.verticalGroup { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; + height: 180px; + width: 100%; +} + +.verticalCol { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + gap: 6px; +} + +.verticalCaption { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); +} + +.verticalLineWrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.labelled { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + text-align: center; +} + +.labelledLine { + flex: 1; + height: 1px; + background: var(--border); +} + +.labelledText { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Divider/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Divider/index.stories.tsx index 6f231768942683..b0c88506e768f6 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Divider/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Divider/index.stories.tsx @@ -1,7 +1,6 @@ import { Divider } from '@fluentui/react-headless-components-preview/divider'; import descriptionMd from './DividerDescription.md'; - export { Default } from './DividerDefault.stories'; export { Vertical } from './DividerVertical.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Drawer/DefaultDrawer.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Drawer/DefaultDrawer.stories.tsx index 77d27bb37e05dc..2945af70d276e3 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Drawer/DefaultDrawer.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Drawer/DefaultDrawer.stories.tsx @@ -8,7 +8,7 @@ import { } from '@fluentui/react-headless-components-preview/drawer'; import { DismissRegular } from '@fluentui/react-icons'; -const buttonClassName = 'rounded bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800'; +import styles from './drawer.module.css'; export const Default = (): React.ReactNode => { const [open, setOpen] = React.useState(false); @@ -18,40 +18,38 @@ export const Default = (): React.ReactNode => { return ( <> <Drawer - className={ - 'fixed inset-y-0 right-0 m-0 hidden min-h-screen w-80 max-w-[calc(100vw-32px)] translate-x-full flex-col border-0 border-l border-zinc-200 bg-white p-0 shadow-xl transition-transform [&[open]]:flex [&[open]]:translate-x-0 [&[open]]:starting:-translate-x-full backdrop:bg-black/40' - } + className={styles.drawerOverlay} open={open} onOpenChange={(_, data) => setOpen(data.open)} unmountOnClose={false} > - <DrawerHeader className="border-b border-zinc-200 px-4 py-3"> + <DrawerHeader className={styles.drawerHeader}> <DrawerHeaderTitle action={ - <button aria-label="Close drawer" className="rounded size-8 hover:bg-zinc-100" onClick={closeDrawer}> + <button aria-label="Close drawer" className={styles.closeButton} onClick={closeDrawer}> <DismissRegular /> </button> } - className="flex items-start justify-between gap-3" - heading={{ className: 'text-lg font-semibold text-zinc-900' }} + className={styles.drawerHeaderTitle} + heading={{ className: styles.drawerHeading }} > Overlay drawer </DrawerHeaderTitle> </DrawerHeader> - <DrawerBody className="flex-grow overflow-auto px-3 py-3 text-sm text-zinc-700"> + <DrawerBody className={styles.drawerBody}> <DrawerContent /> </DrawerBody> - <DrawerFooter className="flex justify-end gap-2 border-t border-zinc-200 px-4 py-3"> - <button className={buttonClassName} onClick={closeDrawer}> + <DrawerFooter className={styles.drawerFooter}> + <button className={styles.primaryButton} onClick={closeDrawer}> Close </button> </DrawerFooter> </Drawer> - <div className="p-4"> - <button className={buttonClassName} onClick={toggleDrawer}> + <div className={styles.trigger}> + <button className={styles.primaryButton} onClick={toggleDrawer}> Open drawer </button> </div> @@ -63,14 +61,9 @@ const DrawerContent = () => { const items = ['Dashboard', 'Activity', 'Projects', 'Calendar', 'Settings']; return ( - <nav aria-label="Example navigation" className="flex flex-col gap-1"> + <nav aria-label="Example navigation" className={styles.nav}> {items.map((item, index) => ( - <a - key={item} - aria-current={index === 0 ? 'page' : undefined} - href="#" - className="rounded px-3 py-2 font-medium no-underline aria-[current]:bg-zinc-200 aria-[current]:text-zinc-950 text-zinc-700 hover:bg-zinc-100 hover:text-zinc-950" - > + <a key={item} aria-current={index === 0 ? 'page' : undefined} href="#" className={styles.navLink}> {item} </a> ))} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Drawer/InlineDrawer.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Drawer/InlineDrawer.stories.tsx index 1ee0568e0da803..ca4e81859348fe 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Drawer/InlineDrawer.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Drawer/InlineDrawer.stories.tsx @@ -7,41 +7,36 @@ import { } from '@fluentui/react-headless-components-preview/drawer'; import { DismissRegular } from '@fluentui/react-icons'; +import styles from './drawer.module.css'; + export const Inline = (): React.ReactNode => { const [open, setOpen] = React.useState(false); const toggleDrawer = () => setOpen(value => !value); const closeDrawer = () => setOpen(false); return ( - <div className="flex h-[420px] overflow-hidden rounded border border-zinc-200 bg-white text-zinc-900"> - <Drawer - className={ - 'shrink-0 overflow-hidden border-r bg-zinc-50 transition-[width,opacity,transform,border-color] duration-200 ease-linear w-0 border-r-transparent opacity-0 data-[open]:w-72 data-[open]:border-r-zinc-200 data-[open]:opacity-100' - } - type="inline" - open={open} - unmountOnClose={false} - > - <DrawerHeader className="border-b border-zinc-200 px-4 py-3"> + <div className={styles.inlineFrame}> + <Drawer className={styles.drawerInline} type="inline" open={open} unmountOnClose={false}> + <DrawerHeader className={styles.drawerHeader}> <DrawerHeaderTitle action={ - <button aria-label="Close drawer" className="rounded size-8 hover:bg-zinc-100" onClick={closeDrawer}> + <button aria-label="Close drawer" className={styles.closeButton} onClick={closeDrawer}> <DismissRegular /> </button> } - className="flex items-center justify-between gap-3" - heading={{ className: 'text-lg font-semibold' }} + className={`${styles.drawerHeaderTitle} ${styles.drawerHeaderTitleInline}`} + heading={{ className: styles.drawerHeadingInline }} > Inline drawer </DrawerHeaderTitle> </DrawerHeader> - <DrawerBody className="px-3 py-3 text-sm text-zinc-700"> + <DrawerBody className={styles.drawerBody}> <DrawerContent /> </DrawerBody> </Drawer> - <main className="flex flex-1 items-start flex-col gap-3 p-4"> - <button className="rounded border border-zinc-300 px-3 py-1.5 text-sm hover:bg-zinc-100" onClick={toggleDrawer}> + <main className={styles.inlineMain}> + <button className={styles.secondaryButton} onClick={toggleDrawer}> {open ? 'Hide inline drawer' : 'Show inline drawer'} </button> </main> @@ -53,14 +48,9 @@ const DrawerContent = () => { const items = ['Dashboard', 'Activity', 'Projects', 'Calendar', 'Settings']; return ( - <nav aria-label="Example navigation" className="flex flex-col gap-1"> + <nav aria-label="Example navigation" className={styles.nav}> {items.map((item, index) => ( - <a - key={item} - aria-current={index === 0 ? 'page' : undefined} - href="#" - className="rounded px-3 py-2 font-medium no-underline aria-[current]:bg-zinc-200 aria-[current]:text-zinc-950 text-zinc-700 hover:bg-zinc-100 hover:text-zinc-950" - > + <a key={item} aria-current={index === 0 ? 'page' : undefined} href="#" className={styles.navLink}> {item} </a> ))} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Drawer/drawer.module.css b/packages/react-components/react-headless-components-preview/stories/src/Drawer/drawer.module.css new file mode 100644 index 00000000000000..f81fb3fcfe02d7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Drawer/drawer.module.css @@ -0,0 +1,222 @@ +/* + Overlay variant: position fixed to the right edge, slides in via translateX + on the [open] attribute. Uses a `::backdrop` (per the native `<dialog>` + Drawer renders into) for the dim layer. +*/ +.drawerOverlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + /* `left: auto` overrides the user-agent `inset-inline-start: 0` on + `<dialog>` so the drawer pins to the right edge instead of centering. */ + left: auto; + margin: 0; + display: none; + min-height: 100vh; + width: 320px; + max-width: calc(100vw - 32px); + flex-direction: column; + border: 0; + border-left: var(--stroke-thin) solid var(--border); + background: var(--bg-elev); + padding: 0; + box-shadow: var(--shadow-4); + transform: translateX(100%); + transition: transform 200ms ease-in-out; +} + +.drawerOverlay[open] { + display: flex; + transform: translateX(0); +} + +@starting-style { + .drawerOverlay[open] { + transform: translateX(100%); + } +} + +.drawerOverlay::backdrop { + background: rgba(0, 0, 0, 0.4); +} + +/* + Inline variant: lives inside a container, animates width + border-color + + opacity on the [data-open] attribute. +*/ +.drawerInline { + flex-shrink: 0; + overflow: hidden; + background: var(--bg-soft); + border-right: var(--stroke-thin) solid transparent; + width: 0; + opacity: 0; + transition: width 200ms linear, opacity 200ms linear, border-color 200ms linear; +} + +.drawerInline[data-open] { + width: 288px; + opacity: 1; + border-right-color: var(--border); +} + +.drawerHeader { + border-bottom: var(--stroke-thin) solid var(--border); + padding: var(--space-3) var(--space-4); +} + +.drawerHeaderTitle { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.drawerHeaderTitleInline { + align-items: center; +} + +.drawerHeading { + font-size: 17px; + font-weight: 600; + color: var(--text); + line-height: 1.3; +} + +.drawerHeadingInline { + font-size: 17px; + font-weight: 600; + line-height: 1.3; +} + +.drawerBody { + flex-grow: 1; + overflow: auto; + padding: var(--space-3); + font-size: 13.5px; + color: var(--text-muted); +} + +.drawerFooter { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + border-top: var(--stroke-thin) solid var(--border); + padding: var(--space-3) var(--space-4); +} + +.closeButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + width: 32px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.closeButton:hover { + background: var(--surface-muted); + color: var(--text); +} + +.closeButton:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.primaryButton { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 var(--space-3); + border: 0; + border-radius: var(--radius-md); + background: var(--text); + color: var(--text-on-accent); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; +} + +.primaryButton:hover { + background: var(--text-muted); +} + +.primaryButton:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.secondaryButton { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 var(--space-3); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-elev); + color: var(--text); + font-size: 13.5px; + cursor: pointer; +} + +.secondaryButton:hover { + background: var(--surface-muted); +} + +.secondaryButton:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.trigger { + padding: var(--space-4); +} + +.inlineFrame { + display: flex; + height: 420px; + overflow: hidden; + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elev); + color: var(--text); +} + +.inlineMain { + display: flex; + flex: 1; + align-items: flex-start; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); +} + +.nav { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.navLink { + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + font-weight: 500; + text-decoration: none; + color: var(--text-muted); +} + +.navLink:hover { + background: var(--surface-muted); + color: var(--text); +} + +.navLink[aria-current] { + background: var(--surface-sunken); + color: var(--text); +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Drawer/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Drawer/index.stories.tsx index 056c77c49af094..1ac4f6e7132e14 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Drawer/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Drawer/index.stories.tsx @@ -10,7 +10,6 @@ import { } from '@fluentui/react-headless-components-preview/drawer'; import descriptionMd from './DrawerDescription.md'; - export { Default } from './DefaultDrawer.stories'; export { Inline } from './InlineDrawer.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Field/FieldDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Field/FieldDefault.stories.tsx index af6349f7d2aa04..d758122ba9caa7 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Field/FieldDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Field/FieldDefault.stories.tsx @@ -1,45 +1,50 @@ import * as React from 'react'; import { Field } from '@fluentui/react-headless-components-preview/field'; import { Input } from '@fluentui/react-headless-components-preview/input'; +import { ErrorCircleRegular } from '@fluentui/react-icons'; -const fieldClass = 'flex flex-col gap-1.5'; -const labelClass = 'text-sm font-medium text-gray-700'; -const inputWrapperClass = - 'flex w-full rounded-md border border-gray-300 bg-white px-3 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2'; -const inputClass = 'flex-1 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 bg-transparent'; - +import fieldStyles from './field.module.css'; +import inputStyles from '../Input/input.module.css'; export const Default = (): React.ReactNode => ( - <div className="flex flex-col gap-5 w-full max-w-sm"> - <Field label={{ children: 'Email address', className: labelClass }} className={fieldClass}> + <div className={fieldStyles.demo}> + <Field label={{ children: 'Email address', className: fieldStyles.label }} className={fieldStyles.field}> <Input type="email" placeholder="you@example.com" - className={inputWrapperClass} - input={{ className: inputClass }} + className={inputStyles.wrap} + input={{ className: inputStyles.input }} /> </Field> <Field - label={{ children: 'Password', className: labelClass }} - hint={{ children: 'Must be at least 8 characters.', className: 'text-xs text-gray-500' }} - className={fieldClass} + label={{ children: 'Password', className: fieldStyles.label }} + hint={{ children: 'Must be at least 8 characters.', className: fieldStyles.hint }} + className={fieldStyles.field} > - <Input type="password" placeholder="••••••••" className={inputWrapperClass} input={{ className: inputClass }} /> + <Input + type="password" + placeholder="••••••••" + className={inputStyles.wrap} + input={{ className: inputStyles.input }} + /> </Field> <Field - label={{ children: 'Username', className: labelClass }} + label={{ children: 'Username', className: fieldStyles.label }} validationState="error" validationMessage={{ children: 'This username is already taken.', - className: 'text-xs text-red-600', + className: `${fieldStyles.message} ${fieldStyles.messageError}`, + }} + validationMessageIcon={{ + children: <ErrorCircleRegular aria-hidden />, }} - className={fieldClass} + className={fieldStyles.field} > <Input defaultValue="johndoe" - className="flex w-full rounded-md border border-red-400 bg-white px-3 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2" - input={{ className: inputClass }} + className={`${inputStyles.wrap} ${inputStyles.wrapError}`} + input={{ className: inputStyles.input }} /> </Field> </div> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Field/field.module.css b/packages/react-components/react-headless-components-preview/stories/src/Field/field.module.css new file mode 100644 index 00000000000000..9b19e1b78c278f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Field/field.module.css @@ -0,0 +1,54 @@ +.field { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +.label { + font-size: 13px; + font-weight: 500; + color: var(--text); +} + +.hint { + font-size: 12px; + color: var(--text-muted); +} + +.message { + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.messageError { + color: var(--brand); +} + +.messageWarning { + color: var(--warning); +} + +.messageSuccess { + color: var(--success); +} + +.messageInfo { + color: var(--info); +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + display: flex; + + flex-direction: column; + + gap: 20px; + + width: 100%; + + max-width: 360px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Field/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Field/index.stories.tsx index 32d0b3336ca330..20e119b040b760 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Field/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Field/index.stories.tsx @@ -1,7 +1,6 @@ import { Field } from '@fluentui/react-headless-components-preview/field'; import descriptionMd from './FieldDescription.md'; - export { Default } from './FieldDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Input/InputBasic.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Input/InputBasic.stories.tsx new file mode 100644 index 00000000000000..885fc6d78dec1b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Input/InputBasic.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { Input } from '@fluentui/react-headless-components-preview/input'; +import { SearchRegular } from '@fluentui/react-icons'; + +import styles from './input.module.css'; +export const Basic = (): React.ReactNode => ( + <div className={`${styles.column} ${styles.demo}`}> + <Input className={styles.wrap} input={{ className: styles.input }} placeholder="Default input" /> + <Input type="email" placeholder="you@example.com" className={styles.wrap} input={{ className: styles.input }} /> + <Input type="password" placeholder="••••••••" className={styles.wrap} input={{ className: styles.input }} /> + <Input + placeholder="With prefix" + className={styles.wrap} + input={{ className: styles.input }} + contentBefore={{ + className: styles.affix, + children: <SearchRegular className={styles.affixIcon} aria-hidden />, + }} + /> + <Input + placeholder="Validation error" + defaultValue="bad value" + className={`${styles.wrap} ${styles.wrapError}`} + input={{ className: styles.input }} + /> + <Input placeholder="Disabled" disabled className={styles.wrap} input={{ className: styles.input }} /> + </div> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Input/InputDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Input/InputDefault.stories.tsx index fd8eafbc3977eb..ef9f91cee24421 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Input/InputDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Input/InputDefault.stories.tsx @@ -1,20 +1,49 @@ import * as React from 'react'; import { Input } from '@fluentui/react-headless-components-preview/input'; +import { AddRegular, MicRegular, MicPulseRegular, SendRegular } from '@fluentui/react-icons'; -const inputWrapperClass = - 'flex w-full rounded-md border border-gray-300 bg-white px-3 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2'; -const innerClass = 'flex-1 py-2 text-sm text-gray-900 focus:outline-none placeholder:text-gray-400 bg-transparent'; - -export const Default = (): React.ReactNode => ( - <div className="flex flex-col gap-3 w-full max-w-sm"> - <Input placeholder="Default input" className={inputWrapperClass} input={{ className: innerClass }} /> - <Input type="email" placeholder="Email address" className={inputWrapperClass} input={{ className: innerClass }} /> - <Input type="password" placeholder="Password" className={inputWrapperClass} input={{ className: innerClass }} /> - <Input - placeholder="Disabled input" - disabled - className="flex w-full rounded-md border border-gray-200 bg-gray-50 px-3 opacity-60 cursor-not-allowed" - input={{ className: `${innerClass} cursor-not-allowed` }} - /> - </div> -); +import chatStyles from './chat-input.module.css'; +export const Default = (): React.ReactNode => { + const [value, setValue] = React.useState(''); + const hasText = value.trim().length > 0; + return ( + <div className={chatStyles.demo}> + <Input + className={chatStyles.chat} + input={{ + className: chatStyles.chatField, + value, + onChange: e => setValue((e.target as HTMLInputElement).value), + placeholder: 'Ask anything…', + 'aria-label': 'Chat input', + }} + contentBefore={{ + className: chatStyles.chatLeading, + children: ( + <button type="button" className={chatStyles.iconBtn} aria-label="Add attachment"> + <AddRegular /> + </button> + ), + }} + contentAfter={{ + className: chatStyles.chatTrailing, + children: ( + <> + <button type="button" className={chatStyles.iconBtn} aria-label="Voice input"> + <MicRegular /> + </button> + <button + type="button" + className={`${chatStyles.iconBtn} ${chatStyles.send}`} + aria-label={hasText ? 'Send message' : 'Live waveform'} + disabled={!hasText && false} + > + {hasText ? <SendRegular /> : <MicPulseRegular />} + </button> + </> + ), + }} + /> + </div> + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Input/chat-input.module.css b/packages/react-components/react-headless-components-preview/stories/src/Input/chat-input.module.css new file mode 100644 index 00000000000000..abd31b6b1646c8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Input/chat-input.module.css @@ -0,0 +1,130 @@ +/* chat-input — borderless field on a soft surface, + with a hairline bottom rule and inline icon buttons. + + Layout: [+] [text · placeholder text] [mic] [waveform | send] +*/ + +.chat { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 8px 8px; + background: var(--bg-soft); + border-radius: var(--radius-pill); + border: 1px solid transparent; + transition: background var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard); +} + +.chat:has(:focus-visible) { + background: var(--bg-elev); + border-color: var(--text); +} + +.chatLeading, +.chatTrailing { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.chatField { + flex: 1; + border: none; + outline: none; + background: transparent; + padding: 8px 8px; + font-size: 14px; + color: var(--text); + min-width: 0; +} + +.chatField::placeholder { + color: var(--text-soft); +} + +.iconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); +} + +.iconBtn:hover { + background: var(--surface-muted); + color: var(--text); +} + +.iconBtn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.iconBtn svg { + width: 18px; + height: 18px; +} + +.iconBtn[disabled] { + opacity: 0.35; + cursor: not-allowed; +} + +.send { + background: var(--accent); + color: var(--accent-contrast); +} + +.send:hover { + background: var(--accent-strong); + color: var(--accent-contrast); +} + +.send svg { + width: 16px; + height: 16px; + stroke-width: 2.25; +} + +.send[disabled] { + background: var(--surface-muted); + color: var(--text-faint); +} + +/* Underline variant — borderless, thin bottom rule (matches the larger + chat input shown in the canvas screenshot). */ + +.chatUnderline { + background: transparent; + border-radius: 0; + border-bottom: 1px solid var(--border); + padding: 6px 4px; +} + +.chatUnderline:has(:focus-visible) { + background: transparent; + border-bottom-color: var(--text); + border-color: transparent; + border-bottom: 1px solid var(--text); +} + +.chatUnderline .chatField { + font-size: 16px; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + width: 100%; + + max-width: 560px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Input/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Input/index.stories.tsx index 29a4e7236e08d7..d7283184f4da77 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Input/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Input/index.stories.tsx @@ -1,8 +1,8 @@ import { Input } from '@fluentui/react-headless-components-preview/input'; import descriptionMd from './InputDescription.md'; - export { Default } from './InputDefault.stories'; +export { Basic } from './InputBasic.stories'; export default { title: 'Headless Components/Input', diff --git a/packages/react-components/react-headless-components-preview/stories/src/Input/input.module.css b/packages/react-components/react-headless-components-preview/stories/src/Input/input.module.css new file mode 100644 index 00000000000000..0c5575df574807 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Input/input.module.css @@ -0,0 +1,85 @@ +/* input wrapper — clean rounded, light border */ +.wrap { + display: flex; + align-items: center; + width: 100%; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-md); + transition: border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.wrap:hover { + border-color: var(--border-strong); +} + +.wrap:has(:focus-visible), +.wrap:focus-within { + border-color: var(--text); + box-shadow: 0 0 0 3px var(--surface-muted); +} + +.wrap[data-disabled], +.wrapDisabled { + background: var(--surface-muted); + cursor: not-allowed; + opacity: 0.7; +} + +.wrapError { + border-color: var(--brand); +} + +.wrapError:has(:focus-visible) { + box-shadow: 0 0 0 3px var(--brand-soft); +} + +.input { + flex: 1; + border: none; + outline: none; + background: transparent; + padding: 8px 12px; + font-size: 13.5px; + color: var(--text); + min-width: 0; +} + +.input::placeholder { + color: var(--text-faint); +} + +.input:disabled { + cursor: not-allowed; +} + +.affix { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + color: var(--text-soft); + flex-shrink: 0; + font-size: 13px; +} + +.affixIcon { + width: 16px; + height: 16px; +} + +.column { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + width: 100%; + + max-width: 360px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Link/LinkDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Link/LinkDefault.stories.tsx index f0a0c5e45c68ab..f69452e2b9203a 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Link/LinkDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Link/LinkDefault.stories.tsx @@ -1,28 +1,26 @@ import * as React from 'react'; import { Link } from '@fluentui/react-headless-components-preview/link'; -const linkClass = - 'text-gray-900 underline underline-offset-4 hover:text-gray-600 hover:no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 rounded transition-colors data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:no-underline'; - +import styles from './link.module.css'; export const Default = (): React.ReactNode => ( - <div className="flex flex-col gap-4 text-sm max-w-sm"> - <Link href="#" className={linkClass}> + <div className={styles.demo}> + <Link href="#" className={styles.link}> View documentation </Link> - <p className="text-gray-700 leading-relaxed"> + <p className={styles.paragraph}> By continuing you agree to our{' '} - <Link href="#" inline className={linkClass}> + <Link href="#" inline className={`${styles.link} ${styles.inline}`}> Terms of Service </Link>{' '} and{' '} - <Link href="#" inline className={linkClass}> + <Link href="#" inline className={`${styles.link} ${styles.inline}`}> Privacy Policy </Link> . </p> - <Link href="#" disabled className={linkClass}> + <Link href="#" disabled className={styles.link}> Disabled link </Link> </div> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Link/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Link/index.stories.tsx index 87d61e39bcf376..277c38bb77beb4 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Link/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Link/index.stories.tsx @@ -1,7 +1,6 @@ import { Link } from '@fluentui/react-headless-components-preview/link'; import descriptionMd from './LinkDescription.md'; - export { Default } from './LinkDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Link/link.module.css b/packages/react-components/react-headless-components-preview/stories/src/Link/link.module.css new file mode 100644 index 00000000000000..4789eea5bd0e0e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Link/link.module.css @@ -0,0 +1,50 @@ +.link { + color: var(--text); + text-decoration: underline; + text-decoration-color: var(--border-strong); + text-underline-offset: 3px; + font-weight: 500; + border-radius: var(--radius-xs); + cursor: pointer; + transition: color var(--duration-fast) var(--ease-standard), + text-decoration-color var(--duration-fast) var(--ease-standard); +} + +.link:hover { + color: var(--text); + text-decoration-color: var(--text); +} + +.link:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.link[data-disabled] { + color: var(--text-faint); + cursor: not-allowed; + text-decoration-color: var(--text-faint); +} + +.inline { + text-decoration: underline; + text-underline-offset: 3px; +} + +.paragraph { + margin: 0; + font-size: 14.5px; + line-height: 1.65; + color: var(--text-muted); + max-width: 56ch; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + display: flex; + + flex-direction: column; + + gap: 16px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/MessageBar/MessageBarDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MessageBar/MessageBarDefault.stories.tsx index a06ac24608c1bd..269139012a5d0b 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/MessageBar/MessageBarDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/MessageBar/MessageBarDefault.stories.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { Button } from '@fluentui/react-headless-components-preview/button'; import { Link } from '@fluentui/react-headless-components-preview/link'; import { MessageBar, @@ -7,37 +6,29 @@ import { MessageBarBody, MessageBarTitle, } from '@fluentui/react-headless-components-preview/message-bar'; +import { DismissRegular, InfoRegular } from '@fluentui/react-icons'; -const classes = { - messageBar: - 'grid w-full max-w-3xl grid-cols-[auto_1fr_auto] items-start gap-x-3 gap-y-2 rounded-xl border border-sky-300 bg-sky-50 px-4 py-3 text-slate-900 shadow-sm data-[layout=multiline]:grid-cols-[auto_1fr] data-[intent=warning]:border-amber-300 data-[intent=warning]:bg-amber-50', - icon: 'mt-0.5 flex h-7 w-7 items-center justify-center rounded-full bg-sky-600 text-sm font-semibold text-white data-[intent=warning]:bg-amber-500', - body: 'min-w-0 text-sm leading-6 text-slate-700', - title: 'mr-2 inline font-semibold text-slate-950', - actions: - 'flex items-center gap-2 data-[layout=multiline]:col-start-2 data-[layout=multiline]:justify-self-end data-[layout=multiline]:pt-1', - actionButton: - 'flex h-8 items-center justify-center rounded-md border border-slate-300 bg-white px-3 text-sm font-medium text-slate-900 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2', - link: 'rounded underline underline-offset-4 transition-colors hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2', -}; - +import linkStyles from '../Link/link.module.css'; +import styles from './message-bar.module.css'; export const Default = (): React.ReactNode => ( <MessageBar - className={classes.messageBar} - icon={{ - className: `${classes.icon} bg-sky-600`, - children: 'i', - }} + className={`${styles.bar} ${styles.info}`} + icon={{ className: styles.icon, children: <InfoRegular aria-hidden /> }} > - <MessageBarBody className={classes.body}> - <MessageBarTitle className={classes.title}>Descriptive title</MessageBarTitle> + <MessageBarBody className={styles.body}> + <MessageBarTitle className={styles.title}>Descriptive title</MessageBarTitle> Message providing information to the user with actionable insights.{' '} - <Link className={classes.link} href="#" inline> + <Link className={`${linkStyles.link} ${linkStyles.inline}`} href="#" inline> Learn more </Link> </MessageBarBody> - <MessageBarActions className={classes.actions}> - <Button className={classes.actionButton}>Dismiss</Button> + <MessageBarActions className={styles.actions}> + <button type="button" className={styles.actionBtn}> + Action + </button> + <button type="button" className={styles.iconBtn} aria-label="Dismiss"> + <DismissRegular aria-hidden /> + </button> </MessageBarActions> </MessageBar> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/MessageBar/MessageBarIntent.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MessageBar/MessageBarIntent.stories.tsx index e53a49aa3966e8..c998815da7a512 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/MessageBar/MessageBarIntent.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/MessageBar/MessageBarIntent.stories.tsx @@ -1,63 +1,57 @@ import * as React from 'react'; import { Link } from '@fluentui/react-headless-components-preview/link'; -import { MessageBar, MessageBarTitle, MessageBarBody } from '@fluentui/react-headless-components-preview/message-bar'; +import { MessageBar, MessageBarBody, MessageBarTitle } from '@fluentui/react-headless-components-preview/message-bar'; +import { CheckmarkCircleRegular, ErrorCircleRegular, InfoRegular, WarningRegular } from '@fluentui/react-icons'; +import linkStyles from '../Link/link.module.css'; +import styles from './message-bar.module.css'; const items = [ { - intent: 'info', - className: 'border-l-sky-600 border-sky-200 bg-sky-50', - icon: { children: 'i', className: 'bg-sky-600' }, + intent: 'info' as const, + variant: styles.info, + icon: <InfoRegular aria-hidden />, title: 'Info message', - body: 'Message providing information to the user with actionable insights.', }, { - intent: 'warning', - className: 'border-l-amber-500 border-amber-200 bg-amber-50', - icon: { children: '!', className: 'bg-amber-500' }, + intent: 'warning' as const, + variant: styles.warning, + icon: <WarningRegular aria-hidden />, title: 'Warning message', - body: 'Message providing information to the user with actionable insights.', }, { - intent: 'error', - className: 'border-l-red-600 border-red-200 bg-red-50', - icon: { children: 'x', className: 'bg-red-600' }, + intent: 'error' as const, + variant: styles.danger, + icon: <ErrorCircleRegular aria-hidden />, title: 'Error message', - body: 'Message providing information to the user with actionable insights.', }, { - intent: 'success', - className: 'border-l-emerald-600 border-emerald-200 bg-emerald-50', - icon: { children: '✓', className: 'bg-emerald-600' }, + intent: 'success' as const, + variant: styles.success, + icon: <CheckmarkCircleRegular aria-hidden />, title: 'Success message', - body: 'Message providing information to the user with actionable insights.', }, -] as const; +]; -export const Intent = (): React.ReactNode => { - return ( - <div className="flex w-full max-w-3xl flex-col gap-3"> - {items.map(item => ( - <MessageBar - key={item.intent} - className={`flex items-center gap-4 rounded-xl border border-l-4 px-4 py-3 shadow-sm ${item.className}`} - icon={{ - children: item.icon.children, - className: `mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold text-white ${item.icon.className}`, - }} - intent={item.intent} - > - <MessageBarBody className="text-sm leading-6 text-slate-700"> - <MessageBarTitle className="mr-2 inline font-semibold text-slate-950">{item.title}</MessageBarTitle> - {item.body}{' '} - <Link className="rounded underline underline-offset-4 transition-colors hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2"> - Link - </Link> - </MessageBarBody> - </MessageBar> - ))} - </div> - ); -}; +export const Intent = (): React.ReactNode => ( + <div className={styles.list}> + {items.map(item => ( + <MessageBar + key={item.intent} + intent={item.intent} + className={`${styles.bar} ${item.variant}`} + icon={{ className: styles.icon, children: item.icon }} + > + <MessageBarBody className={styles.body}> + <MessageBarTitle className={styles.title}>{item.title}</MessageBarTitle> + Message providing information to the user with actionable insights.{' '} + <Link className={`${linkStyles.link} ${linkStyles.inline}`} href="#" inline> + Learn more + </Link> + </MessageBarBody> + </MessageBar> + ))} + </div> +); Intent.parameters = { docs: { diff --git a/packages/react-components/react-headless-components-preview/stories/src/MessageBar/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MessageBar/index.stories.tsx index 96b2b91bf70745..7f6943fba0b199 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/MessageBar/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/MessageBar/index.stories.tsx @@ -6,7 +6,6 @@ import { } from '@fluentui/react-headless-components-preview/message-bar'; import descriptionMd from './MessageBarDescription.md'; - export { Default } from './MessageBarDefault.stories'; export { Intent } from './MessageBarIntent.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/MessageBar/message-bar.module.css b/packages/react-components/react-headless-components-preview/stories/src/MessageBar/message-bar.module.css new file mode 100644 index 00000000000000..d03467267142da --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MessageBar/message-bar.module.css @@ -0,0 +1,125 @@ +.bar { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 12px; + width: 100%; + padding: 8px 12px; + border-radius: var(--radius-pill); + background: var(--bg-elev); + border: 1px solid var(--border); + font-size: 13px; + line-height: 1.45; +} + +.bar[data-layout='multiline'] { + grid-template-columns: auto 1fr; + border-radius: var(--radius-lg); + padding: 12px 16px; +} + +.bar[data-layout='multiline'] .actions { + grid-column: 2; + justify-self: end; +} + +.icon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.icon svg { + width: 16px; + height: 16px; +} + +.body { + color: var(--text); + min-width: 0; +} + +.title { + font-weight: 600; + margin-right: 4px; +} + +.actions { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.actionBtn { + height: 24px; + padding: 0 10px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 500; + background: transparent; + color: var(--text); + border: none; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard); +} + +.actionBtn:hover { + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.iconBtn { + width: 24px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.iconBtn svg { + width: 12px; + height: 12px; +} + +/* Intent variants — subtle pastel backgrounds */ +.info { + background: var(--info-soft); + border-color: transparent; +} +.info .icon { + color: var(--info); +} + +.success { + background: var(--success-soft); + border-color: transparent; +} +.success .icon { + color: var(--success); +} + +.warning { + background: var(--warning-soft); + border-color: transparent; +} +.warning .icon { + color: var(--warning); +} + +.danger { + background: var(--brand-soft); + border-color: transparent; +} +.danger .icon { + color: var(--brand); +} + +.list { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverAnchorToCustomTarget.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverAnchorToCustomTarget.stories.tsx index 25abec496644ba..673b8b0c897310 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverAnchorToCustomTarget.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverAnchorToCustomTarget.stories.tsx @@ -1,42 +1,32 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - outer: 'p-16 min-h-[320px]', - container: 'flex items-start gap-10', - column: 'flex flex-col items-start gap-2', - label: 'text-xs font-semibold text-gray-500 uppercase tracking-wide', - trigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - target: - 'px-4 py-2 rounded-md bg-purple-600 text-white font-medium hover:bg-purple-700 focus-visible:outline-2 focus-visible:outline-purple-500 focus-visible:outline-offset-2 cursor-pointer border-none', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs flex flex-col gap-2', -}; +import styles from './popover.module.css'; export const AnchorToCustomTarget = (): React.ReactNode => { const [target, setTarget] = React.useState<HTMLElement | null>(null); return ( - <div className={classes.outer}> - <div className={classes.container}> - <div className={classes.column}> - <span className={classes.label}>Custom anchor (target)</span> - <button ref={setTarget} className={classes.target}> + <div className={styles.outerPad}> + <div className={styles.cluster}> + <div className={styles.column}> + <span className={styles.fieldLabel}>Custom anchor (target)</span> + <button ref={setTarget} className={`${styles.trigger} ${styles.triggerSecondary}`}> Anchor </button> </div> - <div className={classes.column}> - <span className={classes.label}>Popover trigger (unrelated)</span> + <div className={styles.column}> + <span className={styles.fieldLabel}>Popover trigger (unrelated)</span> <Popover positioning={{ target, position: 'below', offset: 4 }}> <PopoverTrigger> - <button className={classes.trigger}>Open popover</button> + <button className={styles.trigger}>Open popover</button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className="text-sm font-semibold text-gray-900 m-0">Popover content</h3> - <p className="text-sm text-gray-600"> + <PopoverSurface className={`${styles.surface} ${styles.surfaceColumn}`}> + <h3 className={styles.headingFlush}>Popover content</h3> + <p className={styles.body}> Clicking <em>Open popover</em> toggles this surface, but <code>positioning.target</code> makes it anchor - to the purple <em>Anchor</em> button instead of the trigger. It appears to the right of the anchor + to the magenta <em>Anchor</em> button instead of the trigger. It appears to the right of the anchor regardless of where the trigger sits. </p> </PopoverSurface> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverControlled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverControlled.stories.tsx index 65d8fac2b65ee8..7a143aac058460 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverControlled.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverControlled.stories.tsx @@ -1,28 +1,23 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - trigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs', - checkbox: 'flex items-center gap-2 mb-4 text-sm text-gray-700', -}; +import styles from './popover.module.css'; export const Controlled = (): React.ReactNode => { const [open, setOpen] = React.useState(false); return ( - <div className="flex flex-col gap-4"> - <label className={classes.checkbox}> + <div className={styles.columnSpacious}> + <label className={styles.checkbox}> <input type="checkbox" checked={open} onChange={e => setOpen(e.target.checked)} /> Popover open </label> <Popover open={open} onOpenChange={(_e, data) => setOpen(data.open)}> <PopoverTrigger> - <button className={classes.trigger}>Controlled popover</button> + <button className={styles.trigger}>Controlled popover</button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <p className="text-sm text-gray-600"> + <PopoverSurface className={styles.surface}> + <p className={styles.body}> This popover is controlled externally. Toggle the checkbox above or click the trigger to open and close it. </p> </PopoverSurface> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverCustomTrigger.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverCustomTrigger.stories.tsx index f3707950e20aca..37641b38e1ded7 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverCustomTrigger.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverCustomTrigger.stories.tsx @@ -1,16 +1,12 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - trigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs', -}; +import styles from './popover.module.css'; type CustomTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement>; const CustomTriggerButton = React.forwardRef<HTMLButtonElement, CustomTriggerProps>((props, ref) => ( - <button ref={ref} {...props} className={classes.trigger}> + <button ref={ref} {...props} className={styles.trigger}> Custom trigger </button> )); @@ -20,9 +16,9 @@ export const CustomTrigger = (): React.ReactNode => ( <PopoverTrigger> <CustomTriggerButton /> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className="text-sm font-semibold text-gray-900 mb-1">Custom trigger</h3> - <p className="text-sm text-gray-600"> + <PopoverSurface className={styles.surface}> + <h3 className={styles.heading}>Custom trigger</h3> + <p className={styles.body}> Native elements and Fluent components have first-class support as children of <code>PopoverTrigger</code>. To use your own component, forward its ref with <code>React.forwardRef</code> so the popover can wire up the trigger ref and aria attributes. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDefault.stories.tsx index 1ed9aa40c50dab..919836daf15093 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDefault.stories.tsx @@ -1,20 +1,16 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - trigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs', -}; +import styles from './popover.module.css'; export const Default = (): React.ReactNode => ( <Popover> <PopoverTrigger> - <button className={classes.trigger}>Show popover</button> + <button className={styles.trigger}>Show popover</button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className="text-sm font-semibold text-gray-900 mb-1">Popover title</h3> - <p className="text-sm text-gray-600"> + <PopoverSurface className={styles.surface}> + <h3 className={styles.heading}>Popover title</h3> + <p className={styles.body}> This is the content of the popover. Click the trigger again or press Escape to close. </p> </PopoverSurface> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInternalUpdateContent.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInternalUpdateContent.stories.tsx index afb359fd4ec696..1154674b290dd4 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInternalUpdateContent.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInternalUpdateContent.stories.tsx @@ -1,14 +1,7 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - trigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 w-80', - action: - 'px-3 py-1.5 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 cursor-pointer border-none', - link: 'text-blue-600 hover:text-blue-700 underline', -}; +import styles from './popover.module.css'; export const InternalUpdateContent = (): React.ReactNode => { const [revealed, setRevealed] = React.useState(false); @@ -23,25 +16,25 @@ export const InternalUpdateContent = (): React.ReactNode => { return ( <Popover onOpenChange={(_, data) => !data.open && setRevealed(false)}> <PopoverTrigger> - <button className={classes.trigger}>Popover trigger</button> + <button className={styles.trigger}>Popover trigger</button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className="text-sm font-semibold text-gray-900 mb-2">First panel</h3> - <p className="text-sm text-gray-600 mb-3"> + <PopoverSurface className={`${styles.surface} ${styles.surfaceWide}`}> + <h3 className={styles.heading}>First panel</h3> + <p className={`${styles.body} ${styles.bodySpaced}`}> Popover content can change while the popover is open. When new focusable content is revealed, move focus to it so keyboard users can continue interacting. </p> {revealed ? ( - <div className="text-sm text-gray-700"> + <div className={styles.body}> Revealed content with{' '} - <a ref={linkRef} href="#" className={classes.link}> + <a ref={linkRef} href="#" className={styles.link}> a focusable link </a> . </div> ) : ( - <button className={classes.action} onClick={() => setRevealed(true)}> + <button className={styles.actionButton} onClick={() => setRevealed(true)}> Reveal more </button> )} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverNested.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverNested.stories.tsx index 083360f4ed231b..c8e3c1d9ec2127 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverNested.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverNested.stories.tsx @@ -3,31 +3,17 @@ import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headles import type { JSXElement } from '@fluentui/react-components'; import descriptionMd from './PopoverNestedDescription.md'; - -const classes = { - rootTrigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - nestedTrigger: - 'px-3 py-1.5 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 data-[open]:bg-indigo-700 focus-visible:outline-2 focus-visible:outline-indigo-500 focus-visible:outline-offset-2 cursor-pointer border-none', - deepTrigger: - 'px-3 py-1.5 rounded-md bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 data-[open]:bg-purple-700 focus-visible:outline-2 focus-visible:outline-purple-500 focus-visible:outline-offset-2 cursor-pointer border-none', - actionButton: - 'px-3 py-1.5 rounded-md bg-gray-200 text-gray-900 text-sm font-medium hover:bg-gray-300 cursor-pointer border-none', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs flex flex-col gap-3', - heading: 'text-sm font-semibold text-gray-900 m-0', - body: 'text-sm text-gray-600', - row: 'flex flex-wrap items-center gap-2', -}; +import styles from './popover.module.css'; const SecondNestedPopover = (): JSXElement => ( <Popover> <PopoverTrigger> - <button className={classes.deepTrigger}>Second nested trigger</button> + <button className={`${styles.trigger} ${styles.triggerSmall}`}>Second nested trigger</button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className={classes.heading}>Popover content</h3> - <div className={classes.body}>This is some popover content.</div> - <button className={classes.actionButton}>Second nested button</button> + <PopoverSurface className={`${styles.surface} ${styles.surfaceColumnLg}`}> + <h3 className={styles.headingFlush}>Popover content</h3> + <div className={styles.body}>This is some popover content.</div> + <button className={styles.actionButton}>Second nested button</button> </PopoverSurface> </Popover> ); @@ -35,13 +21,15 @@ const SecondNestedPopover = (): JSXElement => ( const FirstNestedPopover = (): JSXElement => ( <Popover> <PopoverTrigger> - <button className={classes.nestedTrigger}>First nested trigger</button> + <button className={`${styles.trigger} ${styles.triggerSecondary} ${styles.triggerSmall}`}> + First nested trigger + </button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className={classes.heading}>Popover content</h3> - <div className={classes.body}>This is some popover content.</div> - <button className={classes.actionButton}>First nested button</button> - <div className={classes.row}> + <PopoverSurface className={`${styles.surface} ${styles.surfaceColumnLg}`}> + <h3 className={styles.headingFlush}>Popover content</h3> + <div className={styles.body}>This is some popover content.</div> + <button className={styles.actionButton}>First nested button</button> + <div className={styles.row}> <SecondNestedPopover /> </div> </PopoverSurface> @@ -51,13 +39,13 @@ const FirstNestedPopover = (): JSXElement => ( export const Nested = (): React.ReactNode => ( <Popover> <PopoverTrigger> - <button className={classes.rootTrigger}>Root trigger</button> + <button className={styles.trigger}>Root trigger</button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className={classes.heading}>Popover content</h3> - <div className={classes.body}>This is some popover content.</div> - <div className={classes.row}> - <button className={classes.actionButton}>Root button</button> + <PopoverSurface className={`${styles.surface} ${styles.surfaceColumnLg}`}> + <h3 className={styles.headingFlush}>Popover content</h3> + <div className={styles.body}>This is some popover content.</div> + <div className={styles.row}> + <button className={styles.actionButton}>Root button</button> <FirstNestedPopover /> </div> </PopoverSurface> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnContext.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnContext.stories.tsx index e42b0567b4d8bf..28a6132e587d4d 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnContext.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnContext.stories.tsx @@ -1,23 +1,17 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - trigger: - 'px-6 py-4 rounded-md bg-gray-100 text-gray-700 font-medium border border-dashed border-gray-400 cursor-context-menu focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 py-2 min-w-[160px]', - menuItem: - 'block w-full px-4 py-1.5 text-sm text-gray-700 text-left hover:bg-gray-100 cursor-pointer border-none bg-transparent', -}; +import styles from './popover.module.css'; export const OpenOnContext = (): React.ReactNode => ( <Popover openOnContext> <PopoverTrigger> - <div className={classes.trigger}>Right-click this area</div> + <div className={styles.contextTrigger}>Right-click this area</div> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <button className={classes.menuItem}>Cut</button> - <button className={classes.menuItem}>Copy</button> - <button className={classes.menuItem}>Paste</button> + <PopoverSurface className={`${styles.surface} ${styles.surfaceMenu}`}> + <button className={styles.menuItem}>Cut</button> + <button className={styles.menuItem}>Copy</button> + <button className={styles.menuItem}>Paste</button> </PopoverSurface> </Popover> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnHover.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnHover.stories.tsx index e25fe7a5b5df99..ae58e1fcad8e51 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnHover.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnHover.stories.tsx @@ -1,20 +1,16 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - trigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs', -}; +import styles from './popover.module.css'; export const OpenOnHover = (): React.ReactNode => ( <Popover openOnHover> <PopoverTrigger> - <button className={classes.trigger}>Hover me</button> + <button className={styles.trigger}>Hover me</button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className="text-sm font-semibold text-gray-900 mb-1">Hover popover</h3> - <p className="text-sm text-gray-600"> + <PopoverSurface className={styles.surface}> + <h3 className={styles.heading}>Hover popover</h3> + <p className={styles.body}> This popover opens when you hover over the trigger and closes when the mouse leaves both the trigger and the surface. </p> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithArrow.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithArrow.stories.tsx index 9507b31cd9d931..1957896890df71 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithArrow.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithArrow.stories.tsx @@ -1,41 +1,17 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - wrapper: 'flex flex-col items-start gap-4 p-16', - trigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - surface: [ - // Base surface look - 'bg-white rounded-lg p-4 min-w-[240px] max-w-xs overflow-visible', - '[filter:drop-shadow(0_0_1px_rgba(0,0,0,0.12))_drop-shadow(0_4px_8px_rgba(0,0,0,0.14))]', - // Arrow base (the rotated square rendered by withArrow) - '[&_[data-arrow]]:absolute [&_[data-arrow]]:w-3 [&_[data-arrow]]:h-3 [&_[data-arrow]]:bg-white [&_[data-arrow]]:rotate-45', - // Main-axis offset — arrow protrudes from the side that faces the trigger - "[&[data-placement^='above']_[data-arrow]]:-bottom-1.5", - "[&[data-placement^='below']_[data-arrow]]:-top-1.5", - "[&[data-placement^='before']_[data-arrow]]:-right-1.5", - "[&[data-placement^='after']_[data-arrow]]:-left-1.5", - // Cross-axis centering for the plain (center-aligned) placements - "[&[data-placement='above']_[data-arrow]]:inset-x-0 [&[data-placement='above']_[data-arrow]]:mx-auto", - "[&[data-placement='below']_[data-arrow]]:inset-x-0 [&[data-placement='below']_[data-arrow]]:mx-auto", - "[&[data-placement='before']_[data-arrow]]:inset-y-0 [&[data-placement='before']_[data-arrow]]:my-auto", - "[&[data-placement='after']_[data-arrow]]:inset-y-0 [&[data-placement='after']_[data-arrow]]:my-auto", - // Start/end-aligned placements — arrow pinned via logical inset, padding from --arrow-padding - "[&[data-placement$='-start']_[data-arrow]]:start-[var(--arrow-padding,12px)]", - "[&[data-placement$='-end']_[data-arrow]]:end-[var(--arrow-padding,12px)]", - ].join(' '), -}; +import styles from './popover.module.css'; export const WithArrow = (): React.ReactNode => ( - <div className={classes.wrapper}> + <div className={styles.columnSpacious}> <Popover withArrow positioning={{ position: 'below', offset: 10 }}> <PopoverTrigger> - <button className={classes.trigger}>Center-aligned</button> + <button className={styles.trigger}>Center-aligned</button> </PopoverTrigger> - <PopoverSurface className={classes.surface}> - <h3 className="text-sm font-semibold text-gray-900 mb-1">Arrow popover</h3> - <p className="text-sm text-gray-600"> + <PopoverSurface className={`${styles.surface} ${styles.surfaceWithArrow}`}> + <h3 className={styles.heading}>Arrow popover</h3> + <p className={styles.body}> Arrow orientation follows the <code>data-placement</code> attribute, which <code>usePositioning</code> keeps in sync with the actual placement as you scroll or resize. </p> @@ -44,12 +20,15 @@ export const WithArrow = (): React.ReactNode => ( <Popover withArrow positioning={{ position: 'below', align: 'start', offset: 10 }}> <PopoverTrigger> - <button className={classes.trigger}>Start-aligned (--arrow-padding: 16px)</button> + <button className={styles.trigger}>Start-aligned (--arrow-padding: 16px)</button> </PopoverTrigger> - <PopoverSurface className={classes.surface} style={{ '--arrow-padding': '16px' } as React.CSSProperties}> - <h3 className="text-sm font-semibold text-gray-900 mb-1">Arrow padded from corner</h3> - <p className="text-sm text-gray-600"> - Arrow positioning is fully CSS-owned. For start/end alignments, the Tailwind variant reads{' '} + <PopoverSurface + className={`${styles.surface} ${styles.surfaceWithArrow}`} + style={{ '--arrow-padding': '16px' } as React.CSSProperties} + > + <h3 className={styles.heading}>Arrow padded from corner</h3> + <p className={styles.body}> + Arrow positioning is fully CSS-owned. For start/end alignments, the rule reads{' '} <code>var(--arrow-padding, 12px)</code>; this surface overrides the fallback by setting{' '} <code>--arrow-padding: 16px</code> in its inline <code>style</code>. </p> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithoutTrigger.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithoutTrigger.stories.tsx index 7305eefbdb9e39..e22d6889691cd8 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithoutTrigger.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithoutTrigger.stories.tsx @@ -1,26 +1,21 @@ import * as React from 'react'; import { Popover, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -const classes = { - container: 'flex flex-col items-start gap-4 p-4', - trigger: - 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none', - surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs flex flex-col gap-2', -}; +import styles from './popover.module.css'; export const WithoutTrigger = (): React.ReactNode => { const [open, setOpen] = React.useState(false); const [buttonEl, setButtonEl] = React.useState<HTMLButtonElement | null>(null); return ( - <div className={classes.container}> - <button ref={setButtonEl} className={classes.trigger} onClick={() => setOpen(value => !value)}> + <div className={styles.columnSpacious}> + <button ref={setButtonEl} className={styles.trigger} onClick={() => setOpen(value => !value)}> Toggle popover </button> <Popover open={open} onOpenChange={(_e, data) => setOpen(data.open)} positioning={{ target: buttonEl }}> - <PopoverSurface className={classes.surface}> - <h3 className="text-sm font-semibold text-gray-900 m-0">Popover content</h3> - <p className="text-sm text-gray-600"> + <PopoverSurface className={`${styles.surface} ${styles.surfaceColumn}`}> + <h3 className={styles.headingFlush}>Popover content</h3> + <p className={styles.body}> This popover has no <code>PopoverTrigger</code>. The surface is controlled externally and anchored to the button via the <code>positioning.target</code> prop. </p> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/index.stories.tsx index 485c7f319013b3..b0ade7f4080f43 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/index.stories.tsx @@ -2,7 +2,6 @@ import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headles import descriptionMd from './PopoverDescription.md'; import bestPracticesMd from './PopoverBestPractices.md'; - export { Default } from './PopoverDefault.stories'; export { WithArrow } from './PopoverWithArrow.stories'; export { Controlled } from './PopoverControlled.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/popover.module.css b/packages/react-components/react-headless-components-preview/stories/src/Popover/popover.module.css new file mode 100644 index 00000000000000..06874d55dacbe2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/popover.module.css @@ -0,0 +1,267 @@ +.trigger { + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 var(--space-4); + border: 0; + border-radius: var(--radius-md); + background: var(--text); + color: var(--text-on-accent); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; +} + +.trigger:hover, +.trigger[data-open] { + background: var(--text-muted); +} + +.trigger:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.triggerSecondary { + background: var(--accent); +} + +.triggerSecondary:hover, +.triggerSecondary[data-open] { + background: var(--accent-strong); +} + +.triggerSecondary:focus-visible { + outline-color: var(--accent); +} + +.triggerSmall { + height: 28px; + padding: 0 var(--space-3); + font-size: 12.5px; +} + +.contextTrigger { + display: inline-block; + padding: var(--space-4) var(--space-6); + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-muted); + font-weight: 500; + border: var(--stroke-thin) dashed var(--border-stronger); + cursor: context-menu; + user-select: none; +} + +.contextTrigger:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.surface { + background: var(--bg-elev); + border-radius: var(--radius-md); + border: var(--stroke-thin) solid var(--border); + box-shadow: var(--shadow-3); + padding: var(--space-4); + min-width: 240px; + max-width: 320px; +} + +.surfaceColumn { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.surfaceColumnLg { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.surfaceWide { + width: 320px; +} + +.surfaceMenu { + padding: var(--space-2) 0; + min-width: 160px; +} + +.heading { + font-size: 13.5px; + font-weight: 600; + color: var(--text); + margin: 0 0 var(--space-1); +} + +.headingFlush { + margin: 0; +} + +.body { + font-size: 13px; + color: var(--text-muted); + line-height: 1.45; +} + +.bodySpaced { + margin: 0 0 var(--space-3); +} + +.actionButton { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 var(--space-3); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-elev); + color: var(--text); + font-size: 12.5px; + font-weight: 500; + cursor: pointer; +} + +.actionButton:hover { + background: var(--surface-muted); +} + +.actionButton:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.menuItem { + display: block; + width: 100%; + padding: var(--space-1) var(--space-4); + border: 0; + background: transparent; + color: var(--text); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.menuItem:hover { + background: var(--surface-muted); +} + +.link { + color: var(--accent); + text-decoration: underline; +} + +.link:hover { + color: var(--accent-strong); +} + +.checkbox { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 13.5px; + color: var(--text-muted); +} + +.fieldLabel { + font-size: 11px; + font-weight: 600; + color: var(--text-soft); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.column { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-2); +} + +.columnSpacious { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-4); +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.cluster { + display: flex; + align-items: flex-start; + gap: var(--space-10); +} + +.outerPad { + padding: var(--space-12); + min-height: 320px; +} + +.localPad { + padding: var(--space-4); +} + +/* + Arrow rendering for PopoverWithArrow. The headless Popover paints a 12×12 + rotated square that tracks the surface's data-placement attribute via the + positioning hook. +*/ +.surfaceWithArrow { + overflow: visible; + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.12)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.14)); + border: 0; + box-shadow: none; +} + +.surfaceWithArrow [data-arrow] { + position: absolute; + width: 12px; + height: 12px; + background: var(--bg-elev); + transform: rotate(45deg); +} + +.surfaceWithArrow[data-placement^='above'] [data-arrow] { + bottom: -6px; +} + +.surfaceWithArrow[data-placement^='below'] [data-arrow] { + top: -6px; +} + +.surfaceWithArrow[data-placement^='before'] [data-arrow] { + right: -6px; +} + +.surfaceWithArrow[data-placement^='after'] [data-arrow] { + left: -6px; +} + +.surfaceWithArrow[data-placement='above'] [data-arrow], +.surfaceWithArrow[data-placement='below'] [data-arrow] { + inset-inline: 0; + margin-inline: auto; +} + +.surfaceWithArrow[data-placement='before'] [data-arrow], +.surfaceWithArrow[data-placement='after'] [data-arrow] { + inset-block: 0; + margin-block: auto; +} + +.surfaceWithArrow[data-placement$='-start'] [data-arrow] { + inset-inline-start: var(--arrow-padding, 12px); +} + +.surfaceWithArrow[data-placement$='-end'] [data-arrow] { + inset-inline-end: var(--arrow-padding, 12px); +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/ProgressBarDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/ProgressBarDefault.stories.tsx index 64e161e445cfbb..e0ae25f8581c74 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/ProgressBarDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/ProgressBarDefault.stories.tsx @@ -1,12 +1,30 @@ import * as React from 'react'; import { ProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; -export const Default = (): React.ReactNode => { - return ( - <ProgressBar - className="h-2 w-full max-w-xs overflow-hidden rounded-full bg-gray-200" - bar={{ className: 'h-full rounded-full bg-gray-900 transition-all duration-500 ease-out' }} - value={0.5} - /> - ); -}; +import styles from './progress-bar.module.css'; +export const Default = (): React.ReactNode => ( + <div className={styles.demo}> + <div className={styles.row}> + <div className={styles.label}> + <span>Uploading</span> + <strong>50%</strong> + </div> + <ProgressBar className={styles.bar} bar={{ className: styles.fill }} value={0.5} /> + </div> + + <div className={styles.row}> + <div className={styles.label}> + <span>Backup complete</span> + <strong>100%</strong> + </div> + <ProgressBar className={`${styles.bar} ${styles.success}`} bar={{ className: styles.fill }} value={1} /> + </div> + + <div className={styles.row}> + <div className={styles.label}> + <span>Indeterminate</span> + </div> + <ProgressBar className={`${styles.bar} ${styles.indeterminate}`} bar={{ className: styles.fill }} /> + </div> + </div> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/index.stories.tsx index 12c8a3ed738d5e..f351eddf85f4c7 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/index.stories.tsx @@ -1,7 +1,6 @@ import { ProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; import descriptionMd from './ProgressBarDescription.md'; - export { Default } from './ProgressBarDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/progress-bar.module.css b/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/progress-bar.module.css new file mode 100644 index 00000000000000..3074223989d858 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ProgressBar/progress-bar.module.css @@ -0,0 +1,82 @@ +.bar { + position: relative; + width: 100%; + height: 4px; + border-radius: var(--radius-pill); + background: var(--surface-sunken); + overflow: hidden; +} + +.fill { + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width var(--duration-medium) var(--ease-standard); +} + +.success .fill { + background: var(--success); +} + +.warning .fill { + background: var(--warning); +} + +.danger .fill { + background: var(--brand); +} + +.indeterminate { + position: relative; + overflow: hidden; +} + +.indeterminate .fill { + position: absolute; + inset: 0; + width: 35%; + animation: slide 1.4s ease-in-out infinite; +} + +.row { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.label { + display: flex; + justify-content: space-between; + font-size: 12.5px; + color: var(--text-muted); +} + +.label strong { + color: var(--text); + font-weight: 500; +} + +@keyframes slide { + 0% { + transform: translateX(-100%); + } + 50% { + transform: translateX(180%); + } + 100% { + transform: translateX(280%); + } +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + display: flex; + + flex-direction: column; + + gap: 24px; + + max-width: 360px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/RadioGroupDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/RadioGroupDefault.stories.tsx index 56127238c0f71b..2761e9ec00295f 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/RadioGroupDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/RadioGroupDefault.stories.tsx @@ -1,33 +1,31 @@ import * as React from 'react'; import { RadioGroup, Radio } from '@fluentui/react-headless-components-preview/radio-group'; -const radioClass = - 'flex items-center gap-2.5 cursor-pointer p-1 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2'; -const radioInputClass = 'h-4 w-4 cursor-pointer accent-gray-900 shrink-0 focus:outline-none'; -const radioLabelClass = 'text-sm text-gray-700 cursor-pointer select-none'; - +import styles from './radio-group.module.css'; const plans = [ - { value: 'free', label: 'Free', description: '$0 / month · Up to 3 projects' }, - { value: 'standard', label: 'Standard', description: '$12 / month · Up to 20 projects' }, - { value: 'pro', label: 'Pro', description: '$29 / month · Unlimited projects' }, + { value: 'free', title: 'Free', subtitle: '$0 / month · Up to 3 projects' }, + { value: 'standard', title: 'Standard', subtitle: '$12 / month · Up to 20 projects' }, + { value: 'pro', title: 'Pro', subtitle: '$29 / month · Unlimited projects' }, ]; export const Default = (): React.ReactNode => ( - <RadioGroup defaultValue="standard" className="flex flex-col gap-1 w-full max-w-xs"> + <RadioGroup defaultValue="standard" className={`${styles.group} ${styles.demo}`}> {plans.map(plan => ( <Radio key={plan.value} value={plan.value} label={{ + className: styles.text, children: ( - <span className="flex flex-col"> - <span className={radioLabelClass}>{plan.label}</span> - <span className="text-xs text-gray-500">{plan.description}</span> - </span> + <> + <span className={styles.title}>{plan.title}</span> + <span className={styles.subtitle}>{plan.subtitle}</span> + </> ), }} - className={radioClass} - input={{ className: radioInputClass }} + className={styles.row} + input={{ className: styles.input }} + indicator={{ className: styles.indicator }} /> ))} </RadioGroup> diff --git a/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/index.stories.tsx index 34b9458af7e1ae..fa22cb5b007063 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/index.stories.tsx @@ -1,7 +1,6 @@ import { RadioGroup, Radio } from '@fluentui/react-headless-components-preview/radio-group'; import descriptionMd from './RadioGroupDescription.md'; - export { Default } from './RadioGroupDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/radio-group.module.css b/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/radio-group.module.css new file mode 100644 index 00000000000000..b8914ef026bd01 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/RadioGroup/radio-group.module.css @@ -0,0 +1,88 @@ +.group { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.row { + position: relative; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elev); + cursor: pointer; + transition: border-color var(--duration-fast) var(--ease-standard), + background var(--duration-fast) var(--ease-standard); +} + +.row:hover { + border-color: var(--border-strong); +} + +.row[data-disabled] { + cursor: not-allowed; + opacity: 0.4; +} + +.input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + margin: 0; +} + +.indicator { + width: 18px; + height: 18px; + border-radius: 50%; + border: 1.5px solid var(--border-stronger); + background: var(--bg-elev); + position: relative; + flex-shrink: 0; + margin-top: 1px; + transition: border-color var(--duration-fast) var(--ease-standard); +} + +.input:checked + .indicator { + border-color: var(--accent); + border-width: 5px; + background: var(--bg-elev); +} + +.input:focus-visible + .indicator { + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.row:has(.input:checked) { + border-color: var(--accent); + background: var(--bg-elev); +} + +.text { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.title { + font-weight: 500; + color: var(--text); + font-size: 13.5px; +} + +.subtitle { + font-size: 12px; + color: var(--text-muted); +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + max-width: 360px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Rating/RatingDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Rating/RatingDefault.stories.tsx index 2295bed470ddb5..1f3226042b5e0b 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Rating/RatingDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Rating/RatingDefault.stories.tsx @@ -2,34 +2,28 @@ import * as React from 'react'; import { Rating, RatingItem } from '@fluentui/react-headless-components-preview/rating'; import { StarFilled, StarRegular } from '@fluentui/react-icons'; +import styles from './rating.module.css'; export const Default = (): React.ReactNode => { const [value, setValue] = React.useState(3); const max = 5; return ( - <div className="flex flex-col gap-3"> - <Rating - max={max} - value={value} - onChange={(_, data) => setValue(data.value)} - className="flex items-center gap-0.5 text-gray-900 cursor-pointer" - > + <div className={styles.row}> + <Rating max={max} value={value} onChange={(_, data) => setValue(data.value)} className={styles.rating}> {Array.from({ length: max }, (_, i) => ( <RatingItem key={i} value={i + 1} - className="relative has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2" - selectedIcon={<StarFilled className="size-5" />} - unselectedIcon={<StarRegular className="size-5" />} - fullValueInput={{ - className: 'peer absolute inset-0 opacity-0 focus:outline-none cursor-pointer', - }} + className={styles.item} + selectedIcon={<StarFilled className={styles.icon} />} + unselectedIcon={<StarRegular className={styles.icon} />} + fullValueInput={{ className: styles.input }} /> ))} </Rating> - <p className="text-sm text-gray-600"> - Rating: <span className="font-medium">{value}</span> out of {max} - </p> + <span className={styles.value}> + {value} / {max} + </span> </div> ); }; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Rating/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Rating/index.stories.tsx index 95d362eb57cd71..9c0fc57f4dc39a 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Rating/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Rating/index.stories.tsx @@ -1,7 +1,6 @@ import { Rating, RatingItem } from '@fluentui/react-headless-components-preview/rating'; import descriptionMd from './RatingDescription.md'; - export { Default } from './RatingDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Rating/rating.module.css b/packages/react-components/react-headless-components-preview/stories/src/Rating/rating.module.css new file mode 100644 index 00000000000000..f637b5276a8241 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Rating/rating.module.css @@ -0,0 +1,44 @@ +.rating { + display: inline-flex; + align-items: center; + gap: 2px; + color: var(--text); +} + +.item { + position: relative; + display: inline-flex; +} + +.input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + margin: 0; +} + +.input:focus-visible ~ svg { + filter: drop-shadow(0 0 0 var(--accent)) drop-shadow(0 0 3px var(--brand)); +} + +.icon { + width: 22px; + height: 22px; + color: inherit; +} + +.row { + display: flex; + align-items: center; + gap: 12px; +} + +.value { + font-weight: 500; + color: var(--text); + font-size: 13px; + font-variant-numeric: tabular-nums; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/RatingDisplayCompact.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/RatingDisplayCompact.stories.tsx index 86607b742170b0..b0b111eeb601b5 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/RatingDisplayCompact.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/RatingDisplayCompact.stories.tsx @@ -1,21 +1,22 @@ import * as React from 'react'; import { RatingDisplay } from '@fluentui/react-headless-components-preview/rating-display'; -import { StarFilled, StarHalfFilled } from '@fluentui/react-icons'; +import { StarFilled, StarHalfFilled, StarRegular } from '@fluentui/react-icons'; -const RatingIcon = () => ( +import styles from './rating-display.module.css'; +const RatingIcon: React.FC = () => ( <> - <StarFilled className="absolute flex size-4 [[data-appearance=filled-half]_&]:invisible [[data-appearance=outline]_&]:text-gray-300 " /> - <StarHalfFilled className="absolute flex size-4 [[data-appearance=filled-half]_&]:visible invisible" /> + <StarFilled className={`${styles.icon} ${styles.iconFilled}`} /> + <StarHalfFilled className={`${styles.icon} ${styles.iconHalf}`} /> + <StarRegular className={`${styles.icon} ${styles.iconOutline}`} /> </> ); -export const Compact = (): React.ReactNode => { - return ( - <RatingDisplay - className="flex items-center gap-1 [&>[data-appearance]]:size-4 [&>[data-appearance]]:relative" - compact - value={3} - icon={RatingIcon} - /> - ); -}; +export const Compact = (): React.ReactNode => ( + <RatingDisplay + className={styles.display} + compact + value={3} + icon={RatingIcon} + valueText={{ className: styles.value }} + /> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/RatingDisplayDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/RatingDisplayDefault.stories.tsx index b2350def921c53..c9e482ccc46570 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/RatingDisplayDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/RatingDisplayDefault.stories.tsx @@ -1,23 +1,23 @@ import * as React from 'react'; import { RatingDisplay } from '@fluentui/react-headless-components-preview/rating-display'; -import { StarFilled, StarHalfFilled } from '@fluentui/react-icons'; +import { StarFilled, StarHalfFilled, StarRegular } from '@fluentui/react-icons'; -const RatingIcon = () => ( +import styles from './rating-display.module.css'; +const RatingIcon: React.FC = () => ( <> - <StarFilled className="absolute size-4 [[data-appearance=filled-half]_&]:invisible" /> - <StarHalfFilled className="absolute size-4 [[data-appearance=filled-half]_&]:visible invisible" /> - <StarFilled className="absolute text-gray-300 size-4 [[data-appearance=outline]_&]:visible invisible" /> + <StarFilled className={`${styles.icon} ${styles.iconFilled}`} /> + <StarHalfFilled className={`${styles.icon} ${styles.iconHalf}`} /> + <StarRegular className={`${styles.icon} ${styles.iconOutline}`} /> </> ); -export const Default = (): React.ReactNode => { - return ( - <RatingDisplay - icon={RatingIcon} - className="flex items-center gap-1 [&>[data-appearance]]:size-4 [&>[data-appearance]]:relative" - value={2.5} - max={5} - valueText={{ className: 'ms-3' }} - /> - ); -}; +export const Default = (): React.ReactNode => ( + <RatingDisplay + icon={RatingIcon} + className={styles.display} + value={2.5} + max={5} + valueText={{ className: styles.value }} + countText={{ className: styles.count, children: '(248)' }} + /> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/index.stories.tsx index 2876fada6d9554..47cdbe210b3f17 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/index.stories.tsx @@ -1,7 +1,6 @@ import { RatingDisplay } from '@fluentui/react-headless-components-preview/rating-display'; import descriptionMd from './RatingDisplayDescription.md'; - export { Default } from './RatingDisplayDefault.stories'; export { Compact } from './RatingDisplayCompact.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/rating-display.module.css b/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/rating-display.module.css new file mode 100644 index 00000000000000..a37b296f025dfb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/rating-display.module.css @@ -0,0 +1,56 @@ +.display { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text); + font-size: 13.5px; +} + +.display [data-appearance] { + width: 18px; + height: 18px; + position: relative; + display: inline-flex; +} + +.display [data-appearance] svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.icon { + width: 18px; + height: 18px; +} + +.display [data-appearance='filled'] .iconHalf, +.display [data-appearance='filled'] .iconOutline { + visibility: hidden; +} + +.display [data-appearance='filled-half'] .iconFilled, +.display [data-appearance='filled-half'] .iconOutline { + visibility: hidden; +} + +.display [data-appearance='outline'] .iconFilled, +.display [data-appearance='outline'] .iconHalf { + visibility: hidden; +} + +.display [data-appearance='outline'] .iconOutline { + color: var(--border-strong); +} + +.value { + color: var(--text); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.count { + color: var(--text-muted); + font-size: 12.5px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/SearchBox/SearchBoxDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SearchBox/SearchBoxDefault.stories.tsx index 7242488585bc77..095fbb27ed0f29 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/SearchBox/SearchBoxDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/SearchBox/SearchBoxDefault.stories.tsx @@ -2,13 +2,18 @@ import * as React from 'react'; import { SearchBox } from '@fluentui/react-headless-components-preview/search-box'; import { SearchRegular } from '@fluentui/react-icons'; +// SearchBox reuses the input CSS module per the story authoring guide. +import styles from '../Input/input.module.css'; export const Default = (): React.ReactNode => ( - <SearchBox - placeholder="Search..." - className="flex w-full max-w-sm items-center rounded-md border border-gray-300 bg-white has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2" - contentBefore={<SearchRegular className="ml-3 h-4 w-4 shrink-0 text-gray-400" />} - input={{ - className: 'flex-1 px-3 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 bg-transparent', - }} - /> + <div className={styles.demo}> + <SearchBox + placeholder="Search…" + className={styles.wrap} + contentBefore={{ + className: styles.affix, + children: <SearchRegular className={styles.affixIcon} aria-hidden />, + }} + input={{ className: styles.input }} + /> + </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/SearchBox/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SearchBox/index.stories.tsx index 46df6762c34a4d..89eb0a93dd14ee 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/SearchBox/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/SearchBox/index.stories.tsx @@ -1,7 +1,6 @@ import { SearchBox } from '@fluentui/react-headless-components-preview/search-box'; import descriptionMd from './SearchBoxDescription.md'; - export { Default } from './SearchBoxDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Select/SelectDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Select/SelectDefault.stories.tsx index d6ff25655f9b83..41c6454fa7b328 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Select/SelectDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Select/SelectDefault.stories.tsx @@ -2,25 +2,23 @@ import * as React from 'react'; import { Select } from '@fluentui/react-headless-components-preview/select'; import { ChevronDownRegular } from '@fluentui/react-icons'; -export const Default = (): React.ReactNode => { - return ( - <div className="flex w-full max-w-sm flex-col gap-2"> - <label className="text-sm font-medium text-gray-700" htmlFor="color-select"> - Color - </label> - <Select - className="relative" - select={{ - className: - 'appearance-none w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - }} - id="color-select" - icon={{ className: 'absolute right-2 top-1/2 -translate-y-1/2', children: <ChevronDownRegular /> }} - > - <option>Red</option> - <option>Green</option> - <option>Blue</option> - </Select> - </div> - ); -}; +import fieldStyles from '../Field/field.module.css'; +import styles from './select.module.css'; +export const Default = (): React.ReactNode => ( + <div className={`${fieldStyles.field} ${styles.demo}`}> + <label className={fieldStyles.label} htmlFor="color-select"> + Color + </label> + <Select + className={styles.wrap} + id="color-select" + select={{ className: styles.select }} + icon={{ className: styles.icon, children: <ChevronDownRegular aria-hidden /> }} + > + <option>Red</option> + <option>Green</option> + <option>Blue</option> + <option>Magenta</option> + </Select> + </div> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Select/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Select/index.stories.tsx index 29e82c89bc1c50..ab827fa08f0f9b 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Select/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Select/index.stories.tsx @@ -1,7 +1,6 @@ import { Select } from '@fluentui/react-headless-components-preview/select'; import descriptionMd from './SelectDescription.md'; - export { Default } from './SelectDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Select/select.module.css b/packages/react-components/react-headless-components-preview/stories/src/Select/select.module.css new file mode 100644 index 00000000000000..1aab37e6bbc280 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Select/select.module.css @@ -0,0 +1,53 @@ +.wrap { + position: relative; + display: inline-block; + width: 100%; +} + +.select { + width: 100%; + appearance: none; + -webkit-appearance: none; + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text); + border-radius: var(--radius-md); + padding: 8px 36px 8px 12px; + font-size: 13.5px; + cursor: pointer; + transition: border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.select:hover { + border-color: var(--border-strong); +} + +.select:focus-visible { + outline: none; + border-color: var(--text); + box-shadow: 0 0 0 3px var(--surface-muted); +} + +.select:disabled { + background: var(--surface-muted); + cursor: not-allowed; + opacity: 0.6; +} + +.icon { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + pointer-events: none; + color: var(--text-soft); +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + max-width: 360px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Skeleton/SkeletonDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Skeleton/SkeletonDefault.stories.tsx index 0049a150c6924b..05211bb105cb9b 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Skeleton/SkeletonDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Skeleton/SkeletonDefault.stories.tsx @@ -1,17 +1,18 @@ import * as React from 'react'; import { Skeleton, SkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; +import styles from './skeleton.module.css'; export const Default = (): React.ReactNode => ( - <Skeleton className="flex flex-col gap-3 w-full max-w-sm rounded-lg border bg-white border-gray-200 p-4"> - <div className="flex items-center gap-3"> - <SkeletonItem className="size-10 shrink-0 rounded-full bg-gray-200 animate-pulse" /> - <div className="flex flex-1 flex-col gap-1.5"> - <SkeletonItem className="h-3 w-3/5 rounded bg-gray-200 animate-pulse" /> - <SkeletonItem className="h-3 w-2/5 rounded bg-gray-200 animate-pulse" /> + <Skeleton className={`${styles.card} ${styles.demo}`}> + <div className={styles.row}> + <SkeletonItem className={styles.circle} /> + <div className={styles.demoFlex}> + <SkeletonItem className={`${styles.bar} ${styles.line60}`} /> + <SkeletonItem className={`${styles.bar} ${styles.line40}`} /> </div> </div> - <SkeletonItem className="h-3 w-full rounded bg-gray-200 animate-pulse" /> - <SkeletonItem className="h-3 w-full rounded bg-gray-200 animate-pulse" /> - <SkeletonItem className="h-3 w-4/5 rounded bg-gray-200 animate-pulse" /> + <SkeletonItem className={`${styles.bar} ${styles.line100}`} /> + <SkeletonItem className={`${styles.bar} ${styles.line100}`} /> + <SkeletonItem className={`${styles.bar} ${styles.line80}`} /> </Skeleton> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Skeleton/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Skeleton/index.stories.tsx index 64f5bf99c37bb8..635ae473eec33d 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Skeleton/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Skeleton/index.stories.tsx @@ -1,7 +1,6 @@ import { Skeleton, SkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import descriptionMd from './SkeletonDescription.md'; - export { Default } from './SkeletonDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Skeleton/skeleton.module.css b/packages/react-components/react-headless-components-preview/stories/src/Skeleton/skeleton.module.css new file mode 100644 index 00000000000000..f77185792ae159 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Skeleton/skeleton.module.css @@ -0,0 +1,71 @@ +.card { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 16px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.row { + display: flex; + gap: 12px; + align-items: center; +} + +.demoFlex { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; +} + +.bar { + height: 12px; + border-radius: var(--radius-xs); + background: var(--surface-muted); + animation: pulse 1.6s ease-in-out infinite; +} + +.circle { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--surface-muted); + flex-shrink: 0; + animation: pulse 1.6s ease-in-out infinite; +} + +.line40 { + width: 40%; +} + +.line60 { + width: 60%; +} + +.line80 { + width: 80%; +} + +.line100 { + width: 100%; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + max-width: 384px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Slider/SliderDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Slider/SliderDefault.stories.tsx index 6d5261324ec1c2..73e9e0c409647f 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Slider/SliderDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Slider/SliderDefault.stories.tsx @@ -1,23 +1,23 @@ import * as React from 'react'; import { Slider } from '@fluentui/react-headless-components-preview/slider'; +import styles from './slider.module.css'; export const Default = (): React.ReactNode => { + const [value, setValue] = React.useState(42); return ( - <Slider - id="custom-slider" - min={0} - max={100} - defaultValue={42} - className="relative w-full max-w-xs" - input={{ className: 'peer absolute opacity-0 h-full w-full z-10 focus:outline-none' }} - rail={{ - className: - 'h-1 rounded-full bg-gray-200 shadow-xs relative after:block after:content-[""] after:absolute after:inset-0 after:rounded-full after:bg-gray-900 after:border after:border-gray-800 after:w-(--fui-Slider--progress)', - }} - thumb={{ - className: - 'absolute -top-2 bg-gray-900 rounded-full size-5 shadow border-2 border-white peer-focus-visible:ring-2 peer-focus-visible:ring-black peer-focus-visible:ring-offset-2 left-(--fui-Slider--progress) -ml-2', - }} - /> + <div className={`${styles.row} ${styles.demo}`}> + <Slider + id="custom-slider" + min={0} + max={100} + value={value} + onChange={(_, data) => setValue(data.value)} + className={styles.slider} + input={{ className: styles.input }} + rail={{ className: styles.rail }} + thumb={{ className: styles.thumb }} + /> + <span className={styles.value}>{value}</span> + </div> ); }; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Slider/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Slider/index.stories.tsx index 66dcf2e476976b..db39864751edce 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Slider/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Slider/index.stories.tsx @@ -1,7 +1,6 @@ import { Slider } from '@fluentui/react-headless-components-preview/slider'; import descriptionMd from './SliderDescription.md'; - export { Default } from './SliderDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Slider/slider.module.css b/packages/react-components/react-headless-components-preview/stories/src/Slider/slider.module.css new file mode 100644 index 00000000000000..969e2b54e300ad --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Slider/slider.module.css @@ -0,0 +1,87 @@ +.slider { + position: relative; + width: 100%; + max-width: 320px; + height: 28px; + display: flex; + align-items: center; +} + +.input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + z-index: 2; + margin: 0; +} + +.input:disabled { + cursor: not-allowed; +} + +.rail { + position: relative; + width: 100%; + height: 4px; + border-radius: var(--radius-pill); + background: var(--surface-sunken); + overflow: hidden; +} + +.rail::after { + content: ''; + position: absolute; + inset: 0; + width: var(--fui-Slider--progress, 0%); + background: var(--accent); + border-radius: inherit; + transition: width var(--duration-fast) var(--ease-standard); +} + +.thumb { + position: absolute; + top: 50%; + left: var(--fui-Slider--progress, 0%); + transform: translate(-50%, -50%); + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--bg-elev); + border: 2px solid var(--accent); + box-shadow: var(--shadow-2); + transition: transform 80ms var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); +} + +.input:focus-visible ~ .thumb, +.input:focus-visible + .thumb { + box-shadow: 0 0 0 4px var(--surface-muted), var(--shadow-2); +} + +.input:active ~ .thumb { + transform: translate(-50%, -50%) scale(1.12); +} + +.row { + display: flex; + align-items: center; + gap: 16px; + width: 100%; +} + +.value { + font-variant-numeric: tabular-nums; + font-weight: 500; + color: var(--text); + min-width: 38px; + text-align: right; + font-size: 13px; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + max-width: 360px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/SpinButton/SpinButtonDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SpinButton/SpinButtonDefault.stories.tsx index b484dac080e3f6..b296cdcc57f1c0 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/SpinButton/SpinButtonDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/SpinButton/SpinButtonDefault.stories.tsx @@ -1,9 +1,12 @@ import * as React from 'react'; import { SpinButton } from '@fluentui/react-headless-components-preview/spin-button'; +import { ChevronDownRegular, ChevronUpRegular } from '@fluentui/react-icons'; +import fieldStyles from '../Field/field.module.css'; +import styles from './spin-button.module.css'; export const Default = (): React.ReactNode => ( - <div className="flex w-full max-w-sm flex-col gap-2"> - <label className="text-sm font-medium text-gray-700" htmlFor="quantity-spinbutton"> + <div className={`${fieldStyles.field} ${styles.demo}`}> + <label className={fieldStyles.label} htmlFor="quantity-spinbutton"> Quantity </label> <SpinButton @@ -11,20 +14,15 @@ export const Default = (): React.ReactNode => ( defaultValue={1} min={0} max={99} - className="relative inline-flex w-40 items-center overflow-hidden rounded-md border border-gray-300 bg-white shadow-sm transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2" - input={{ - className: - 'w-full flex-1 bg-transparent py-2 pl-3 pr-9 text-center text-sm font-medium text-gray-900 tabular-nums outline-none placeholder:text-gray-400', + className={styles.wrap} + input={{ className: styles.input }} + incrementButton={{ + className: `${styles.btn} ${styles.btnUp}`, + children: <ChevronUpRegular className={styles.icon} aria-hidden />, }} decrementButton={{ - className: - 'absolute bottom-0 right-0 flex h-1/2 w-8 items-center justify-center border-l border-t border-gray-300 bg-gray-50/70 text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - children: '-', - }} - incrementButton={{ - className: - 'absolute right-0 top-0 flex h-1/2 w-8 items-center justify-center border-b border-l border-gray-300 bg-gray-50/70 text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', - children: '+', + className: `${styles.btn} ${styles.btnDown}`, + children: <ChevronDownRegular className={styles.icon} aria-hidden />, }} /> </div> diff --git a/packages/react-components/react-headless-components-preview/stories/src/SpinButton/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SpinButton/index.stories.tsx index 4751309e286065..1536ca236f68bd 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/SpinButton/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/SpinButton/index.stories.tsx @@ -1,7 +1,6 @@ import { SpinButton } from '@fluentui/react-headless-components-preview/spin-button'; import descriptionMd from './SpinButtonDescription.md'; - export { Default } from './SpinButtonDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/SpinButton/spin-button.module.css b/packages/react-components/react-headless-components-preview/stories/src/SpinButton/spin-button.module.css new file mode 100644 index 00000000000000..9508b4f67911de --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SpinButton/spin-button.module.css @@ -0,0 +1,83 @@ +.wrap { + position: relative; + display: inline-flex; + align-items: center; + width: 160px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + transition: border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.wrap:hover { + border-color: var(--border-strong); +} + +.wrap:has(:focus-visible) { + border-color: var(--text); + box-shadow: 0 0 0 3px var(--surface-muted); +} + +.input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-size: 13.5px; + font-variant-numeric: tabular-nums; + font-weight: 500; + text-align: center; + padding: 8px 36px 8px 12px; + min-width: 0; + color: var(--text); +} + +.btn { + position: absolute; + right: 0; + width: 28px; + height: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-left: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text-muted); + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); +} + +.btn:hover { + background: var(--surface-muted); + color: var(--text); +} + +.btn:focus-visible { + outline: none; + z-index: 1; + box-shadow: inset 0 0 0 2px var(--accent); +} + +.btnUp { + top: 0; + border-bottom: 1px solid var(--border); +} + +.btnDown { + bottom: 0; +} + +.icon { + width: 11px; + height: 11px; + stroke-width: 2; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + max-width: 240px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Spinner/SpinnerDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Spinner/SpinnerDefault.stories.tsx index de8b736947d72c..fe96ef6a958c64 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Spinner/SpinnerDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Spinner/SpinnerDefault.stories.tsx @@ -2,11 +2,29 @@ import * as React from 'react'; import { Spinner } from '@fluentui/react-headless-components-preview/spinner'; import { SpinnerIosRegular } from '@fluentui/react-icons'; +import styles from './spinner.module.css'; export const Default = (): React.ReactNode => ( - <Spinner - spinnerTail={{ - className: 'flex animate-spin origin-center size-5 text-gray-900', - children: <SpinnerIosRegular className="size-full" />, - }} - /> + <div className={styles.demoRow}> + <Spinner + className={styles.spinner} + spinnerTail={{ + className: styles.tail, + children: <SpinnerIosRegular />, + }} + /> + <Spinner + className={`${styles.spinner} ${styles.large}`} + spinnerTail={{ + className: styles.tail, + children: <SpinnerIosRegular />, + }} + /> + <Spinner + className={`${styles.spinner} ${styles.muted}`} + spinnerTail={{ + className: styles.tail, + children: <SpinnerIosRegular />, + }} + /> + </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Spinner/SpinnerLabels.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Spinner/SpinnerLabels.stories.tsx index d275bb9c74749e..02ac4d69131f58 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Spinner/SpinnerLabels.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Spinner/SpinnerLabels.stories.tsx @@ -2,13 +2,25 @@ import * as React from 'react'; import { Spinner } from '@fluentui/react-headless-components-preview/spinner'; import { SpinnerIosRegular } from '@fluentui/react-icons'; +import styles from './spinner.module.css'; export const Labels = (): React.ReactNode => ( - <Spinner - className="flex items-center gap-2" - label="Loading..." - spinnerTail={{ - className: 'flex animate-spin origin-center size-5 text-gray-900', - children: <SpinnerIosRegular className="size-full" />, - }} - /> + <div className={styles.demoCol}> + <Spinner + className={styles.spinner} + label="Loading…" + spinnerTail={{ + className: styles.tail, + children: <SpinnerIosRegular />, + }} + /> + <Spinner + className={styles.column} + label="Saving changes" + labelPosition="below" + spinnerTail={{ + className: styles.tail, + children: <SpinnerIosRegular />, + }} + /> + </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Spinner/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Spinner/index.stories.tsx index 026cb52782d28c..6c59a6ce188e22 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Spinner/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Spinner/index.stories.tsx @@ -1,7 +1,6 @@ import { Spinner } from '@fluentui/react-headless-components-preview/spinner'; import descriptionMd from './SpinnerDescription.md'; - export { Default } from './SpinnerDefault.stories'; export { Labels } from './SpinnerLabels.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Spinner/spinner.module.css b/packages/react-components/react-headless-components-preview/stories/src/Spinner/spinner.module.css new file mode 100644 index 00000000000000..7775ac2ed5c037 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Spinner/spinner.module.css @@ -0,0 +1,63 @@ +.spinner { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-muted); +} + +.tail { + display: inline-flex; + width: 20px; + height: 20px; + color: var(--accent); + animation: spin 800ms linear infinite; +} + +.tail svg { + width: 100%; + height: 100%; +} + +.large .tail { + width: 32px; + height: 32px; +} + +.muted .tail { + color: var(--text-muted); +} + +.column { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Demo helpers (used by Storybook examples) */ + +.demoRow { + display: flex; + + align-items: center; + + gap: 32px; +} + +.demoCol { + display: flex; + + flex-direction: column; + + gap: 16px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Switch/SwitchDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Switch/SwitchDefault.stories.tsx index 7f47cad2dee88c..63186f01baaf91 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Switch/SwitchDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Switch/SwitchDefault.stories.tsx @@ -1,19 +1,42 @@ import * as React from 'react'; import { Switch } from '@fluentui/react-headless-components-preview/switch'; -const classes = { - root: 'relative inline-flex cursor-pointer items-center gap-3 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50', - input: 'peer absolute left-0 top-0 m-0 h-5 w-10 cursor-pointer opacity-0 z-1 focus:outline-none', - indicator: - 'relative h-5 w-10 rounded-full border border-gray-300 bg-white transition-colors after:absolute after:left-px after:top-px after:h-4 after:w-4 after:rounded-full after:bg-gray-400 after:transition-transform after:content-[""] peer-checked:border-gray-900 peer-checked:bg-gray-900 peer-checked:after:translate-x-5 peer-checked:after:bg-white peer-focus-visible:ring-2 peer-focus-visible:ring-black peer-focus-visible:ring-offset-2 peer-disabled:border-gray-200 peer-disabled:bg-gray-50 peer-disabled:after:bg-gray-300', -}; - +import styles from './switch.module.css'; export const Default = (): React.ReactNode => ( - <Switch - defaultChecked - label="Enable notifications" - className={classes.root} - input={{ className: classes.input }} - indicator={{ className: classes.indicator }} - /> + <div className={styles.list}> + <Switch + defaultChecked + label={{ + className: styles.label, + children: ( + <> + <span className={styles.title}>Enable notifications</span> + <span className={styles.subtitle}>Email me when something changes.</span> + </> + ), + }} + className={styles.row} + input={{ className: styles.input }} + indicator={{ className: styles.indicator }} + /> + <Switch + label={{ + className: styles.label, + children: <span className={styles.title}>Show preview</span>, + }} + className={styles.row} + input={{ className: styles.input }} + indicator={{ className: styles.indicator }} + /> + <Switch + disabled + label={{ + className: styles.label, + children: <span className={styles.title}>Disabled toggle</span>, + }} + className={styles.row} + input={{ className: styles.input }} + indicator={{ className: styles.indicator }} + /> + </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Switch/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Switch/index.stories.tsx index b8526d4046140d..a1d5edf795237c 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Switch/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Switch/index.stories.tsx @@ -1,7 +1,6 @@ import { Switch } from '@fluentui/react-headless-components-preview/switch'; import descriptionMd from './SwitchDescription.md'; - export { Default } from './SwitchDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Switch/switch.module.css b/packages/react-components/react-headless-components-preview/stories/src/Switch/switch.module.css new file mode 100644 index 00000000000000..3bc3c402bbe33f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Switch/switch.module.css @@ -0,0 +1,87 @@ +.row { + position: relative; + display: inline-flex; + align-items: center; + gap: 12px; + cursor: pointer; + font-size: 13.5px; + user-select: none; + color: var(--text); +} + +.row[data-disabled] { + cursor: not-allowed; + opacity: 0.4; +} + +.input { + position: absolute; + inset: 0; + width: 38px; + height: 22px; + opacity: 0; + cursor: pointer; + z-index: 1; +} + +.indicator { + position: relative; + width: 38px; + height: 22px; + border-radius: var(--radius-pill); + background: var(--surface-sunken); + border: 1px solid var(--border); + flex-shrink: 0; + transition: background var(--duration-medium) var(--ease-standard), + border-color var(--duration-medium) var(--ease-standard); +} + +.indicator::after { + content: ''; + position: absolute; + top: 1px; + left: 1px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--bg-elev); + box-shadow: var(--shadow-2); + transition: transform var(--duration-medium) var(--ease-emphasized); +} + +.input:checked + .indicator { + background: var(--accent); + border-color: var(--accent); +} + +.input:checked + .indicator::after { + transform: translateX(16px); + background: var(--accent-contrast); +} + +.input:focus-visible + .indicator { + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.label { + display: flex; + flex-direction: column; + gap: 2px; +} + +.title { + font-weight: 500; + color: var(--text); +} + +.subtitle { + font-size: 12px; + color: var(--text-muted); +} + +.list { + display: flex; + flex-direction: column; + gap: 14px; + align-items: flex-start; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/TabList/TabListDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TabList/TabListDefault.stories.tsx index 99e9c98ad89628..40bbbe1346e4c1 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/TabList/TabListDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/TabList/TabListDefault.stories.tsx @@ -1,33 +1,38 @@ import * as React from 'react'; import { TabList, Tab } from '@fluentui/react-headless-components-preview/tab-list'; +import styles from './tab-list.module.css'; const tabs = [ { value: 'account', label: 'Account', content: 'Manage your account settings and preferences.' }, - { value: 'security', label: 'Security', content: 'Update your password and configure two-factor authentication.' }, + { + value: 'security', + label: 'Security', + content: 'Update your password and configure two-factor authentication.', + }, { value: 'notifications', label: 'Notifications', content: 'Choose what you are notified about and how.' }, ]; export const Default = (): React.ReactNode => { const [selected, setSelected] = React.useState('account'); + const active = tabs.find(t => t.value === selected); return ( - <div className="w-full max-w-md"> + <div className={`${styles.layout} ${styles.demo}`}> <TabList selectedValue={selected} onTabSelect={(_, data) => setSelected(data.value as string)} - className="flex border-b border-gray-200" + className={styles.tabs} > {tabs.map(tab => ( - <Tab - key={tab.value} - value={tab.value} - className="-mb-px px-4 py-2.5 text-sm font-medium text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 border-b-2 border-b-transparent data-[selected]:border-gray-900 data-[selected]:text-gray-900" - > + <Tab key={tab.value} value={tab.value} className={styles.tab}> {tab.label} </Tab> ))} </TabList> - <div className="p-4 text-sm text-gray-600">{tabs.find(t => t.value === selected)?.content}</div> + <div className={styles.panel}> + <h4 className={styles.panelTitle}>{active?.label}</h4> + {active?.content} + </div> </div> ); }; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TabList/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TabList/index.stories.tsx index 79610b435731a1..7b8c4d85ac5e48 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/TabList/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/TabList/index.stories.tsx @@ -1,7 +1,6 @@ import { TabList } from '@fluentui/react-headless-components-preview/tab-list'; import descriptionMd from './TabListDescription.md'; - export { Default } from './TabListDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/TabList/tab-list.module.css b/packages/react-components/react-headless-components-preview/stories/src/TabList/tab-list.module.css new file mode 100644 index 00000000000000..17594e81db2557 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TabList/tab-list.module.css @@ -0,0 +1,86 @@ +.tabs { + display: flex; + gap: 4px; + padding: 4px; + background: var(--surface-muted); + border-radius: var(--radius-pill); +} + +.tabsVertical { + flex-direction: column; + border-radius: var(--radius-lg); + width: 200px; + align-self: flex-start; +} + +.tab { + position: relative; + padding: 7px 14px; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border-radius: var(--radius-pill); + text-align: left; + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); +} + +.tabsVertical .tab { + border-radius: var(--radius-md); +} + +.tab:hover { + color: var(--text); +} + +.tab:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.tab[data-selected] { + background: var(--bg-elev); + color: var(--text); + box-shadow: var(--shadow-1); +} + +.layout { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.layoutVertical { + flex-direction: row; + align-items: stretch; + gap: 24px; +} + +.panel { + padding: 16px 4px 4px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.6; + flex: 1; +} + +.layoutVertical .panel { + padding: 4px 0 0; +} + +.panelTitle { + margin: 0 0 6px; + color: var(--text); + font-size: 14px; + font-weight: 600; + letter-spacing: var(--tracking-tight); +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + max-width: 520px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Textarea/TextareaDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Textarea/TextareaDefault.stories.tsx index c7831a29e42a0d..fa339202b4ceab 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Textarea/TextareaDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Textarea/TextareaDefault.stories.tsx @@ -1,24 +1,20 @@ import * as React from 'react'; import { Textarea } from '@fluentui/react-headless-components-preview/textarea'; -const wrapperClass = - 'flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2'; -const innerClass = - 'w-full min-h-24 resize-y text-sm text-gray-900 outline-none placeholder:text-gray-400 bg-transparent'; - +import styles from './textarea.module.css'; export const Default = (): React.ReactNode => ( - <div className="flex flex-col gap-4 w-full max-w-sm"> - <Textarea placeholder="Write your message..." className={wrapperClass} textarea={{ className: innerClass }} /> + <div className={styles.demo}> + <Textarea placeholder="Write your message…" className={styles.wrap} textarea={{ className: styles.textarea }} /> <Textarea - placeholder="This textarea cannot be resized..." - className={wrapperClass} - textarea={{ className: `${innerClass} resize-none` }} + placeholder="This textarea cannot be resized…" + className={styles.wrap} + textarea={{ className: `${styles.textarea} ${styles.noResize}` }} /> <Textarea placeholder="Disabled textarea" disabled - className="flex w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 opacity-60 cursor-not-allowed" - textarea={{ className: `${innerClass} cursor-not-allowed` }} + className={styles.wrap} + textarea={{ className: styles.textarea }} /> </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Textarea/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Textarea/index.stories.tsx index 7b31f29ee66bcc..f9c7febdffffa5 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Textarea/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Textarea/index.stories.tsx @@ -1,7 +1,6 @@ import { Textarea } from '@fluentui/react-headless-components-preview/textarea'; import descriptionMd from './TextareaDescription.md'; - export { Default } from './TextareaDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Textarea/textarea.module.css b/packages/react-components/react-headless-components-preview/stories/src/Textarea/textarea.module.css new file mode 100644 index 00000000000000..c99b21a0fc7a98 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Textarea/textarea.module.css @@ -0,0 +1,54 @@ +.wrap { + display: flex; + width: 100%; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 8px 12px; + transition: border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.wrap:hover { + border-color: var(--border-strong); +} + +.wrap:has(:focus-visible) { + border-color: var(--text); + box-shadow: 0 0 0 3px var(--surface-muted); +} + +.textarea { + width: 100%; + min-height: 96px; + border: none; + outline: none; + background: transparent; + resize: vertical; + font-size: 13.5px; + line-height: 1.55; + color: var(--text); + font-family: inherit; +} + +.textarea::placeholder { + color: var(--text-faint); +} + +.noResize { + resize: none; +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + display: flex; + + flex-direction: column; + + gap: 16px; + + width: 100%; + + max-width: 360px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/ToggleButtonDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/ToggleButtonDefault.stories.tsx index 9b56458b5f6595..43b38899f9d209 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/ToggleButtonDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/ToggleButtonDefault.stories.tsx @@ -1,16 +1,45 @@ import * as React from 'react'; import { ToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; +import { TextBoldRegular, TextItalicRegular, TextUnderlineRegular } from '@fluentui/react-icons'; +import styles from './toggle-button.module.css'; export const Default = (): React.ReactNode => { - const [checked, setChecked] = React.useState(false); + const [bold, setBold] = React.useState(false); + const [italic, setItalic] = React.useState(false); + const [underline, setUnderline] = React.useState(false); + return ( - <ToggleButton - className="flex items-center justify-center size-9 px-0 border border-gray-300 rounded-md bg-white font-inherit text-sm font-bold text-gray-700 select-none cursor-pointer hover:bg-gray-50 hover:data-[disabled]:bg-white data-[checked]:bg-gray-900 data-[checked]:text-white data-[checked]:border-gray-900 data-[checked]:hover:bg-gray-800 data-[checked]:hover:data-[disabled]:bg-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed" - checked={checked} - onClick={() => setChecked(v => !v)} - aria-label="Toggle value" - > - {checked ? 'On' : 'Off'} - </ToggleButton> + <div className={styles.demo}> + <div className={styles.demoRow}> + <ToggleButton className={styles.toggle} checked={bold} onClick={() => setBold(v => !v)}> + {bold ? 'On' : 'Off'} + </ToggleButton> + <ToggleButton className={styles.toggle} disabled> + Disabled + </ToggleButton> + </div> + + <div className={styles.group} role="group" aria-label="Text formatting"> + <ToggleButton className={styles.groupItem} aria-label="Bold" checked={bold} onClick={() => setBold(v => !v)}> + <TextBoldRegular /> + </ToggleButton> + <ToggleButton + className={styles.groupItem} + aria-label="Italic" + checked={italic} + onClick={() => setItalic(v => !v)} + > + <TextItalicRegular /> + </ToggleButton> + <ToggleButton + className={styles.groupItem} + aria-label="Underline" + checked={underline} + onClick={() => setUnderline(v => !v)} + > + <TextUnderlineRegular /> + </ToggleButton> + </div> + </div> ); }; diff --git a/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/index.stories.tsx index b320175873cc02..76ea4d5fea976c 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/index.stories.tsx @@ -1,7 +1,6 @@ import { ToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; import descriptionMd from './ToggleButtonDescription.md'; - export { Default } from './ToggleButtonDefault.stories'; export default { diff --git a/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/toggle-button.module.css b/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/toggle-button.module.css new file mode 100644 index 00000000000000..12fce645654d76 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ToggleButton/toggle-button.module.css @@ -0,0 +1,114 @@ +.toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard); +} + +.toggle:hover { + background: var(--surface-muted); + border-color: var(--border-strong); +} + +.toggle:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.toggle[data-checked] { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); +} + +.toggle[data-checked]:hover { + background: var(--accent-strong); + border-color: var(--accent-strong); +} + +.toggle[data-disabled] { + opacity: 0.4; + cursor: not-allowed; +} + +.icon { + width: 14px; + height: 14px; +} + +.iconOnly { + width: 32px; + padding: 0; +} + +/* Segmented group — single bordered shell, dividers between cells */ +.group { + display: inline-flex; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--bg-elev); + padding: 2px; + gap: 2px; +} + +.groupItem { + height: 28px; + width: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius-pill); + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); +} + +.groupItem:hover { + background: var(--surface-muted); + color: var(--text); +} + +.groupItem[data-checked] { + background: var(--accent); + color: var(--accent-contrast); +} + +.groupItem[data-checked]:hover { + background: var(--accent-strong); +} + +.groupItem:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 3px var(--accent); +} + +/* Demo helpers (used by Storybook examples) */ + +.demo { + display: flex; + + flex-direction: column; + + gap: 16px; +} + +.demoRow { + display: flex; + + gap: 12px; + + align-items: center; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarDefault.stories.tsx index 100b43b12fa473..48fb999e04dca0 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarDefault.stories.tsx @@ -8,106 +8,74 @@ import { ToolbarToggleButton, } from '@fluentui/react-headless-components-preview/toolbar'; import { - CutRegular, - CopyRegular, ClipboardPasteRegular, + CopyRegular, + CutRegular, + TextAlignCenterRegular, + TextAlignLeftRegular, + TextAlignRightRegular, TextBoldRegular, TextItalicRegular, TextUnderlineRegular, - TextAlignLeftRegular, - TextAlignCenterRegular, - TextAlignRightRegular, } from '@fluentui/react-icons'; -const classes = { - toolbar: - 'inline-flex items-center gap-0.5 rounded-lg border border-gray-200 bg-white p-1 shadow-sm data-[vertical]:flex-col', - button: - 'flex items-center justify-center size-8 rounded border-none bg-transparent p-0 text-gray-700 cursor-pointer ' + - 'hover:bg-gray-100 active:bg-gray-200 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 ' + - 'data-[disabled]:opacity-40 data-[disabled]:cursor-not-allowed data-[disabled]:pointer-events-none', - activeButton: - 'flex items-center justify-center size-8 rounded border-none p-0 text-blue-700 bg-blue-50 cursor-pointer ' + - 'hover:bg-blue-100 active:bg-blue-200 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-1', - toggleButton: - 'flex items-center justify-center size-8 rounded border-none bg-transparent p-0 text-gray-700 cursor-pointer ' + - 'hover:bg-gray-100 active:bg-gray-200 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-1 ' + - 'data-[checked]:text-blue-700 data-[checked]:bg-blue-50 ' + - 'data-[disabled]:opacity-40 data-[disabled]:cursor-not-allowed data-[disabled]:pointer-events-none', - divider: 'mx-0.5 w-px self-stretch bg-gray-200 data-[vertical]:h-px data-[vertical]:w-auto data-[vertical]:my-0.5', - group: 'flex items-center gap-0.5 data-[vertical]:flex-col', -}; - +import styles from './toolbar.module.css'; const alignIcons = { left: TextAlignLeftRegular, center: TextAlignCenterRegular, right: TextAlignRightRegular, -}; +} as const; export const Default = (): React.ReactNode => { - const [align, setAlign] = React.useState('left'); + const [align, setAlign] = React.useState<'left' | 'center' | 'right'>('left'); return ( - <Toolbar className={classes.toolbar} aria-label="Text formatting"> - <ToolbarButton className={classes.button} aria-label="Cut" icon={<CutRegular />} /> - <ToolbarButton className={classes.button} aria-label="Copy" icon={<CopyRegular />} /> - <ToolbarButton className={classes.button} aria-label="Paste" icon={<ClipboardPasteRegular />} /> + <Toolbar className={styles.toolbar} aria-label="Text formatting"> + <ToolbarButton className={styles.btn} aria-label="Cut" icon={<CutRegular />} /> + <ToolbarButton className={styles.btn} aria-label="Copy" icon={<CopyRegular />} /> + <ToolbarButton className={styles.btn} aria-label="Paste" icon={<ClipboardPasteRegular />} /> - <ToolbarDivider className={classes.divider} /> + <ToolbarDivider className={styles.divider} /> - <ToolbarGroup className={classes.group} aria-label="Text formatting"> + <ToolbarGroup className={styles.group} aria-label="Text formatting"> <ToolbarToggleButton name="format" value="bold" - className={classes.toggleButton} + className={styles.btn} aria-label="Bold" icon={<TextBoldRegular />} - onClick={() => undefined} /> <ToolbarToggleButton name="format" value="italic" - className={classes.toggleButton} + className={styles.btn} aria-label="Italic" icon={<TextItalicRegular />} - onClick={() => undefined} /> <ToolbarToggleButton name="format" value="underline" - className={classes.toggleButton} + className={styles.btn} aria-label="Underline" icon={<TextUnderlineRegular />} - onClick={() => undefined} - /> - <ToolbarToggleButton - name="format" - value="strikethrough" - disabled - className={classes.toggleButton} - aria-label="Strikethrough" - icon={<TextUnderlineRegular />} /> </ToolbarGroup> - <ToolbarDivider className={classes.divider} /> + <ToolbarDivider className={styles.divider} /> - <ToolbarRadioGroup className={classes.group} aria-label="Text alignment"> - {Object.entries(alignIcons).map(([option, Icon]) => { - return ( + <ToolbarRadioGroup className={styles.group} aria-label="Text alignment"> + {(Object.entries(alignIcons) as Array<['left' | 'center' | 'right', typeof TextAlignLeftRegular]>).map( + ([option, Icon]) => ( <ToolbarButton key={option} - className={align === option ? classes.activeButton : classes.button} + className={`${styles.btn}${align === option ? ` ${styles.btnActive}` : ''}`} aria-label={`Align ${option}`} aria-pressed={align === option} icon={<Icon />} onClick={() => setAlign(option)} /> - ); - })} + ), + )} </ToolbarRadioGroup> </Toolbar> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarToggleButton.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarToggleButton.stories.tsx index 14eb1c062df25a..8e02d0e4b27997 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarToggleButton.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarToggleButton.stories.tsx @@ -2,45 +2,31 @@ import * as React from 'react'; import { Toolbar, ToolbarGroup, ToolbarToggleButton } from '@fluentui/react-headless-components-preview/toolbar'; import { TextBoldRegular, TextItalicRegular, TextUnderlineRegular } from '@fluentui/react-icons'; -const classes = { - toolbar: - 'inline-flex items-center gap-0.5 rounded-lg border border-gray-200 bg-white p-1 shadow-sm data-[vertical]:flex-col', - toggleButton: - 'flex items-center justify-center size-8 rounded border-none bg-transparent p-0 text-gray-700 cursor-pointer ' + - 'hover:bg-gray-100 active:bg-gray-200 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-1 ' + - 'data-[checked]:text-blue-700 data-[checked]:bg-blue-50 ' + - 'data-[disabled]:opacity-40 data-[disabled]:cursor-not-allowed data-[disabled]:pointer-events-none', - divider: 'mx-0.5 w-px self-stretch bg-gray-200 data-[vertical]:h-px data-[vertical]:w-auto data-[vertical]:my-0.5', - group: 'flex items-center gap-0.5 data-[vertical]:flex-col', -}; - -export const Toggle = (): React.ReactNode => { - return ( - <Toolbar className={classes.toolbar} aria-label="Multiple formatting states"> - <ToolbarGroup className={classes.group} aria-label="Pre-selected formatting"> - <ToolbarToggleButton - name="format" - value="bold" - className={classes.toggleButton} - aria-label="Bold (checked)" - icon={<TextBoldRegular />} - /> - <ToolbarToggleButton - name="format" - value="italic" - className={classes.toggleButton} - aria-label="Italic" - icon={<TextItalicRegular />} - /> - <ToolbarToggleButton - name="format" - value="underline" - className={classes.toggleButton} - aria-label="Underline" - icon={<TextUnderlineRegular />} - /> - </ToolbarGroup> - </Toolbar> - ); -}; +import styles from './toolbar.module.css'; +export const Toggle = (): React.ReactNode => ( + <Toolbar className={styles.toolbar} aria-label="Text formatting toggles"> + <ToolbarGroup className={styles.group} aria-label="Toggle states"> + <ToolbarToggleButton + name="format" + value="bold" + className={styles.btn} + aria-label="Bold" + icon={<TextBoldRegular />} + /> + <ToolbarToggleButton + name="format" + value="italic" + className={styles.btn} + aria-label="Italic" + icon={<TextItalicRegular />} + /> + <ToolbarToggleButton + name="format" + value="underline" + className={styles.btn} + aria-label="Underline" + icon={<TextUnderlineRegular />} + /> + </ToolbarGroup> + </Toolbar> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarVertical.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarVertical.stories.tsx index 434a95d7b1fc01..bf2e91f0950d37 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarVertical.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/ToolbarVertical.stories.tsx @@ -6,38 +6,27 @@ import { ToolbarGroup, } from '@fluentui/react-headless-components-preview/toolbar'; import { - CutRegular, - CopyRegular, ClipboardPasteRegular, + CopyRegular, + CutRegular, TextBoldRegular, TextItalicRegular, TextUnderlineRegular, } from '@fluentui/react-icons'; -const classes = { - toolbar: - 'inline-flex items-center gap-0.5 rounded-lg border border-gray-200 bg-white p-1 shadow-sm data-[vertical]:flex-col', - button: - 'flex items-center justify-center size-8 rounded border-none bg-transparent p-0 text-gray-700 cursor-pointer ' + - 'hover:bg-gray-100 active:bg-gray-200 ' + - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 ' + - 'data-[disabled]:opacity-40 data-[disabled]:cursor-not-allowed data-[disabled]:pointer-events-none', - divider: 'mx-0.5 w-px self-stretch bg-gray-200 data-[vertical]:h-px data-[vertical]:w-auto data-[vertical]:my-0.5', - group: 'flex items-center gap-0.5 data-[vertical]:flex-col', -}; - +import styles from './toolbar.module.css'; export const Vertical = (): React.ReactNode => ( - <Toolbar className={classes.toolbar} vertical aria-label="Text formatting"> - <ToolbarButton className={classes.button} aria-label="Cut" icon={<CutRegular />} /> - <ToolbarButton className={classes.button} aria-label="Copy" icon={<CopyRegular />} /> - <ToolbarButton className={classes.button} aria-label="Paste" icon={<ClipboardPasteRegular />} /> + <Toolbar className={styles.toolbar} vertical aria-label="Text formatting"> + <ToolbarButton className={styles.btn} aria-label="Cut" icon={<CutRegular />} /> + <ToolbarButton className={styles.btn} aria-label="Copy" icon={<CopyRegular />} /> + <ToolbarButton className={styles.btn} aria-label="Paste" icon={<ClipboardPasteRegular />} /> - <ToolbarDivider className={classes.divider} /> + <ToolbarDivider className={styles.divider} /> - <ToolbarGroup className={classes.group} aria-label="Text formatting"> - <ToolbarButton className={classes.button} aria-label="Bold" icon={<TextBoldRegular />} /> - <ToolbarButton className={classes.button} aria-label="Italic" icon={<TextItalicRegular />} /> - <ToolbarButton className={classes.button} aria-label="Underline" icon={<TextUnderlineRegular />} /> + <ToolbarGroup className={styles.group} aria-label="Text formatting"> + <ToolbarButton className={styles.btn} aria-label="Bold" icon={<TextBoldRegular />} /> + <ToolbarButton className={styles.btn} aria-label="Italic" icon={<TextItalicRegular />} /> + <ToolbarButton className={styles.btn} aria-label="Underline" icon={<TextUnderlineRegular />} /> </ToolbarGroup> </Toolbar> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/index.stories.tsx index 14d8261b5a6638..bebaafe5c3d289 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/index.stories.tsx @@ -8,7 +8,6 @@ import { } from '@fluentui/react-headless-components-preview/toolbar'; import descriptionMd from './ToolbarDescription.md'; - export { Default } from './ToolbarDefault.stories'; export { Vertical } from './ToolbarVertical.stories'; export { Toggle } from './ToolbarToggleButton.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/toolbar.module.css b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/toolbar.module.css new file mode 100644 index 00000000000000..01a75a455a2c5a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/toolbar.module.css @@ -0,0 +1,87 @@ +.toolbar { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 4px; + border-radius: var(--radius-pill); + background: var(--bg-elev); + border: 1px solid var(--border); + box-shadow: var(--shadow-1); +} + +.toolbar[data-vertical] { + flex-direction: column; + border-radius: var(--radius-lg); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-muted); + border: none; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); +} + +.btn:hover { + background: var(--surface-muted); + color: var(--text); +} + +.btn:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--accent); +} + +.btn[data-checked] { + background: var(--accent); + color: var(--accent-contrast); +} + +.btn[data-checked]:hover { + background: var(--accent-strong); + color: var(--accent-contrast); +} + +.btn[data-disabled] { + opacity: 0.4; + cursor: not-allowed; +} + +.btnActive { + background: var(--accent); + color: var(--accent-contrast); +} + +.divider { + width: 1px; + align-self: stretch; + margin: 4px 4px; + background: var(--border); +} + +.toolbar[data-vertical] .divider { + width: auto; + height: 1px; + margin: 4px 4px; +} + +.group { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.toolbar[data-vertical] .group { + flex-direction: column; +} + +.icon { + width: 16px; + height: 16px; +} diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/etc/react-storybook-addon-export-to-sandbox.api.md b/packages/react-components/react-storybook-addon-export-to-sandbox/etc/react-storybook-addon-export-to-sandbox.api.md index beb2c2400f49a5..041d863be69b62 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/etc/react-storybook-addon-export-to-sandbox.api.md +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/etc/react-storybook-addon-export-to-sandbox.api.md @@ -22,8 +22,9 @@ export { Parameters_2 as Parameters } export interface PresetConfig { // (undocumented) babelLoaderOptionsUpdater?: (value: TransformOptions) => typeof value; + cssModules?: BabelPluginOptions['cssModules']; // (undocumented) - importMappings: BabelPluginOptions; + importMappings: BabelPluginOptions['importMappings']; // (undocumented) webpackRule?: RuleSetRule; } 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 d9ee74588310c4..8e11ec38664212 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 @@ -30,7 +30,16 @@ export interface ParametersExtension { } export interface PresetConfig { - importMappings: import('@fluentui/babel-preset-storybook-full-source').BabelPluginOptions; + importMappings: import('@fluentui/babel-preset-storybook-full-source').BabelPluginOptions['importMappings']; webpackRule?: import('webpack').RuleSetRule; babelLoaderOptionsUpdater?: (value: import('@babel/core').TransformOptions) => typeof value; + /** + * When `true` (or a config object), enables CSS module auto-detection in the babel plugin: + * - Preserves `*.module.css` imports (rewriting paths to `./styles/<basename>`) + * - Auto-detects CSS module files on disk and injects `Story.parameters.cssModuleSources.cssModules` + * - If `tokensFilePath` is provided, reads the file and injects `Story.parameters.cssModuleSources.tokensSource` + * + * @default false + */ + cssModules?: import('@fluentui/babel-preset-storybook-full-source').BabelPluginOptions['cssModules']; } 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 4937e8b8cb4a09..ad3a68f9984656 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 @@ -739,4 +739,117 @@ describe(`sabdbox-scaffold`, () => { expect(capturedKeys).toContain('vite.config.ts'); }); }); + + describe('applyCssModuleTransform (via scaffold.vite)', () => { + const baseConfig = { + bundler: 'vite' as const, + provider: 'stackblitz-cloud' as const, + dependencies: {}, + storyExportToken: 'Default', + storyFile: ` + import * as React from 'react'; + import styles from './button.module.css'; + + export const Default = () => <div className={styles.root}>hello</div>; + `, + description: 'test', + title: 'test', + requiredDependencies: {}, + optionalDependencies: {}, + devDependencies: {}, + }; + + it('should not apply CSS module transform when cssModuleSources is absent', () => { + const result = scaffold.vite(baseConfig); + + expect(result['src/styles/button.module.css']).toBeUndefined(); + expect(result['src/App.tsx']).not.toContain('./styles/tokens.css'); + }); + + it('should add CSS module files under src/styles/', () => { + const result = scaffold.vite({ + ...baseConfig, + cssModuleSources: { + cssModules: [{ name: 'button.module.css', source: '.root { color: red; }' }], + }, + }); + + expect(result['src/styles/button.module.css']).toBe('.root { color: red; }'); + }); + + it('should add tokens.css under src/styles/ when tokensSource is provided', () => { + const result = scaffold.vite({ + ...baseConfig, + cssModuleSources: { + cssModules: [{ name: 'button.module.css', source: '.root { color: red; }' }], + tokensSource: ':root { --my-token: blue; }', + }, + }); + + expect(result['src/styles/tokens.css']).toBe(':root { --my-token: blue; }'); + }); + + it('should prepend tokens.css import to App.tsx when tokensSource is provided', () => { + const result = scaffold.vite({ + ...baseConfig, + cssModuleSources: { + cssModules: [], + tokensSource: ':root { --my-token: blue; }', + }, + }); + + expect(result['src/App.tsx'].startsWith("import './styles/tokens.css';")).toBe(true); + }); + + it('should not prepend tokens.css import when tokensSource is absent', () => { + const result = scaffold.vite({ + ...baseConfig, + cssModuleSources: { + cssModules: [{ name: 'button.module.css', source: '.root {}' }], + }, + }); + + expect(result['src/App.tsx']).not.toContain('./styles/tokens.css'); + }); + + it('should rewrite relative module.css imports to ./styles/<basename>', () => { + const result = scaffold.vite({ + ...baseConfig, + cssModuleSources: { + cssModules: [{ name: 'button.module.css', source: '.root {}' }], + }, + }); + + expect(result['src/example.tsx']).toContain("from './styles/button.module.css'"); + expect(result['src/example.tsx']).not.toContain('./button.module.css'); + }); + + it('should rewrite deeply nested relative imports to ./styles/<basename>', () => { + const storyFile = `import styles from '../../components/button.module.css';\nexport const Default = () => <div className={styles.root} />;`; + const result = scaffold.vite({ + ...baseConfig, + storyFile, + cssModuleSources: { + cssModules: [{ name: 'button.module.css', source: '.root {}' }], + }, + }); + + expect(result['src/example.tsx']).toContain("'./styles/button.module.css'"); + }); + + it('should include all provided CSS modules under src/styles/', () => { + const result = scaffold.vite({ + ...baseConfig, + cssModuleSources: { + cssModules: [ + { name: 'button.module.css', source: '.root { color: red; }' }, + { name: 'card.module.css', source: '.root { color: blue; }' }, + ], + }, + }); + + expect(result['src/styles/button.module.css']).toBe('.root { color: red; }'); + expect(result['src/styles/card.module.css']).toBe('.root { color: blue; }'); + }); + }); }); 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 c58d3201664654..5b0dfb2cffabcb 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 @@ -51,8 +51,17 @@ export const scaffold = { }; function applyTransform(base: Record<string, string>, data: Data): Record<string, string> { + let files = base; + + // Auto-inject CSS module files when detected by the babel plugin. + // This replaces the need for a manual `withCssModuleSource` + `transformFiles` + // callback in every story meta. + if (data.cssModuleSources?.cssModules?.length || data.cssModuleSources?.tokensSource) { + files = applyCssModuleTransform(files, data); + } + if (!data.transformFiles) { - return base; + return files; } const ctx: SandboxContext = { provider: data.provider, @@ -64,7 +73,44 @@ function applyTransform(base: Record<string, string>, data: Data): Record<string optionalDependencies: data.optionalDependencies, devDependencies: data.devDependencies, }; - return data.transformFiles(base, ctx); + return data.transformFiles(files, ctx); +} + +/** + * Generates sandbox files for auto-detected CSS modules: + * 1. Places each CSS module (and optional `tokens.css`) under `src/styles/`. + * 2. Rewrites relative `*.module.css` imports in `src/example.tsx` to `./styles/<basename>`. + * 3. Prepends `import './styles/tokens.css'` to `src/App.tsx` so design tokens cascade. + */ +function applyCssModuleTransform(files: Record<string, string>, data: Data): Record<string, string> { + const next = { ...files }; + const { cssModuleSources } = data; + + for (const mod of cssModuleSources?.cssModules ?? []) { + next[`src/styles/${mod.name}`] = mod.source; + } + + if (cssModuleSources?.tokensSource) { + next['src/styles/tokens.css'] = cssModuleSources.tokensSource; + } + + // Rewrite relative *.module.css imports to ./styles/<basename> + const example = next['src/example.tsx']; + if (typeof example === 'string') { + next['src/example.tsx'] = example.replace(/(['"])\.\.?\/[^'"]+\.module\.css\1/g, match => { + const quote = match[0]; + const basename = match.slice(1, -1).split('/').pop(); + return `${quote}./styles/${basename}${quote}`; + }); + } + + // Prepend tokens import to App.tsx + const app = next['src/App.tsx']; + if (cssModuleSources?.tokensSource && typeof app === 'string' && !app.includes('./styles/tokens.css')) { + next['src/App.tsx'] = `import './styles/tokens.css';\n${app}`; + } + + return next; } const Vite = { 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 8f3076e348c00e..3493342ac3658b 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 @@ -1,7 +1,7 @@ import dedent from 'dedent'; import { getDependencies } from './getDependencies'; -import { StoryContext, ParametersExtension } from './types'; +import type { StoryContext, ParametersExtension } from './types'; type ParametersConfig = NonNullable<ParametersExtension['exportToSandbox']>; @@ -63,6 +63,8 @@ export type Data = Pick<Required<ParametersConfig>, 'provider' | 'bundler'> & { optionalDependencies: Record<string, string>; devDependencies: Record<string, string>; transformFiles?: NonNullable<ParametersConfig['transformFiles']>; + /** CSS module sources injected by the babel plugin (modules + tokens). */ + cssModuleSources?: StoryContext['parameters']['cssModuleSources']; }; export function prepareData(context: StoryContext): Data | null { @@ -113,6 +115,7 @@ export function prepareData(context: StoryContext): Data | null { optionalDependencies: addonConfig.optionalDependencies, devDependencies: addonConfig.devDependencies, transformFiles: addonConfig.transformFiles, + cssModuleSources: context.parameters.cssModuleSources, }; return demoData; diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/types.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/types.ts index b0a8005fd3e7ec..32238dbba6ec2d 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/types.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/types.ts @@ -1,8 +1,18 @@ import type { StoryContext as StoryContextOrigin, Parameters } from '@storybook/react-webpack5'; import type { ParametersExtension, PresetConfig } from './public-types'; +export interface CssModuleEntry { + name: string; + source: string; +} + +/** Parameters injected per-story at build time by the babel plugin. Not user-configurable. */ +interface InjectedParameters { + cssModuleSources?: { cssModules?: CssModuleEntry[]; tokensSource?: string }; +} + export interface StoryContext extends StoryContextOrigin { - parameters: Parameters & ParametersExtension; + parameters: Parameters & ParametersExtension & InjectedParameters; } export type { ParametersExtension, PresetConfig }; diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.spec.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.spec.ts index 4cb4d4dd9e3d58..07327204e7246c 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.spec.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.spec.ts @@ -19,7 +19,12 @@ describe(`webpack`, () => { use: { loader: expect.stringContaining('custom-babel-loader'), options: { - plugins: [[expect.stringContaining('babel-preset-storybook-full-source'), undefined]], + plugins: [ + [ + expect.stringContaining('babel-preset-storybook-full-source'), + { importMappings: undefined, cssModules: false }, + ], + ], }, }, }, @@ -56,7 +61,10 @@ describe(`webpack`, () => { plugins: [ [ expect.stringContaining('babel-preset-storybook-full-source'), - { '@proj/foo': { replace: '@proj/moo' } }, + { + importMappings: { '@proj/foo': { replace: '@proj/moo' } }, + cssModules: false, + }, ], ], presets: ['babel-foo-bar-preset'], @@ -65,4 +73,37 @@ describe(`webpack`, () => { }, ]); }); + + it.each([ + ['boolean true', true as const], + ['object with tokensFilePath', { tokensFilePath: '/path/to/tokens.css' }], + ])(`should propagate cssModules config (%s) to babel plugin`, (_label, cssModules) => { + const actual = webpack({ module: { rules: [] } }, { + presetsList: [ + { + name: 'node_modules/@fluentui/react-storybook-addon-export-to-sandbox/lib/preset.js', + preset: {}, + options: { cssModules } as PresetConfig, + }, + ], + } as WebpackFinalOptions); + + expect(actual.module?.rules).toEqual([ + { + enforce: 'post', + test: /\.stories\.(jsx?$|tsx?$)/, + use: { + loader: expect.stringContaining('custom-babel-loader'), + options: { + plugins: [ + [ + expect.stringContaining('babel-preset-storybook-full-source'), + { importMappings: undefined, cssModules }, + ], + ], + }, + }, + }, + ]); + }); }); diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts index 5808e04aadecd7..b6d7cabf57e09f 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts @@ -17,6 +17,7 @@ const addonFilePattern = /react-storybook-addon-export-to-sandbox\/[a-z/]+.[jt]s const defaultOptions = { webpackRule: {}, babelLoaderOptionsUpdater: identity, + cssModules: false, }; const PLUGIN_PATH = @@ -25,9 +26,9 @@ const PLUGIN_PATH = : '@fluentui/babel-preset-storybook-full-source'; function createBabelLoaderRule(config: Required<PresetConfig>): import('webpack').RuleSetRule { - const { babelLoaderOptionsUpdater, importMappings, webpackRule } = config; + const { babelLoaderOptionsUpdater, importMappings, webpackRule, cssModules } = config; - const plugin = [require.resolve(PLUGIN_PATH), importMappings]; + const plugin = [require.resolve(PLUGIN_PATH), { importMappings, cssModules }]; return { test: /\.stories\.(jsx?$|tsx?$)/, diff --git a/scripts/test-ssr/README.md b/scripts/test-ssr/README.md index babe4ac17e7c82..92eceb34116101 100644 --- a/scripts/test-ssr/README.md +++ b/scripts/test-ssr/README.md @@ -52,3 +52,11 @@ flowchart TB #### Debugging All assets are available in `node_modules/.cache/ssr-tests` folder. You can open `./node_modules/.cache/ssr-tests/index.html` in any browser and debug relevant issues. + +#### Webpack-only loaders supported during SSR + +`buildAssets.ts` registers a custom esbuild plugin (`src/utils/esbuild-plugin.ts`) so stories that work in webpack-driven Storybook also work in the SSR pipeline: + +- `*.module.css` imports — shimmed to a `Proxy` whose getter echoes the property name (so `styles.foo === 'foo'`). Sufficient for SSR snapshots without running the full CSS-Modules transform. + +If a story authors needs another webpack-only loader (e.g. `?inline`, custom asset modules), extend the plugins in `src/utils/esbuild-plugin.ts` rather than excluding the package from `testSSR`. diff --git a/scripts/test-ssr/src/utils/buildAssets.test.ts b/scripts/test-ssr/src/utils/buildAssets.test.ts index 1362cb4a756295..f5355718614eb4 100644 --- a/scripts/test-ssr/src/utils/buildAssets.test.ts +++ b/scripts/test-ssr/src/utils/buildAssets.test.ts @@ -171,4 +171,51 @@ describe('buildAssets', () => { `[Error: Multiple TS path mappings are not supported. Please adjust your config. "@proj/hello": [ packages/hello/index.ts,packages/hello/foo.ts ]"]`, ); }); + + it('shims *.module.css imports with a Proxy that echoes class names', async () => { + const template = stripIndents` + import styles from './button.module.css'; + + export const className = styles.root; + `; + + const { getEsmContent, getCjsContent, getCjsContentWithoutHelpers, distDirectory, ...apiArgs } = await setup( + template, + ); + + // Create a dummy CSS module file so esbuild can resolve the path + await fs.promises.writeFile(path.resolve(distDirectory, 'button.module.css'), '.root { color: red; }'); + + await buildAssets({ + chromeVersion: 100, + distDirectory, + ...apiArgs, + }); + + const cjsContent = await getCjsContent(); + const esmContent = await getEsmContent(); + + const cjsContentWithoutHelpers = getCjsContentWithoutHelpers(cjsContent); + + expect(esmContent).toMatchInlineSnapshot(` + "(() => { + + var styles = new Proxy({}, { get: (_, key) => typeof key === \\"string\\" ? key : \\"\\" }); + var button_default = styles; + + + var className = button_default.root; + })();" + `); + expect(cjsContentWithoutHelpers).toMatchInlineSnapshot(` + "module.exports = __toCommonJS(cjs_exports); + + + var styles = new Proxy({}, { get: (_, key) => typeof key === \\"string\\" ? key : \\"\\" }); + var button_default = styles; + + + var className = button_default.root;" + `); + }, /* Sets 15s timeout to allow for the build to complete */ 15000); }); diff --git a/scripts/test-ssr/src/utils/buildAssets.ts b/scripts/test-ssr/src/utils/buildAssets.ts index 5d3f8a60201cb2..f03a6ebacb305a 100644 --- a/scripts/test-ssr/src/utils/buildAssets.ts +++ b/scripts/test-ssr/src/utils/buildAssets.ts @@ -1,7 +1,7 @@ import { build } from 'esbuild'; import type { BuildOptions } from 'esbuild'; -import { tsConfigPathsPlugin } from './esbuild-plugin'; +import { cssModulesShimPlugin, tsConfigPathsPlugin } from './esbuild-plugin'; const NODE_MAJOR_VERSION = process.versions.node.split('.')[0]; @@ -30,7 +30,7 @@ type BuildConfig = { export async function buildAssets(config: BuildConfig): Promise<void> { const { chromeVersion, cjsEntryPoint, cjsOutfile, esmEntryPoint, esmOutfile, distDirectory } = config; - const pluginInstance = tsConfigPathsPlugin({ cwd: distDirectory }); + const plugins = [tsConfigPathsPlugin({ cwd: distDirectory }), cssModulesShimPlugin()]; try { // Used for SSR rendering, see renderToHTML.js @@ -44,7 +44,7 @@ export async function buildAssets(config: BuildConfig): Promise<void> { external: ['@griffel/core', '@griffel/react', 'react', 'react-dom', 'react-dom/server', 'scheduler'], format: 'cjs', target: `node${NODE_MAJOR_VERSION}`, - plugins: [pluginInstance], + plugins, }); // Used in generated bundle that will be server by a browser @@ -61,7 +61,7 @@ export async function buildAssets(config: BuildConfig): Promise<void> { ], format: 'iife', target: `chrome${chromeVersion}`, - plugins: [pluginInstance], + plugins, }); } catch (err) { throw new Error( diff --git a/scripts/test-ssr/src/utils/esbuild-plugin.ts b/scripts/test-ssr/src/utils/esbuild-plugin.ts index b9d2fef404d8ec..8d51bb93e9db24 100644 --- a/scripts/test-ssr/src/utils/esbuild-plugin.ts +++ b/scripts/test-ssr/src/utils/esbuild-plugin.ts @@ -43,3 +43,27 @@ export function tsConfigPathsPlugin(options: { cwd: string }): Plugin { return pluginConfig; } + +/** + * SSR shim for `*.module.css` imports. Returns a Proxy that echoes the requested + * property name (so `styles.foo === 'foo'`), which keeps className strings stable + * for SSR rendering without needing the actual CSS-Modules transform. + */ +export function cssModulesShimPlugin(): Plugin { + return { + name: 'css-modules-shim', + setup({ onResolve, onLoad }) { + onResolve({ filter: /\.module\.css$/ }, args => { + const absolute = path.isAbsolute(args.path) ? args.path : path.resolve(args.resolveDir, args.path); + return { path: absolute, namespace: 'css-modules-shim' }; + }); + onLoad({ filter: /.*/, namespace: 'css-modules-shim' }, () => ({ + contents: [ + `const styles = new Proxy({}, { get: (_, key) => typeof key === 'string' ? key : '' });`, + `export default styles;`, + ].join('\n'), + loader: 'js', + })); + }, + }; +} diff --git a/typings/static-assets/index.d.ts b/typings/static-assets/index.d.ts index af7269dabed90e..d73d3587940f8d 100644 --- a/typings/static-assets/index.d.ts +++ b/typings/static-assets/index.d.ts @@ -31,3 +31,8 @@ declare module '*.md' { const src: string; export default src; } + +declare module '*.module.css' { + const classes: { readonly [key: string]: string }; + export default classes; +}