fix(Select): match search against rendered DOM text - #1154
Conversation
🦋 Changeset detectedLatest commit: 64078fb The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
639286a to
56c9741
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 56c9741. Configure here.
Search matched a statically-extracted approximation of each item's text (getTextFromNodes walking the React tree), so any item whose visible text wasn't a plain string child was invisible to search and got filtered out: text rendered by a child component, the `label` prop, and numeric children. Now search matches the text the browser actually rendered, read from the DOM — like a native <select>. Also fixes two issues uncovered along the way: - Disabled options (options API) were keyboard-navigable: the initial navigable set was built from the component's `disabled` prop instead of the per-item `disabled`, so arrow keys could land on a disabled row. - The trigger rendered empty on first paint / SSR because the selected value and placeholder were gated behind an `isInitialized` flag flipped only in a post-mount effect. Technical shape: split the old `list` (useState + useEffect) into `normalizedOptions` (memo; value + disabled; feeds the hidden native <select> and the trigger's valueNode) and a `searchSource` state map read from the DOM by a callback ref on the list container. The ref runs once the list node commits (robust to Radix mounting the popover a commit later) and re-attaches when the option set changes, so async-loaded options stay searchable. Visibility, navigability, and the effective highlight are derived from search + normalizedOptions + searchSource, removing onUpdateSearch, the rebuild effect, onOpenAutoFocus, and the isInitialized/ visibleList/navigatable state. Group heading text is captured too, via cui-select-group markers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
56c9741 to
9228141
Compare
Rename the search derivations to say what they hold — matchedOptions (option objects), visibleValues / navigableValues (value strings) — inline the two single-use helpers, and drop comments that restated the type or a path not taken. Add a test pinning that search matches an option's description text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Filtered items now render hidden (via the `hidden` attribute) instead of unmounting, so every option's text stays in the DOM for the search-source capture. This makes options that arrive while the menu is open — e.g. async loads, or fetch-as-you-type — searchable without reopening, and lets the capture drop its carry-over since it always reads the complete set. Group visibility now counts non-hidden items. Select-family search tests assert visibility (toBeVisible / not.toBeVisible) rather than DOM presence, since a filtered row is now present-but-hidden. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
InternalSelect no longer builds a static item model, so the shared SelectItemObject type (and its re-export) is dead — AutoComplete keeps its own local copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Storybook Preview Deployed✅ Preview URL: https://click-l709v3pin-clickhouse.vercel.app Built from commit: |
| if ('options' in option) { | ||
| return (option.options ?? []).map(item => { | ||
| valueNode.current.set(item.value, item); | ||
| return { value: item.value, disabled: item.disabled }; |
There was a problem hiding this comment.
Should be registerValueNode as well
| setSearchSource(next); | ||
| }, | ||
| [highlighted, list] | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on normalizedOptions to re-read DOM if options change |
| } | ||
| const searchLowerCase = search.toLowerCase(); | ||
| return normalizedOptions.filter(item => | ||
| (searchSource.get(item.value) ?? '').includes(searchLowerCase) |
There was a problem hiding this comment.
Might want to split the search by whitespace and parts.some(p => source includes(p)) for a better search ux
| return null; | ||
| } | ||
| // Filtered items stay mounted (just hidden) so their text remains readable | ||
| // from the DOM for search; see the searchSource capture in InternalSelect. |

What
Select search now matches the text the browser actually renders (read from the DOM), instead of a static walk of the React tree. Any item whose visible text isn't a plain string child was invisible to that walk, so matching rows got hidden. Now search works for any item.
Bugs fixed
labelprop, or a number were unsearchable and dropped from results. (Also now matches an item'sdescription)How
textContent) into state, rather than statically extracted from the React tree.updateList/onUpdateSearch/isInitialized/visibleList/navigatable) are gone.Notes
AutoCompleteshares the same search pattern and the same latent bug; it'll be ported to this approach separately.Testing
New
Select.search.test.tsxcovers the custom-component / label / numeric / description / group-heading / async / keyboard cases; the Select-family search tests assert visibility (toBeVisible), so a wrongly-hidden option fails the suite. 82 tests green.🤖 Generated with Claude Code