From a5da15e4e91f888ce7058c445c06ac8a77502ef0 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 28 Aug 2026 05:27:39 -0700 Subject: [PATCH 1/3] feat(react-icons): expose the bundled icon variant as a data attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bundleIcon` renders both glyphs of a pair and hides one with `data-fui-icon-hidden`. A consumer stylesheet that wants to style the two variants differently — or select the visible one — currently has to key off the `fui-Icon-filled` / `fui-Icon-regular` class names, which are a rendering detail rather than a contract. This stamps `data-fui-icon-variant="filled" | "regular"` on the glyphs `bundleIcon` renders, exported as `DATA_FUI_ICON_VARIANT` alongside the existing `DATA_FUI_ICON`, `DATA_FUI_ICON_RTL`, `DATA_FUI_ICON_HIDDEN` and `DATA_FUI_ICON_FONT`. It is additive: no existing attribute, class name or export changes, and nothing renders differently. The headless entrypoint's test for bundled-variant data attributes is extended to cover it, and both copies of the attribute documentation (`docs/headless.md` and the docsite mirror) are updated. --- .../stories/Icons/Headless/IconsHeadless.md | 16 ++++++++------ packages/react-icons/docs/headless.md | 16 ++++++++------ .../react-icons/src/headless/bundleIcon.tsx | 7 ++++-- .../src/headless/headless.test.tsx | 22 ++++++++++++++++++- packages/react-icons/src/headless/index.ts | 1 + packages/react-icons/src/headless/shared.ts | 3 +++ 6 files changed, 48 insertions(+), 17 deletions(-) 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/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, From 8a6a92240c1455c109d8076d7833e5c0766581d0 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 28 Aug 2026 05:28:10 -0700 Subject: [PATCH 2/3] docs(react-icons): explain how the hidden-variant rule is meant to be layered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[data-fui-icon-hidden]` is the one rule in this file consumers routinely have to override — a hover or selection glyph swap reveals the inactive variant. Every other non-obvious rule here already carries a rationale block; this one did not, so the constraint it places on consumers (import this file BELOW your component layers, then a normal declaration wins) was only discoverable by experiment. Comment only; the declaration is unchanged. --- packages/react-icons/src/headless/styles.css | 8 ++++++++ 1 file changed, 8 insertions(+) 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; } From 0b18543653294acf40189913954f80c139af863b Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 28 Aug 2026 05:28:35 -0700 Subject: [PATCH 3/3] fix(react-icons): dedupe CJS rewrite targets so concurrent rewrites cannot tear a file `finalizeCjs` collected rewrite targets into an array. A stale pre-renamed file and its fresh source can resolve to the same target path, so the same file was queued twice and `forEachConcurrent` rewrote it from two workers at once, tearing it. Collecting into a `Set` makes the target list unique. Unrelated to the variant attribute in the preceding commits; happy to split this into its own PR if preferred. --- packages/react-icons/scripts/module-format.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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) {