From 70a3e37d2c78a209f6b01203b039fc81a386762f Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 5 May 2026 12:21:00 +0200 Subject: [PATCH 1/6] feat(storybook): modularize CSS module support as pluggable preset config (#36088) Co-authored-by: Tudor Popa Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- .../.storybook/main.js | 15 +- .../.storybook/manager-head.html | 113 ++++--- .../.storybook/preview-head.html | 111 ++----- .../.storybook/preview.js | 21 +- .../.storybook/theme.js | 41 +-- apps/public-docsite-v9-headless/project.json | 3 +- ...-533c6664-8c70-4ba3-9465-119e1f33b61c.json | 7 + ...-0a90a8e4-2429-4858-8a15-c3a7d7555692.json | 7 + .../README.md | 3 +- .../babel-preset-storybook-full-source.api.md | 5 +- .../css-module-with-tokens/code.js | 4 + .../css-module-with-tokens/example.module.css | 3 + .../css-module-with-tokens/output.js | 22 ++ .../css-module-with-tokens/tokens.css | 4 + .../css-module-auto-detect/code.js | 4 + .../css-module-auto-detect/example.module.css | 4 + .../css-module-auto-detect/output.js | 21 ++ .../src/fullsource.test.ts | 29 +- .../src/fullsource.ts | 100 +++++++ .../src/modifyImports.test.ts | 10 +- .../src/modifyImports.ts | 20 +- .../src/types.ts | 25 +- .../stories/.storybook/HeadlessDocsPage.tsx | 136 +++++++++ .../.storybook/HeadlessSourcePanel.tsx | 281 ++++++++++++++++++ .../stories/.storybook/css-modules-webpack.js | 54 ++++ .../stories/.storybook/headless-docs-page.css | 47 +++ .../stories/.storybook/main.js | 40 ++- .../stories/.storybook/preview-head.html | 7 +- .../stories/.storybook/preview.js | 16 +- .../stories/.storybook/theme.js | 55 ++++ .../stories/.storybook/tokens.css | 209 +++++++++++++ .../stories/README.md | 181 ++++++++++- .../AccordionCollapsible.stories.tsx | 25 +- .../Accordion/AccordionDefault.stories.tsx | 37 ++- .../src/Accordion/accordion.module.css | 85 ++++++ .../stories/src/Accordion/index.stories.tsx | 1 - .../src/Avatar/AvatarDefault.stories.tsx | 37 ++- .../stories/src/Avatar/avatar.module.css | 126 ++++++++ .../stories/src/Avatar/index.stories.tsx | 1 - .../src/Badge/BadgeDefault.stories.tsx | 23 +- .../stories/src/Badge/badge.module.css | 86 ++++++ .../stories/src/Badge/index.stories.tsx | 1 - .../Breadcrumb/BreadcrumbDefault.stories.tsx | 33 +- .../src/Breadcrumb/breadcrumb.module.css | 63 ++++ .../stories/src/Breadcrumb/index.stories.tsx | 1 - .../src/Button/ButtonDefault.stories.tsx | 56 +++- .../stories/src/Button/button.module.css | 149 ++++++++++ .../stories/src/Button/index.stories.tsx | 1 - .../stories/src/Card/CardDefault.stories.tsx | 52 +--- .../stories/src/Card/CardDisabled.stories.tsx | 29 +- .../src/Card/CardSelectable.stories.tsx | 54 +--- .../stories/src/Card/card.module.css | 206 +++++++++++++ .../stories/src/Card/index.stories.tsx | 1 - .../src/Checkbox/CheckboxDefault.stories.tsx | 42 ++- .../stories/src/Checkbox/checkbox.module.css | 77 +++++ .../stories/src/Checkbox/index.stories.tsx | 1 - .../src/Dialog/DialogAlert.stories.tsx | 67 ++--- .../src/Dialog/DialogControlled.stories.tsx | 37 +-- .../src/Dialog/DialogDefault.stories.tsx | 20 +- .../src/Dialog/DialogKeepMounted.stories.tsx | 40 ++- .../src/Dialog/DialogNested.stories.tsx | 41 ++- .../src/Dialog/DialogNoTrigger.stories.tsx | 38 +-- .../src/Dialog/DialogNonModal.stories.tsx | 26 +- .../Dialog/DialogWithCloseButton.stories.tsx | 72 +++-- .../stories/src/Dialog/dialog.module.css | 121 ++++++++ .../stories/src/Dialog/index.stories.tsx | 1 - .../src/Divider/DividerDefault.stories.tsx | 21 +- .../src/Divider/DividerVertical.stories.tsx | 49 ++- .../stories/src/Divider/divider.module.css | 181 +++++++++++ .../stories/src/Divider/index.stories.tsx | 1 - .../src/Drawer/DefaultDrawer.stories.tsx | 33 +- .../src/Drawer/InlineDrawer.stories.tsx | 36 +-- .../stories/src/Drawer/drawer.module.css | 222 ++++++++++++++ .../stories/src/Drawer/index.stories.tsx | 1 - .../src/Field/FieldDefault.stories.tsx | 43 +-- .../stories/src/Field/field.module.css | 54 ++++ .../stories/src/Field/index.stories.tsx | 1 - .../stories/src/Input/InputBasic.stories.tsx | 28 ++ .../src/Input/InputDefault.stories.tsx | 63 ++-- .../stories/src/Input/chat-input.module.css | 130 ++++++++ .../stories/src/Input/index.stories.tsx | 2 +- .../stories/src/Input/input.module.css | 85 ++++++ .../stories/src/Link/LinkDefault.stories.tsx | 16 +- .../stories/src/Link/index.stories.tsx | 1 - .../stories/src/Link/link.module.css | 50 ++++ .../MessageBar/MessageBarDefault.stories.tsx | 39 +-- .../MessageBar/MessageBarIntent.stories.tsx | 80 +++-- .../stories/src/MessageBar/index.stories.tsx | 1 - .../src/MessageBar/message-bar.module.css | 125 ++++++++ .../PopoverAnchorToCustomTarget.stories.tsx | 36 +-- .../src/Popover/PopoverControlled.stories.tsx | 17 +- .../Popover/PopoverCustomTrigger.stories.tsx | 14 +- .../src/Popover/PopoverDefault.stories.tsx | 14 +- .../PopoverInternalUpdateContent.stories.tsx | 23 +- .../src/Popover/PopoverNested.stories.tsx | 52 ++-- .../Popover/PopoverOpenOnContext.stories.tsx | 18 +- .../Popover/PopoverOpenOnHover.stories.tsx | 14 +- .../src/Popover/PopoverWithArrow.stories.tsx | 49 +-- .../Popover/PopoverWithoutTrigger.stories.tsx | 17 +- .../stories/src/Popover/index.stories.tsx | 1 - .../stories/src/Popover/popover.module.css | 267 +++++++++++++++++ .../ProgressBarDefault.stories.tsx | 36 ++- .../stories/src/ProgressBar/index.stories.tsx | 1 - .../src/ProgressBar/progress-bar.module.css | 82 +++++ .../RadioGroup/RadioGroupDefault.stories.tsx | 28 +- .../stories/src/RadioGroup/index.stories.tsx | 1 - .../src/RadioGroup/radio-group.module.css | 88 ++++++ .../src/Rating/RatingDefault.stories.tsx | 26 +- .../stories/src/Rating/index.stories.tsx | 1 - .../stories/src/Rating/rating.module.css | 44 +++ .../RatingDisplayCompact.stories.tsx | 29 +- .../RatingDisplayDefault.stories.tsx | 32 +- .../src/RatingDisplay/index.stories.tsx | 1 - .../RatingDisplay/rating-display.module.css | 56 ++++ .../SearchBox/SearchBoxDefault.stories.tsx | 21 +- .../stories/src/SearchBox/index.stories.tsx | 1 - .../src/Select/SelectDefault.stories.tsx | 42 ++- .../stories/src/Select/index.stories.tsx | 1 - .../stories/src/Select/select.module.css | 53 ++++ .../src/Skeleton/SkeletonDefault.stories.tsx | 19 +- .../stories/src/Skeleton/index.stories.tsx | 1 - .../stories/src/Skeleton/skeleton.module.css | 71 +++++ .../src/Slider/SliderDefault.stories.tsx | 32 +- .../stories/src/Slider/index.stories.tsx | 1 - .../stories/src/Slider/slider.module.css | 87 ++++++ .../SpinButton/SpinButtonDefault.stories.tsx | 26 +- .../stories/src/SpinButton/index.stories.tsx | 1 - .../src/SpinButton/spin-button.module.css | 83 ++++++ .../src/Spinner/SpinnerDefault.stories.tsx | 30 +- .../src/Spinner/SpinnerLabels.stories.tsx | 28 +- .../stories/src/Spinner/index.stories.tsx | 1 - .../stories/src/Spinner/spinner.module.css | 63 ++++ .../src/Switch/SwitchDefault.stories.tsx | 51 +++- .../stories/src/Switch/index.stories.tsx | 1 - .../stories/src/Switch/switch.module.css | 87 ++++++ .../src/TabList/TabListDefault.stories.tsx | 23 +- .../stories/src/TabList/index.stories.tsx | 1 - .../stories/src/TabList/tab-list.module.css | 86 ++++++ .../src/Textarea/TextareaDefault.stories.tsx | 20 +- .../stories/src/Textarea/index.stories.tsx | 1 - .../stories/src/Textarea/textarea.module.css | 54 ++++ .../ToggleButtonDefault.stories.tsx | 47 ++- .../src/ToggleButton/index.stories.tsx | 1 - .../src/ToggleButton/toggle-button.module.css | 114 +++++++ .../src/Toolbar/ToolbarDefault.stories.tsx | 80 ++--- .../Toolbar/ToolbarToggleButton.stories.tsx | 70 ++--- .../src/Toolbar/ToolbarVertical.stories.tsx | 35 +-- .../stories/src/Toolbar/index.stories.tsx | 1 - .../stories/src/Toolbar/toolbar.module.css | 87 ++++++ ...t-storybook-addon-export-to-sandbox.api.md | 3 +- .../src/public-types.ts | 11 +- .../src/sandbox-scaffold.spec.ts | 113 +++++++ .../src/sandbox-scaffold.ts | 50 +++- .../src/sandbox-utils.ts | 5 +- .../src/types.ts | 12 +- .../src/webpack.spec.ts | 45 ++- .../src/webpack.ts | 5 +- scripts/test-ssr/README.md | 8 + .../test-ssr/src/utils/buildAssets.test.ts | 47 +++ scripts/test-ssr/src/utils/buildAssets.ts | 8 +- scripts/test-ssr/src/utils/esbuild-plugin.ts | 24 ++ typings/static-assets/index.d.ts | 5 + 162 files changed, 5797 insertions(+), 1316 deletions(-) create mode 100644 change/@fluentui-babel-preset-storybook-full-source-533c6664-8c70-4ba3-9465-119e1f33b61c.json create mode 100644 change/@fluentui-react-storybook-addon-export-to-sandbox-0a90a8e4-2429-4858-8a15-c3a7d7555692.json create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/code.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/example.module.css create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/output.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-with-tokens/css-module-with-tokens/tokens.css create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/code.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/example.module.css create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/css-module-auto-detect/output.js create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/HeadlessDocsPage.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/HeadlessSourcePanel.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/css-modules-webpack.js create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/headless-docs-page.css create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/theme.js create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/tokens.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Accordion/accordion.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Avatar/avatar.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Badge/badge.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Breadcrumb/breadcrumb.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Button/button.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Card/card.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Checkbox/checkbox.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Dialog/dialog.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Divider/divider.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Drawer/drawer.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Field/field.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Input/InputBasic.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Input/chat-input.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Input/input.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Link/link.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/MessageBar/message-bar.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Popover/popover.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ProgressBar/progress-bar.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/RadioGroup/radio-group.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Rating/rating.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/RatingDisplay/rating-display.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Select/select.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Skeleton/skeleton.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Slider/slider.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/SpinButton/spin-button.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Spinner/spinner.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Switch/switch.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/TabList/tab-list.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Textarea/textarea.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ToggleButton/toggle-button.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toolbar/toolbar.module.css 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; +} From b0cb4c7930b9d4389a5fbcb7b448527cd323a364 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa <dmytrokirpa@microsoft.com> Date: Tue, 5 May 2026 13:36:43 +0200 Subject: [PATCH 2/6] feat(react-headless-components-preview): add Tooltip component (#36079) --- ...-021e6dbb-6618-4217-a7ce-8567198a60ff.json | 7 + package.json | 4 +- .../bundle-size/AllComponents.fixture.js | 2 + .../library/config/tests.js | 19 +- .../library/etc/tooltip.api.md | 40 +++ .../library/package.json | 11 +- .../components/Provider/renderProvider.tsx | 9 +- .../src/components/Tooltip/Tooltip.cy.tsx | 228 +++++++++++++++ .../src/components/Tooltip/Tooltip.test.tsx | 171 +++++++++++ .../src/components/Tooltip/Tooltip.tsx | 16 ++ .../src/components/Tooltip/Tooltip.types.ts | 19 ++ .../library/src/components/Tooltip/index.ts | 10 + .../src/components/Tooltip/renderTooltip.tsx | 25 ++ .../src/components/Tooltip/useTooltip.ts | 257 +++++++++++++++++ .../library/src/tooltip.ts | 8 + .../src/Tooltip/TooltipBestPractices.md | 22 ++ .../src/Tooltip/TooltipControlled.stories.tsx | 30 ++ .../src/Tooltip/TooltipDefault.stories.tsx | 16 ++ .../stories/src/Tooltip/TooltipDescription.md | 7 + .../src/Tooltip/TooltipPositions.stories.tsx | 22 ++ ...TooltipRelationshipDescription.stories.tsx | 27 ++ .../TooltipRelationshipLabel.stories.tsx | 38 +++ .../src/Tooltip/TooltipWithArrow.stories.tsx | 47 +++ .../stories/src/Tooltip/index.stories.tsx | 23 ++ .../projects-test/src/performBrowserTest.ts | 2 +- scripts/test-ssr/src/utils/visitPage.ts | 2 +- yarn.lock | 272 +++++++++--------- 27 files changed, 1169 insertions(+), 165 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-021e6dbb-6618-4217-a7ce-8567198a60ff.json create mode 100644 packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.cy.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tooltip/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/tooltip.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipBestPractices.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipControlled.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipPositions.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipDescription.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipLabel.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipWithArrow.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/index.stories.tsx diff --git a/change/@fluentui-react-headless-components-preview-021e6dbb-6618-4217-a7ce-8567198a60ff.json b/change/@fluentui-react-headless-components-preview-021e6dbb-6618-4217-a7ce-8567198a60ff.json new file mode 100644 index 00000000000000..44ecd1d26ade41 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-021e6dbb-6618-4217-a7ce-8567198a60ff.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add Tooltip component", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/package.json b/package.json index bf69faa4d3e769..33e10fd97974c2 100644 --- a/package.json +++ b/package.json @@ -264,7 +264,7 @@ "postcss-modules": "4.1.3", "prettier": "2.8.8", "progress": "2.0.3", - "puppeteer": "19.6.3", + "puppeteer": "24.42.0", "raw-loader": "4.0.2", "react": "19.2.0", "react-app-polyfill": "2.0.0", @@ -352,7 +352,7 @@ "esbuild": "0.25.0", "swc-loader": "^0.2.6", "prettier": "2.8.8", - "puppeteer": "19.6.3", + "puppeteer": "24.42.0", "ws": "8.17.1", "playwright": "1.55.1", "**/prismjs": "^1.30.0", diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index 7a0987ba6a87b0..0084b681e53e3e 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js @@ -29,6 +29,7 @@ import * as TabList from '@fluentui/react-headless-components-preview/tab-list'; import * as Textarea from '@fluentui/react-headless-components-preview/textarea'; import * as ToggleButton from '@fluentui/react-headless-components-preview/toggle-button'; import * as Toolbar from '@fluentui/react-headless-components-preview/toolbar'; +import * as Tooltip from '@fluentui/react-headless-components-preview/tooltip'; console.log({ Accordion, @@ -62,6 +63,7 @@ console.log({ Textarea, ToggleButton, Toolbar, + Tooltip, }); export default { diff --git a/packages/react-components/react-headless-components-preview/library/config/tests.js b/packages/react-components/react-headless-components-preview/library/config/tests.js index 4173749f4f265c..3507289757d2ef 100644 --- a/packages/react-components/react-headless-components-preview/library/config/tests.js +++ b/packages/react-components/react-headless-components-preview/library/config/tests.js @@ -1,6 +1,7 @@ /** Jest test setup file. */ require('@testing-library/jest-dom'); +require('@oddbird/popover-polyfill'); global.ResizeObserver = class ResizeObserver { observe() { @@ -36,21 +37,3 @@ if (typeof HTMLDialogElement !== 'undefined') { }; } } - -// JSDOM does not implement the Popover API yet. -// Provide a minimal test shim so components using showPopover/hidePopover can run in Jest. -if (typeof HTMLElement !== 'undefined') { - const proto = HTMLElement.prototype; - - if (!proto.showPopover) { - proto.showPopover = function showPopover() { - /* no-op */ - }; - } - - if (!proto.hidePopover) { - proto.hidePopover = function hidePopover() { - /* no-op */ - }; - } -} diff --git a/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md b/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md new file mode 100644 index 00000000000000..e947373cd61667 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md @@ -0,0 +1,40 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { JSXElement } from '@fluentui/react-utilities'; +import { OnVisibleChangeData } from '@fluentui/react-tooltip'; +import type { TooltipBaseProps } from '@fluentui/react-tooltip'; +import type { TooltipBaseState } from '@fluentui/react-tooltip'; +import { TooltipSlots } from '@fluentui/react-tooltip'; +import { TooltipTriggerProps } from '@fluentui/react-tooltip'; + +export { OnVisibleChangeData } + +// @public +export const renderTooltip: (state: TooltipState) => JSXElement; + +// @public +export const Tooltip: { + (props: TooltipProps): JSXElement; + displayName: string; +}; + +// @public +export type TooltipProps = Omit<TooltipBaseProps, 'mountNode'>; + +export { TooltipSlots } + +// @public +export type TooltipState = Omit<TooltipBaseState, 'mountNode'>; + +export { TooltipTriggerProps } + +// @public +export const useTooltip: (props: TooltipProps) => TooltipState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 91dfc4990f90d4..11ad6ce8169218 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -54,9 +54,11 @@ "@fluentui/react-spinner": "^9.8.2", "@fluentui/react-switch": "^9.7.2", "@fluentui/react-tabs": "^9.12.1", + "@fluentui/react-tabster": "^9.26.14", "@fluentui/react-tags": "^9.8.1", "@fluentui/react-textarea": "^9.7.2", "@fluentui/react-toolbar": "^9.8.0", + "@fluentui/react-tooltip": "^9.10.1", "@fluentui/react-utilities": "^9.26.3", "@swc/helpers": "^0.5.1" }, @@ -259,6 +261,12 @@ "import": "./lib/toolbar.js", "require": "./lib-commonjs/toolbar.js" }, + "./tooltip": { + "types": "./dist/tooltip.d.ts", + "node": "./lib-commonjs/tooltip.js", + "import": "./lib/tooltip.js", + "require": "./lib-commonjs/tooltip.js" + }, "./package.json": "./package.json" }, "beachball": { @@ -268,6 +276,7 @@ ] }, "devDependencies": { - "@fluentui/scripts-cypress": "*" + "@fluentui/scripts-cypress": "*", + "@oddbird/popover-polyfill": "^0.6.1" } } diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Provider/renderProvider.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Provider/renderProvider.tsx index f3e9ec71e485ed..62c92840d2af28 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Provider/renderProvider.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Provider/renderProvider.tsx @@ -3,10 +3,7 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import { - Provider_unstable as Provider, - TooltipVisibilityProvider_unstable as TooltipVisibilityProvider, -} from '@fluentui/react-shared-contexts'; +import { Provider_unstable as Provider } from '@fluentui/react-shared-contexts'; import type { FluentProviderContextValues, FluentProviderSlots } from '@fluentui/react-provider'; import type { ProviderState } from './Provider.types'; @@ -18,9 +15,7 @@ export const renderProvider = (state: ProviderState, contextValues: FluentProvid return ( <Provider value={contextValues.provider}> - <TooltipVisibilityProvider value={contextValues.tooltip}> - <state.root /> - </TooltipVisibilityProvider> + <state.root /> </Provider> ); }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.cy.tsx new file mode 100644 index 00000000000000..b726fd447efca9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.cy.tsx @@ -0,0 +1,228 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { Tooltip } from './Tooltip'; +import type { TooltipProps } from './Tooltip.types'; +import type { JSXElement } from '@fluentui/react-utilities'; + +const mount = (element: JSXElement) => mountBase(element); + +const tooltipSelector = '[role="tooltip"]'; + +const Example = (props: Partial<TooltipProps>) => ( + <Tooltip content="Tooltip content" relationship="description" {...props}> + <button>Trigger</button> + </Tooltip> +); + +describe('Tooltip', () => { + describe('visibility', () => { + it('should be hidden by default', () => { + mount(<Example />); + cy.get(tooltipSelector).should('not.be.visible'); + }); + + it('should show after pointer enters trigger', () => { + mount(<Example />); + cy.get('button').trigger('pointerover'); + cy.get(tooltipSelector).should('be.visible'); + }); + + it('should hide after pointer leaves trigger', () => { + mount(<Example />); + cy.get('button').trigger('pointerover'); + cy.get(tooltipSelector).should('be.visible'); + cy.get('button').trigger('pointerout', { force: true }); + cy.get(tooltipSelector).should('not.be.visible'); + }); + + it('should remain visible when pointer moves to tooltip content', () => { + cy.clock(); + mount(<Example showDelay={0} hideDelay={300} />); + cy.get('button').trigger('pointerover'); + cy.tick(10); + cy.get('button').trigger('pointerout', { force: true }); + cy.get(tooltipSelector).trigger('pointerover'); + cy.tick(500); // Well past hideDelay; timer was cancelled + cy.get(tooltipSelector).should('be.visible'); + }); + + it('should hide after pointer leaves tooltip content', () => { + mount(<Example />); + cy.get('button').trigger('pointerover'); + cy.get(tooltipSelector).should('be.visible'); + cy.get(tooltipSelector).trigger('pointerout'); + cy.get(tooltipSelector).should('not.be.visible'); + }); + }); + + describe('keyboard and focus', () => { + it('should show on trigger focus', () => { + mount(<Example />); + cy.get('button').focus(); + cy.get(tooltipSelector).should('be.visible'); + }); + + it('should hide immediately on trigger blur', () => { + mount(<Example />); + cy.get('button').focus(); + cy.get(tooltipSelector).should('be.visible'); + cy.get('button').blur(); + cy.get(tooltipSelector).should('not.be.visible'); + }); + + it('should hide when browser dismisses the popover', () => { + mount(<Example />); + cy.get('button').trigger('pointerover'); + cy.get(tooltipSelector).should('be.visible'); + cy.get(tooltipSelector).then($tooltip => { + ($tooltip[0] as HTMLElement).hidePopover(); + }); + cy.get(tooltipSelector).should('not.be.visible'); + }); + }); + + describe('show and hide delays', () => { + it('should not show before showDelay has elapsed', () => { + cy.clock(); + mount(<Example showDelay={400} />); + cy.get('button').trigger('pointerover'); + cy.tick(200); + cy.get(tooltipSelector).should('not.be.visible'); + cy.tick(200); + cy.get(tooltipSelector).should('be.visible'); + }); + + it('should not hide before hideDelay has elapsed', () => { + cy.clock(); + mount(<Example showDelay={0} hideDelay={400} />); + cy.get('button').trigger('pointerover'); + cy.tick(10); + cy.get('button').trigger('pointerout', { force: true }); + cy.tick(200); + cy.get(tooltipSelector).should('be.visible'); + cy.tick(200); + cy.get(tooltipSelector).should('not.be.visible'); + }); + + it('should cancel the hide timer when pointer re-enters trigger', () => { + cy.clock(); + mount(<Example showDelay={0} hideDelay={300} />); + cy.get('button').trigger('pointerover'); + cy.tick(10); + cy.get('button').trigger('pointerout', { force: true }); + cy.tick(150); // Partway through hideDelay + cy.get('button').trigger('pointerover', { force: true }); // Re-enter — cancels hide timer + cy.tick(500); + cy.get(tooltipSelector).should('be.visible'); + }); + }); + + describe('controlled', () => { + const ControlledExample = () => { + const [visible, setVisible] = React.useState(false); + return ( + <> + <Tooltip + content="Controlled tooltip" + relationship="description" + visible={visible} + onVisibleChange={(_, data) => setVisible(data.visible)} + > + <button id="trigger">Trigger</button> + </Tooltip> + <button id="toggle" onClick={() => setVisible(v => !v)}> + Toggle + </button> + </> + ); + }; + + it('should show when visible is set to true', () => { + mount(<ControlledExample />); + cy.get('#toggle').click(); + cy.get(tooltipSelector).should('be.visible'); + }); + + it('should hide when visible is set to false', () => { + mount(<ControlledExample />); + cy.get('#toggle').click(); + cy.get(tooltipSelector).should('be.visible'); + cy.get('#toggle').click({ force: true }); + cy.get(tooltipSelector).should('not.be.visible'); + }); + + it('should call onVisibleChange with visible=true when shown', () => { + const onVisibleChange = cy.stub().as('onVisibleChange'); + mount(<Example onVisibleChange={onVisibleChange} />); + cy.get('button').trigger('pointerover'); + cy.get(tooltipSelector).should('be.visible'); + cy.get('@onVisibleChange').should( + 'have.been.calledWith', + Cypress.sinon.match.any, + Cypress.sinon.match({ visible: true }), + ); + }); + + it('should call onVisibleChange with visible=false when hidden', () => { + const onVisibleChange = cy.stub().as('onVisibleChange'); + mount(<Example onVisibleChange={onVisibleChange} />); + cy.get('button').trigger('pointerover'); + cy.get(tooltipSelector).should('be.visible'); + cy.get('button').trigger('pointerout', { force: true }); + cy.get(tooltipSelector).should('not.be.visible'); + cy.get('@onVisibleChange').should( + 'have.been.calledWith', + Cypress.sinon.match.any, + Cypress.sinon.match({ visible: false }), + ); + }); + }); + + describe('aria relationship', () => { + it('should set aria-label when relationship="label" with string content', () => { + mount( + <Tooltip content="Label text" relationship="label"> + <button>Trigger</button> + </Tooltip>, + ); + cy.get('button').should('have.attr', 'aria-label', 'Label text'); + // Not rendered since aria-label is sufficient + cy.get(tooltipSelector).should('not.exist'); + }); + + it('should set aria-labelledby when relationship="label" with non-string content', () => { + mount( + <Tooltip content={<span>Complex label</span>} relationship="label"> + <button>Trigger</button> + </Tooltip>, + ); + cy.get('button') + .invoke('attr', 'aria-labelledby') + .then(id => { + cy.get(`#${id}`).should('exist'); + }); + }); + + it('should set aria-describedby when relationship="description"', () => { + mount( + <Tooltip content="Description text" relationship="description"> + <button>Trigger</button> + </Tooltip>, + ); + cy.get('button') + .invoke('attr', 'aria-describedby') + .then(id => { + cy.get(`#${id}`).should('exist'); + }); + }); + + it('should always render tooltip for aria-describedby even when hidden', () => { + mount( + <Tooltip content="Always here" relationship="description"> + <button>Trigger</button> + </Tooltip>, + ); + cy.get(tooltipSelector).should('exist'); + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.test.tsx new file mode 100644 index 00000000000000..5137f1b26b42c8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.test.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { resetIdsForTests } from '@fluentui/react-utilities'; +import { isConformant } from '../../testing/isConformant'; +import type { IsConformantOptions } from '@fluentui/react-conformance'; +import { Tooltip } from './Tooltip'; + +export const getTooltipElement: IsConformantOptions['getTargetElement'] = () => { + return screen.queryByRole('tooltip') as HTMLElement; +}; + +describe('Tooltip', () => { + isConformant({ + Component: Tooltip, + displayName: 'Tooltip', + requiredProps: { + content: 'Example tooltip', + relationship: 'label', + children: <button aria-label="trigger" />, + visible: true, + }, + getTargetElement: getTooltipElement, + disabledTests: [ + // Tooltip is a wrapper with no root DOM element — ref/className tests don't apply + 'component-handles-ref', + 'component-has-root-ref', + 'component-handles-classname', + ], + testOptions: { + 'consistent-callback-args': { + legacyCallbacks: ['onVisibleChange'], + }, + }, + }); + + afterEach(() => { + resetIdsForTests(); + }); + + it('renders trigger and tooltip content with correct positioning attributes', async () => { + render( + <Tooltip + content="Default Tooltip" + relationship="label" + visible + positioning={{ position: 'above', align: 'center' }} + > + <button>Trigger</button> + </Tooltip>, + ); + + expect(screen.getByLabelText('Default Tooltip')).toBeInTheDocument(); + expect(screen.getByRole('tooltip')).toHaveAttribute('popover', 'hint'); + }); + + it('renders only aria-label for a simple label tooltip', () => { + const tooltipText = 'The tooltip text'; + render( + <Tooltip content={tooltipText} relationship="label"> + <button data-testid="the-target">Trigger</button> + </Tooltip>, + ); + + expect(screen.queryByRole('tooltip')).toBeNull(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', tooltipText); + }); + + it('renders the content of a nontrivial label tooltip', () => { + render( + <Tooltip + relationship="label" + content={{ + children: ( + <span> + This is a <strong>formatted</strong> tooltip + </span> + ), + id: 'the-tooltip-id', + }} + > + <button>Trigger</button> + </Tooltip>, + ); + + const tooltip = screen.getByRole('tooltip'); + const target = screen.getByRole('button'); + expect(tooltip.id).toBe('the-tooltip-id'); + expect(target).toHaveAttribute('aria-labelledby', 'the-tooltip-id'); + }); + + it('renders a description tooltip content always', () => { + render( + <Tooltip content="Description tooltip" relationship="description"> + <button>Trigger</button> + </Tooltip>, + ); + + const tooltip = screen.getByRole('tooltip'); + const target = screen.getByRole('button'); + expect(target).toHaveAttribute('aria-describedby', tooltip.id); + }); + + it('renders arrow element when withArrow is true', () => { + render( + <Tooltip content="Arrow tooltip" relationship="label" visible withArrow> + <button>Trigger</button> + </Tooltip>, + ); + + expect(screen.getByRole('tooltip').querySelector('[data-arrow]')).not.toBeNull(); + }); + + it('does not render arrow element when withArrow is false', () => { + render( + <Tooltip content="No arrow tooltip" relationship="label" visible> + <button>Trigger</button> + </Tooltip>, + ); + + expect(screen.getByRole('tooltip').querySelector('[data-arrow]')).toBeNull(); + }); + + it("doesn't set any aria attributes for relationship='inaccessible'", () => { + render( + <Tooltip content="Inaccessible tooltip" relationship="inaccessible"> + <button>Trigger</button> + </Tooltip>, + ); + + const target = screen.getByRole('button'); + expect(target).not.toHaveAttribute('aria-label'); + expect(target).not.toHaveAttribute('aria-labelledby'); + expect(target).not.toHaveAttribute('aria-description'); + expect(target).not.toHaveAttribute('aria-describedby'); + }); + + it("doesn't override trigger's aria-label", () => { + render( + <Tooltip content="Label tooltip" relationship="label"> + <button aria-label="test-label" /> + </Tooltip>, + ); + + const target = screen.getByRole('button'); + expect(target).toHaveAttribute('aria-label', 'test-label'); + expect(target).not.toHaveAttribute('aria-labelledby'); + }); + + it("doesn't override trigger's aria-labelledby", () => { + render( + <Tooltip content="Label tooltip" relationship="label"> + <button aria-labelledby="test-labelledby">Trigger</button> + </Tooltip>, + ); + + const target = screen.getByRole('button'); + expect(target).toHaveAttribute('aria-labelledby', 'test-labelledby'); + }); + + it("doesn't override trigger's aria-describedby", () => { + render( + <Tooltip content="Description tooltip" relationship="description"> + <button aria-describedby="test-describedby">Trigger</button> + </Tooltip>, + ); + + const target = screen.getByRole('button'); + expect(target).not.toHaveAttribute('aria-description'); + expect(target).toHaveAttribute('aria-describedby', 'test-describedby'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 00000000000000..89bb943540ac9e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,16 @@ +'use client'; + +import type { JSXElement } from '@fluentui/react-utilities'; +import { useTooltip } from './useTooltip'; +import { renderTooltip } from './renderTooltip'; +import type { TooltipProps } from './Tooltip.types'; + +/** + * Tooltip renders a non-modal floating label or description anchored to a trigger element. + */ +export const Tooltip = (props: TooltipProps): JSXElement => { + const state = useTooltip(props); + return renderTooltip(state); +}; + +Tooltip.displayName = 'Tooltip'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.types.ts new file mode 100644 index 00000000000000..b283828fb6462e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.types.ts @@ -0,0 +1,19 @@ +import type { TooltipBaseProps, TooltipBaseState } from '@fluentui/react-tooltip'; + +export type { OnVisibleChangeData, TooltipSlots, TooltipTriggerProps } from '@fluentui/react-tooltip'; + +/** + * Props for the Tooltip component. + * + * Reuses Tooltip base props while omitting `mountNode` for the headless preview API surface. + * Positioning is handled by the Tooltip base implementation via `usePositioning` from + * `@fluentui/react-positioning`. + */ +export type TooltipProps = Omit<TooltipBaseProps, 'mountNode'>; + +/** + * State used in rendering Tooltip. + * + * Extends Tooltip base state with headless-specific data attributes used for styling hooks. + */ +export type TooltipState = Omit<TooltipBaseState, 'mountNode'>; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/index.ts new file mode 100644 index 00000000000000..cdf8abd62ab732 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/index.ts @@ -0,0 +1,10 @@ +export { Tooltip } from './Tooltip'; +export type { + OnVisibleChangeData, + TooltipTriggerProps, + TooltipProps, + TooltipSlots, + TooltipState, +} from './Tooltip.types'; +export { renderTooltip } from './renderTooltip'; +export { useTooltip } from './useTooltip'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx new file mode 100644 index 00000000000000..4e8d795e2ae94c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx @@ -0,0 +1,25 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TooltipState, TooltipSlots } from './Tooltip.types'; + +/** + * Render the final JSX of Tooltip. + */ +export const renderTooltip = (state: TooltipState): JSXElement => { + assertSlots<TooltipSlots>(state); + + return ( + <> + {state.children} + {state.shouldRenderTooltip && ( + <state.content> + {state.withArrow && <div ref={state.arrowRef} data-arrow="" />} + {state.content.children} + </state.content> + )} + </> + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts new file mode 100644 index 00000000000000..b4533f428d1097 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts @@ -0,0 +1,257 @@ +'use client'; + +import * as React from 'react'; +import { + applyTriggerPropsToChildren, + getReactElementRef, + getTriggerChild, + mergeCallbacks, + slot, + useControllableState, + useEventCallback, + useId, + useIsomorphicLayoutEffect, + useIsSSR, + useMergedRefs, + useTimeout, +} from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import type { KeyborgFocusInEvent } from '@fluentui/react-tabster'; +import { KEYBORG_FOCUSIN, useIsNavigatingWithKeyboard } from '@fluentui/react-tabster'; + +import type { OnVisibleChangeData, TooltipProps, TooltipState, TooltipTriggerProps } from './Tooltip.types'; +import { resolvePositioningShorthand, usePositioning } from '../../positioning'; + +/** + * Create the state required to render Tooltip. + * + * @param props - props from this instance of Tooltip + */ +export const useTooltip = (props: TooltipProps): TooltipState => { + 'use no memo'; + + const isServerSideRender = useIsSSR(); + const { targetDocument } = useFluent(); + + const [visible, setVisibleInternal] = useControllableState({ state: props.visible, initialState: false }); + + const { + children, + content, + positioning = 'above', + withArrow = false, + onVisibleChange, + relationship, + showDelay = 250, + hideDelay = 250, + } = props; + + const state: TooltipState = { + positioning, + showDelay, + hideDelay, + relationship, + visible, + shouldRenderTooltip: visible, + withArrow, + // Slots + components: { + content: 'div', + }, + content: slot.always(content, { + defaultProps: { + role: 'tooltip', + popover: 'hint', + }, + elementType: 'div', + }), + }; + + const positioningOptions = resolvePositioningShorthand(positioning); + const { targetRef, containerRef } = usePositioning(positioningOptions); + + state.content.id = useId('tooltip-', state.content.id); + + const contentRef = useMergedRefs(state.content.ref, containerRef); + state.content.ref = contentRef; + + const [setDelayTimeout, clearDelayTimeout] = useTimeout(); + + const setVisible = React.useCallback( + (ev: React.PointerEvent<HTMLElement> | React.FocusEvent<HTMLElement> | undefined, data: OnVisibleChangeData) => { + clearDelayTimeout(); + setVisibleInternal(oldVisible => { + if (data.visible !== oldVisible) { + onVisibleChange?.(ev, data); + } + return data.visible; + }); + }, + [clearDelayTimeout, setVisibleInternal, onVisibleChange], + ); + + const onToggle = useEventCallback((event: Event) => { + if ((event as ToggleEvent).newState === 'closed') { + setVisible(undefined, { visible: false }); + } + }); + + // Keep the tooltip in sync with the state when it is changed programmatically. + // Also sync React state when the browser auto-dismisses the hint popover (click outside, Escape). + useIsomorphicLayoutEffect(() => { + const el = contentRef.current; + if (!el) { + return; + } + + el.addEventListener('toggle', onToggle); + + try { + if (visible) { + el.showPopover(); + } else if (el.matches(':popover-open')) { + el.hidePopover(); + } + } catch (error) { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.warn( + [ + 'Popover API is not supported in this browser, and the tooltip will not work correctly.', + 'Please include a popover polyfill for better browser support.', + ].join(' '), + { error }, + ); + } + } + + return () => { + el.removeEventListener('toggle', onToggle); + }; + }, [contentRef, visible, setVisible, onToggle]); + + // Used to skip showing the tooltip in certain situations when the trigger is focused. + // See comments where this is set for more info. + const ignoreNextFocusEventRef = React.useRef(false); + + // Listener for onPointerEnter and onFocus on the trigger element + const onEnterTrigger = React.useCallback( + (ev: React.PointerEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => { + if (ev.type === 'focus' && ignoreNextFocusEventRef.current) { + ignoreNextFocusEventRef.current = false; + return; + } + + setDelayTimeout(() => { + setVisible(ev, { visible: true }); + }, state.showDelay); + + ev.persist(); // Persist the event since the setVisible call will happen asynchronously + }, + [setDelayTimeout, setVisible, state.showDelay], + ); + + const isNavigatingWithKeyboard = useIsNavigatingWithKeyboard(); + + // Callback ref that attaches a keyborg:focusin event listener. + const [keyborgListenerCallbackRef] = React.useState(() => { + const onKeyborgFocusIn = ((ev: KeyborgFocusInEvent) => { + // Skip showing the tooltip if focus moved programmatically. + // For example, we don't want to show the tooltip when a dialog is closed + // and Tabster programmatically restores focus to the trigger button. + // See https://github.com/microsoft/fluentui/issues/27576 + if (ev.detail?.isFocusedProgrammatically && !isNavigatingWithKeyboard()) { + ignoreNextFocusEventRef.current = true; + } + }) as EventListener; + + // Save the current element to remove the listener when the ref changes + let current: Element | null = null; + + // Callback ref that attaches the listener to the element + return (element: Element | null) => { + current?.removeEventListener(KEYBORG_FOCUSIN, onKeyborgFocusIn); + element?.addEventListener(KEYBORG_FOCUSIN, onKeyborgFocusIn); + current = element; + }; + }); + + // Listener for onPointerLeave and onBlur on the trigger element + const onLeaveTrigger = React.useCallback( + (ev: React.PointerEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => { + let delay = state.hideDelay; + + if (ev.type === 'blur') { + // Hide immediately when losing focus + delay = 0; + + // The focused element gets a blur event when the document loses focus + // (e.g. switching tabs in the browser), but we don't want to show the + // tooltip again when the document gets focus back. Handle this case by + // checking if the blurred element is still the document's activeElement. + // See https://github.com/microsoft/fluentui/issues/13541 + ignoreNextFocusEventRef.current = targetDocument?.activeElement === ev.target; + } + + setDelayTimeout(() => { + setVisible(ev, { visible: false }); + }, delay); + + ev.persist(); // Persist the event since the setVisible call will happen asynchronously + }, + [setDelayTimeout, setVisible, state.hideDelay, targetDocument], + ); + + // Cancel the hide timer when the mouse or focus enters the tooltip, and restart it when the mouse or focus leaves. + // This keeps the tooltip visible when the mouse is moved over it, or it has focus within. + state.content.onPointerEnter = mergeCallbacks(state.content.onPointerEnter, clearDelayTimeout); + state.content.onPointerLeave = mergeCallbacks(state.content.onPointerLeave, onLeaveTrigger); + state.content.onFocus = mergeCallbacks(state.content.onFocus, clearDelayTimeout); + state.content.onBlur = mergeCallbacks(state.content.onBlur, onLeaveTrigger); + + const child = getTriggerChild(children); + + const triggerAriaProps: Pick<TooltipTriggerProps, 'aria-label' | 'aria-labelledby' | 'aria-describedby'> = {}; + const isPopupExpanded = + child?.props?.['aria-haspopup'] && + (child?.props?.['aria-expanded'] === true || child?.props?.['aria-expanded'] === 'true'); + + if (relationship === 'label') { + // aria-label only works if the content is a string. Otherwise, need to use aria-labelledby. + if (typeof state.content.children === 'string') { + triggerAriaProps['aria-label'] = state.content.children; + } else { + triggerAriaProps['aria-labelledby'] = state.content.id; + // Always render the tooltip even if hidden, so that aria-labelledby refers to a valid element + state.shouldRenderTooltip = true; + } + } else if (relationship === 'description') { + triggerAriaProps['aria-describedby'] = state.content.id; + // Always render the tooltip even if hidden, so that aria-describedby refers to a valid element + state.shouldRenderTooltip = true; + } + + // Case 1: Don't render the Tooltip in SSR to avoid hydration errors + // Case 2: Don't render the Tooltip, if it triggers Menu or another popup and it's already opened + if (isServerSideRender || isPopupExpanded) { + state.shouldRenderTooltip = false; + } + + // Apply the trigger props to the child, either by calling the render function, or cloning with the new props + state.children = applyTriggerPropsToChildren(children, { + ...triggerAriaProps, + ...child?.props, + ref: useMergedRefs( + getReactElementRef<HTMLButtonElement>(child), + keyborgListenerCallbackRef, + // If the target prop is not provided, attach targetRef to the trigger element's ref prop + positioningOptions.target === undefined ? targetRef : undefined, + ), + onPointerEnter: useEventCallback(mergeCallbacks(child?.props?.onPointerEnter, onEnterTrigger)), + onPointerLeave: useEventCallback(mergeCallbacks(child?.props?.onPointerLeave, onLeaveTrigger)), + onFocus: useEventCallback(mergeCallbacks(child?.props?.onFocus, onEnterTrigger)), + onBlur: useEventCallback(mergeCallbacks(child?.props?.onBlur, onLeaveTrigger)), + }); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/tooltip.ts b/packages/react-components/react-headless-components-preview/library/src/tooltip.ts new file mode 100644 index 00000000000000..8cba5b09ac8668 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/tooltip.ts @@ -0,0 +1,8 @@ +export { Tooltip, renderTooltip, useTooltip } from './components/Tooltip'; +export type { + OnVisibleChangeData, + TooltipTriggerProps, + TooltipProps, + TooltipSlots, + TooltipState, +} from './components/Tooltip'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipBestPractices.md b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipBestPractices.md new file mode 100644 index 00000000000000..05c7ef3d9d4d84 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipBestPractices.md @@ -0,0 +1,22 @@ +### Best Practices + +- Don’t use a tooltip to restate a button name that’s already shown in the UI. +- When a control or UI element is unlabeled, use a simple, descriptive noun phrase. For example: “Highlighting pen”. Only capitalize the first word (unless a subsequent word is a proper noun), and don’t use a period. +- For a disabled control that could use an explanation, provide a brief description of the state in which the control will be enabled. For example: “This feature is available for line charts.” +- Only use periods for complete sentences. + +For a UI label that needs some explanation: + +- Briefly describe what you can do with the UI element. +- Use the imperative verb form. For example, "Find text in this file" (not "Finds text in this file"). +- Don't include end punctuation unless there is at least one complete sentence. + +For a truncated label or a label that’s likely to truncate in some languages: + +- Provide the untruncated label in the tooltip. +- Don't provide a tooltip if the untruncated info is provided elsewhere on the page or flow. +- Optional: On another line, provide a clarifying description, but only if needed. + +### Accessibility + +- Don't add tooltips to unfocusable or disabled controls. People using the keyboard or screen readers can't consistently access or read tooltips associated with elements that can't receive focus or are disabled. Consider having the information statically available on the page or through a "help"-style toggle button. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipControlled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipControlled.stories.tsx new file mode 100644 index 00000000000000..a9f5bd109a4c9d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipControlled.stories.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +export const Controlled = (): React.ReactNode => { + const [visible, setVisible] = React.useState(false); + const toggleTooltip = () => setVisible(v => !v); + + return ( + <div className="flex gap-2 items-center"> + <Tooltip + content={{ + className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', + children: 'Controlled tooltip', + }} + relationship="description" + visible={visible} + > + <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> + Trigger + </button> + </Tooltip> + <button + onClick={toggleTooltip} + className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer" + > + {visible ? 'Hide' : 'Show'} Tooltip + </button> + </div> + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDefault.stories.tsx new file mode 100644 index 00000000000000..725d8804e3bd5b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDefault.stories.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +export const Default = (): React.ReactNode => ( + <Tooltip + content={{ + className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', + children: 'This is the tooltip label', + }} + relationship="description" + > + <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> + Hover or focus me + </button> + </Tooltip> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDescription.md new file mode 100644 index 00000000000000..67451482be19cf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDescription.md @@ -0,0 +1,7 @@ +A tooltip displays additional information about another component. +The information is displayed above and near the target component. +<br /> +Tooltip is not expected to handle interactive content. +If this is necessary behavior, an expand/collapse button + popover should be used instead. +<br /> +Hover or focus the buttons in the examples to see their tooltips. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipPositions.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipPositions.stories.tsx new file mode 100644 index 00000000000000..52a40b16f7c274 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipPositions.stories.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +export const Positions = (): React.ReactNode => ( + <div className="flex gap-4 flex-wrap p-20"> + {(['above', 'below', 'before', 'after'] as const).map(position => ( + <Tooltip + key={position} + content={{ + className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', + children: `Position: ${position}`, + }} + relationship="description" + positioning={position} + > + <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer capitalize"> + {position} + </button> + </Tooltip> + ))} + </div> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipDescription.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipDescription.stories.tsx new file mode 100644 index 00000000000000..02ce19debbaeb0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipDescription.stories.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +export const RelationshipDescription = (): React.ReactNode => ( + <Tooltip + content={{ + className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', + children: 'This is the description of the button', + }} + relationship="description" + > + <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> + Button + </button> + </Tooltip> +); + +RelationshipDescription.storyName = 'Relationship: description'; +RelationshipDescription.parameters = { + docs: { + description: { + story: `A tooltip can provide supplementary description for its trigger via \`aria-describedby\`. + Use this when the trigger already has a visible label but needs additional descriptive context + for screen readers.`, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipLabel.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipLabel.stories.tsx new file mode 100644 index 00000000000000..8b8dc3526072ba --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipLabel.stories.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +const tooltipContent = (text: string) => ({ + className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', + children: text, +}); + +export const RelationshipLabel = (): React.ReactNode => ( + <div className="flex gap-2"> + <Tooltip content={tooltipContent('Bold')} relationship="label"> + <button className="w-8 h-8 rounded border border-gray-300 bg-white hover:bg-gray-50 font-bold text-sm cursor-pointer"> + B + </button> + </Tooltip> + <Tooltip content={tooltipContent('Italic')} relationship="label"> + <button className="w-8 h-8 rounded border border-gray-300 bg-white hover:bg-gray-50 italic text-sm cursor-pointer"> + I + </button> + </Tooltip> + <Tooltip content={tooltipContent('Underline')} relationship="label"> + <button className="w-8 h-8 rounded border border-gray-300 bg-white hover:bg-gray-50 underline text-sm cursor-pointer"> + U + </button> + </Tooltip> + </div> +); + +RelationshipLabel.storyName = 'Relationship: label'; +RelationshipLabel.parameters = { + docs: { + description: { + story: `A tooltip can serve as the accessible label for its trigger. Use this for icon-only buttons + with no visible label text. The tooltip sets \`aria-label\` on the trigger so screen readers + announce the label even when the tooltip is not visible.`, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipWithArrow.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipWithArrow.stories.tsx new file mode 100644 index 00000000000000..c0f4d6e9b20415 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipWithArrow.stories.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +const contentClass = [ + 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md overflow-visible', + // Arrow base (the rotated square rendered by withArrow) + '[&_[data-arrow]]:absolute [&_[data-arrow]]:w-2 [&_[data-arrow]]:h-2 [&_[data-arrow]]:bg-gray-900 [&_[data-arrow]]:rotate-45', + // Main-axis offset + "[&[data-placement^='above']_[data-arrow]]:-bottom-1", + "[&[data-placement^='below']_[data-arrow]]:-top-1", + "[&[data-placement^='before']_[data-arrow]]:-right-1", + "[&[data-placement^='after']_[data-arrow]]:-left-1", + // Cross-axis centering + "[&[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 + "[&[data-placement$='-start']_[data-arrow]]:start-[var(--arrow-padding,8px)]", + "[&[data-placement$='-end']_[data-arrow]]:end-[var(--arrow-padding,8px)]", +].join(' '); + +export const WithArrow = (): React.ReactNode => ( + <div className="flex flex-col items-start gap-4 p-16"> + <Tooltip + relationship="description" + content={{ className: contentClass, children: 'Center-aligned tooltip' }} + withArrow + positioning={{ position: 'below', offset: 10 }} + > + <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> + Center-aligned + </button> + </Tooltip> + + <Tooltip + relationship="description" + positioning={{ position: 'below', align: 'start', offset: 10 }} + content={{ className: contentClass, children: 'Start-aligned tooltip' }} + withArrow + > + <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> + Start-aligned + </button> + </Tooltip> + </div> +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/index.stories.tsx new file mode 100644 index 00000000000000..e4b5e33f923cb5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/index.stories.tsx @@ -0,0 +1,23 @@ +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +import descriptionMd from './TooltipDescription.md'; +import bestPracticesMd from './TooltipBestPractices.md'; + +export { Default } from './TooltipDefault.stories'; +export { WithArrow } from './TooltipWithArrow.stories'; +export { Positions } from './TooltipPositions.stories'; +export { Controlled } from './TooltipControlled.stories'; +export { RelationshipLabel } from './TooltipRelationshipLabel.stories'; +export { RelationshipDescription } from './TooltipRelationshipDescription.stories'; + +export default { + title: 'Headless Components/Tooltip', + component: Tooltip, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + }, + }, + }, +}; diff --git a/scripts/projects-test/src/performBrowserTest.ts b/scripts/projects-test/src/performBrowserTest.ts index 7b87b29f900e01..7dc7ffbf8f8e06 100644 --- a/scripts/projects-test/src/performBrowserTest.ts +++ b/scripts/projects-test/src/performBrowserTest.ts @@ -95,7 +95,7 @@ export async function performBrowserTest(publicDirectory: string) { } }); page.on('pageerror', pageError => { - error = pageError; + error = pageError as Error; }); await visitUrl(page, url); diff --git a/scripts/test-ssr/src/utils/visitPage.ts b/scripts/test-ssr/src/utils/visitPage.ts index 9868da92783e2b..b6a6fb697f43f5 100644 --- a/scripts/test-ssr/src/utils/visitPage.ts +++ b/scripts/test-ssr/src/utils/visitPage.ts @@ -42,7 +42,7 @@ export async function visitPage(browser: Browser, url: string) { }); page.on('pageerror', err => { - error = err; + error = err as Error; }); await visitUrl(page, url); diff --git a/yarn.lock b/yarn.lock index e3ef4dec8adba3..cc51f68bb20dc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3215,6 +3215,11 @@ css-tree "^3.0.0" nanoid "^5.0.8" +"@oddbird/popover-polyfill@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@oddbird/popover-polyfill/-/popover-polyfill-0.6.1.tgz#dc2d49dd79cc74420eece5b97ccef6ba3f9d42a9" + integrity sha512-Papau51NPG+NajXvoEHCNT1CBJv4WIyvIGfH5gpJPLL8MZJt/4giC0jJ/mNZRtJmdzRuXWpUFB6QxkJa1RvMAQ== + "@parcel/watcher-android-arm64@2.5.1": version "2.5.1" resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" @@ -3339,6 +3344,19 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.4.4.tgz#11d5db19bd178936ec89cd84519c4de439574398" integrity sha512-1oO6+dN5kdIA3sKPZhRGJTfGVP4SWV6KqlMOwry4J3HfyD68sl/3KmG7DeYUzvN+RbhXDnv/D8vNNB8168tAMg== +"@puppeteer/browsers@2.13.0": + version "2.13.0" + resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.13.0.tgz#10f980c6d65efeff77f8a3cac6e1a7ac10604500" + integrity sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA== + dependencies: + debug "^4.4.3" + extract-zip "^2.0.1" + progress "^2.0.3" + proxy-agent "^6.5.0" + semver "^7.7.4" + tar-fs "^3.1.1" + yargs "^17.7.2" + "@react-native/babel-plugin-codegen@0.73.4": version "0.73.4" resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.73.4.tgz#8a2037d5585b41877611498ae66adbf1dddfec1b" @@ -5746,14 +5764,7 @@ afterframe@1.0.2: resolved "https://registry.yarnpkg.com/afterframe/-/afterframe-1.0.2.tgz#c63e17cdb29e4e60be2e618a315caf5ab5ade0c0" integrity sha512-0JeMZI7dIfVs5guqLgidQNV7c6jBC2HO0QNSekAUB82Hr7PdU9QXNAF3kpFkvATvHYDDTGto7FPsRu1ey+aKJQ== -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -agent-base@^7.0.2, agent-base@^7.1.0, agent-base@^7.1.1, agent-base@^7.1.2: +agent-base@^7.1.0, agent-base@^7.1.2: version "7.1.4" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== @@ -7259,6 +7270,14 @@ chromedriver@^125.0.0: proxy-from-env "^1.1.0" tcp-port-used "^1.0.2" +chromium-bidi@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-14.0.0.tgz#15a12ab083ae519a49a724e94994ca0a9ced9c8e" + integrity sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw== + dependencies: + mitt "^3.0.1" + zod "^3.24.1" + ci-info@3.9.0, ci-info@^3.7.0: version "3.9.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" @@ -7859,16 +7878,6 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -cosmiconfig@8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.0.0.tgz#e9feae014eab580f858f8a0288f38997a7bebe97" - integrity sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ== - dependencies: - import-fresh "^3.2.1" - js-yaml "^4.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - cosmiconfig@8.2.0, cosmiconfig@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.2.0.tgz#f7d17c56a590856cd1e7cee98734dca272b0d8fd" @@ -7900,6 +7909,16 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" +cosmiconfig@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz#df110631a8547b5d1a98915271986f06e3011379" + integrity sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ== + dependencies: + env-paths "^2.2.1" + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + create-error-class@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6" @@ -7919,13 +7938,6 @@ cross-env@^5.1.4: dependencies: cross-spawn "^6.0.5" -cross-fetch@3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" - integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== - dependencies: - node-fetch "2.6.7" - cross-spawn@^5.0.1: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -8328,7 +8340,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7, debug@^4.4.1: +debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -8342,13 +8354,6 @@ debug@4.3.1: dependencies: ms "2.1.2" -debug@4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - debug@^3.1.0, debug@^3.1.1, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -8654,10 +8659,10 @@ devlop@^1.0.0, devlop@^1.1.0: dependencies: dequal "^2.0.0" -devtools-protocol@0.0.1082910: - version "0.0.1082910" - resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1082910.tgz#d79490dc66ef23eb17a24423c9ce5ce661714a91" - integrity sha512-RqoZ2GmqaNxyx+99L/RemY5CkwG9D0WEfOKxekwCRXOGrDCep62ngezEJUVMq6rISYQ+085fJnWDQqGHlxVNww== +devtools-protocol@0.0.1595872: + version "0.0.1595872" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz#6f3f537a8518887d30d5181e41788f697f2a4ab2" + integrity sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg== devtools-protocol@0.0.894172: version "0.0.894172" @@ -9057,6 +9062,11 @@ entities@^6.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== +env-paths@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + envinfo@^7.7.3: version "7.8.1" resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475" @@ -11610,15 +11620,7 @@ http2-wrapper@^2.1.10: quick-lru "^5.1.1" resolve-alpn "^1.2.0" -https-proxy-agent@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - -https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.2, https-proxy-agent@^7.0.3, https-proxy-agent@^7.0.6: +https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== @@ -11720,10 +11722,10 @@ import-fresh@^2.0.0: caller-path "^2.0.0" resolve-from "^3.0.0" -import-fresh@^3.2.1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== +import-fresh@^3.2.1, import-fresh@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== dependencies: parent-module "^1.0.0" resolve-from "^4.0.0" @@ -11871,13 +11873,10 @@ invert-kv@^1.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= -ip-address@^9.0.5: - version "9.0.5" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" - integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g== - dependencies: - jsbn "1.1.0" - sprintf-js "^1.1.3" +ip-address@^10.1.1: + version "10.2.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" + integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== ip-regex@^4.1.0: version "4.3.0" @@ -13107,11 +13106,6 @@ js-yaml@^4.0.0, js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -jsbn@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" - integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== - jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -15107,6 +15101,11 @@ minizlib@^3.1.0: dependencies: minipass "^7.1.2" +mitt@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -15389,7 +15388,7 @@ node-emoji@^1.10.0: dependencies: lodash.toarray "^4.4.0" -node-fetch@2.6.7, node-fetch@^2.6.7: +node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== @@ -15956,21 +15955,21 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -pac-proxy-agent@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz#6b9ddc002ec3ff0ba5fdf4a8a21d363bcc612d75" - integrity sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A== +pac-proxy-agent@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz#9cfaf33ff25da36f6147a20844230ec92c06e5df" + integrity sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA== dependencies: "@tootallnate/quickjs-emscripten" "^0.23.0" - agent-base "^7.0.2" + agent-base "^7.1.2" debug "^4.3.4" get-uri "^6.0.1" http-proxy-agent "^7.0.0" - https-proxy-agent "^7.0.2" - pac-resolver "^7.0.0" - socks-proxy-agent "^8.0.2" + https-proxy-agent "^7.0.6" + pac-resolver "^7.0.1" + socks-proxy-agent "^8.0.5" -pac-resolver@^7.0.0: +pac-resolver@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-7.0.1.tgz#54675558ea368b64d210fd9c92a640b5f3b8abb6" integrity sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg== @@ -16710,26 +16709,26 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" -proxy-agent@^6.4.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.4.0.tgz#b4e2dd51dee2b377748aef8d45604c2d7608652d" - integrity sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ== +proxy-agent@^6.4.0, proxy-agent@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.5.0.tgz#9e49acba8e4ee234aacb539f89ed9c23d02f232d" + integrity sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A== dependencies: - agent-base "^7.0.2" + agent-base "^7.1.2" debug "^4.3.4" http-proxy-agent "^7.0.1" - https-proxy-agent "^7.0.3" + https-proxy-agent "^7.0.6" lru-cache "^7.14.1" - pac-proxy-agent "^7.0.1" + pac-proxy-agent "^7.1.0" proxy-from-env "^1.1.0" - socks-proxy-agent "^8.0.2" + socks-proxy-agent "^8.0.5" proxy-from-env@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4= -proxy-from-env@1.1.0, proxy-from-env@^1.1.0: +proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== @@ -16752,32 +16751,30 @@ punycode@^2.1.0, punycode@^2.3.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -puppeteer-core@19.6.3: - version "19.6.3" - resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-19.6.3.tgz#e3334fbb4ccb2c1ca6f4597e2f082de5a80599da" - integrity sha512-8MbhioSlkDaHkmolpQf9Z7ui7jplFfOFTnN8d5kPsCazRRTNIH6/bVxPskn0v5Gh9oqOBlknw0eHH0/OBQAxpQ== - dependencies: - cross-fetch "3.1.5" - debug "4.3.4" - devtools-protocol "0.0.1082910" - extract-zip "2.0.1" - https-proxy-agent "5.0.1" - proxy-from-env "1.1.0" - rimraf "3.0.2" - tar-fs "2.1.1" - unbzip2-stream "1.4.3" - ws "8.11.0" - -puppeteer@19.6.3, puppeteer@^1.13.0: - version "19.6.3" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-19.6.3.tgz#4edc7ea87f7e7e7b2885395326a6c9e5a222a10b" - integrity sha512-K03xTtGDwS6cBXX/EoqoZxglCUKcX2SLIl92fMnGMRjYpPGXoAV2yKEh3QXmXzKqfZXd8TxjjFww+tEttWv8kw== - dependencies: - cosmiconfig "8.0.0" - https-proxy-agent "5.0.1" - progress "2.0.3" - proxy-from-env "1.1.0" - puppeteer-core "19.6.3" +puppeteer-core@24.42.0: + version "24.42.0" + resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.42.0.tgz#85c1f6c73e6225be0d50fc6a4f2914690280e8cf" + integrity sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg== + dependencies: + "@puppeteer/browsers" "2.13.0" + chromium-bidi "14.0.0" + debug "^4.4.3" + devtools-protocol "0.0.1595872" + typed-query-selector "^2.12.1" + webdriver-bidi-protocol "0.4.1" + ws "^8.19.0" + +puppeteer@24.42.0, puppeteer@^1.13.0: + version "24.42.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.42.0.tgz#2efe442c240ea44c05138a12a98aa0fdba1a6b83" + integrity sha512-94MoPfFp2eY3eYIMdINkez4IOP5TMHntlZbVx06fHlQTtiQiYgaY0L2Zzfod8PVUkPqP7m3Qlre2v8YS8cudPA== + dependencies: + "@puppeteer/browsers" "2.13.0" + chromium-bidi "14.0.0" + cosmiconfig "^9.0.0" + devtools-protocol "0.0.1595872" + puppeteer-core "24.42.0" + typed-query-selector "^2.12.1" pure-rand@^6.0.0: version "6.0.2" @@ -17560,7 +17557,7 @@ rimraf@2, rimraf@^2.6.3, rimraf@~2.6.2: dependencies: glob "^7.1.3" -rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: +rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -17924,10 +17921,10 @@ semver@^6.0.0, semver@^6.2.0, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.1.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3, semver@^7.7.1, semver@^7.7.2, semver@^7.7.3: - version "7.7.3" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" - integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== +semver@^7.0.0, semver@^7.1.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3, semver@^7.7.1, semver@^7.7.2, semver@^7.7.3, semver@^7.7.4: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== semver@~7.3.0: version "7.3.8" @@ -18241,21 +18238,21 @@ sockjs@^0.3.24: uuid "^8.3.2" websocket-driver "^0.7.4" -socks-proxy-agent@^8.0.2: - version "8.0.3" - resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz#6b2da3d77364fde6292e810b496cb70440b9b89d" - integrity sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A== +socks-proxy-agent@^8.0.5: + version "8.0.5" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee" + integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw== dependencies: - agent-base "^7.1.1" + agent-base "^7.1.2" debug "^4.3.4" - socks "^2.7.1" + socks "^2.8.3" -socks@^2.7.1: - version "2.8.3" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5" - integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw== +socks@^2.8.3: + version "2.8.8" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.8.tgz#23bef6d02748eac847ad75610deb6c472554c67a" + integrity sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog== dependencies: - ip-address "^9.0.5" + ip-address "^10.1.1" smart-buffer "^4.2.0" sort-css-media-queries@^1.4.3: @@ -18430,11 +18427,6 @@ split-string@^3.0.1: dependencies: extend-shallow "^3.0.0" -sprintf-js@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" - integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -19018,7 +19010,7 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== -tar-fs@2.1.1, tar-fs@^2.1.1, tar-fs@^2.1.4: +tar-fs@^2.1.1, tar-fs@^2.1.4, tar-fs@^3.1.1: version "2.1.4" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== @@ -19635,6 +19627,11 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" +typed-query-selector@^2.12.1: + version "2.12.2" + resolved "https://registry.yarnpkg.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz#65e2462ac6b0aecfae1bfac1a4f3027070dbabaa" + integrity sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ== + typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" @@ -19700,7 +19697,7 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -unbzip2-stream@1.4.3, unbzip2-stream@^1.4.3: +unbzip2-stream@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== @@ -20332,6 +20329,11 @@ web-streams-polyfill@^3.0.3: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== +webdriver-bidi-protocol@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz#d411e7b8e158408d83bb166b0b4f1054fa3f077e" + integrity sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -20781,7 +20783,7 @@ write-file-webpack-plugin@^4.1.0: moment "^2.22.1" write-file-atomic "^2.3.0" -ws@8.11.0, ws@8.17.1, ws@>=8.16.0, ws@^7.2.0, ws@^7.3.1, ws@^8.13.0, ws@^8.18.0: +ws@8.17.1, ws@>=8.16.0, ws@^7.2.0, ws@^7.3.1, ws@^8.13.0, ws@^8.18.0, ws@^8.19.0: version "8.17.1" resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== @@ -21043,10 +21045,10 @@ zod-validation-error@^3.0.3: resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af" integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw== -zod@^3.22.4: - version "3.24.4" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.4.tgz#e2e2cca5faaa012d76e527d0d36622e0a90c315f" - integrity sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg== +zod@^3.22.4, zod@^3.24.1: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== zwitch@^2.0.0: version "2.0.4" From f1e6a792f57be18492e2e0e1de7c96df10e91ac3 Mon Sep 17 00:00:00 2001 From: Victor Genaev <vgenaev@gmail.com> Date: Tue, 5 May 2026 14:26:33 +0200 Subject: [PATCH 3/6] fix(popover-headless): set popover="auto" as a default (#36090) Co-authored-by: Dmytro Kirpa <dmytrokirpa@microsoft.com> --- ...-6e87151c-2e77-4dd7-8c18-4b4e44cb29b0.json | 7 +++ .../PopoverSurface/usePopoverSurface.ts | 1 + .../src/components/Popover/usePopover.ts | 4 -- .../usePositioning/usePositioning.test.tsx | 60 +++++++++++++++---- 4 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-6e87151c-2e77-4dd7-8c18-4b4e44cb29b0.json diff --git a/change/@fluentui-react-headless-components-preview-6e87151c-2e77-4dd7-8c18-4b4e44cb29b0.json b/change/@fluentui-react-headless-components-preview-6e87151c-2e77-4dd7-8c18-4b4e44cb29b0.json new file mode 100644 index 00000000000000..9d52e821c8b923 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-6e87151c-2e77-4dd7-8c18-4b4e44cb29b0.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: set popover=auto on surface via jsx defaults", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts index 30ad950cb58c97..17d70a6ba78e04 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts @@ -34,6 +34,7 @@ export const usePopoverSurface = (props: PopoverSurfaceProps, ref: React.Ref<HTM role: 'group', ...props, id: surfaceId, + popover: 'auto', 'data-popover-surface': '', 'data-open': stringifyDataAttribute(open), }, diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts index 7b34416addc505..7699b51b235def 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts @@ -98,10 +98,6 @@ export const usePopover = (props: PopoverProps): PopoverState => { return; } - if (!surface.hasAttribute('popover') || surface.getAttribute('popover') !== 'auto') { - surface.setAttribute('popover', 'auto'); - } - if (!(SUPPORTS_POPOVER_OPEN_SELECTOR && surface.matches(':popover-open'))) { surface.showPopover(); } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx index 22f24886ef7cb8..da160557099bca 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx @@ -38,7 +38,10 @@ describe('usePositioning', () => { it('containerRef writes position-anchor and position-area matching the props', () => { const result = mountHook({ position: 'below', align: 'start' }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node.style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); expect(node).toHaveStyle({ positionArea: 'block-end span-inline-end' }); @@ -47,7 +50,10 @@ describe('usePositioning', () => { it('containerRef writes position: absolute by default and clears the UA inset/margin defaults', () => { const result = mountHook(); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveStyle({ position: 'absolute', inset: 'auto', margin: '0px' }); }); @@ -55,7 +61,10 @@ describe('usePositioning', () => { it('containerRef honors strategy: "fixed"', () => { const result = mountHook({ strategy: 'fixed' }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveStyle({ position: 'fixed' }); }); @@ -63,7 +72,10 @@ describe('usePositioning', () => { it('containerRef writes data-placement matching (position, align)', () => { const result = mountHook({ position: 'below', align: 'start' }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveAttribute('data-placement', 'below-start'); }); @@ -71,7 +83,10 @@ describe('usePositioning', () => { it('containerRef sets position-try-fallbacks to the three-try flip chain by default', () => { const result = mountHook(); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveStyle({ positionTryFallbacks: 'flip-block, flip-inline, flip-block flip-inline' }); }); @@ -79,7 +94,10 @@ describe('usePositioning', () => { it('containerRef uses custom fallbackPositions verbatim when provided', () => { const result = mountHook({ fallbackPositions: ['below-start', 'after'] }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveStyle({ positionTryFallbacks: 'block-end span-inline-end, inline-end' }); }); @@ -88,7 +106,10 @@ describe('usePositioning', () => { const result = mountHook({ pinned: true }); const node = document.createElement('div'); node.style.setProperty('position-try-fallbacks', 'flip-block'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node.style.getPropertyValue('position-try-fallbacks')).toBe(''); }); @@ -96,7 +117,10 @@ describe('usePositioning', () => { it('containerRef writes cover self-alignment when coverTarget is true', () => { const result = mountHook({ coverTarget: true, position: 'above', align: 'start' }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveStyle({ positionArea: 'center', alignSelf: 'end', justifySelf: 'start' }); }); @@ -104,7 +128,10 @@ describe('usePositioning', () => { it('containerRef writes place-self: anchor-center for center alignment (crbug 438334710 workaround)', () => { const result = mountHook({ position: 'above', align: 'center' }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveStyle({ placeSelf: 'anchor-center' }); }); @@ -112,7 +139,10 @@ describe('usePositioning', () => { it('containerRef does not write place-self for non-center alignments', () => { const result = mountHook({ position: 'above', align: 'start' }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node.style.getPropertyValue('place-self')).toBe(''); expect(node.style.getPropertyValue('justify-self')).toBe(''); @@ -122,7 +152,10 @@ describe('usePositioning', () => { it('containerRef writes matchTargetSize width via anchor-size()', () => { const result = mountHook({ matchTargetSize: 'width' }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveStyle({ width: 'anchor-size(width)' }); }); @@ -130,7 +163,10 @@ describe('usePositioning', () => { it('containerRef applies offset as logical margins', () => { const result = mountHook({ position: 'below', offset: { mainAxis: 8, crossAxis: 4 } }); const node = document.createElement('div'); - result.current.containerRef(node); + + act(() => { + result.current.containerRef(node); + }); expect(node).toHaveStyle({ marginBlockStart: '8px', marginInlineStart: '4px' }); }); From 11eee6951aa3686b92b301cd6b01ec96e1b2dbad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 16:38:34 +0200 Subject: [PATCH 4/6] chore(deps-dev): bump vite from 6.3.4 to 6.4.2 (#35953) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 33e10fd97974c2..170a0a8ae1d2c6 100644 --- a/package.json +++ b/package.json @@ -313,7 +313,7 @@ "turndown-plugin-gfm": "1.0.2", "typescript": "5.7.3", "typescript-eslint": "8.46.2", - "vite": "6.3.4", + "vite": "6.4.2", "webpack": "5.99.8", "webpack-bundle-analyzer": "4.10.1", "webpack-cli": "5.1.4", diff --git a/yarn.lock b/yarn.lock index cc51f68bb20dc5..4bfa52df25eae0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20274,10 +20274,10 @@ vfile@^6.0.0: "@types/unist" "^3.0.0" vfile-message "^4.0.0" -vite@6.3.4: - version "6.3.4" - resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.4.tgz#d441a72c7cd9a93b719bb851250a4e6c119c9cff" - integrity sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw== +vite@6.4.2: + version "6.4.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.2.tgz#a4e548ca3a90ca9f3724582cab35e1ba15efc6f2" + integrity sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ== dependencies: esbuild "^0.25.0" fdir "^6.4.4" From 05f0d7e8f1ad74ce598f07ef479450fc08558400 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 16:39:46 +0200 Subject: [PATCH 5/6] chore(deps-dev): bump postcss from 8.4.31 to 8.5.10 (#36041) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 27 +++++++++------------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 170a0a8ae1d2c6..586e301ed95135 100644 --- a/package.json +++ b/package.json @@ -259,7 +259,7 @@ "playwright": "1.55.1", "plop": "2.6.0", "portfinder": "1.0.28", - "postcss": "8.4.31", + "postcss": "8.5.10", "postcss-loader": "4.1.0", "postcss-modules": "4.1.3", "prettier": "2.8.8", diff --git a/yarn.lock b/yarn.lock index 4bfa52df25eae0..8ee9c3bdaa766e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15269,10 +15269,10 @@ nano-staged@0.9.0: dependencies: picocolors "^1.0.0" -nanoid@^3.3.6, nanoid@^3.3.8: - version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +nanoid@^3.3.11: + version "3.3.12" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05" + integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== nanoid@^5.0.8: version "5.0.9" @@ -16552,21 +16552,12 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.31: - version "8.4.31" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" - integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== - dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -postcss@^8.1.4, postcss@^8.4.33, postcss@^8.5.3: - version "8.5.3" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" - integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== +postcss@8.5.10, postcss@^8.1.4, postcss@^8.4.33, postcss@^8.5.3: + version "8.5.10" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" + integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== dependencies: - nanoid "^3.3.8" + nanoid "^3.3.11" picocolors "^1.1.1" source-map-js "^1.2.1" From 30a42808ed35608a185fab611fd0465ec05b7748 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa <dmytrokirpa@microsoft.com> Date: Tue, 5 May 2026 16:43:47 +0200 Subject: [PATCH 6/6] feat(react-headless-components-preview): update stories styles to use CSS modules (#36097) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../stories/src/Card/card.module.css | 8 +- .../stories/src/Dialog/dialog.module.css | 8 ++ .../src/Drawer/InlineDrawer.stories.tsx | 2 +- .../stories/src/Drawer/drawer.module.css | 14 +- .../stories/src/Input/input.module.css | 3 +- .../MessageBar/MessageBarDefault.stories.tsx | 2 +- .../stories/src/Popover/popover.module.css | 6 +- .../stories/src/Rating/rating.module.css | 5 + .../stories/src/Select/select.module.css | 3 +- .../src/SpinButton/spin-button.module.css | 3 +- .../stories/src/TabList/tab-list.module.css | 11 +- .../stories/src/Textarea/textarea.module.css | 3 +- .../src/Tooltip/TooltipControlled.stories.tsx | 16 +-- .../src/Tooltip/TooltipDefault.stories.tsx | 25 ++-- .../src/Tooltip/TooltipPositions.stories.tsx | 12 +- ...TooltipRelationshipDescription.stories.tsx | 25 ++-- .../TooltipRelationshipLabel.stories.tsx | 31 ++-- .../src/Tooltip/TooltipWithArrow.stories.tsx | 31 +--- .../stories/src/Tooltip/tooltip.module.css | 136 ++++++++++++++++++ 19 files changed, 238 insertions(+), 106 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tooltip/tooltip.module.css 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 index 1fb057600a10c3..f59f2f8a9fbbd7 100644 --- 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 @@ -19,7 +19,7 @@ } .card:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } @@ -131,7 +131,7 @@ } .iconButton:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } @@ -171,7 +171,7 @@ } .footerButton:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } @@ -186,7 +186,7 @@ } .checkbox:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } 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 index 7b9866ba8ddd65..4d28698556bb80 100644 --- 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 @@ -65,6 +65,14 @@ transition: background var(--duration-fast) var(--ease-standard); } +.btn:focus { + outline: none; +} + +.btn:focus-visible { + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + .btn:hover { background: var(--surface-sunken); } 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 ca4e81859348fe..c5212164fdafdc 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 @@ -36,7 +36,7 @@ export const Inline = (): React.ReactNode => { </Drawer> <main className={styles.inlineMain}> - <button className={styles.secondaryButton} onClick={toggleDrawer}> + <button className={styles.primaryButton} onClick={toggleDrawer}> {open ? 'Hide inline drawer' : 'Show inline drawer'} </button> </main> 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 index f81fb3fcfe02d7..aeef3fb72caea8 100644 --- 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 @@ -125,7 +125,7 @@ } .closeButton:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } @@ -135,20 +135,20 @@ height: 32px; padding: 0 var(--space-3); border: 0; - border-radius: var(--radius-md); - background: var(--text); - color: var(--text-on-accent); + border-radius: var(--radius-pill); + background: var(--accent); + color: var(--accent-contrast); font-size: 13.5px; font-weight: 500; cursor: pointer; } .primaryButton:hover { - background: var(--text-muted); + background: var(--accent-strong); } .primaryButton:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } @@ -170,7 +170,7 @@ } .secondaryButton:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } 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 index 0c5575df574807..5f51df12e4e22c 100644 --- 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 @@ -16,8 +16,7 @@ .wrap:has(:focus-visible), .wrap:focus-within { - border-color: var(--text); - box-shadow: 0 0 0 3px var(--surface-muted); + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); } .wrap[data-disabled], 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 269139012a5d0b..117f14f4502b62 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 @@ -26,7 +26,7 @@ export const Default = (): React.ReactNode => ( <button type="button" className={styles.actionBtn}> Action </button> - <button type="button" className={styles.iconBtn} aria-label="Dismiss"> + <button type="button" className={`${styles.actionBtn} ${styles.iconBtn}`} aria-label="Dismiss"> <DismissRegular aria-hidden /> </button> </MessageBarActions> 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 index 06874d55dacbe2..30cf3d3123b18a 100644 --- 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 @@ -18,7 +18,7 @@ } .trigger:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } @@ -54,7 +54,7 @@ } .contextTrigger:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } @@ -129,7 +129,7 @@ } .actionButton:focus-visible { - outline: var(--stroke-thick) solid var(--text); + outline: var(--stroke-thick) solid var(--accent); outline-offset: 2px; } 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 index f637b5276a8241..c88aef233d11fb 100644 --- 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 @@ -8,6 +8,11 @@ .item { position: relative; display: inline-flex; + border-radius: var(--radius-sm); +} + +.item:has(:focus-within) { + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); } .input { 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 index 1aab37e6bbc280..3f415a4c727874 100644 --- 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 @@ -25,8 +25,7 @@ .select:focus-visible { outline: none; - border-color: var(--text); - box-shadow: 0 0 0 3px var(--surface-muted); + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); } .select:disabled { 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 index 9508b4f67911de..0046ebd476bc9c 100644 --- 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 @@ -16,8 +16,7 @@ } .wrap:has(:focus-visible) { - border-color: var(--text); - box-shadow: 0 0 0 3px var(--surface-muted); + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); } .input { 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 index 17594e81db2557..65d894e9813bb2 100644 --- 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 @@ -4,6 +4,7 @@ padding: 4px; background: var(--surface-muted); border-radius: var(--radius-pill); + width: max-content; } .tabsVertical { @@ -35,17 +36,17 @@ 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); } +.tab:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + .layout { display: flex; flex-direction: column; 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 index c99b21a0fc7a98..27c219a5e775ff 100644 --- 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 @@ -14,8 +14,7 @@ } .wrap:has(:focus-visible) { - border-color: var(--text); - box-shadow: 0 0 0 3px var(--surface-muted); + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); } .textarea { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipControlled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipControlled.stories.tsx index a9f5bd109a4c9d..264a55a46416b9 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipControlled.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipControlled.stories.tsx @@ -1,28 +1,26 @@ import * as React from 'react'; import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import styles from './tooltip.module.css'; + export const Controlled = (): React.ReactNode => { const [visible, setVisible] = React.useState(false); const toggleTooltip = () => setVisible(v => !v); return ( - <div className="flex gap-2 items-center"> + <div className={styles.row}> <Tooltip content={{ - className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', + className: styles.content, children: 'Controlled tooltip', }} relationship="description" visible={visible} + positioning={{ offset: 8 }} > - <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> - Trigger - </button> + <button className={`${styles.trigger} ${styles.triggerOutline}`}>Trigger</button> </Tooltip> - <button - onClick={toggleTooltip} - className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer" - > + <button onClick={toggleTooltip} className={styles.trigger}> {visible ? 'Hide' : 'Show'} Tooltip </button> </div> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDefault.stories.tsx index 725d8804e3bd5b..1a1d796b8b1eb4 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipDefault.stories.tsx @@ -1,16 +1,19 @@ import * as React from 'react'; import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import styles from './tooltip.module.css'; + export const Default = (): React.ReactNode => ( - <Tooltip - content={{ - className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', - children: 'This is the tooltip label', - }} - relationship="description" - > - <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> - Hover or focus me - </button> - </Tooltip> + <div className={styles.row}> + <Tooltip + content={{ + className: styles.content, + children: 'This is the tooltip label', + }} + relationship="description" + positioning={{ offset: 8 }} + > + <button className={styles.trigger}>Hover or focus me</button> + </Tooltip> + </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipPositions.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipPositions.stories.tsx index 52a40b16f7c274..50757ac42d3ced 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipPositions.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipPositions.stories.tsx @@ -1,21 +1,21 @@ import * as React from 'react'; import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import styles from './tooltip.module.css'; + export const Positions = (): React.ReactNode => ( - <div className="flex gap-4 flex-wrap p-20"> + <div className={styles.row}> {(['above', 'below', 'before', 'after'] as const).map(position => ( <Tooltip key={position} content={{ - className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', + className: styles.content, children: `Position: ${position}`, }} relationship="description" - positioning={position} + positioning={{ position, offset: 8 }} > - <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer capitalize"> - {position} - </button> + <button className={`${styles.trigger} ${styles.capitalize}`}>{position}</button> </Tooltip> ))} </div> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipDescription.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipDescription.stories.tsx index 02ce19debbaeb0..5ed36f991c4f3a 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipDescription.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipDescription.stories.tsx @@ -1,18 +1,21 @@ import * as React from 'react'; import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import styles from './tooltip.module.css'; + export const RelationshipDescription = (): React.ReactNode => ( - <Tooltip - content={{ - className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', - children: 'This is the description of the button', - }} - relationship="description" - > - <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> - Button - </button> - </Tooltip> + <div className={styles.row}> + <Tooltip + content={{ + className: styles.content, + children: 'This is the description of the button', + }} + relationship="description" + positioning={{ offset: 8 }} + > + <button className={styles.trigger}>Button</button> + </Tooltip> + </div> ); RelationshipDescription.storyName = 'Relationship: description'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipLabel.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipLabel.stories.tsx index 8b8dc3526072ba..63e6c39e3fa488 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipLabel.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipRelationshipLabel.stories.tsx @@ -1,25 +1,26 @@ import * as React from 'react'; import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; -const tooltipContent = (text: string) => ({ - className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md', - children: text, -}); +import styles from './tooltip.module.css'; export const RelationshipLabel = (): React.ReactNode => ( - <div className="flex gap-2"> - <Tooltip content={tooltipContent('Bold')} relationship="label"> - <button className="w-8 h-8 rounded border border-gray-300 bg-white hover:bg-gray-50 font-bold text-sm cursor-pointer"> - B - </button> + <div className={styles.row}> + <Tooltip content={{ className: styles.content, children: 'Bold' }} relationship="label" positioning={{ offset: 8 }}> + <button className={`${styles.trigger} ${styles.triggerOutline} ${styles.triggerIcon} ${styles.bold}`}>B</button> </Tooltip> - <Tooltip content={tooltipContent('Italic')} relationship="label"> - <button className="w-8 h-8 rounded border border-gray-300 bg-white hover:bg-gray-50 italic text-sm cursor-pointer"> - I - </button> + <Tooltip + content={{ className: styles.content, children: 'Italic' }} + relationship="label" + positioning={{ offset: 8 }} + > + <button className={`${styles.trigger} ${styles.triggerOutline} ${styles.triggerIcon} ${styles.italic}`}>I</button> </Tooltip> - <Tooltip content={tooltipContent('Underline')} relationship="label"> - <button className="w-8 h-8 rounded border border-gray-300 bg-white hover:bg-gray-50 underline text-sm cursor-pointer"> + <Tooltip + content={{ className: styles.content, children: 'Underline' }} + relationship="label" + positioning={{ offset: 8 }} + > + <button className={`${styles.trigger} ${styles.triggerOutline} ${styles.triggerIcon} ${styles.underline}`}> U </button> </Tooltip> diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipWithArrow.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipWithArrow.stories.tsx index c0f4d6e9b20415..f6e145d4c29971 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipWithArrow.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/TooltipWithArrow.stories.tsx @@ -1,36 +1,19 @@ import * as React from 'react'; import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; -const contentClass = [ - 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-md overflow-visible', - // Arrow base (the rotated square rendered by withArrow) - '[&_[data-arrow]]:absolute [&_[data-arrow]]:w-2 [&_[data-arrow]]:h-2 [&_[data-arrow]]:bg-gray-900 [&_[data-arrow]]:rotate-45', - // Main-axis offset - "[&[data-placement^='above']_[data-arrow]]:-bottom-1", - "[&[data-placement^='below']_[data-arrow]]:-top-1", - "[&[data-placement^='before']_[data-arrow]]:-right-1", - "[&[data-placement^='after']_[data-arrow]]:-left-1", - // Cross-axis centering - "[&[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 - "[&[data-placement$='-start']_[data-arrow]]:start-[var(--arrow-padding,8px)]", - "[&[data-placement$='-end']_[data-arrow]]:end-[var(--arrow-padding,8px)]", -].join(' '); +import styles from './tooltip.module.css'; + +const contentClass = [styles.content, styles.contentWithArrow].join(' '); export const WithArrow = (): React.ReactNode => ( - <div className="flex flex-col items-start gap-4 p-16"> + <div className={styles.row}> <Tooltip relationship="description" content={{ className: contentClass, children: 'Center-aligned tooltip' }} withArrow positioning={{ position: 'below', offset: 10 }} > - <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> - Center-aligned - </button> + <button className={styles.trigger}>Center-aligned</button> </Tooltip> <Tooltip @@ -39,9 +22,7 @@ export const WithArrow = (): React.ReactNode => ( content={{ className: contentClass, children: 'Start-aligned tooltip' }} withArrow > - <button className="px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 text-sm cursor-pointer"> - Start-aligned - </button> + <button className={styles.trigger}>Start-aligned</button> </Tooltip> </div> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/tooltip.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/tooltip.module.css new file mode 100644 index 00000000000000..0ebd94d9eafc6a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/tooltip.module.css @@ -0,0 +1,136 @@ +/* Tooltip content bubble — inverse surface (dark on light, light on dark) */ +.content { + background: var(--bg-elev); + color: var(--text); + font-size: 12px; + line-height: 1.4; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-md); + box-shadow: var(--shadow-3); + border: none; + max-width: 240px; +} + +/* + Arrow-enabled content: keep overflow visible so the rotated square + isn't clipped, and replace box-shadow with a drop-shadow filter so + the shadow wraps the arrow too. +*/ +.contentWithArrow { + overflow: visible; + box-shadow: none; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.22)); +} + +.contentWithArrow [data-arrow] { + position: absolute; + width: 8px; + height: 8px; + background: var(--bg-elev); + transform: rotate(45deg); +} + +/* Main-axis placement — push arrow to the outer edge */ +.contentWithArrow[data-placement^='above'] [data-arrow] { + bottom: -4px; +} + +.contentWithArrow[data-placement^='below'] [data-arrow] { + top: -4px; +} + +.contentWithArrow[data-placement^='before'] [data-arrow] { + right: -4px; +} + +.contentWithArrow[data-placement^='after'] [data-arrow] { + left: -4px; +} + +/* Cross-axis centering for cardinal placements */ +.contentWithArrow[data-placement='above'] [data-arrow], +.contentWithArrow[data-placement='below'] [data-arrow] { + inset-inline: 0; + margin-inline: auto; +} + +.contentWithArrow[data-placement='before'] [data-arrow], +.contentWithArrow[data-placement='after'] [data-arrow] { + inset-block: 0; + margin-block: auto; +} + +/* Start/end-aligned placements — offset from the near edge */ +.contentWithArrow[data-placement$='-start'] [data-arrow] { + inset-inline-start: var(--arrow-padding, 8px); +} + +.contentWithArrow[data-placement$='-end'] [data-arrow] { + inset-inline-end: var(--arrow-padding, 8px); +} + +/* Trigger button — primary filled style */ +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 var(--space-3); + border: 0; + border-radius: var(--radius-pill); + background: var(--accent); + color: var(--accent-contrast); + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.trigger:hover { + background: var(--accent-strong); +} + +.trigger:focus-visible { + outline: var(--stroke-thick) solid var(--accent); + outline-offset: 2px; +} + +.triggerOutline { + background: transparent; + color: var(--text); + border: 1px solid var(--border-strong); +} + +.triggerOutline:hover { + background: var(--surface-muted); + border-color: var(--text); +} + +/* Icon-only trigger — square, fixed size */ +.triggerIcon { + width: 32px; + padding: 0; +} + +/* Layout helpers */ +.row { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-8); +} + +.capitalize { + text-transform: capitalize; +} + +.bold { + font-weight: bold; +} + +.italic { + font-style: italic; +} + +.underline { + text-decoration: underline; +}