Skip to content

feat(theme): add themeable indicators - #4712

Merged
cixzhang merged 9 commits into
mainfrom
feat/control-icons
Aug 11, 2026
Merged

feat(theme): add themeable indicators#4712
cixzhang merged 9 commits into
mainfrom
feat/control-icons

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Adds indicators — the componentized form of stateful control visuals — and makes them replaceable by name from a theme.

Rescoped. This PR previously carried component icon slots (componentIcons) and a parallel componentIndicators slot map. Both are dropped: replacement by name covers every case we have, and slot maps stay additive for when it doesn't. #4647 is closed with the reasoning.

What an indicator is

The checkbox box, the radio circle, the mark on a chosen option. The owning component keeps the input, role, accessible name, focus, and keyboard behavior; the indicator turns state into a picture. It renders aria-hidden and owns no semantics.

Consumer-facing API

Replace an indicator. Replacement is by name, so one entry reaches every component that draws it:

import {RadioIndicator} from '@astryxdesign/core/Indicator';

defineTheme({name: 'brand', indicators: {check: RadioIndicator}});

Every single-selection mark in the app is now a radio — Selector options included, drawing an empty circle on unselected rows, which a check mark never could.

Restyle one instead. An indicator renders stable astryx-* targets, so defineTheme({components: {checkbox: …}}) reaches it like any other component. Prefer this; replacement is for when the shape itself is wrong.

