diff --git a/apps/public-docsite-v9-headless/.storybook/HeadlessDocsPage.tsx b/apps/public-docsite-v9-headless/.storybook/HeadlessDocsPage.tsx new file mode 100644 index 00000000000000..6150289904561e --- /dev/null +++ b/apps/public-docsite-v9-headless/.storybook/HeadlessDocsPage.tsx @@ -0,0 +1,156 @@ +/** + * `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. + * + * Wired in by `.storybook/preview.js` via `parameters.docs.page`. Lives in the + * docsite app (not the stories package) — see PR #36073 review thread. + */ +import * as React from 'react'; + +import { + Anchor, + ArgTypes, + Canvas, + Description, + DocsContext, + HeaderMdx, + Subtitle, + Title, +} from '@storybook/addon-docs/blocks'; + +import { HeadlessSourcePanel } from './HeadlessSourcePanel'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyStory = Record; + +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 = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const docsContext = React.useContext(DocsContext) as any; + let stories: AnyStory[] = docsContext.componentStories(); + + // Mirrors the filtering rules from Storybook's built-in `` block: + // honor a project-level `parameters.docs.stories.filter`, and when any + // story is `autodocs`-tagged, restrict to those (skipping `usesMount` + // stories which can't render inline). + const filter = docsContext.projectAnnotations?.parameters?.docs?.stories?.filter as + | ((s: AnyStory, ctx: AnyStory) => boolean) + | undefined; + if (filter) { + stories = stories.filter(story => filter(story, docsContext.getStoryContext(story))); + } + if (stories.some(story => story.tags?.includes('autodocs'))) { + stories = stories.filter(story => story.tags?.includes('autodocs') && !story.usesMount); + } + + 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/apps/public-docsite-v9-headless/.storybook/HeadlessSourcePanel.tsx b/apps/public-docsite-v9-headless/.storybook/HeadlessSourcePanel.tsx new file mode 100644 index 00000000000000..007d7a02cf2951 --- /dev/null +++ b/apps/public-docsite-v9-headless/.storybook/HeadlessSourcePanel.tsx @@ -0,0 +1,283 @@ +/** + * `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.docs.source.originalSource` (set via `withStorySource`); the CSS + * comes from `parameters.theme.cssModules` (set via `withCssModuleSource`). + * + * Lives in the docsite app (not the stories package) — see PR #36073 review + * thread. 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. The `useSourceProps` hook +// resolves the Source block's effective `code`/`language` for a story (honoring +// `parameters.docs.source.transform`, `originalSource`, etc). +import { DocsContext, SourceContext, useOf, useSourceProps } 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'; + +import type { + CssModule, + HeadlessSourceParameters, + // eslint-disable-next-line @nx/enforce-module-boundaries -- relative import: stories package authoring helpers are colocated source, not a public npm dependency +} from '../../../packages/react-components/react-headless-components-preview/stories/src/_helpers/withCssModuleSource'; + +// 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 docsContext = React.useContext(DocsContext); + const sourceContext = React.useContext(SourceContext); + // `useSourceProps` returns the code that Storybook's built-in Source block + // would have rendered. Pulling from it (rather than reading raw `?raw` + // imports ourselves) keeps `withStorySource` / `originalSource` semantics + // intact and follows whatever transform a story sets. + const sourceProps = useSourceProps({ of }, docsContext, sourceContext) as AnyProps; + const expanded = useNativeToggleState(story.id); + const portalTarget = useCanvasPortalTarget(story.id); + const [activeTabId, setActiveTabId] = React.useState<string>('story-tsx'); + + const tsxCode: string = typeof sourceProps.code === 'string' ? sourceProps.code : ''; + const tsxLanguage = 'tsx' as const; + const allCssModules: CssModule[] = + (story.parameters?.theme 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/apps/public-docsite-v9-headless/.storybook/headless-docs-page.css b/apps/public-docsite-v9-headless/.storybook/headless-docs-page.css new file mode 100644 index 00000000000000..3e65ab063698b2 --- /dev/null +++ b/apps/public-docsite-v9-headless/.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/apps/public-docsite-v9-headless/.storybook/main.js b/apps/public-docsite-v9-headless/.storybook/main.js index f1f764f65e3c0d..0e30e57d55cb30 100644 --- a/apps/public-docsite-v9-headless/.storybook/main.js +++ b/apps/public-docsite-v9-headless/.storybook/main.js @@ -1,4 +1,16 @@ +const path = require('path'); + const rootMain = require('../../../.storybook/main'); +const { + createCssModuleRule, + patchRules, + STORIES_PACKAGE_ROOT, +} = require('../../../packages/react-components/react-headless-components-preview/stories/.storybook/css-modules-webpack'); + +const repoRoot = path.resolve(__dirname, '../../..'); +const tokensDir = path.resolve(repoRoot, 'theme'); + +const cssModuleRule = createCssModuleRule({ tokensDir, headlessStoriesDir: STORIES_PACKAGE_ROOT }); module.exports = /** @type {Omit<import('../../../.storybook/main'), 'typescript'|'babel'>} */ ({ ...rootMain, @@ -18,6 +30,10 @@ module.exports = /** @type {Omit<import('../../../.storybook/main'), 'typescript webpackFinal: (config, options) => { const localConfig = /** @type config */ ({ ...rootMain.webpackFinal(config, options) }); + localConfig.module = localConfig.module || { rules: [] }; + const rules = patchRules([...(localConfig.module.rules || [])]); + localConfig.module.rules = [cssModuleRule, ...rules]; + 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..dd8096b434079d 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 @@ <link href="/shell.css" rel="stylesheet" /> <!-- - Override the default styles used in the Storybook svg icons for the left tree panel. - - @see https://storybook.js.org/docs/react/configure/theming#css-escape-hatches - - > 💡 NOTE: - > - > This is brittle way for providing custom non thenable styles for manager UI - > - > Those selectors might change on any storybook version bump. + Segoe UI from Microsoft's static font CDN. The fallbacks in `font-family` + below cover users on networks blocking the CDN, but on a healthy connection + the docsite renders Segoe UI on every platform — not just Windows. --> +<link rel="preconnect" href="https://c.s-microsoft.com" crossorigin /> +<link rel="stylesheet" href="https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.css" /> +<link rel="stylesheet" href="https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.css" /> +<link rel="stylesheet" href="https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.css" /> +<link rel="stylesheet" href="https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.css" /> -<style> - @font-face { - font-family: 'Segoe UI'; - src: local('Segoe UI Light'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.woff2) format('woff2'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.woff) format('woff'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.ttf) format('truetype'); - font-weight: 100; - } - - @font-face { - font-family: 'Segoe UI'; - src: local('Segoe UI Semilight'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.woff2) format('woff2'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.woff) format('woff'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.ttf) format('truetype'); - font-weight: 200; - } +<!-- + Headless docsite chrome overrides for the Storybook manager UI. - @font-face { - font-family: 'Segoe UI'; - src: local('Segoe UI'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.woff2) format('woff2'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.woff) format('woff'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.ttf) format('truetype'); - font-weight: 400; - } + The sidebar selectors are brittle — they depend on Storybook's manager DOM + structure and may need updating on Storybook version bumps. Color values + mirror `theme/tokens.css` (light mode). - @font-face { - font-family: 'Segoe UI'; - src: local('Segoe UI Semibold'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.woff2) format('woff2'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.woff) format('woff'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.ttf) format('truetype'); - font-weight: 600; - } + @see https://storybook.js.org/docs/react/configure/theming#css-escape-hatches + --> - @font-face { - font-family: 'Segoe UI'; - src: local('Segoe UI Bold'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.woff2) format('woff2'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.woff) format('woff'), - url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.ttf) format('truetype'); - font-weight: 700; +<style> + :root { + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; + --hl-text: #0a0a0a; + --hl-text-muted: #52525b; + --hl-accent: #9b1f5a; + --hl-accent-contrast: #ffffff; } #storybook-preview-iframe { @@ -75,12 +47,12 @@ .sidebar-item svg, .sidebar-svg-icon { - color: #11100f !important; + color: var(--hl-text) !important; } .sidebar-item[data-selected='true'] svg, .sidebar-item[data-selected='true'] .sidebar-svg-icon { - color: #ffffff !important; + color: var(--hl-accent-contrast) !important; } /** @@ -91,11 +63,11 @@ .sidebar-subheading, button[data-action='collapse-ref'] { font-weight: 600 !important; - font-size: 16px !important; - letter-spacing: 0px !important; - line-height: 24px !important; + font-size: 13px !important; + letter-spacing: -0.01em !important; + line-height: 20px !important; text-transform: none !important; - color: #11100f !important; + color: var(--hl-text) !important; } .sidebar-subheading button, @@ -107,10 +79,30 @@ .sidebar-item { align-items: center !important; font-weight: 400 !important; - font-size: 14px !important; + font-size: 13px !important; letter-spacing: -0.01em !important; line-height: 24px !important; - color: #11100f !important; + color: var(--hl-text) !important; + border-radius: 999px !important; + } + + /* + Override Storybook's default hover (a saturated pink derived from + `colorSecondary`) with a subtle neutral surface tone. Matches the + `--surface-muted` token from `theme/tokens.css`. + */ + .sidebar-item:hover, + .sidebar-item:hover svg, + .sidebar-item:hover .sidebar-svg-icon { + background: #f2f2f4 !important; + color: var(--hl-text) !important; + } + + .sidebar-item[data-selected='true']:hover, + .sidebar-item[data-selected='true']:hover svg, + .sidebar-item[data-selected='true']:hover .sidebar-svg-icon { + background: var(--hl-accent) !important; + color: var(--hl-accent-contrast) !important; } .sidebar-item a { @@ -119,10 +111,11 @@ .sidebar-item[data-selected='true'] { font-weight: 600 !important; - font-size: 14px !important; + font-size: 13px !important; letter-spacing: -0.01em !important; line-height: 24px !important; - color: #ffffff !important; + color: var(--hl-accent-contrast) !important; + background: var(--hl-accent) !important; } .sidebar-item > span:first-child, diff --git a/apps/public-docsite-v9-headless/.storybook/preview-head.html b/apps/public-docsite-v9-headless/.storybook/preview-head.html index 20a739a71db453..8d2f43fb5e77d7 100644 --- a/apps/public-docsite-v9-headless/.storybook/preview-head.html +++ b/apps/public-docsite-v9-headless/.storybook/preview-head.html @@ -1,99 +1,24 @@ -<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> -<style type="text/tailwindcss"> - @layer theme, base, utilities; - @import 'tailwindcss/theme' layer(theme); - @import 'tailwindcss/utilities' layer(utilities); - :root { - interpolate-size: allow-keywords; - } -</style> <!-- - Tailwind's Preflight is omitted above because it resets h1/h2/p/a in - MDX docs (which rely on typography from react-storybook-addon/src/styles.css). - Re-apply the essential resets here inside `@layer base`, scoped to - `.docs-story` so rendered Canvas examples get border/box-sizing/form-element - normalization without affecting the surrounding MDX page. --> -<style> - @layer base { - @scope (.docs-story) { - *, - ::before, - ::after, - ::backdrop, - ::file-selector-button { - box-sizing: border-box; - border: 0 solid currentColor; - } - - img, - svg, - video, - canvas, - audio, - iframe, - embed, - object { - display: block; - vertical-align: middle; - } - - img, - video { - max-width: 100%; - height: auto; - } - - button, - input, - optgroup, - select, - textarea, - ::file-selector-button { - font-family: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - font-size: 100%; - font-weight: inherit; - line-height: inherit; - letter-spacing: inherit; - color: inherit; - } + Story canvas head. - button, - select { - text-transform: none; - } + Design tokens (`theme/tokens.css`) are imported from `preview.js` so they + reach the iframe via webpack. This file holds raw <link> / <style> tags that + must be present in the iframe document head before any story renders. +--> - button, - input:where([type='button'], [type='reset'], [type='submit']), - ::file-selector-button { - appearance: button; - background-color: transparent; - background-image: none; - } - - :-moz-focusring { - outline: auto; - } - - ::placeholder { - opacity: 1; - color: color-mix(in oklab, currentColor 50%, transparent); - } - - textarea { - resize: vertical; - } - - ol, - ul, - menu { - list-style: none; - } +<!-- + Segoe UI from Microsoft's static font CDN — same set as `manager-head.html`. + Story canvases declare `font-family: 'Segoe UI', system-ui` via the design + tokens, so loading the webfont here makes them render Segoe on every OS. +--> +<link rel="preconnect" href="https://c.s-microsoft.com" crossorigin /> +<link rel="stylesheet" href="https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.css" /> +<link rel="stylesheet" href="https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.css" /> +<link rel="stylesheet" href="https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.css" /> +<link rel="stylesheet" href="https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.css" /> - [hidden]:where(:not([hidden='until-found'])) { - display: none !important; - } - } +<style> + :root { + interpolate-size: allow-keywords; } </style> diff --git a/apps/public-docsite-v9-headless/.storybook/preview.js b/apps/public-docsite-v9-headless/.storybook/preview.js index 920889c0e46fb8..c817a2bfa220ab 100644 --- a/apps/public-docsite-v9-headless/.storybook/preview.js +++ b/apps/public-docsite-v9-headless/.storybook/preview.js @@ -1,7 +1,16 @@ import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; import * as rootPreview from '../../../.storybook/preview'; -import { tailwindSandboxTemplate } from './tailwind-sandbox-template'; + +// Design tokens (light + dark CSS custom properties on :root and +// [data-theme="dark"]), plus a few base resets for body/html. Loaded once +// for every story rendered in this Storybook. +import '../../../packages/react-components/react-headless-components-preview/stories/theme/tokens.css'; + +// Custom docs page chrome (disclaimer card, divider, headings) and the +// docs-page wiring rules that let HeadlessSourcePanel portal into the canvas card. +import './headless-docs-page.css'; +import { HeadlessDocsPage } from './HeadlessDocsPage'; polyfillBodyAndObserve(); @@ -13,6 +22,7 @@ export const parameters = { ...rootPreview.parameters, docs: { ...rootPreview.parameters.docs, + page: HeadlessDocsPage, }, options: { storySort: { @@ -20,10 +30,6 @@ export const parameters = { 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..8ff4a868495041 100644 --- a/apps/public-docsite-v9-headless/.storybook/theme.js +++ b/apps/public-docsite-v9-headless/.storybook/theme.js @@ -1,37 +1,52 @@ import { create } from 'storybook/theming'; /** - * Theming and branding the storybook to fluent. Taken from https://storybook.js.org/docs/react/configure/theming + * Custom Storybook chrome for the headless components docsite. + * + * Values mirror the light-mode tokens in `theme/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 `theme/tokens.css` if + * the design tokens shift. */ const theme = create({ base: 'light', - // Storybook-specific color palette - colorPrimary: 'rgba(255, 255, 255, .4)', - colorSecondary: '#0078d4', + // Storybook color palette + colorPrimary: '#9b1f5a', // matches --accent + colorSecondary: '#9b1f5a', - // UI - appBg: '#ffffff', - appContentBg: '#ffffff', - appBorderColor: '#e0e0e0', // use msft gray - appBorderRadius: 4, + // UI surfaces + appBg: '#f7f7f8', // --bg-soft + appContentBg: '#ffffff', // --bg + appPreviewBg: '#ffffff', + appBorderColor: '#e4e4e7', // --border + appBorderRadius: 12, // --radius-lg // Fonts - fontBase: - '"Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;', - fontCode: 'monospace', + 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 - // 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', diff --git a/apps/public-docsite-v9-headless/project.json b/apps/public-docsite-v9-headless/project.json index f231a9a1891f67..ae1c88653ca5e9 100644 --- a/apps/public-docsite-v9-headless/project.json +++ b/apps/public-docsite-v9-headless/project.json @@ -11,6 +11,12 @@ "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], "target": "build" } + ], + "inputs": [ + "default", + "{workspaceRoot}/.storybook/**", + "{projectRoot}/.storybook/**", + "{workspaceRoot}/packages/react-components/react-headless-components-preview/stories/theme/**" ] }, "build-storybook:docsite": { @@ -19,6 +25,12 @@ "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], "target": "build" } + ], + "inputs": [ + "default", + "{workspaceRoot}/.storybook/**", + "{projectRoot}/.storybook/**", + "{workspaceRoot}/packages/react-components/react-headless-components-preview/stories/theme/**" ] } } 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..fed33d7a73354b --- /dev/null +++ b/change/@fluentui-babel-preset-storybook-full-source-533c6664-8c70-4ba3-9465-119e1f33b61c.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "chore: auto-inject parameters.docs.source.code/originalSource for stories (consumers no longer need to wire withStorySource)", + "packageName": "@fluentui/babel-preset-storybook-full-source", + "email": "tudor.popa@microsoft.com", + "dependentChangeType": "none" +} 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..bcaaf621b1518d 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. +- **Adds `context.parameters.docs.source.code` / `originalSource`**: the cleaned raw file contents (with colocated `*.module.css` paths rewritten to the `./styles/<basename>` layout used by the Stackblitz sandbox), so Storybook's "Show code" panel and any custom docs page render the file the author wrote without per-story plumbing. ## Note diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/add-parameter/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/add-parameter/output.js index 43d4721bea42ea..546932dea5f34f 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/add-parameter/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/add-parameter/output.js @@ -9,3 +9,10 @@ Default.parameters = { }; Default.parameters.fullSource = 'import * as React from "react";\n\nexport const Default = () => <Button>Click me</Button>;\n'; +Default.parameters.docs = Object.assign({}, Default.parameters.docs, { + source: Object.assign({}, Default.parameters.docs && Default.parameters.docs.source, { + code: "import * as React from 'react';\n\nexport const Default = () => <Button>Click me</Button>;\nDefault.parameters = {\n docsMode: {\n description: {\n story: 'The default story',\n },\n },\n};\n", + originalSource: + "import * as React from 'react';\n\nexport const Default = () => <Button>Click me</Button>;\nDefault.parameters = {\n docsMode: {\n description: {\n story: 'The default story',\n },\n },\n};\n", + }), +}); diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/add-with-parameters/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/add-with-parameters/output.js index 55b4bd34f66f32..09f68421512405 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/add-with-parameters/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/add-with-parameters/output.js @@ -3,3 +3,9 @@ export const Default = () => /*#__PURE__*/ React.createElement(Button, null, 'Cl Default.parameters = {}; Default.parameters.fullSource = 'import * as React from "react";\n\nexport const Default = () => <Button>Click me</Button>;\n'; +Default.parameters.docs = Object.assign({}, Default.parameters.docs, { + source: Object.assign({}, Default.parameters.docs && Default.parameters.docs.source, { + code: "import * as React from 'react';\n\nexport const Default = () => <Button>Click me</Button>;\n", + originalSource: "import * as React from 'react';\n\nexport const Default = () => <Button>Click me</Button>;\n", + }), +}); diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/keep-sourcecode-spacing/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/keep-sourcecode-spacing/output.js index 74298daed100e6..092d7213dc158a 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/keep-sourcecode-spacing/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/keep-sourcecode-spacing/output.js @@ -36,3 +36,10 @@ export const ButtonAppearance = () => ButtonAppearance.parameters = {}; ButtonAppearance.parameters.fullSource = 'import * as React from "react";\n\nexport const ButtonAppearance = () => (\n <>\n <Button>Default button</Button>\n <Button appearance="primary">Primary button</Button>\n <Button appearance="outline">Outline button</Button>\n <Button appearance="subtle">Subtle button</Button>\n <Button appearance="transparent">Transparent button</Button>\n </>\n);\n'; +ButtonAppearance.parameters.docs = Object.assign({}, ButtonAppearance.parameters.docs, { + source: Object.assign({}, ButtonAppearance.parameters.docs && ButtonAppearance.parameters.docs.source, { + code: 'import * as React from \'react\';\n\nexport const ButtonAppearance = () => (\n <>\n <Button>Default button</Button>\n <Button appearance="primary">Primary button</Button>\n <Button appearance="outline">Outline button</Button>\n <Button appearance="subtle">Subtle button</Button>\n <Button appearance="transparent">Transparent button</Button>\n </>\n);\n', + originalSource: + 'import * as React from \'react\';\n\nexport const ButtonAppearance = () => (\n <>\n <Button>Default button</Button>\n <Button appearance="primary">Primary button</Button>\n <Button appearance="outline">Outline button</Button>\n <Button appearance="subtle">Subtle button</Button>\n <Button appearance="transparent">Transparent button</Button>\n </>\n);\n', + }), +}); diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/mutilple-components/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/mutilple-components/output.js index 2b8a474e59394a..3ead6381f799be 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/mutilple-components/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource/mutilple-components/output.js @@ -48,3 +48,10 @@ const Child2 = () => ButtonAppearance.parameters = {}; ButtonAppearance.parameters.fullSource = 'import * as React from "react";\n\nconst Child1 = () => (\n <>\n <Button>Default button</Button>\n <Button appearance="primary">Primary button</Button>\n <Button appearance="outline">Outline button</Button>\n </>\n);\n\nexport const ButtonAppearance = () => (\n <>\n <Child1 />\n <Child2 />\n </>\n);\n\nconst Child2 = () => (\n <>\n <Button appearance="subtle">Subtle button</Button>\n <Button appearance="transparent">Transparent button</Button>\n </>\n);\n'; +ButtonAppearance.parameters.docs = Object.assign({}, ButtonAppearance.parameters.docs, { + source: Object.assign({}, ButtonAppearance.parameters.docs && ButtonAppearance.parameters.docs.source, { + code: 'import * as React from \'react\';\n\nconst Child1 = () => (\n <>\n <Button>Default button</Button>\n <Button appearance="primary">Primary button</Button>\n <Button appearance="outline">Outline button</Button>\n </>\n);\n\nexport const ButtonAppearance = () => (\n <>\n <Child1 />\n <Child2 />\n </>\n);\n\nconst Child2 = () => (\n <>\n <Button appearance="subtle">Subtle button</Button>\n <Button appearance="transparent">Transparent button</Button>\n </>\n);\n', + originalSource: + 'import * as React from \'react\';\n\nconst Child1 = () => (\n <>\n <Button>Default button</Button>\n <Button appearance="primary">Primary button</Button>\n <Button appearance="outline">Outline button</Button>\n </>\n);\n\nexport const ButtonAppearance = () => (\n <>\n <Child1 />\n <Child2 />\n </>\n);\n\nconst Child2 = () => (\n <>\n <Button appearance="subtle">Subtle button</Button>\n <Button appearance="transparent">Transparent button</Button>\n </>\n);\n', + }), +}); diff --git a/packages/react-components/babel-preset-storybook-full-source/src/cleanSource.ts b/packages/react-components/babel-preset-storybook-full-source/src/cleanSource.ts new file mode 100644 index 00000000000000..769ea6ec4d03bf --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/cleanSource.ts @@ -0,0 +1,24 @@ +/** + * Normalize a story file's raw source for display in the docs page's + * "Show code" panel: + * + * - Strip any leftover `withStorySource` plumbing (kept idempotent in case a + * few stories haven't been migrated yet). + * - Rewrite colocated `*.module.css` import paths to `./styles/<basename>`, + * matching the layout used by the generated Stackblitz sandbox so the + * snippet is paste-ready. + * - Collapse blank-line runs left behind by removed lines. + */ +export function cleanStorySource(source: string): string { + return source + .replace(/^import\s+\w+\s+from\s+['"]\..*?\.stories\?raw['"];\s*\r?\n/m, '') + .replace(/^import\s*\{\s*withStorySource\s*\}\s*from\s+['"][^'"]+['"];\s*\r?\n/m, '') + .replace(/^\w+\.parameters\s*=\s*withStorySource\([\s\S]*?\);\s*\r?\n?/m, '') + .replace( + /(['"])(?:\.{1,2}\/)+(?:[^'"\/]+\/)*([^'"\/]+\.module\.css)\1/g, + (_match, quote, basename) => `${quote}./styles/${basename}${quote}`, + ) + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + .concat('\n'); +} 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..749a6c2007c833 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 @@ -2,6 +2,7 @@ import * as Babel from '@babel/core'; import * as prettier from 'prettier'; import * as fs from 'fs'; +import { cleanStorySource } from './cleanSource'; import { modifyImportsPlugin } from './modifyImports'; import { removeStorybookParameters } from './removeStorybookParameters'; import { BabelPluginOptions } from './types'; @@ -50,6 +51,47 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption ); }; + /** + * Builds: + * + * Story.parameters.docs = Object.assign({}, Story.parameters.docs, { + * source: Object.assign({}, + * Story.parameters.docs && Story.parameters.docs.source, + * { code: <SOURCE>, originalSource: <SOURCE> }), + * }); + * + * Set as a separate statement (rather than inlined into the parameters + * literal) so it composes with whatever shape the story author already + * declared — `parameters.docs.description.story`, custom transforms, etc. + */ + const createDocsSourceAssignmentExpression = (cleanedSource: string) => { + const storyParametersDocs = t.memberExpression( + t.memberExpression(t.identifier(storyName), t.identifier('parameters')), + t.identifier('docs'), + ); + const storyParametersDocsSource = t.memberExpression(storyParametersDocs, t.identifier('source')); + + const source = t.stringLiteral(cleanedSource); + const newSourceProps = t.objectExpression([ + t.objectProperty(t.identifier('code'), source), + t.objectProperty(t.identifier('originalSource'), source), + ]); + + const sourceAssign = t.callExpression(t.memberExpression(t.identifier('Object'), t.identifier('assign')), [ + t.objectExpression([]), + t.logicalExpression('&&', storyParametersDocs, storyParametersDocsSource), + newSourceProps, + ]); + + const docsAssign = t.callExpression(t.memberExpression(t.identifier('Object'), t.identifier('assign')), [ + t.objectExpression([]), + storyParametersDocs, + t.objectExpression([t.objectProperty(t.identifier('source'), sourceAssign)]), + ]); + + return t.expressionStatement(t.assignmentExpression('=', storyParametersDocs, docsAssign)); + }; + return { name: PLUGIN_NAME, visitor: { @@ -116,6 +158,7 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption } path.pushContainer('body', createFullSourceAssignmentExpression(code)); + path.pushContainer('body', createDocsSourceAssignmentExpression(cleanStorySource(fileContents))); }, }, }, 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..69c2765bf02ef7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/css-modules-webpack.js @@ -0,0 +1,90 @@ +/** + * Shared CSS-Modules + `?raw` webpack wiring for the headless stories. + * + * Source of truth lives here (in the stories package). The app at + * `apps/public-docsite-v9-headless/.storybook/main.js` consumes it via require. + * + * Storybook's `@storybook/builder-webpack5` ships a default `\.css$` rule that + * pipes any CSS through `style-loader` + plain `css-loader`. That handles + * `theme/tokens.css` correctly. For `*.module.css` files (the per-component + * design-system styles) we want CSS Modules, so we narrow the default rule to + * skip `.module.css` and add a dedicated rule that turns on `modules: true`. + * + * `?raw` imports must go through Storybook's built-in `resourceQuery:/raw/` + * asset/source rule. We mark our CSS-Modules rule (and any default rule that + * would otherwise re-process `?raw` imports) with `resourceQuery: { not: [/raw/] }` + * so the asset/source rule wins. + */ +const path = require('path'); + +const RAW_QUERY_NOT = { not: [/raw/] }; + +/** + * @param {{ tokensDir: string; headlessStoriesDir: string }} options + */ +function createCssModuleRule({ tokensDir, headlessStoriesDir }) { + return { + test: /\.module\.css$/, + include: [tokensDir, headlessStoriesDir], + resourceQuery: RAW_QUERY_NOT, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: { localIdentName: '[name]__[local]--[hash:base64:5]' }, + importLoaders: 0, + }, + }, + ], + }; +} + +/** + * Mutates rule entries in place: + * 1. Storybook's default `\.css$` rule gets a `\.module\.css$` exclude. + * 2. Any rule whose test matches `.css`/`.tsx?` and has no `resourceQuery` + * filter is told to skip `?raw` queries — including `.stories.tsx?` shapes + * so the export-order-loader and the @fluentui export-to-sandbox addon + * don't re-process raw imports. + * + * @param {any[]} rules + */ +function patchRules(rules) { + for (const rule of rules) { + if (!rule || typeof rule !== 'object') continue; + const test = rule.test; + const isRegExp = test instanceof RegExp; + const matchesPlainCss = isRegExp && test.source === /\.css$/.source; + if (matchesPlainCss) { + const existing = rule.exclude; + const moduleRegex = /\.module\.css$/; + if (Array.isArray(existing)) { + rule.exclude = [...existing, moduleRegex]; + } else if (existing) { + rule.exclude = [existing, moduleRegex]; + } else { + rule.exclude = moduleRegex; + } + } + const matchesCssOrTs = + isRegExp && + (test.test('a.css') || + test.test('a.tsx') || + test.test('a.ts') || + test.test('a.stories.tsx') || + test.test('a.stories.ts')); + if (matchesCssOrTs && rule.resourceQuery == null) { + rule.resourceQuery = RAW_QUERY_NOT; + } + } + return rules; +} + +const STORIES_PACKAGE_ROOT = path.resolve(__dirname, '..'); + +module.exports = { + STORIES_PACKAGE_ROOT, + createCssModuleRule, + patchRules, +}; 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..d1444a1eb1acc9 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,23 @@ +const path = require('path'); + const rootMain = require('../../../../../.storybook/main'); +const { createCssModuleRule, patchRules, STORIES_PACKAGE_ROOT } = require('./css-modules-webpack'); + +const repoRoot = path.resolve(__dirname, '../../../../..'); +const tokensDir = path.resolve(repoRoot, 'theme'); + +const cssModuleRule = createCssModuleRule({ tokensDir, headlessStoriesDir: STORIES_PACKAGE_ROOT }); module.exports = /** @type {Omit<import('../../../../../.storybook/main'), 'typescript'|'babel'>} */ ({ ...rootMain, stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], addons: [...rootMain.addons], webpackFinal: (config, options) => { - const localConfig = { ...rootMain.webpackFinal(config, options) }; + const localConfig = /** @type {any} */ ({ ...rootMain.webpackFinal(config, options) }); - // add your own webpack tweaks if needed + localConfig.module = localConfig.module || { rules: [] }; + const rules = patchRules([...(localConfig.module.rules || [])]); + localConfig.module.rules = [cssModuleRule, ...rules]; 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..d423b8d194d142 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,19 @@ import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowles import * as rootPreview from '../../../../../.storybook/preview'; +// Design tokens — mirrors the import in +// apps/public-docsite-v9-headless/.storybook/preview.js so the per-package +// storybook (built by `pr-website-deploy.yml`) renders identical stories. +import '../theme/tokens.css'; + polyfillBodyAndObserve(); /** @type {typeof rootPreview.decorators} */ export const decorators = [...rootPreview.decorators]; /** @type {typeof rootPreview.parameters} */ -export const parameters = { ...rootPreview.parameters }; +export const parameters = { + ...rootPreview.parameters, +}; export const tags = ['autodocs']; 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..d7ff50963ba903 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,215 @@ # @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 under `theme/` at the repo root, and the stories pull both together. +`theme/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 `theme/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 the + `withCssModuleSource(...)` spread that registers the CSS Module source + (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} />; +``` + +Notes: + +- The `../../../../../../` reaches the repo root from + `stories/src/<Name>/<File>.tsx`. The webpack rule that handles `*.module.css` + is registered both in the docsite (`apps/public-docsite-v9-headless/.storybook/main.js`) + and in this package's per-package storybook + (`stories/.storybook/main.js`). If you add the file outside `theme/` make sure + the rule's `include` list covers it. +- No inline styles, no Tailwind, no Griffel. Tokens come from `theme/tokens.css`. +- Every CSS value must resolve through a `var(--…)` token — search the diff + for raw `#` and `rgb(` to confirm. + +### 3 · Show code wiring (`index.stories.tsx`) + +Two pieces feed the docsite's tabbed "Show code" panel: + +- **Story TSX** is injected automatically. `@fluentui/babel-preset-storybook-full-source` + reads each `*.stories.tsx` file at build time and writes + `Story.parameters.docs.source.code` / `originalSource` for every story + export. Story files don't need to import their own source via `?raw` or + call any helper — just author the component as usual. +- **CSS Module source** is registered at the meta level via the + `withCssModuleSource` helper (`stories/src/_helpers/withCssModuleSource.ts`). + Spread its return into `parameters` so the docsite picks up the modules and + the Stackblitz sandbox bundles them under `src/styles/`: + +```tsx +import { MyComponent } from '@fluentui/react-headless-components-preview/my-component'; + +import descriptionMd from './MyComponentDescription.md'; +import myComponentCss from './my-component.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; + +export { Default } from './MyComponentDefault.stories'; + +export default { + title: 'Headless Components/MyComponent', + component: MyComponent, + parameters: { + docs: { + description: { component: descriptionMd }, + }, + ...withCssModuleSource({ name: 'my-component.module.css', source: myComponentCss }), + }, +}; +``` + +If a story uses multiple modules (e.g. `Field` stories nest `Input`), pass them +all to `withCssModuleSource` in render order: + +```ts +...withCssModuleSource( + { name: 'field.module.css', source: fieldCss }, + { name: 'input.module.css', source: inputCss }, +), +``` + +The `?raw` suffix is webpack's "asset/source" — Storybook 9's +`@storybook/builder-webpack5` ships the rule out of the box. The ambient TS +declaration for `*?raw` is scoped to this package via +`stories/src/_helpers/raw.d.ts`. + +### 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: `theme/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 | +| -------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `theme/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 + `parameters.docs.source.transform` wiring. | +| `stories/src/_helpers/withCssModuleSource.ts` | Registers CSS Module source for the docs page + Stackblitz sandbox. | +| `stories/src/_helpers/raw.d.ts` | Ambient `*?raw` declaration, scoped to this package. | +| `stories/.storybook/css-modules-webpack.js` | Source-of-truth webpack wiring for `*.module.css` + `?raw`. | +| `stories/.storybook/main.js` | Per-package storybook (consumes the shared webpack module). | +| `apps/public-docsite-v9-headless/.storybook/main.js` | Deployed docsite config (also consumes the shared webpack module). | +| `apps/public-docsite-v9-headless/.storybook/HeadlessDocsPage.tsx` | Custom docs page wired into the docsite's `parameters.docs.page`. | +| `apps/public-docsite-v9-headless/.storybook/HeadlessSourcePanel.tsx` | Tabbed "Show code" panel (TSX + each referenced CSS Module). | +| `typings/static-assets/index.d.ts` | Ambient `*.module.css` declaration (workspace-wide). | diff --git a/packages/react-components/react-headless-components-preview/stories/eslint.config.js b/packages/react-components/react-headless-components-preview/stories/eslint.config.js index f8362c3e413031..ff02f0fd1ce58f 100644 --- a/packages/react-components/react-headless-components-preview/stories/eslint.config.js +++ b/packages/react-components/react-headless-components-preview/stories/eslint.config.js @@ -7,4 +7,15 @@ module.exports = [ { rules: {}, }, + { + // Storybook-only utilities consumed by `*.stories.tsx`. They live alongside + // stories (not in a published package), pull React/Storybook from the + // workspace, and run only in the docs preview — so the same relaxations + // we apply to story files apply here. + files: ['src/_helpers/**/*.{ts,tsx}'], + rules: { + 'import/no-extraneous-dependencies': 'off', + '@fluentui/react-components/enforce-use-client': 'off', + }, + }, ]; diff --git a/packages/react-components/react-headless-components-preview/stories/project.json b/packages/react-components/react-headless-components-preview/stories/project.json index 0effce2bfe286a..28c575776c6671 100644 --- a/packages/react-components/react-headless-components-preview/stories/project.json +++ b/packages/react-components/react-headless-components-preview/stories/project.json @@ -4,5 +4,10 @@ "projectType": "library", "sourceRoot": "packages/react-components/react-headless-components-preview/stories/src", "tags": ["vNext", "platform:web", "type:stories", "react-headless"], - "implicitDependencies": [] + "implicitDependencies": [], + "targets": { + "build-storybook": { + "inputs": ["default", "{workspaceRoot}/.storybook/**", "{projectRoot}/.storybook/**", "{projectRoot}/theme/**"] + } + } } 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..78e8911a7112f4 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,6 +6,8 @@ import { } from '@fluentui/react-headless-components-preview/accordion'; import descriptionMd from './AccordionDescription.md'; +import accordionCss from './accordion.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './AccordionDefault.stories'; export { Collapsible } from './AccordionCollapsible.stories'; @@ -20,5 +22,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'accordion.module.css', source: accordionCss }), }, }; 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..02402ff7c8dfb4 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,6 +1,8 @@ import { Avatar } from '@fluentui/react-headless-components-preview/avatar'; import descriptionMd from './AvatarDescription.md'; +import avatarCss from './avatar.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './AvatarDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'avatar.module.css', source: avatarCss }), }, }; 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..44141410ff2862 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,6 +1,8 @@ import { Badge } from '@fluentui/react-headless-components-preview/badge'; import descriptionMd from './BadgeDescription.md'; +import badgeCss from './badge.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './BadgeDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'badge.module.css', source: badgeCss }), }, }; 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..db81817c52a043 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,6 +6,8 @@ import { } from '@fluentui/react-headless-components-preview/breadcrumb'; import descriptionMd from './BreadcrumbDescription.md'; +import breadcrumbCss from './breadcrumb.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './BreadcrumbDefault.stories'; @@ -19,5 +21,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'breadcrumb.module.css', source: breadcrumbCss }), }, }; 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..c5e8a5b52cc8bc 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,6 +1,8 @@ import { Button } from '@fluentui/react-headless-components-preview/button'; import descriptionMd from './ButtonDescription.md'; +import buttonCss from './button.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './ButtonDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'button.module.css', source: buttonCss }), }, }; 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..4938c44cccff28 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,6 +1,8 @@ import { Card, CardHeader, CardPreview, CardFooter } from '@fluentui/react-headless-components-preview/card'; import descriptionMd from './CardDescription.md'; +import cardCss from './card.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './CardDefault.stories'; export { Selectable } from './CardSelectable.stories'; @@ -16,5 +18,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'card.module.css', source: cardCss }), }, }; 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..73e5c58b7d9463 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,6 +1,8 @@ import { Checkbox } from '@fluentui/react-headless-components-preview/checkbox'; import descriptionMd from './CheckboxDescription.md'; +import checkboxCss from './checkbox.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './CheckboxDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'checkbox.module.css', source: checkboxCss }), }, }; 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..77ec0a89c950c3 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,6 +8,11 @@ import { } from '@fluentui/react-headless-components-preview/dialog'; import descriptionMd from './DialogDescription.md'; +import dialogCss from './dialog.module.css?raw'; +import checkboxCss from '../Checkbox/checkbox.module.css?raw'; +import textareaCss from '../Textarea/textarea.module.css?raw'; +import inputCss from '../Input/input.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './DialogDefault.stories'; export { NonModal } from './DialogNonModal.stories'; @@ -28,5 +33,12 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource( + { name: 'dialog.module.css', source: dialogCss }, + { name: 'checkbox.module.css', source: checkboxCss }, + { name: 'textarea.module.css', source: textareaCss }, + { name: 'input.module.css', source: inputCss }, + ), }, }; 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..983c7e25e7119e 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,6 +1,8 @@ import { Divider } from '@fluentui/react-headless-components-preview/divider'; import descriptionMd from './DividerDescription.md'; +import dividerCss from './divider.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './DividerDefault.stories'; export { Vertical } from './DividerVertical.stories'; @@ -14,5 +16,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'divider.module.css', source: dividerCss }), }, }; 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..a7b3d1470856a6 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,6 +10,8 @@ import { } from '@fluentui/react-headless-components-preview/drawer'; import descriptionMd from './DrawerDescription.md'; +import drawerCss from './drawer.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './DefaultDrawer.stories'; export { Inline } from './InlineDrawer.stories'; @@ -32,5 +34,6 @@ export default { component: descriptionMd, }, }, + ...withCssModuleSource({ name: 'drawer.module.css', source: drawerCss }), }, }; 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..fdfe84c35e50f2 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,6 +1,9 @@ import { Field } from '@fluentui/react-headless-components-preview/field'; import descriptionMd from './FieldDescription.md'; +import fieldCss from './field.module.css?raw'; +import inputCss from '../Input/input.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './FieldDefault.stories'; @@ -13,5 +16,10 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource( + { name: 'field.module.css', source: fieldCss }, + { name: 'input.module.css', source: inputCss }, + ), }, }; 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..a432828a69a542 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,12 @@ import { Input } from '@fluentui/react-headless-components-preview/input'; import descriptionMd from './InputDescription.md'; +import inputCss from './input.module.css?raw'; +import chatInputCss from './chat-input.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './InputDefault.stories'; +export { Basic } from './InputBasic.stories'; export default { title: 'Headless Components/Input', @@ -13,5 +17,10 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource( + { name: 'input.module.css', source: inputCss }, + { name: 'chat-input.module.css', source: chatInputCss }, + ), }, }; 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..f9bc1d45c25587 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,6 +1,8 @@ import { Link } from '@fluentui/react-headless-components-preview/link'; import descriptionMd from './LinkDescription.md'; +import linkCss from './link.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './LinkDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'link.module.css', source: linkCss }), }, }; 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..87360d5bbe8c0d 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,6 +6,9 @@ import { } from '@fluentui/react-headless-components-preview/message-bar'; import descriptionMd from './MessageBarDescription.md'; +import messageBarCss from './message-bar.module.css?raw'; +import linkCss from '../Link/link.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './MessageBarDefault.stories'; export { Intent } from './MessageBarIntent.stories'; @@ -24,5 +27,10 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource( + { name: 'message-bar.module.css', source: messageBarCss }, + { name: 'link.module.css', source: linkCss }, + ), }, }; 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..feec05ec7e850d 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,6 +2,8 @@ import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headles import descriptionMd from './PopoverDescription.md'; import bestPracticesMd from './PopoverBestPractices.md'; +import popoverCss from './popover.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './PopoverDefault.stories'; export { WithArrow } from './PopoverWithArrow.stories'; @@ -24,5 +26,6 @@ export default { component: [descriptionMd, bestPracticesMd].join('\n'), }, }, + ...withCssModuleSource({ name: 'popover.module.css', source: popoverCss }), }, }; 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..0094fe2b163b4c 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,6 +1,8 @@ import { ProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; import descriptionMd from './ProgressBarDescription.md'; +import progressBarCss from './progress-bar.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './ProgressBarDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'progress-bar.module.css', source: progressBarCss }), }, }; 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..084baaa1cf7482 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,6 +1,8 @@ import { RadioGroup, Radio } from '@fluentui/react-headless-components-preview/radio-group'; import descriptionMd from './RadioGroupDescription.md'; +import radioGroupCss from './radio-group.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './RadioGroupDefault.stories'; @@ -14,5 +16,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'radio-group.module.css', source: radioGroupCss }), }, }; 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..cfaa38a9b9be58 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,6 +1,8 @@ import { Rating, RatingItem } from '@fluentui/react-headless-components-preview/rating'; import descriptionMd from './RatingDescription.md'; +import ratingCss from './rating.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './RatingDefault.stories'; @@ -14,5 +16,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'rating.module.css', source: ratingCss }), }, }; 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..3f1d68d8428222 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,6 +1,8 @@ import { RatingDisplay } from '@fluentui/react-headless-components-preview/rating-display'; import descriptionMd from './RatingDisplayDescription.md'; +import ratingDisplayCss from './rating-display.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './RatingDisplayDefault.stories'; export { Compact } from './RatingDisplayCompact.stories'; @@ -14,5 +16,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'rating-display.module.css', source: ratingDisplayCss }), }, }; 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..0f42650d797778 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,6 +1,8 @@ import { SearchBox } from '@fluentui/react-headless-components-preview/search-box'; import descriptionMd from './SearchBoxDescription.md'; +import inputCss from '../Input/input.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './SearchBoxDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'input.module.css', source: inputCss }), }, }; 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..0ef7114a1f4b0e 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,6 +1,9 @@ import { Select } from '@fluentui/react-headless-components-preview/select'; import descriptionMd from './SelectDescription.md'; +import selectCss from './select.module.css?raw'; +import fieldCss from '../Field/field.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './SelectDefault.stories'; @@ -13,5 +16,10 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource( + { name: 'select.module.css', source: selectCss }, + { name: 'field.module.css', source: fieldCss }, + ), }, }; 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..a0274887b1014a 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,6 +1,8 @@ import { Skeleton, SkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import descriptionMd from './SkeletonDescription.md'; +import skeletonCss from './skeleton.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './SkeletonDefault.stories'; @@ -14,5 +16,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'skeleton.module.css', source: skeletonCss }), }, }; 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..8371b8e3a67821 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,6 +1,8 @@ import { Slider } from '@fluentui/react-headless-components-preview/slider'; import descriptionMd from './SliderDescription.md'; +import sliderCss from './slider.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './SliderDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'slider.module.css', source: sliderCss }), }, }; 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..c082b7203299b7 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,6 +1,9 @@ import { SpinButton } from '@fluentui/react-headless-components-preview/spin-button'; import descriptionMd from './SpinButtonDescription.md'; +import spinButtonCss from './spin-button.module.css?raw'; +import fieldCss from '../Field/field.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './SpinButtonDefault.stories'; @@ -13,5 +16,10 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource( + { name: 'spin-button.module.css', source: spinButtonCss }, + { name: 'field.module.css', source: fieldCss }, + ), }, }; 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..153f2a5c5cb0f2 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,6 +1,8 @@ import { Spinner } from '@fluentui/react-headless-components-preview/spinner'; import descriptionMd from './SpinnerDescription.md'; +import spinnerCss from './spinner.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './SpinnerDefault.stories'; export { Labels } from './SpinnerLabels.stories'; @@ -14,5 +16,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'spinner.module.css', source: spinnerCss }), }, }; 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..a4d51a6a5b3da3 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,6 +1,8 @@ import { Switch } from '@fluentui/react-headless-components-preview/switch'; import descriptionMd from './SwitchDescription.md'; +import switchCss from './switch.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './SwitchDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'switch.module.css', source: switchCss }), }, }; 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..aacf5126c43dd9 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,6 +1,8 @@ import { TabList } from '@fluentui/react-headless-components-preview/tab-list'; import descriptionMd from './TabListDescription.md'; +import tabListCss from './tab-list.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './TabListDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'tab-list.module.css', source: tabListCss }), }, }; 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..2acb7294f0338b 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,6 +1,8 @@ import { Textarea } from '@fluentui/react-headless-components-preview/textarea'; import descriptionMd from './TextareaDescription.md'; +import textareaCss from './textarea.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './TextareaDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'textarea.module.css', source: textareaCss }), }, }; 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..8d085286e28f7f 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,6 +1,8 @@ import { ToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; import descriptionMd from './ToggleButtonDescription.md'; +import toggleButtonCss from './toggle-button.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './ToggleButtonDefault.stories'; @@ -13,5 +15,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'toggle-button.module.css', source: toggleButtonCss }), }, }; 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..4cafb8ea690ff2 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,6 +8,8 @@ import { } from '@fluentui/react-headless-components-preview/toolbar'; import descriptionMd from './ToolbarDescription.md'; +import toolbarCss from './toolbar.module.css?raw'; +import { withCssModuleSource } from '../_helpers/withCssModuleSource'; export { Default } from './ToolbarDefault.stories'; export { Vertical } from './ToolbarVertical.stories'; @@ -23,5 +25,7 @@ export default { component: descriptionMd, }, }, + + ...withCssModuleSource({ name: 'toolbar.module.css', source: toolbarCss }), }, }; 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-headless-components-preview/stories/src/_helpers/raw.d.ts b/packages/react-components/react-headless-components-preview/stories/src/_helpers/raw.d.ts new file mode 100644 index 00000000000000..a24fdafcf9fb06 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/_helpers/raw.d.ts @@ -0,0 +1,11 @@ +/** + * Scoped to this package — webpack/Storybook serve `?raw` imports as the file's + * raw text. Used by `withCssModuleSource` to bundle CSS-Module source into the + * Stackblitz sandbox; story TSX is auto-injected by + * `@fluentui/babel-preset-storybook-full-source`, so individual stories no + * longer import their own source via `?raw`. + */ +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/_helpers/withCssModuleSource.ts b/packages/react-components/react-headless-components-preview/stories/src/_helpers/withCssModuleSource.ts new file mode 100644 index 00000000000000..74b23a898f6f71 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/_helpers/withCssModuleSource.ts @@ -0,0 +1,138 @@ +/** + * Story meta helper — registers the CSS Module source(s) a story relies on so: + * + * 1. The docsite app's custom docs page (`HeadlessSourcePanel`, lives at + * `apps/public-docsite-v9-headless/.storybook/HeadlessSourcePanel.tsx`) + * can surface them as tabs in the "Show code" panel. + * 2. The "Open in Stackblitz" button (provided by + * `@fluentui/react-storybook-addon-export-to-sandbox`) can bundle them — + * together with `theme/tokens.css` — into the generated sandbox so the + * example renders with the correct theme out of the box. + * + * Spread the result into a story's `parameters` object: + * + * ```tsx + * import buttonCss from '../Button/button.module.css?raw'; + * import { withCssModuleSource } from '../_helpers/withCssModuleSource'; + * + * export default { + * title: 'Headless Components/Button', + * component: Button, + * parameters: { + * docs: { description: { component: descriptionMd } }, + * ...withCssModuleSource({ name: 'button.module.css', source: buttonCss }), + * }, + * }; + * ``` + * + * Pass multiple modules when a single component pulls from several CSS files + * (e.g., Input + chat-input variant): each becomes a tab. + */ + +// Loaded via the `?raw` resourceQuery rule configured in `.storybook/main.js`. +// Bundling tokens.css inline lets the Stackblitz scaffold include them without +// requiring story authors to wire imports manually. +import tokensCss from '../../theme/tokens.css?raw'; + +/** A CSS Module file surfaced as a tab in the docs page's "Show code" panel. */ +export interface CssModule { + /** Display name shown on the tab (e.g. `button.module.css`). */ + name: string; + /** Raw CSS source for the module (typically imported via `?raw`). */ + source: string; +} + +/** Shape consumed by the docsite's `HeadlessSourcePanel` via `parameters.theme`. */ +export interface HeadlessSourceParameters { + cssModules?: CssModule[]; +} + +/** + * Minimal local mirror of the `SandboxContext` shape from + * `@fluentui/react-storybook-addon-export-to-sandbox`. We don't import the + * type from the addon itself to keep the stories package free of a direct + * dependency on the addon's source — the addon is consumed at runtime via + * Storybook's addon registry, not statically. + */ +interface SandboxContext { + provider: string; + bundler: 'vite' | 'cra'; + storyExportToken: string; + storyFile: string; + dependencies: Record<string, string>; +} + +interface ExportToSandboxFragment { + exportToSandbox: { + transformFiles: (files: Record<string, string>, ctx: SandboxContext) => Record<string, string>; + }; +} + +export function withCssModuleSource( + ...modules: CssModule[] +): { theme: HeadlessSourceParameters } & ExportToSandboxFragment { + return { + theme: { cssModules: modules }, + exportToSandbox: { + transformFiles: (files, ctx) => buildSandboxFiles(files, ctx, modules), + }, + }; +} + +/** + * The story file imports each CSS Module via a colocated relative path that + * points to `<Component>/<name>.module.css`. In the sandbox, those folders + * don't exist — so we: + * + * 1. Drop a flat copy of `tokens.css` and each module under `src/styles/`. + * 2. Rewrite every relative `<…>/<name>.module.css` import in the story + * file to `./styles/<basename>` (or `../styles/<basename>` from `App`). + * 3. Inject `import './styles/tokens.css'` at the top of `src/App.tsx` + * so the design tokens cascade onto the rendered example. + */ +function buildSandboxFiles( + files: Record<string, string>, + _ctx: SandboxContext, + modules: CssModule[], +): Record<string, string> { + const next = { ...files }; + + next['src/styles/tokens.css'] = tokensCss; + + // The meta typically lists every CSS Module a component uses across all + // stories so each individual story can pull what it needs without having + // to redeclare the imports. For the generated sandbox we only want the + // files actually referenced from `src/example.tsx`, otherwise unused + // modules (e.g. `checkbox.module.css` in a Dialog/Alert story that only + // uses `dialog.module.css`) clutter the file tree. + const example = next['src/example.tsx']; + const referenced = new Set<string>(); + if (typeof example === 'string') { + for (const match of example.matchAll(/([a-z][a-z0-9-]*\.module\.css)/gi)) { + referenced.add(match[1]); + } + } + const usedModules = referenced.size ? modules.filter(m => referenced.has(m.name)) : modules; + for (const m of usedModules) { + next[`src/styles/${m.name}`] = m.source; + } + + // Story file lives at `src/example.tsx`; rewrite the colocated + // `<...>/<file>.module.css` import to a sibling path under `./styles/`. + if (typeof example === 'string') { + next['src/example.tsx'] = example.replace( + /(['"])(?:\.{1,2}\/)+(?:[^'"\/]+\/)*([^'"\/]+\.module\.css)\1/g, + (_match, quote, basename) => `${quote}./styles/${basename}${quote}`, + ); + } + + // Prepend the tokens import to App so `:root` custom properties apply + // everywhere (Storybook injects them via `preview.js`; in the sandbox we + // need the equivalent global import). + const app = next['src/App.tsx']; + if (typeof app === 'string' && !app.includes('./styles/tokens.css')) { + next['src/App.tsx'] = `import './styles/tokens.css';\n${app}`; + } + + return next; +} diff --git a/packages/react-components/react-headless-components-preview/stories/theme/tokens.css b/packages/react-components/react-headless-components-preview/stories/theme/tokens.css new file mode 100644 index 00000000000000..24a88d06ceb8bc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/theme/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/scripts/test-ssr/README.md b/scripts/test-ssr/README.md index babe4ac17e7c82..45372dea6b820e 100644 --- a/scripts/test-ssr/README.md +++ b/scripts/test-ssr/README.md @@ -52,3 +52,12 @@ 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 two custom esbuild plugins (`src/utils/esbuild-plugin.ts`) so stories that work in webpack-driven Storybook also work in the SSR pipeline: + +- `?raw` queries — strips the suffix and loads the underlying file as text. Mirrors webpack's `resourceQuery: /raw/` asset/source rule, including extension-less imports like `'./Foo.stories?raw'` resolving to `./Foo.stories.tsx`. +- `*.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.ts b/scripts/test-ssr/src/utils/buildAssets.ts index 5d3f8a60201cb2..a5a53bb6464a50 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, rawQueryPlugin, 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 }), rawQueryPlugin(), 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..8804f8462a5906 100644 --- a/scripts/test-ssr/src/utils/esbuild-plugin.ts +++ b/scripts/test-ssr/src/utils/esbuild-plugin.ts @@ -1,3 +1,4 @@ +import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { Plugin } from 'esbuild'; @@ -43,3 +44,73 @@ export function tsConfigPathsPlugin(options: { cwd: string }): Plugin { return pluginConfig; } + +/** + * Resolves `import x from './foo.ext?raw'` by stripping the suffix and loading the + * underlying file as text. Webpack/Storybook handle this via `resourceQuery: /raw/`; + * esbuild has no built-in equivalent. Mirrors webpack's behaviour where the bare + * import path may omit the extension (e.g. `./Foo.stories?raw` resolves to + * `./Foo.stories.tsx`). + */ +export function rawQueryPlugin(): Plugin { + const candidateExtensions = ['', '.tsx', '.ts', '.jsx', '.js', '.css', '.json']; + + async function resolveExisting(candidatePath: string): Promise<string | null> { + for (const ext of candidateExtensions) { + const full = candidatePath + ext; + try { + await fs.access(full); + return full; + } catch { + // try next extension + } + } + return null; + } + + return { + name: 'raw-query', + setup({ onResolve, onLoad }) { + onResolve({ filter: /\?raw$/ }, async args => { + const cleanPath = args.path.replace(/\?raw$/, ''); + const base = path.isAbsolute(cleanPath) ? cleanPath : path.resolve(args.resolveDir, cleanPath); + const resolved = await resolveExisting(base); + if (!resolved) { + return { errors: [{ text: `raw-query: could not resolve ${args.path} from ${args.resolveDir}` }] }; + } + return { path: resolved, namespace: 'raw-query' }; + }); + onLoad({ filter: /.*/, namespace: 'raw-query' }, async args => { + const contents = await fs.readFile(args.path, 'utf8'); + return { contents, loader: 'text' }; + }); + }, + }; +} + +/** + * 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 => { + if (args.path.includes('?')) { + return null; + } + 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; +}