Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions packages/docsite/stories/Icons/Headless/IconsHeadless.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
16 changes: 9 additions & 7 deletions packages/react-icons/docs/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions packages/react-icons/scripts/module-format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>}
*/
const toRewrite = new Set();
let jsCount = 0;
let dtsCount = 0;

Expand All @@ -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);
}
}

Expand All @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions packages/react-icons/src/headless/bundleIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -18,11 +19,13 @@ export const bundleIcon = (FilledIcon: FluentIcon, RegularIcon: FluentIcon): Flu
<FilledIcon
{...rest}
className={cx(iconFilledClassName, className)}
{...{ [DATA_FUI_ICON_VARIANT]: 'filled' }}
{...(!filled ? { [DATA_FUI_ICON_HIDDEN]: '' } : undefined)}
/>
<RegularIcon
{...rest}
className={cx(iconRegularClassName, className)}
{...{ [DATA_FUI_ICON_VARIANT]: 'regular' }}
{...(filled ? { [DATA_FUI_ICON_HIDDEN]: '' } : undefined)}
/>
</React.Fragment>
Expand Down
22 changes: 21 additions & 1 deletion packages/react-icons/src/headless/headless.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(<BundledIcon filled={filled} />);

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]);
Expand Down
1 change: 1 addition & 0 deletions packages/react-icons/src/headless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
DATA_FUI_ICON_RTL,
DATA_FUI_ICON_HIDDEN,
DATA_FUI_ICON_FONT,
DATA_FUI_ICON_VARIANT,
cx,
} from './shared';

Expand Down
3 changes: 3 additions & 0 deletions packages/react-icons/src/headless/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/react-icons/src/headless/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down