Skip to content

Selector, MultiSelector: configurable panel empty states - #5462

Merged
cixzhang merged 5 commits into
mainfrom
selector-empty-state
Aug 25, 2026
Merged

Selector, MultiSelector: configurable panel empty states#5462
cixzhang merged 5 commits into
mainfrom
selector-empty-state

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Why

Both selectors hardcoded the English string No results found into their dropdown panel. It could not be translated and could not be replaced, while every neighbouring surface — CommandPalette, Typeahead — already takes the message as a prop. And Selector rendered that message only while searching, so a Selector handed zero options opened onto a completely blank panel.

What

Two ReactNode props on each component, following the CommandPalette pair (emptySearchText / emptyBootstrapText):

  • emptyText — no options to show at all. Default No options.
  • emptySearchText — a search query matched nothing. Default No results found.

The panel message is role="presentation"role="listbox" only permits option/group children — so it reaches assistive tech solely through the polite live region. Everything else here follows from keeping those two channels in step. Whatever the panel shows, the region says; when the panel deliberately shows nothing, the region is silent.

  • Both props feed announce() as well as the panel. A string override is spoken verbatim; a richer node falls back to the default copy rather than going silent.
  • The no-options message is driven by state, not by the open event: open, not loading, no query, no options. That covers opening onto an empty list and a fetch that lands empty while the panel is already open — the open event alone misses the second, which is the common one.
  • Neither message renders while isLoading, and neither is announced. The options have not arrived, so either would be a claim the component cannot make; the trigger's spinner carries that state.
  • Type-to-open seeds the query through onSearchSeed, which never reached the search input's change handler and so announced nothing. The announcing half is split out and the seed path calls it, so a seeded query and a typed one behave identically.

Defaults reuse the shipped @astryx.selector.emptySearchResults / @astryx.multiSelector.emptySearchResults keys, which own both sinks now; two new keys (@astryx.selector.empty, @astryx.multiSelector.empty) cover the no-options case. The astryx-selector-empty-state / astryx-multi-selector-empty-state theme targets are unchanged.

Screenshots

Chromium, neutral theme. announced is the polite live region's text, read from the DOM at the same instant as the frame.

Selector, no options — the blank panel is the bug

Before: panel blank, nothing announced After: No options, announced

MultiSelector, no options — search wording with no query typed

Before: No results found, nothing announced After: No options, announced

Custom text, and what a screen reader hears

Review caught that the first commit sent custom text to the panel and the built-in string to the live region. Middle column is that commit; right column is head.

Before First commit: panel custom, announced No results found After: both custom

The loading boundary

A source that takes ~3s, sampled at 500ms and 4500ms. Left is still fetching, right is after it lands with zero options.

isLoading: no message, nothing announced Landed empty: message and announcement

Typing while the fetch is still out stays silent in both channels (head__*_search_while_loading__*), and a load that lands with options shows no empty state at all (head__selector_loading_to_full__t4500).

Risk

Three visible behaviour changes, all above:

  • Selector with no options used to render an empty panel; it now renders No options and announces it.
  • MultiSelector with no options used to say No results found — the search wording — even with no query typed; it now says No options. A query with no matches still says No results found.
  • MultiSelector with isLoading and no options used to say No results found; both components now say nothing until the options arrive.

Internal consumers of Selector (PowerSearch, Table filtering, Pagination) inherit the first change; a filled panel beats a blank one in all three.

Testing

Selector + MultiSelector: 272 passing, including twelve new cases. Each of the ten covering the announcement, the seed path and the loading behaviour was checked to fail without its change. Consumers (PowerSearch, Table, Pagination, ComplexSelector, Typeahead): 739 passing together with the two suites. tsc, eslint, prettier and check:repo clean.

Two review passes ran over this PR, and their probes are banked in probe-kit/selector-empty-async-strand.cjs (the loading timeline above), selector-seed-query-announce.cjs and selector-empty-state-announce.cjs. Every finding they raised is fixed here; the async probe is what turned up the fetch-lands-empty hole in the fix itself.

