Selector, MultiSelector: configurable panel empty states - #5462
Merged
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
cixzhang
marked this pull request as ready for review
August 25, 2026 01:14
Contributor
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsMultiSelector (@astryxdesign/core) · View in Storybook
Selector (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
…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
…. the loading panel
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Both selectors hardcoded the English string
No results foundinto 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. AndSelectorrendered that message only while searching, so aSelectorhanded zero options opened onto a completely blank panel.What
Two
ReactNodeprops on each component, following the CommandPalette pair (emptySearchText/emptyBootstrapText):emptyText— no options to show at all. DefaultNo options.emptySearchText— a search query matched nothing. DefaultNo 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.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.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.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.emptySearchResultskeys, which own both sinks now; two new keys (@astryx.selector.empty,@astryx.multiSelector.empty) cover the no-options case. Theastryx-selector-empty-state/astryx-multi-selector-empty-statetheme targets are unchanged.Screenshots
Chromium, neutral theme.
announcedis 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
No options, announcedMultiSelector, no options — search wording with no query typed
No results found, nothing announcedNo options, announcedCustom 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.
No results foundThe 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 announcedTyping 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:
Selectorwith no options used to render an empty panel; it now rendersNo optionsand announces it.MultiSelectorwith no options used to sayNo results found— the search wording — even with no query typed; it now saysNo options. A query with no matches still saysNo results found.MultiSelectorwithisLoadingand no options used to sayNo 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 andcheck:repoclean.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.cjsandselector-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.