diff --git a/packages/docsite/stories/Icons/Headless/IconsHeadless.md b/packages/docsite/stories/Icons/Headless/IconsHeadless.md index 8894ec0636..047aec1f12 100644 --- a/packages/docsite/stories/Icons/Headless/IconsHeadless.md +++ b/packages/docsite/stories/Icons/Headless/IconsHeadless.md @@ -14,13 +14,14 @@ The **Headless API** is a drop-in replacement for the standard icon API that rem The standard API uses Griffel's `makeStyles` / `mergeClasses` to inject CSS rules at runtime. The headless API replaces this with HTML `data-*` attributes and a shipped CSS file (`styles.css`) that targets them: -| Concern | Standard (Griffel) | Headless (CSS) | -| --------------------- | ----------------------------------- | -------------------------------------------------------------------------- | -| Base icon layout | `useRootStyles()` → atomic classes | `[data-fui-icon] { display: inline; line-height: 0 }` | -| High-contrast mode | `@media (forced-colors)` in JS | `@media (forced-colors) { [data-fui-icon] { forced-color-adjust: auto } }` | -| RTL directional flip | `transform: scaleX(-1)` via Griffel | `[data-fui-icon-rtl] { transform: scaleX(-1) }` | -| bundleIcon visibility | Generated show/hide classes | `[data-fui-icon-hidden] { display: none }` | -| Font icon family | Griffel styles per variant | `[data-fui-icon-font="filled"] { font-family: '...' }` | +| Concern | Standard (Griffel) | Headless (CSS) | +| --------------------- | -------------------------------------------------- | -------------------------------------------------------------------------- | +| Base icon layout | `useRootStyles()` → atomic classes | `[data-fui-icon] { display: inline; line-height: 0 }` | +| High-contrast mode | `@media (forced-colors)` in JS | `@media (forced-colors) { [data-fui-icon] { forced-color-adjust: auto } }` | +| RTL directional flip | `transform: scaleX(-1)` via Griffel | `[data-fui-icon-rtl] { transform: scaleX(-1) }` | +| bundleIcon visibility | Generated show/hide classes | `[data-fui-icon-hidden] { display: none }` | +| bundleIcon variant | `fui-Icon-filled` / `fui-Icon-regular` class names | `[data-fui-icon-variant="filled"] { ... }` | +| Font icon family | Griffel styles per variant | `[data-fui-icon-font="filled"] { font-family: '...' }` | ## CSS Setup @@ -112,6 +113,7 @@ import { DATA_FUI_ICON_RTL, // 'data-fui-icon-rtl' DATA_FUI_ICON_HIDDEN, // 'data-fui-icon-hidden' DATA_FUI_ICON_FONT, // 'data-fui-icon-font' + DATA_FUI_ICON_VARIANT, // 'data-fui-icon-variant' // Context IconDirectionContextProvider, diff --git a/packages/react-icons/docs/headless.md b/packages/react-icons/docs/headless.md index 688ef6fd43..1256cccd22 100644 --- a/packages/react-icons/docs/headless.md +++ b/packages/react-icons/docs/headless.md @@ -14,13 +14,14 @@ The Headless API is a drop-in replacement for the standard icon API that removes The standard API uses Griffel's `makeStyles` / `mergeClasses` to inject CSS rules at runtime. The headless API replaces this with HTML `data-*` attributes and a shipped CSS file (`styles.css`) that targets them: -| Concern | Standard (Griffel) | Headless (CSS) | -| --------------------- | ----------------------------------- | -------------------------------------------------------------------------- | -| Base icon layout | `useRootStyles()` → atomic classes | `[data-fui-icon] { display: inline; line-height: 0 }` | -| High-contrast mode | `@media (forced-colors)` in JS | `@media (forced-colors) { [data-fui-icon] { forced-color-adjust: auto } }` | -| RTL directional flip | `transform: scaleX(-1)` via Griffel | `[data-fui-icon-rtl] { transform: scaleX(-1) }` | -| bundleIcon visibility | Generated show/hide classes | `[data-fui-icon-hidden] { display: none }` | -| Font icon family | Griffel styles per variant | `[data-fui-icon-font="filled"] { font-family: '...' }` | +| Concern | Standard (Griffel) | Headless (CSS) | +| --------------------- | -------------------------------------------------- | -------------------------------------------------------------------------- | +| Base icon layout | `useRootStyles()` → atomic classes | `[data-fui-icon] { display: inline; line-height: 0 }` | +| High-contrast mode | `@media (forced-colors)` in JS | `@media (forced-colors) { [data-fui-icon] { forced-color-adjust: auto } }` | +| RTL directional flip | `transform: scaleX(-1)` via Griffel | `[data-fui-icon-rtl] { transform: scaleX(-1) }` | +| bundleIcon visibility | Generated show/hide classes | `[data-fui-icon-hidden] { display: none }` | +| bundleIcon variant | `fui-Icon-filled` / `fui-Icon-regular` class names | `[data-fui-icon-variant="filled"] { ... }` | +| Font icon family | Griffel styles per variant | `[data-fui-icon-font="filled"] { font-family: '...' }` | ## CSS Setup @@ -112,6 +113,7 @@ import { DATA_FUI_ICON_RTL, // 'data-fui-icon-rtl' DATA_FUI_ICON_HIDDEN, // 'data-fui-icon-hidden' DATA_FUI_ICON_FONT, // 'data-fui-icon-font' + DATA_FUI_ICON_VARIANT, // 'data-fui-icon-variant' // Context IconDirectionContextProvider, diff --git a/packages/react-icons/scripts/module-format.js b/packages/react-icons/scripts/module-format.js index 14b6bbc2bc..3ddf600566 100644 --- a/packages/react-icons/scripts/module-format.js +++ b/packages/react-icons/scripts/module-format.js @@ -213,8 +213,13 @@ async function finalizeCjs(dir) { const fileSet = new Set(); /** @type {Array<{ from: string, to: string }>} */ const renames = []; - /** @type {string[]} */ - const toRewrite = []; + /** + * A stale pre-renamed file plus its fresh source can both map to one target; a set keeps + * the target recorded once, since duplicates would race two concurrent rewrites on the + * same path and tear the file. + * @type {Set} + */ + const toRewrite = new Set(); let jsCount = 0; let dtsCount = 0; @@ -235,7 +240,7 @@ async function finalizeCjs(dir) { fileSet.add(target); if (target.endsWith('.cjs') || target.endsWith('.d.cts')) { - toRewrite.push(target); + toRewrite.add(target); } } @@ -244,7 +249,7 @@ async function finalizeCjs(dir) { const rewrite = createRewriter(fileSet, '.cjs'); - await forEachConcurrent(toRewrite, async (file) => { + await forEachConcurrent([...toRewrite], async (file) => { const code = await readFile(file, 'utf8'); const next = rewrite(code, dirname(file), file); if (next !== code) { diff --git a/packages/react-icons/src/headless/bundleIcon.tsx b/packages/react-icons/src/headless/bundleIcon.tsx index 3b1ae272ab..81bff6115e 100644 --- a/packages/react-icons/src/headless/bundleIcon.tsx +++ b/packages/react-icons/src/headless/bundleIcon.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; -import { cx, iconFilledClassName, iconRegularClassName, DATA_FUI_ICON_HIDDEN } from './shared'; +import { cx, iconFilledClassName, iconRegularClassName, DATA_FUI_ICON_HIDDEN, DATA_FUI_ICON_VARIANT } from './shared'; import type { FluentIcon } from './shared'; /** * Headless bundleIcon — combines Filled and Regular icon variants. * - * Renders both icons; the inactive variant gets `data-fui-icon-hidden`. + * Renders both icons; each glyph states its own variant with `data-fui-icon-variant`, and the + * inactive one additionally gets `data-fui-icon-hidden`. * The shipped styles.css handles visibility via `[data-fui-icon-hidden] { display: none }`. */ export const bundleIcon = (FilledIcon: FluentIcon, RegularIcon: FluentIcon): FluentIcon => { @@ -18,11 +19,13 @@ export const bundleIcon = (FilledIcon: FluentIcon, RegularIcon: FluentIcon): Flu diff --git a/packages/react-icons/src/headless/headless.test.tsx b/packages/react-icons/src/headless/headless.test.tsx index ccf13ec497..51db3a8cb3 100644 --- a/packages/react-icons/src/headless/headless.test.tsx +++ b/packages/react-icons/src/headless/headless.test.tsx @@ -11,7 +11,13 @@ import { createFluentIcon } from './createFluentIcon'; import { createFluentIcon as createFluentSpriteIcon } from './createFluentIcon.svg-sprite'; import { bundleIcon } from './bundleIcon'; import type { FluentIcon } from './shared'; -import { DATA_FUI_ICON, DATA_FUI_ICON_RTL, DATA_FUI_ICON_HIDDEN, DATA_FUI_ICON_FONT } from './shared'; +import { + DATA_FUI_ICON, + DATA_FUI_ICON_RTL, + DATA_FUI_ICON_HIDDEN, + DATA_FUI_ICON_FONT, + DATA_FUI_ICON_VARIANT, +} from './shared'; import { IconDirectionContextProvider } from '../contexts'; describe('Headless API — SVG icons', () => { @@ -278,6 +284,20 @@ describe('Headless API — bundleIcon', () => { expect(regularSvg).not.toHaveAttribute(DATA_FUI_ICON_HIDDEN); }); + test('bundleIcon stamps data-fui-icon-variant on both glyphs regardless of filled', () => { + const d = 'M1 2 L3 4'; + const FilledIcon = createFluentIcon('TestFilled', '1em', [d]); + const RegularIcon = createFluentIcon('TestRegular', '1em', [d]); + const BundledIcon = bundleIcon(FilledIcon, RegularIcon); + + for (const filled of [true, false]) { + const { container } = render(); + + expect(container.querySelector('.fui-Icon-filled')).toHaveAttribute(DATA_FUI_ICON_VARIANT, 'filled'); + expect(container.querySelector('.fui-Icon-regular')).toHaveAttribute(DATA_FUI_ICON_VARIANT, 'regular'); + } + }); + test('bundleIcon preserves fui-Icon class on both variants', () => { const d = 'M1 2 L3 4'; const FilledIcon = createFluentIcon('TestFilled', '1em', [d]); diff --git a/packages/react-icons/src/headless/index.ts b/packages/react-icons/src/headless/index.ts index be3bbea18b..8ad36eaf40 100644 --- a/packages/react-icons/src/headless/index.ts +++ b/packages/react-icons/src/headless/index.ts @@ -14,6 +14,7 @@ export { DATA_FUI_ICON_RTL, DATA_FUI_ICON_HIDDEN, DATA_FUI_ICON_FONT, + DATA_FUI_ICON_VARIANT, cx, } from './shared'; diff --git a/packages/react-icons/src/headless/shared.ts b/packages/react-icons/src/headless/shared.ts index a104025153..55892a0c79 100644 --- a/packages/react-icons/src/headless/shared.ts +++ b/packages/react-icons/src/headless/shared.ts @@ -13,6 +13,9 @@ export const DATA_FUI_ICON_HIDDEN = 'data-fui-icon-hidden'; /** Data attribute for font icon font-family variant selection (filled|regular|resizable|light). */ export const DATA_FUI_ICON_FONT = 'data-fui-icon-font'; +/** Data attribute stamped by `bundleIcon` on each glyph, stating its variant (filled|regular). */ +export const DATA_FUI_ICON_VARIANT = 'data-fui-icon-variant'; + // Re-export existing constants (CSS class names for consumer targeting) export { iconClassName, diff --git a/packages/react-icons/src/headless/styles.css b/packages/react-icons/src/headless/styles.css index a41a927551..3e3629a193 100644 --- a/packages/react-icons/src/headless/styles.css +++ b/packages/react-icons/src/headless/styles.css @@ -52,6 +52,14 @@ } /* ===== bundleIcon: Hidden Variant ===== */ +/* + * Higher specificity than the `:where()`-flat default above, so it wins wherever both apply. + * Consumers importing this file into a cascade layer must place it BELOW their component + * layers: a component revealing the inactive variant (hover glyph swap) then overrides this + * with a normal declaration. Icon components carrying UNLAYERED classes that set `display` + * (the Griffel `@fluentui/react-icons` API) cannot be bundled by this API — use the headless + * icon atoms under `./svg/*`. + */ [data-fui-icon-hidden] { display: none; }