Skip to content

feat(core): let Selector, MultiSelector, and ComplexSelector drop the trigger chevron - #5318

Open
ernestt wants to merge 1 commit into
mainfrom
feat/selector-indicator-opt-out
Open

feat(core): let Selector, MultiSelector, and ComplexSelector drop the trigger chevron#5318
ernestt wants to merge 1 commit into
mainfrom
feat/selector-indicator-opt-out

Conversation

@ernestt

@ernestt ernestt commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Problem

The chevron renders as a sibling of the trigger button, after the optional InputClearButton. So with hasClear and a value selected, the trigger shows both a × and a chevron in the same end slot, and consumers who wanted the clear affordance to replace the chevron had no way to say so:

  • The chevron's theme target (astryx-selector-indicator-icon) only carries data-state="expanded|collapsed".
  • The selector root publishes data-variant, data-size, data-status, data-disabled — nothing for "has a value".
  • StyleX has no descendant selectors and stylex.when.* only reads upward, so an xstyle on the field cannot reach the icon.

No sanctioned escape hatch existed.

API added

One optional boolean on all three components:

/** Whether to show the chevron at the end of the trigger. ... @default true */
hasChevron?: boolean;
<Selector hasClear hasChevron={false} value={value} onChange={setValue} />
  • Default unchanged. hasChevron = true in the destructure (the component idiom here — DropdownMenu uses hasChevron = true, Selector uses indicatorPosition = 'end'; the ?? true form in feat(Table): let useTableSelection opt out of the checked-row highlight #5310 was for a hook config object, which has no destructure to default). A consumer that omits it renders identically.
  • Gates only the chevron. On Selector and MultiSelector the end slot is a ternary: a status glyph renders instead of the chevron when a status is attached. hasChevron gates the chevron arm alone — a status glyph reports on the value, so suppressing it would hide information rather than an affordance. With an attached status the flag is a no-op, which is asserted.
  • a11y-neutral, verified in the markup rather than assumed. Icon without a label emits aria-hidden="true", and the chevron is a sibling of the <button>, not a descendant — so it is neither in the a11y tree nor part of the button's name computation. Both facts are asserted in tests, alongside accessible name, textContent, and tab order being identical across the two modes.

Why hasChevron, not hasIndicator

indicator is already taken on two of the three components, meaning something else. Selector and MultiSelector both expose indicatorPosition — which edge of an option row inside the popup carries the selected mark (a check on Selector, a checkbox on MultiSelector). Selector.tsx imports useIndicator and IndicatorPosition from the Indicator module for exactly that. hasIndicator={false} next to indicatorPosition="start" would read as "turn off the option checkmarks."

hasChevron is the existing house name for this exact affordance: DropdownMenu already ships hasChevron?: boolean defaulting to true to suppress its trigger chevron — and its theme target is likewise named astryx-dropdown-menu-indicator-icon, so the theme-target/prop-name split is precedent, not a new inconsistency. It also matches the has* convention for presentational sub-affordances (hasClear, hasSearch, hasHover, hasRowHighlight).

Per-component notes

They are consistent in name, default, and semantics, but the trigger markup differs in two ways worth flagging:

  • ComplexSelector has no hasClear. Its end slot holds only the chevron, so the clear-replaces-chevron motivation does not apply. The flag is still useful (a trigger whose composed content supplies its own end affordance), and its doc example says so rather than reusing the clear-button framing.
  • ComplexSelector renders no on-field status glyph, so its chevron was unconditional. It becomes {hasChevron && <Icon …/>}; the other two become a third ternary arm.

Docs

Per-file, matching each doc's actual shape rather than assuming three surfaces everywhere:

  • Selector.doc.mjsdocs.props + a new examples entry. Its docsZh/docsDense are usage-only overlays with no propDescriptions at all; adding a lone Chinese entry would have been the only one in the file, so it was left alone.
  • MultiSelector.doc.mjs — all three: docs…props, docsZh…propDescriptions (translated), docsDense…propDescriptions, plus an examples entry.
  • ComplexSelector.doc.mjsdocs…props + docsDense.propDescriptions (it has no docsZh), plus an examples entry.

SYNC: headers were checked on all three sources — each already lists its own .doc.mjs, so unlike #5310 there was no missing entry to add. The lists also name storybook stories and packages/cli/assets/templates/blocks/**; neither needs a change, since the prop is additive with an unchanged default (and templates were out of bounds for this PR).

Changeset: [feat] / patch, per the 0.x coupling check:changesets enforces (only [breaking] may bump the minor while 0.x).

Test plan

19 tests added — 7 on Selector, 7 on MultiSelector, 5 on ComplexSelector:

  • The chevron renders by default (all three)
  • hasChevron={false} removes it (all three)
  • With hasClear + a value, the clear button is still present and the chevron is gone (Selector, MultiSelector)
  • Accessible name, trigger textContent, and tab order are identical across both modes (all three)
  • The chevron is aria-hidden="true" and is not contained by the trigger button (all three)
  • An attached status glyph still renders with the chevron off, and the icon count is the same either way (Selector, MultiSelector)
  • The popup still opens and aria-expanded still flips with the chevron off (all three)

Non-vacuity was checked, not assumed. I stashed only the three .tsx sources, kept the tests, and re-ran: 11 of the 19 fail without the change. The 8 that pass either way do so by construction, and I want to be straight about which:

  • 3 × "renders the chevron by default" — a default-preservation guard necessarily passes both ways; its value is catching a future change of default.
  • 3 × "chevron is aria-hidden and outside the button" — deliberate characterization of pre-existing markup. This is the evidence for the a11y-neutrality claim, so it is meant to hold before and after.
  • 2 × "keeps the status glyph" — with an attached status the chevron arm never runs either way, so this is a forward guard on the semantic split (it fails if someone later widens the flag to gate the status icon).

Every test that asserts the opt-out itself fails without the change; each hasChevron={false} render asserts the chevron is absent so none of them can drift vacuous.

Gates:

  • pnpm -F @astryxdesign/core typecheck and typecheck:docs — clean
  • eslint on all 10 changed files at ASTRYX_STRICT_LINT=1 — 0 errors (the one warning, unused borderVars in MultiSelector.tsx, reproduces on origin/main and is untouched here)
  • prettier --check on all 10 changed files — clean
  • vitest run — Selector 150 passed, MultiSelector 112 passed, ComplexSelector 17 passed (279 total, 3 files)
  • pnpm check:repo — exit 0
  • astryx component <Name> --dense renders the new prop row and example for all three

Made with Cursor

… trigger chevron

The chevron renders as a sibling of the trigger button, after the optional
clear button, so a selector with hasClear and a value showed both a × and a
chevron in the same slot. StyleX has no descendant selectors and
stylex.when.* only reads upward, so an xstyle on the field could not reach
the icon — there was no sanctioned way to suppress it.

hasChevron defaults to true, so existing selectors render identically. It
gates only the chevron: a status glyph shares that slot and still appears.
The chevron is decorative (aria-hidden) and outside the button, so the
accessible name, focus order, and keyboard behaviour are unaffected.

Named to match DropdownMenu's existing hasChevron rather than hasIndicator,
which would collide with indicatorPosition — already the selected mark inside
an option row on Selector and MultiSelector.

Co-authored-by: Cursor <cursoragent@cursor.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 22, 2026
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 22, 2026 1:35am

Request Review

@github-actions github-actions Bot added the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Aug 22, 2026
@github-actions

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

ComplexSelector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 457 -
Complexity N/A Very High (43) -
MultiSelector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1468 -
Complexity N/A Very High (184) -
Selector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1581 -
Complexity N/A Very High (169) -

Bundle Size Summary

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

Accessibility Audit

Status: No accessibility violations detected.


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

github-actions Bot added a commit that referenced this pull request Aug 22, 2026
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:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant