Skip to content

feat(theme): add component icon mappings - #4647

Closed
cixzhang wants to merge 6 commits into
mainfrom
feat/component-icon-mapping
Closed

feat(theme): add component icon mappings#4647
cixzhang wants to merge 6 commits into
mainfrom
feat/component-icon-mapping

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Why

Some icon choices are component semantics rather than global glyph choices. Selector is a good example: its selected-option indicator defaults to check, but a theme may want that same purpose to render a different registry icon or no icon at all.

What

Adds themeable component icon mappings on top of the theme icon registry work:

  • Adds componentIcons to defineTheme() / DefinedTheme, inherited through extends.
  • Adds useIcon(slot, fallback) for component internals and getComponentIcon() / getComponentIconName() for explicit-source resolution.
  • Wires Selector's selected-option indicator through the selector-selected-option slot.
  • Documents component icon slots in component theming docs and CLI output.
  • Adds a Storybook demo with an ad hoc Selector theme remapping the selected-option slot.
  • Preserves componentIcons in astryx theme build output.

Risk

The default Selector selected option remains a check icon. Themes can now remap or suppress that purpose through componentIcons; this is additive unless a theme opts in.

Testing

  • pnpm exec vitest run --project ui packages/core/src/Icon/globalIconRegistry.test.tsx packages/core/src/Selector/Selector.test.tsx packages/core/src/theme/defineTheme.test.ts --reporter=dot
  • pnpm exec vitest run --project node packages/cli/clients/cli/lib/component-format.test.mjs packages/cli/api/theme/build/build.test.mjs --reporter=dot
  • pnpm -F @astryxdesign/core typecheck
  • pnpm -F @astryxdesign/core lint
  • pnpm -F @astryxdesign/cli typecheck:authoring
  • pnpm check:sync
  • pnpm check:package-boundaries
  • pnpm check:changesets
  • pnpm -F @astryxdesign/core build
Screenshot 2026-08-04 at 1 41 07 PM

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 2, 2026
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
astryx Ignored Ignored Preview Aug 10, 2026 8:35pm

Request Review