Note: pr-visual is red for an unrelated reason — Theme butter is not built … run pnpm build before the visual gate, which is failing the same way on other open PRs (e.g. #5475) and has a fix in flight on ci-visual-gate-core-dist.

Both selectors hardcoded the English string "No results found" in their
dropdown panel, so it could not be translated or replaced. Follow the
CommandPalette pattern instead: two ReactNode props with i18n-backed
defaults.

- emptyText — no options to show at all (default "No options")
- emptySearchText — a query matched nothing (default "No results found")

Selector previously rendered the empty state only while searching, so a
Selector given zero options opened onto a blank panel; it now shows
emptyText. MultiSelector already covered both cases with the same
message and now distinguishes them.
@vercel

vercel Bot commented Aug 25, 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 25, 2026 5:16am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 25, 2026
@cixzhang
cixzhang marked this pull request as ready for review August 25, 2026 01:14
@github-actions

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

MultiSelector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1558 -
Complexity N/A Very High (202) -
Selector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1659 -
Complexity N/A Very High (181) -

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 25, 2026
…t while loading

Review of the first commit found the new props reached the panel and
nothing else. The panel message is role="presentation" and is justified
by the live region covering it, so a caller who set emptySearchText got
custom text on screen and the built-in string in a screen reader; with
no options at all, nothing was announced and the justification was
simply untrue.

- Both messages now feed announce() as well as the panel. A string
  override is announced verbatim; a ReactNode cannot be spoken, so that
  case falls back to the catalog copy rather than going silent.
- Opening a panel with no options announces emptyText — previously
  there was no option to focus and no result count, so nothing spoke.
- Neither message renders while isLoading: the options have not arrived,
  so "No options" is a claim the component cannot make. This also fixes
  a pre-existing MultiSelector bug where a loading panel said "No
  results found".
- @astryx.selector.emptySearch duplicated @astryx.selector.emptySearchResults
  word for word; the new keys are gone and the shipped ones now own both
  sinks. Same for the multiSelector pair.

Each of the six new tests fails without the corresponding change.
cixzhang added a commit to cixzhang/astryx that referenced this pull request Aug 25, 2026
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
The second review found the loading gate and the announcement had been
applied to different sets of paths, so the panel and the live region
could disagree — the same defect the first round fixed, in the places
the fix did not reach.

- While isLoading the panel shows nothing but handleSearchChange still
  announced a result, putting a claim in the one channel the screen had
  deliberately gone quiet for. Both components now gate the
  announcement on the same condition as the panel.
- Type-to-open seeds the query through onSearchSeed, which never
  reached handleSearchChange, so a seeded query announced nothing at
  all — and with no options it left the on-open "No options"
  announcement standing while the panel had switched to the search
  message. The announcing half is split out as announceSearchResults
  and the seed path now calls it. A ref mirrors the query so the seed
  can still append without dropping a character, which is what the
  state updater was there for.

Also flush a frame before asserting the live region stayed empty:
useAnnounce writes on a rAF, so the three negative assertions passed
whether or not anything was announced. Each of the three new tests now
fails without its change.
…nds empty

Driving the reviewer's own async probe against the previous commit showed
the announcement still had a hole: it fired on the open event, so a panel
opened while loading and then handed zero options filled in on screen and
said nothing. The open event is the wrong trigger — the panel can arrive
at "empty" without one.

Both components now watch the state instead: open, not loading, no query,
no options. That covers opening onto an empty list and a fetch landing
empty with one mechanism, and drops the special case from onOpen. A ref
keyed on the message keeps it to one announcement per arrival rather than
one per render.
cixzhang added a commit to cixzhang/astryx that referenced this pull request Aug 25, 2026
@cixzhang
cixzhang merged commit 8985bb9 into main Aug 25, 2026
20 of 21 checks passed
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
@github-actions
github-actions Bot deleted the selector-empty-state branch August 25, 2026 06:12
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.

1 participant