Write a replacement. Four rules, each one a mistake made by hand in this PR's first draft:

  • render children when present (the owner passes a loading Spinner through it)
  • set aria-hidden (the owner provides role and accessible name)
  • do not draw a focus ring (it lives on the owner's wrapper, so it survives replacement)
  • use theme tokens for every color, radius, and border width

Families

A family fixes the state space, and each uses the ARIA vocabulary for the state it expresses:

family states source indicators
singleSelection unchecked | checked aria-checked check, radio
multiSelection unchecked | checked | indeterminate aria-checked checkbox

The registry is family-checked, so a replacement can't be written against the wrong states — a radio replacement expecting indeterminate is a type error. Adding a family is additive and module-augmentable.

Vocabulary comes from the vibe test in #4831, not from taste: given one shared enum across families, generators re-derived the split anyway and wrote aria-expanded={isOpen} beside state="on" on adjacent lines.

Notable

  • Selector renders its mark unconditionally, passing state. {isSelected && …} would make a replaced radio unable to draw its unselected form. The default check still renders nothing when unchecked.
  • CheckIndicator renders <Icon> directly rather than wrapping it, so a host's theme target lands on the glyph — and it adds no new theme target, since astryx-icon plus the host's target already reach it. Continues the wrapper reduction from fix(core): drop styling wrappers around icons; render glyphs through Icon #4838/fix(selector): drop the shared trigger-icon wrapper #4846.
  • Focus-ring fix. CheckboxInput's input is opacity: 0, so with the ring inside the indicator, a theme replacement left the control with no visible focus (WCAG 2.4.7). Moved to the wrapper that owns the input, mirroring RadioListItem. Found by an independent review pass; pinned by tests.
  • ~450 lines of duplicated control styling removed from CheckboxInput, RadioListItem, and the menu items.
  • Two a11y improvements fall out: the radio visual picks up the prefers-reduced-motion override it lacked, and menu radios gain forced-colors treatment.

Migration

Menu radios now use the shared radio / radio-dot targets:

  • dropdown-menu-radio-dot is removed — target radio-dot, which now also reaches RadioList.
  • Move fill/border/radius rules from dropdown-menu-radio to radio; layout rules (size, order) stay.
  • astryx theme build reports the removed key. Runtime themes are not validated.
  • Menu controls use the standard 20/24px control sizes instead of a bespoke 18/22px.

Test plan

  • Indicator.test.tsx — contract per indicator, and family typing.
  • Selector.test.tsx — the default mark is the glyph itself (no wrapper); a theme mapping checkRadioIndicator puts a radio on every row and fills exactly the selected one.
  • CheckboxInput.test.tsx — focus ring survives indicator replacement. Verified by reverting the fix and confirming failure.
  • Full UI suite: 5 failures, identical to origin/main on the same quiet machine (Table perf budget, TransferList draft timing — pre-existing flakes). 6528 passing vs main's 6516.
  • Typecheck clean; lint clean under CI=true (strict mode — warnings become errors).

Visual verification with before/after screenshots to follow.

Closes #4831

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

vercel Bot commented Aug 4, 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 11, 2026 6:52am

Request Review

@cixzhang

cixzhang commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Heads up on an overlap with #4724 (RadioControl): both PRs refactor RadioListItem and both own the astryx-radio / astryx-radio-dot targets, so whichever lands second needs a rebase.

They're solving adjacent problems — #4724 extracts the radio input + visual so a single radio can render outside a RadioList; this PR extracts the visual alone so a theme can restyle or replace it everywhere it appears. Both could stand, with RadioControl composing the indicator for its visual rather than drawing its own, which would keep one set of theme targets in one place. Worth agreeing on that before either merges rather than reconciling it afterward.

@cixzhang
cixzhang force-pushed the feat/component-icon-mapping branch from 61535c1 to 4fb13b6 Compare August 10, 2026 15:31
@cixzhang
cixzhang force-pushed the feat/control-icons branch from b3b2c26 to 4e7a7ee Compare August 10, 2026 15:35
@cixzhang
cixzhang changed the base branch from feat/component-icon-mapping to main August 10, 2026 21:21
@cixzhang
cixzhang force-pushed the feat/control-icons branch from 4e7a7ee to 6fa7871 Compare August 10, 2026 21:21
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Aug 10, 2026
@cixzhang cixzhang changed the title feat(theme): add themeable selection indicators feat(theme): add themeable indicators Aug 10, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

Rescoped to the minimal API, and retargeted to main.

Dropped: componentIcons and the parallel componentIndicators slot map. What's left is defineTheme({indicators}) — replacement by name. EPS replaces CheckIndicator with their radio visual and every single-selection mark follows, which was the actual use case.

Size: 82 files / +2850 → 26 files / +1574. Four changesets → one, 57 words.

Also removed a wrapper I'd added. The first pass had CheckIndicator render a <span> around an <Icon> with its own check-indicator theme target. Both were wrong: the wrapper moved astryx-selector-check off the glyph onto a box around it (undoing #4838/#4846), and the new target violated "don't add new theme targets." Caught by two existing Selector tests that assert the target lands on the glyph and that the default check renders byte-identically to a plain <Icon>. It now renders <Icon> directly and adds no target.

On the test failures: the full UI suite shows 5, in Table perf and TransferList draft timing. I ran pristine origin/main in a separate worktree on a quiet machine — also 5, same files. My first comparison showed a difference, but that was me running suites in parallel; under load the baseline itself swung between 2 and 6.

Not yet done: visual before/after screenshots. Doing those next unless you want to look at the API first.

@github-actions

github-actions Bot commented Aug 10, 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.

New Components

Indicator (@astryxdesign/core)
Metric Value
Bundle Size (ESM) N/A
Bundle Size (CJS) 1.1KB
Lines of Code 499
Source Files 8
Complexity High (24)
Exports CheckboxIndicator, CheckIndicator, RadioIndicator, defaultIndicators, getIndicator, useIndicator, indicatorScope
Props Count N/A
Has Tests Yes
Has Stories No

Modified Components

CheckboxInput (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 296 -
Complexity N/A Very High (36) -
DropdownMenu (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1365 -
Complexity N/A Very High (148) -
RadioList (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 421 -
Complexity N/A Very High (43) -
Selector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1493 -
Complexity N/A Very High (152) -

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.

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

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

github-actions Bot added a commit that referenced this pull request Aug 10, 2026
Comment on lines +69 to +84
// The focus ring lives on the wrapper — which owns the (visually hidden)
// native input — rather than on the checkbox visual, so replacing the
// checkbox indicator through `defineTheme({indicators})` cannot leave the
// control without a visible focus indicator (WCAG 2.4.7). Mirrors
// RadioListItem's radioWrapperFocus.
checkboxWrapperFocus: {
outline: {
default: 'none',
':has(:focus-visible)': `2px solid ${colorVars['--color-accent']}`,
},
outlineOffset: {
default: '0',
':has(:focus-visible)': '2px',
},
borderRadius: radiusVars['--radius-inner'],
},

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 styling belongs on the indicator now.

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.

Moved — and you were pointing at a real bug, not just a placement preference.

The ring can't use a plain :focus-visible on the indicator: the native input is a visually hidden sibling, not a descendant. So it now arrives the same way hover already does, through the owner's indicatorScope marker:

outline: {
  default: 'none',
  [stylex.when.ancestor(':has(:focus-visible)', indicatorScope)]: `2px solid ${colorVars['--color-accent']}`,
},

The bug it fixes: with the ring on the wrapper, the host had to hardcode the indicator's shape. RadioListItem literally carried borderRadius: '50%' for a circle it doesn't own, and CheckboxInput carried --radius-inner. Replace either indicator with a different shape and the ring traced the wrong outline. Now each indicator draws its own — measured in Chrome after the move: checkbox 2px solid, offset 2px, radius 6px; radio 2px solid, offset 2px, radius 50%.

The tradeoff, stated plainly: a replacement that doesn't draw a ring now ships a control with no visible focus (WCAG 2.4.7) — previously the wrapper covered for it. I flipped the rule in Indicator.doc.mjs from "don't draw your own focus ring" to "draw it, keyed off the scope marker," with the sibling-input reason. Worth noting the vibe test in #4831 measured 4/4 replacements honoring explicitly-stated contract rules, which is the evidence that documenting it works.

Tests updated: the ring is asserted on the indicator, plus a regression guard that no host reintroduces one (two rings, or one of the wrong shape).

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.

Right — that was a bad answer on my part. Callsites shouldn't, and now they don't.

I looked at the two built-in rings and they were byte-identical, and neither mentions a shape. That's the tell: outline follows the element's own border-radius automatically, so the rule is completely generic. There was never anything per-indicator in it, which means "write your own" was me exporting a chore.

So it's one exported style now, next to indicatorScope:

import {indicatorFocusRing} from '@astryxdesign/core/Indicator';

<span {...stylex.props(indicatorFocusRing, myStyles.box)} aria-hidden="true" />

The built-ins spread exactly that. A replacement spreads the same one line and gets a ring that matches its own shape for free — the doc rule now points at the export instead of describing rules to write.

It still has to live on the indicator rather than the owner, for the reason the original comment was getting at: the owner would have to hardcode the shape of whatever it hosts. And it keys off the scope marker because the native input is a hidden sibling. But neither of those is now the replacement author's problem.

One case that fell out: CheckIndicator deliberately doesn't take the ring. A check marks a chosen row in a listbox or menu, and the row takes focus, not the mark. I noticed because adding it broke the existing test asserting the default check renders byte-identically to a plain <Icon> — the test was right.

Re-measured in Chrome after the refactor, unchanged: checkbox 2px solid / offset 2px / radius 6px, radio 2px solid / offset 2px / radius 50%.

Comment on lines +194 to +198
themeProps('dropdown-menu-checkbox', {
size: controlSize,
checked: value ? 'checked' : null,
disabled: isDisabled ? 'disabled' : null,
}),

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 this be on the CheckboxControl instead? Themes should collapse onto the visible element. Also we apply size on this extra span which seems redundant to the CheckboxControl

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.

Both right, and the wrapper is gone.

The size was pure duplication — markerSizeStyles was {sm: 20×20, md: 24×24}, byte-identical to the indicator's own boxSizeStyles. Item renders {marker} with no wrapper of its own, so the span had nothing left to do; what remains (order, coarse-pointer placement, pointerEvents: none) is genuinely the menu's business and rides xstyle on the indicator.

One correction while doing this: I had put a dropdown-menu-checkbox target on it, and that would have been a new theme target — main doesn't declare one (only dropdown-menu-radio and -radio-dot are in DropdownMenu.doc.mjs). I checked what main actually renders on that element and it's astryx-checkbox, which is still there. So the menu checkbox adds no target at all. The radio keeps dropdown-menu-radio, which did pre-exist, now on the painted circle instead of a box around it.

Visual diff vs main with the menu open: menu-checkbox-items is 0px changed; the radio stories differ by the disclosed 22→24px control-size alignment.

Comment on lines 176 to 180
@@ -205,24 +178,13 @@ export function DropdownMenuRadioItem({
checked: isChecked ? 'checked' : null,
disabled: isDisabled ? 'disabled' : null,
}),

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 comment here as above for the dropdown menu checkbox item.

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 change here — wrapper removed, dropdown-menu-radio moved onto the painted circle (it pre-existed on main, so it stays), and the duplicated size styles are gone.

This is the one place with a real pixel change: the menu radio was a bespoke 18/22px and now uses the standard 20/24px, so it matches a RadioList radio. Measured 1.8% of pixels in lab-radio-group and 0.4% in lab-selectable-sizes; every other story I diffed is 0px.

@cixzhang cixzhang left a comment

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.

Can we get a storybook for swapping the selector's single selection indicator to use the radio?

@cixzhang

Copy link
Copy Markdown
Contributor Author

All four addressed in ade8e38.

Storybook for the radio swap — added two stories side by side, both opened by default so the listbox is visible:

  • RadioSelectionIndicatordefineTheme({indicators: {check: RadioIndicator}}). Every row draws a circle; only the selected one is filled.
  • DefaultSelectionIndicator — unthemed, for comparison: a check on the selected row, nothing on the others.

That contrast is the whole argument for rendering the mark unconditionally — the empty circles only exist because the indicator is told its state instead of being conditionally rendered.

Visual verification. Ran both Storybooks (this branch and pristine origin/main) and diffed the same stories pixel by pixel:

story pixels changed
checkboxinput--all-variations 0
checkboxinput--indeterminate 0
radiolist--all-variations 0
dropdownmenu--lab-checkbox-items 0
dropdownmenu--lab-radio-group 1.82%
dropdownmenu--lab-selectable-sizes 0.37%

The two non-zero ones are the disclosed 22→24px menu radio alignment. Everything else is byte-identical.

Two things I got wrong before the numbers were trustworthy, both caught by checking rather than by the numbers looking plausible: the first run compared two "Couldn't find story" pages (I'd guessed story IDs), and the second compared two closed menus because my click was wrapped in a silent catch. I added a positive control — a deliberate 6px red ring on the radio dot moved 2602 pixels — so the harness is proven to detect change.

Test suite: 8 failures on the full run, all in perf/timing files (Table perf, Markdown perf, TransferList ×5, submenu roving). Pristine main on the same machine gives 5, from the same set, and the count swings run to run under load. Every component this PR touches — Indicator, CheckboxInput, RadioList, DropdownMenu, Selector — is 256/256 passing in isolation.

github-actions Bot added a commit that referenced this pull request Aug 11, 2026
Indicators are the componentized form of stateful control visuals — the
checkbox box, the radio circle, the mark on a chosen option. The owning
component keeps the input, role, accessible name, focus, and keyboard
behavior; the indicator turns state into a picture.

Replacement is by NAME through defineTheme({indicators}), so one entry
reaches every component that draws that indicator: mapping `check` to
RadioIndicator gives radio visuals to every single-selection mark.

- Families (singleSelection, multiSelection) fix the state space, each
  using the ARIA vocabulary for the state it expresses (#4831). The
  registry is family-checked, so a replacement cannot be written against
  the wrong states.
- CheckboxInput, RadioList, and the DropdownMenu selectable items render
  the shared indicators; ~450 lines of duplicated control styling go.
- Selector renders its mark unconditionally with state, so a replaced
  indicator can draw its unselected form (a radio's empty circle) — the
  default check still draws nothing when unchecked.
- CheckIndicator renders <Icon> directly rather than wrapping it, so a
  host's theme target lands on the glyph, and it adds no new theme target
  of its own.
- The focus ring moves to the wrapper owning the visually-hidden input,
  so replacing an indicator cannot leave a control without a visible
  focus indicator (WCAG 2.4.7).

Deliberately excluded: component icon/indicator slot maps. Replacement
by name covers the known cases; slots stay additive for when it doesn't.
…ppers

Review feedback: styling that describes the visible control belongs on
the control, not on a wrapper around it.

- The focus ring moves from the host wrappers to the indicators, keyed
  off the owner's indicatorScope marker (the input is a visually hidden
  SIBLING, so a plain :focus-visible can never match). This also removes
  the hosts' hardcoded guesses about indicator shape — RadioListItem had
  `borderRadius: 50%` for a circle it does not own. Measured after the
  move: checkbox ring 6px, radio ring 50%, each from its own indicator.
  Drawing the ring is now part of the documented replacement contract.
- The DropdownMenu marker wrappers are gone. Their size styles duplicated
  the indicator's own control size exactly (20/24), and the theme target
  now lands on the visible box.
- No new theme target for the menu checkbox: main reached that element
  through `astryx-checkbox`, which is still on it. `dropdown-menu-radio`
  pre-existed and moves onto the circle.
- Storybook: RadioSelectionIndicator + DefaultSelectionIndicator show the
  check -> radio swap side by side.

Visual diff vs origin/main, both Storybooks running the same stories:
checkbox variations, indeterminate, and radiolist are 0px changed; menu
radio group differs 1.8% and selectable sizes 0.4%, which is the
disclosed 22->24px control-size alignment. Verified the harness detects
change with a positive control, after an earlier run compared two
'story not found' pages and two closed menus.
…echeck

Replacements should not hand-write focus rules. The two built-in rings
were byte-identical and mention no shape — outline follows the element's
own border-radius — so there was never anything per-indicator in them.

- `indicatorFocusRing` is exported alongside `indicatorScope`. Built-ins
  spread it; a replacement spreads the same one line and is done.
- CheckIndicator deliberately does NOT take it: a check marks a chosen
  row in a listbox or menu, and the row takes focus, not the mark. Caught
  by the test asserting the default check renders byte-identically to a
  plain <Icon>.
- Docs point at the exported style instead of describing rules to write.

Also two CI failures from the previous commit, both from checks I had
not run locally:
- `scripts/sync-exports.js` — the new Indicator/ subpath needed a
  package.json export entry.
- `pnpm -F @astryxdesign/storybook typecheck` — my new stories typed
  value as `string | null`, which Selector only accepts with hasClear.

Verified in Chrome after the refactor: checkbox ring 2px/offset 2px/
radius 6px, radio 2px/offset 2px/radius 50% — unchanged.
Removing the marker wrapper took the themeProps call with it, but left
the target declared in DropdownMenu.doc.mjs — a documented target with
no element behind it. build-theme.registry.test.mjs catches exactly
this; I had not run the node project locally.

The declared list is now main's minus dropdown-menu-radio-dot (the
documented removal), with nothing added.
radiusVars in CheckboxInput (the wrapper ring used it), and Theme /
defineTheme / IndicatorProps in the test (the old replacement-survives
test used them). Strict lint reports these as warnings, so CI stayed
green while they accumulated.
Three defects, all surfaced by asking why the doc said group: 'Checkbox'.

- group was 'Checkbox', which picks one of three indicators arbitrarily
  and makes the docsite nest the family under CheckboxInput while MCP
  offers CheckboxList as related. Indicator is its own family with three
  components, like TreeList or Selector, so it groups as 'Indicator'.
- CheckIndicator was undocumented — the component whose replacement is
  the headline use case of the whole feature.
- CheckboxIndicator and RadioIndicator took bare IndicatorProps, whose
  default is the union of every family, so RadioIndicator advertised an
  'indeterminate' state its family cannot express. It typechecked
  (contravariance: accepting more states is assignable where fewer are
  passed), which is why nothing caught it — the family parameter was
  decorative on the components that define the families. Now
  IndicatorProps<'multiSelection'> and <'singleSelection'>, and the doc
  matches.
Two findings from an independent audit against the component rubric,
plus the target rename.

BLOCK — a themed indicator could ship with no visible focus (WCAG
2.4.7). Measured: the ThemedIndicator story rendered byte-identical
rest vs focus, against a default control that differed. Cause: I had
moved the ring onto the indicator, so a replacement omitting it left
nothing to see — the real input is opacity: 0. Passing the style down
as a prop does not fix it either; the sample replacement in our own
Storybook destructures {state, size, isDisabled} and drops everything
else, which is what a theme author plausibly writes.

The ring goes back on the owner, where a replacement cannot reach it.
The cost is that a replacement of a different shape gets the built-in's
ring shape — a cosmetic bug in place of an accessibility failure. I
tried a CSS variable to hand the shape back, and dropped it: the
theming architecture requires every documented var to be reachable
through derived[] standard-property mapping, which would have hijacked
borderRadius on the whole component. Not worth a new theme surface for
a case nobody has hit.

BLOCK — the docs recommended --color-positive and --color-muted, which
do not exist, so following the restyle example produced an invisible
checkbox. This is precisely what the vibe test in #4831 measured
replacements doing (4/4 invented tokens) and what its docs
recommendation was meant to prevent. Every token named in the
directory now exists in tokens.stylex.ts, verified by grep.

Targets renamed to follow the component-name convention:
checkbox -> checkbox-indicator, radio -> radio-indicator, radio-dot ->
radio-indicator-dot. A target is public API, so the old names are still
emitted on the same element via a new themeProps legacyNames option;
themes styling them keep working until the next major. Verified in
Chrome that both selectors paint and a bogus one does not.

Also removes __audit_probe2.tsx, which my  swept in from
the audit run.

Verified: cb-focus and radio-focus are 0px different from origin/main
(the ring renders exactly as it always did); the themed replacement now
shows focus; 876 tests pass across the touched components.
…ner guarantees

Answers 'why can't the indicator control the outline and the owner
control its display'. It can, and :has() is what makes it work.

- The indicator draws its own ring, so it traces its own shape — the
  built-in checkbox rings a rounded square, the radio rings a circle,
  neither describing itself (outline follows border-radius).
- The owner draws one only when the indicator doesn't. An indicator that
  takes over sets a marker; the owner's rule suppresses itself through
  ':has([data-indicator-focus]):has(:focus-visible)', which out-specifies
  its own ':has(:focus-visible)' two pseudo-classes to one.

So a third-party replacement that omits the ring gets the owner's rather
than nothing (WCAG 2.4.7), and one that wants a different shape takes it
over — the compromise in the previous commit is gone, along with the
owners' hardcoded guesses about the shape they host.

The ring and the suppression marker ship as ONE spread
(indicatorFocusRingProps()), because setting the marker without drawing a
ring would be worse than either alone.

Verified in Chrome, all three cases focus-visible:
  built-in checkbox   indicator r=6px,  owner stands down
  built-in radio      indicator r=50%,  owner stands down
  bare replacement    owner r=6px,      indicator draws none
And 0px changed vs origin/main on cb-focus, radio-focus, and the
variation sweeps — the rings look exactly as they do today.

Mechanism proven in isolation first (a standalone :has() page), which is
how I caught that a sibling combinator cannot see a preceding input:
the indicator's ring has to key off the ancestor scope marker.

Also fixes a silently-dropped 5th argument to mergeProps, which takes
four positional arguments — the ring props are nested into the first
instead.
…d outline

Replaces the :has()-suppression machinery with the approach #4654 makes
possible: the owner paints the standard ring onto the indicator's own
element at focus time.

Why the owner and not the indicator: the control's native input is
opacity: 0, so the ring has to land on the picture beside it -- and that
picture is third-party code once a theme replaces it. A replacement that
simply does not draw one ships a control with no visible focus (WCAG
2.4.7), which is the DEFAULT outcome: our own sample replacement
destructures {state, size, isDisabled} and drops the rest. Nothing can
enforce otherwise; a component ignoring every prop is still assignable
to IndicatorComponent, and branding the return type does not work either
because JSX types every expression as JSX.Element.

Why the indicator's element and not a wrapper: outline follows that
element's border-radius, so the ring is shaped correctly without the
owner hardcoding a guess about what it hosts. RadioListItem used to
carry border-radius: 50% for a circle it did not own.

Two details, both measured rather than assumed:

- Inline style, not an injected class. React replaces className
  wholesale on re-render, so an injected class disappears the moment the
  control changes state -- pressing Space on a focused checkbox lost the
  ring. React reconciles style per property, so an outline set this way
  survives.
- The handler checks :focus-visible. The CSS this replaces got
  keyboard-vs-pointer for free; asking the element keeps the browser's
  heuristic, including focus({focusVisible: true}).

Adds FOCUS_OUTLINE_PARTS to the shared focus-outline module -- the same
ring as its StyleX variants, exposed as longhand values for the one case
that must apply it imperatively. Longhands rather than the outline
shorthand for the reason #4654 established: a shorthand resets every
longhand it covers, so a caller could not re-color the ring.

Each owner wraps its indicator in a display: contents slot, so the ring
has one unambiguous target whatever a theme renders.

Verified in Chrome: built-in checkbox rings at r=6px, built-in radio at
r=50%, a replacement that forwards nothing still rings, and a mouse
click rings nothing. 298 tests pass across the touched components.
@cixzhang
cixzhang force-pushed the feat/control-icons branch from 3283572 to df2430d Compare August 11, 2026 06:52
@cixzhang
cixzhang merged commit 505ccaa into main Aug 11, 2026
19 checks passed
cixzhang added a commit to athz/astryx that referenced this pull request Aug 11, 2026
Review cleanup on facebook#4628, applying the paint-vs-layout frame:

- multi-selector-option is the only new target, and now carries the row's
  size plus its select-all / selected / disabled states, so a theme can
  express "selected option at large" or restyle just the Select All row.
- Row typography moves from the label span onto the row, so a theme's
  font override on the row target actually reaches the text — and reaches
  renderOption content too, not just the fallback span.
- Drops -option-checkbox: the wrapper is inert/layout-only, and the
  checkbox visual is directly themeable via checkbox-indicator (facebook#4712).
- Drops -select-all (now a state) and -select-all-divider (a target whose
  purpose is hiding an internal element is a design question, not theming).
cixzhang added a commit that referenced this pull request Aug 11, 2026
…try types

The docsite keeps its own copy of the authoring doc types, and `next build`
type-checks the emitted componentRegistry.ts against it. Two fields had
drifted out of that copy, so any full docsite build failed:

  deprecatedFor  added to ComponentThemingTarget by #4712 (Indicator's
                 renamed theme targets)
  replaces       on ComponentThemingDerivedVar, used by TextArea

Pre-existing on main, not introduced here -- verified by building the
docsite on a clean origin/main worktree, which fails with the same
`'deprecatedFor' does not exist in type 'ThemingTarget'`, and passes
(315/315 static pages) with only this one-file fix applied. It surfaced
on this PR because the change touches core .doc.mjs inputs, so Vercel's
ignored-build-step ran a docsite build for the first time since #4712.

Adds SYNC breadcrumbs on both authoring interfaces, since `check:sync`
is the mechanism the repo already uses to catch exactly this drift.

Diffed all six shared shapes; these two were the only gaps.
cixzhang added a commit that referenced this pull request Aug 11, 2026
…try types

The docsite keeps its own copy of the authoring doc types, and `next build`
type-checks the emitted componentRegistry.ts against it. Two fields had
drifted out of that copy, so any full docsite build failed:

  deprecatedFor  added to ComponentThemingTarget by #4712 (Indicator's
                 renamed theme targets)
  replaces       on ComponentThemingDerivedVar, used by TextArea

Pre-existing on main, not introduced here -- verified by building the
docsite on a clean origin/main worktree, which fails with the same
`'deprecatedFor' does not exist in type 'ThemingTarget'`, and passes
(315/315 static pages) with only this one-file fix applied. It surfaced
on this PR because the change touches core .doc.mjs inputs, so Vercel's
ignored-build-step ran a docsite build for the first time since #4712.

Adds SYNC breadcrumbs on both authoring interfaces, since `check:sync`
is the mechanism the repo already uses to catch exactly this drift.

Diffed all six shared shapes; these two were the only gaps.
cixzhang added a commit that referenced this pull request Aug 11, 2026
#4913)

* fix(indicator): a falsy child no longer deletes the state mark

The busy idiom a host writes is `children={isBusy && <Spinner/>}`, which
passes `false` when it is not busy. `false` is not null and is not caught
by `??`, so every indicator took its children path, rendered nothing in
it, and dropped the checkmark, the checkbox tick and the radio dot on
every selected row.

Filed as #4893 against CheckIndicator, where #4890 moved the branch. It
was wider: measured before writing anything, ALL THREE indicators lost
their mark on `false` and on `''`. CheckIndicator additionally leaked an
empty span. #4890 widened its case from one state to every state -- the
unchecked early return had been masking it.

All three now guard with `isRenderable`, the util the repo already ships
for this. `0` still counts as content: it renders the character "0".

Also, both from the #4890 audit and contained to the same lines:
CheckIndicator's children slot now reserves the glyph's 1rem box and
carries its color, so swapping a Spinner in neither shifts the row nor
loses the disabled shade.

Adds apps/storybook/stories/Indicator.stories.tsx -- the indicators had
no story file (#4894), which is why the children path had no rendered
coverage and this shipped unseen.

Not fixed here: `ref` is dropped on the glyph path. Icon in string mode
routes through IconFromRegistry, which takes no ref, so that is a change
to Icon's ref behavior for every caller -- its own PR.

* fix(storybook): key the Indicator stories to the component, not to meta

CI caught what my local run could not: 'satisfies Meta' + StoryObj<typeof
meta> makes `args` required on every story, and `state` is required on an
indicator, so five render-only stories failed the storybook typecheck.

122 of the 123 story files use `const meta: Meta<typeof X>` with
`StoryObj<typeof X>`; mine was the one that did not. Now it matches.

Verified the way CI does, after a full `pnpm build`: storybook typecheck
clean, storybook build completes, core typecheck (incl. tests) clean, cli
typecheck:strict clean, docsite generate + 364 tests green.

* fix(docsite): carry deprecatedFor and replaces in the generated registry types

The docsite keeps its own copy of the authoring doc types, and `next build`
type-checks the emitted componentRegistry.ts against it. Two fields had
drifted out of that copy, so any full docsite build failed:

  deprecatedFor  added to ComponentThemingTarget by #4712 (Indicator's
                 renamed theme targets)
  replaces       on ComponentThemingDerivedVar, used by TextArea

Pre-existing on main, not introduced here -- verified by building the
docsite on a clean origin/main worktree, which fails with the same
`'deprecatedFor' does not exist in type 'ThemingTarget'`, and passes
(315/315 static pages) with only this one-file fix applied. It surfaced
on this PR because the change touches core .doc.mjs inputs, so Vercel's
ignored-build-step ran a docsite build for the first time since #4712.

Adds SYNC breadcrumbs on both authoring interfaces, since `check:sync`
is the mechanism the repo already uses to catch exactly this drift.

Diffed all six shared shapes; these two were the only gaps.
cixzhang added a commit that referenced this pull request Aug 12, 2026
…d size (#4628)

* feat(multi-selector): expose dropdown row/checkbox/select-all theme targets

Expose theme targets for the dropdown option rows, per-row checkbox,
Select All row, and Select All divider. Font styling is themeable via
the option target directly (no separate label target needed).

The option target is intended for visual overrides (borderRadius,
backgroundColor, padding) — the layout properties (flex, gap) are
internal structure and should not be themed.

* feat(multi-selector): one themeable option target, with states and size

Review cleanup on #4628, applying the paint-vs-layout frame:

- multi-selector-option is the only new target, and now carries the row's
  size plus its select-all / selected / disabled states, so a theme can
  express "selected option at large" or restyle just the Select All row.
- Row typography moves from the label span onto the row, so a theme's
  font override on the row target actually reaches the text — and reaches
  renderOption content too, not just the fallback span.
- Drops -option-checkbox: the wrapper is inert/layout-only, and the
  checkbox visual is directly themeable via checkbox-indicator (#4712).
- Drops -select-all (now a state) and -select-all-divider (a target whose
  purpose is hiding an internal element is a design question, not theming).

---------

Co-authored-by: athz <28840834+athz@users.noreply.github.com>
Co-authored-by: Cindy Zhang <cindyxz@meta.com>
cixzhang added a commit that referenced this pull request Aug 12, 2026
…ets guard

MoreMenu hand-assembled its `astryx-more-menu` theme target with
`stableClassName()` and string concatenation. Route it through
`themeProps()`/`mergeProps()` instead, so the target sits on the same
reflection surface as every other one. The rendered DOM is unchanged.

themingTargets.test.ts grows the second direction of the guard —
documented targets must actually be rendered — and scans both
`@astryxdesign/core` and `@astryxdesign/lab` package-wide for the
`docs` block. `docsZh` stays a per-directory check.

Rebased onto main, which moved 69 commits under it:

- `themeProps()` gained a third `options.legacyNames` argument
  (#4712, #4890) for targets renamed behind a deprecation window. The
  extractor only read the first two arguments, so `astryx-checkbox`,
  `astryx-radio` and `astryx-radio-dot` — emitted through
  `legacyNames` and deliberately documented with `deprecatedFor` —
  looked unrendered. Teach it to read `legacyNames` string literals as
  rendered sites of their own, with the same prop keys (they land on the
  same element), and cover it with two extractor tests.
- ListInput (#4740) and TransferList (#4870) landed after this branch
  was cut and render five undocumented targets between them. Document
  them, the same way the other lab components here are documented.
- Stepper's targets were extended on main with the same
  `indicatorPosition` entry this branch added, plus
  `astryx-step-indicator` and the matching `docsZh` block; kept
  main's superset. Both sides' new MoreMenu tests are kept.
cixzhang added a commit that referenced this pull request Aug 12, 2026
…ets guard

MoreMenu hand-assembled its `astryx-more-menu` theme target with
`stableClassName()` and string concatenation. Route it through
`themeProps()`/`mergeProps()` instead, so the target sits on the same
reflection surface as every other one. The rendered DOM is unchanged.

themingTargets.test.ts grows the second direction of the guard —
documented targets must actually be rendered — and scans both
`@astryxdesign/core` and `@astryxdesign/lab` package-wide for the
`docs` block. `docsZh` stays a per-directory check.

Rebased onto main, which moved 69 commits under it:

- `themeProps()` gained a third `options.legacyNames` argument
  (#4712, #4890) for targets renamed behind a deprecation window. The
  extractor only read the first two arguments, so `astryx-checkbox`,
  `astryx-radio` and `astryx-radio-dot` — emitted through
  `legacyNames` and deliberately documented with `deprecatedFor` —
  looked unrendered. Teach it to read `legacyNames` string literals as
  rendered sites of their own, with the same prop keys (they land on the
  same element), and cover it with two extractor tests.
- ListInput (#4740) and TransferList (#4870) landed after this branch
  was cut and render five undocumented targets between them. Document
  them, the same way the other lab components here are documented.
- Stepper's targets were extended on main with the same
  `indicatorPosition` entry this branch added, plus
  `astryx-step-indicator` and the matching `docsZh` block; kept
  main's superset. Both sides' new MoreMenu tests are kept.
@github-actions
github-actions Bot deleted the feat/control-icons branch August 13, 2026 06:36
cixzhang added a commit that referenced this pull request Aug 14, 2026
…ets guard

MoreMenu hand-assembled its `astryx-more-menu` theme target with
`stableClassName()` and string concatenation. Route it through
`themeProps()`/`mergeProps()` instead, so the target sits on the same
reflection surface as every other one. The rendered DOM is unchanged.

themingTargets.test.ts grows the second direction of the guard —
documented targets must actually be rendered — and scans both
`@astryxdesign/core` and `@astryxdesign/lab` package-wide for the
`docs` block. `docsZh` stays a per-directory check.

Rebased onto main, which moved 69 commits under it:

- `themeProps()` gained a third `options.legacyNames` argument
  (#4712, #4890) for targets renamed behind a deprecation window. The
  extractor only read the first two arguments, so `astryx-checkbox`,
  `astryx-radio` and `astryx-radio-dot` — emitted through
  `legacyNames` and deliberately documented with `deprecatedFor` —
  looked unrendered. Teach it to read `legacyNames` string literals as
  rendered sites of their own, with the same prop keys (they land on the
  same element), and cover it with two extractor tests.
- ListInput (#4740) and TransferList (#4870) landed after this branch
  was cut and render five undocumented targets between them. Document
  them, the same way the other lab components here are documented.
- Stepper's targets were extended on main with the same
  `indicatorPosition` entry this branch added, plus
  `astryx-step-indicator` and the matching `docsZh` block; kept
  main's superset. Both sides' new MoreMenu tests are kept.
cixzhang added a commit that referenced this pull request Aug 14, 2026
…ets guard

MoreMenu hand-assembled its `astryx-more-menu` theme target with
`stableClassName()` and string concatenation. Route it through
`themeProps()`/`mergeProps()` instead, so the target sits on the same
reflection surface as every other one. The rendered DOM is unchanged.

themingTargets.test.ts grows the second direction of the guard —
documented targets must actually be rendered — and scans both
`@astryxdesign/core` and `@astryxdesign/lab` package-wide for the
`docs` block. `docsZh` stays a per-directory check.

Rebased onto main, which moved 69 commits under it:

- `themeProps()` gained a third `options.legacyNames` argument
  (#4712, #4890) for targets renamed behind a deprecation window. The
  extractor only read the first two arguments, so `astryx-checkbox`,
  `astryx-radio` and `astryx-radio-dot` — emitted through
  `legacyNames` and deliberately documented with `deprecatedFor` —
  looked unrendered. Teach it to read `legacyNames` string literals as
  rendered sites of their own, with the same prop keys (they land on the
  same element), and cover it with two extractor tests.
- ListInput (#4740) and TransferList (#4870) landed after this branch
  was cut and render five undocumented targets between them. Document
  them, the same way the other lab components here are documented.
- Stepper's targets were extended on main with the same
  `indicatorPosition` entry this branch added, plus
  `astryx-step-indicator` and the matching `docsZh` block; kept
  main's superset. Both sides' new MoreMenu tests are kept.
cixzhang added a commit that referenced this pull request Aug 14, 2026
…ets guard

MoreMenu hand-assembled its `astryx-more-menu` theme target with
`stableClassName()` and string concatenation. Route it through
`themeProps()`/`mergeProps()` instead, so the target sits on the same
reflection surface as every other one. The rendered DOM is unchanged.

themingTargets.test.ts grows the second direction of the guard —
documented targets must actually be rendered — and scans both
`@astryxdesign/core` and `@astryxdesign/lab` package-wide for the
`docs` block. `docsZh` stays a per-directory check.

Rebased onto main, which moved 69 commits under it:

- `themeProps()` gained a third `options.legacyNames` argument
  (#4712, #4890) for targets renamed behind a deprecation window. The
  extractor only read the first two arguments, so `astryx-checkbox`,
  `astryx-radio` and `astryx-radio-dot` — emitted through
  `legacyNames` and deliberately documented with `deprecatedFor` —
  looked unrendered. Teach it to read `legacyNames` string literals as
  rendered sites of their own, with the same prop keys (they land on the
  same element), and cover it with two extractor tests.
- ListInput (#4740) and TransferList (#4870) landed after this branch
  was cut and render five undocumented targets between them. Document
  them, the same way the other lab components here are documented.
- Stepper's targets were extended on main with the same
  `indicatorPosition` entry this branch added, plus
  `astryx-step-indicator` and the matching `docsZh` block; kept
  main's superset. Both sides' new MoreMenu tests are kept.
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. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Indicators: componentized, themeable selection visuals (vibe test results)

1 participant