targets: [
{className: 'astryx-selector', visualProps: ['size', 'status']},
{className: 'astryx-selector-option'},
{className: 'astryx-selector-selected-option-icon'},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this leave a className?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — resolved. The new astryx-selector-selected-option-icon target is gone; the slot now rides main's existing astryx-selector-check target. Two independent axes on one element: componentIcons['selector-selected-option'] picks which glyph, astryx-selector-check styles how it looks. No new theme surface for this feature.

Comment thread packages/core/src/Selector/Selector.doc.mjs
Comment thread packages/core/src/Selector/Selector.tsx Outdated
</span>
{isSelected && <Icon icon="check" size="sm" color="accent" />}
{isSelected && selectedOptionIcon != null && (
<span

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm should we handle most of this logic within Icon instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and that's where it lives now. The wrapper span is gone; the callsite is just:

<Icon icon="selector-selected-option" fallbackIcon="check" size="sm" color="accent" {...themeProps('selector-check')} />

Icon does the slot resolution (useIcon), the fallback, and the render-nothing case when a theme maps the slot to null. Components just name the slot.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Icon (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 609 -
Complexity N/A Very High (31) -
Selector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1457 -
Complexity N/A Very High (138) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.7KB 1.2KB

Accessibility Audit

Status: 1 accessibility violation(s) found — 1 serious.

Icon - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/18 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@cixzhang
cixzhang force-pushed the feat/component-icon-mapping branch from 61535c1 to 4fb13b6 Compare August 10, 2026 15:31
@github-actions github-actions Bot removed the needs:design-review Affects visuals — Design should review label Aug 10, 2026
github-actions Bot added a commit that referenced this pull request Aug 10, 2026
Comment thread packages/core/src/theme/defineTheme.ts Outdated
* Component icon slot mappings — maps component-specific purposes to global
* semantic icon names. Use `null` to intentionally render no icon.
*/
componentIcons?: ComponentIconMap;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be Partial, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It already was, just hidden inside the alias — ComponentIconMap was declared as Partial<Record<ComponentIconSlotName, IconName | null>>. But that's exactly why the question is worth acting on: the field read as non-partial next to icons?: Partial<IconRegistry>.

Flipped it to match the sibling convention in 4423a81: ComponentIconMap is now the complete Record, and both theme fields declare Partial<ComponentIconMap>. Same runtime behavior, and partialness is now visible where it's used. Safe to reshape since the type isn't released yet.

Comment thread packages/core/src/theme/defineTheme.ts Outdated
/** Icon registry */
icons?: Partial<IconRegistry>;
/** Component icon slot mappings */
componentIcons?: ComponentIconMap;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the same commit — DefinedTheme.componentIcons is Partial<ComponentIconMap> too.

- Icon.doc.mjs: fallbackIcon's 'valid semantic names' list was missing
  chevronsLeft/chevronsRight (added on main), failing docPropLiterals.
  Also translates the docsZh copy, which was left in English.
- ComponentIconMap is now the complete Record; theme fields declare
  Partial<ComponentIconMap>, matching icons?: Partial<IconRegistry>.
Comment thread packages/core/src/theme/defineTheme.ts Outdated
: (input.icons ?? base?.icons);

// 6. Merge component icon mappings — input mappings override base mappings.
// `null` is an intentional override meaning “render no icon”.

@cixzhang cixzhang Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should skip null as an intentional override for now. It's often confused for "default" so if we do decide to add one later we should have a clear symbol.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — dropped it in 96a764c.

A theme now maps a slot to another global icon name, or leaves it alone; there is no value that means "render nothing." ComponentIconMap values are IconName, and Icon's fallbackIcon / useIcon's fallback follow (no | null), so the "is this the default or is this off?" question can't be asked. The doc prose, the docsite icon-slots table, and the CLI component table all lose their "none" case too.

Left a comment on getComponentIconName recording why, so a future reader doesn't add it back casually: if suppression turns out to be a real need it should arrive as an explicit symbol.

And it isn't load-bearing for selection — an indicator draws its own empty state (an unchecked check indicator renders nothing) rather than relying on the slot to vanish.

`null` reads as "default" at least as often as it reads as "render
nothing", so a theme can no longer suppress a component icon slot: it
maps a slot to another global icon name, or leaves it alone. If
suppression turns out to be a real need it should arrive as an explicit
symbol that can't be confused with the default.

The selection use case doesn't need it — an indicator draws its own
empty state (an unchecked check indicator renders nothing) rather than
relying on the icon slot to vanish.

- ComponentIconMap values are IconName (no null); Icon's fallbackIcon
  and useIcon's fallback follow.
- Doc prose, the docsite icon-slots table, and the CLI component table
  drop the "none" case.
github-actions Bot added a commit that referenced this pull request Aug 10, 2026
github-actions Bot added a commit that referenced this pull request Aug 10, 2026
Comment thread packages/core/src/Icon/useIcon.ts Outdated
Comment on lines +37 to +40
export function useIcon(
nameOrSlot: IconName | ComponentIconSlotName,
fallback?: IconName,
): ReactNode {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be better to have useComponentIcon instead since the check for calling getComponentIcon is whether a fallback is defined but there's nothing guaranteeing a fallback is provided when component icon is provided in nameOrSlot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it was worse than the hook — done in d0e38d0.

Your read is right that nothing tied the two together. The overload did reject useIcon('selector-selected-option') (disjoint unions, no matching signature), but I probed the neighbor and found the real hole:

<Icon icon="selector-selected-option" />   // typechecked fine

That resolves through no fallback, so an unmapped slot renders nothing — a blank where the check should be, no error anywhere.

Two changes:

  1. useComponentIcon(slot, fallback) as its own export, as you suggest; useIcon(name) goes back to a single signature. The fallback is now a required parameter of the function that needs it, instead of an optional second argument whose absence silently picked the lookup. No production callsite used the two-arg form, so the split is clean.

  2. IconProps is a discriminated union — a global icon takes fallbackIcon?: never, a slot takes fallbackIcon: IconName (required). Same guarantee at the component, where the hole actually was.

Pinned with @ts-expect-error tests, since compile time is the only place these can be checked. Verified they bite by reverting the union — both directives went unused and the build failed.

A component icon slot is meaningless without the component's own
default: an unmapped slot with no fallback resolves to nothing, so the
icon silently disappears. Nothing enforced the pairing — `useIcon`
chose its lookup from `arguments.length`, and `<Icon` accepted a slot
with no `fallbackIcon` at all.

- Split the overloaded hook: `useIcon(name)` for a global icon,
  `useComponentIcon(slot, fallback)` for a slot, with the fallback a
  required parameter of the one that needs it.
- `IconProps` becomes a discriminated union: a global icon takes
  `fallbackIcon?: never`, a slot takes `fallbackIcon: IconName`.

Pinned by @ts-expect-error tests, verified by reverting the union and
confirming they fail.
Calling useIcon/useComponentIcon outside a component tripped
rules-of-hooks and no-floating-promises (ReactNode includes a thenable
in React 19's types). These only ever needed the compiler, so assert on
the argument tuples instead.
github-actions Bot added a commit that referenced this pull request Aug 10, 2026
github-actions Bot added a commit that referenced this pull request Aug 10, 2026
A componentIcons key core does not expose is silent: resolution falls
back to the component default, so a typo'd slot — or one carried by a
built theme from a core version that has since renamed it — ships as a
theme that simply does not apply.

- `theme build` warns, following validateComponentOverrides: known
  slots are derived from the component docs (the same *.doc.mjs the
  docsite renders), not a second hardcoded registry, and validation
  is skipped entirely when docs are unavailable.
- docIconSlots.test.ts pins the two directions: every slot declared in
  ComponentIconSlotMap is documented, and no doc names a slot core does
  not declare. Slot names are parsed from source, so a new slot fails
  the test until it is documented. This is also what keeps the build's
  derived known-set honest.
- Docsite theming section states the rules a slot implies: appearance
  changes, semantics do not; slots take precedence over the icons
  registry; slot names are versioned API.
@cixzhang

Copy link
Copy Markdown
Contributor Author

Closing this — we're deferring component icon mappings entirely, and taking the minimal API first.

Why. Every motivating case for icon selection turns out to be stateful: the checkmark vs. radio dot in a selector option, the chevron on an expand toggle, the arrow vs. chevron for sort. Those are indicator families, and indicator replacement covers them without a slot map. The cases that genuinely need a static glyph swapped — the clear ×, the status icons — are ones nobody has actually asked for. This PR was infrastructure ahead of demand.

What replaces it. defineTheme({indicators}) alone: a theme replaces a named indicator, so a product that wants radio visuals in its selector options replaces CheckIndicator and every consumer of it follows. No per-slot mapping, no new key namespace, and nothing to version yet.

Deliberately leaving room. Slot maps stay additive: componentIcons / componentIndicators can arrive later, keyed by name so they still survive theme build, once there's a use case that indicator replacement can't serve. Deferring is cheaper than shipping a key namespace we'd have to keep — as the discussion here established, slot names are versioned API, and removing one is silent (resolution falls back to the default, no warning).

Worth keeping from this PR, as separate changes:

  • The field-status icon dedup — four identical Record<StatusType, IconName> literals across useInputStatusIcon, FieldStatus, Selector, and MultiSelector. Pure consolidation, no new API.
  • The theme build silent-loss guard. A runtime-value theme field written inline rather than as an imported binding is dropped from the built module with no error — indicators: {check: MyCheck} builds green and does nothing. That mattered little when componentIcons was the main path; with indicators as the only mechanism it should be a hard error.

Thanks for the reviews — the Partial, null, and useComponentIcon catches all sharpened the design, and the null discussion is a good part of why the slot layer looks premature.

Continuing in #4712, rescoped to indicators only.

@cixzhang cixzhang closed this Aug 10, 2026
github-actions Bot added a commit that referenced this pull request Aug 10, 2026
@github-actions
github-actions Bot deleted the feat/component-icon-mapping branch August 11, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants