From b068abbf075b6e93fed0b1095337ab9b135377e7 Mon Sep 17 00:00:00 2001 From: Kian Bazarjani Date: Sat, 28 Feb 2026 10:39:20 -0500 Subject: [PATCH] fix: normalize search query whitespace for fuzzy filtering --- .../listbox/store/ListboxStore.test.ts | 17 +++++++ .../internal/listbox/store/ListboxStore.ts | 45 ++++++++++++------- .../__tests__/stateful-items.test.ts | 32 +++++++++++++ .../internal/popup-menu/deep-search/utils.ts | 19 +++++--- 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/packages/react/src/internal/listbox/store/ListboxStore.test.ts b/packages/react/src/internal/listbox/store/ListboxStore.test.ts index d2cf8989..2b5c6f43 100644 --- a/packages/react/src/internal/listbox/store/ListboxStore.test.ts +++ b/packages/react/src/internal/listbox/store/ListboxStore.test.ts @@ -367,6 +367,15 @@ describe('ListboxStore', () => { expect(ids).not.toContain('cherry') }) + it('ignores trailing whitespace in search query', () => { + store.setSearch('app ') + + const ids = store.getVisibleItemIds() + expect(ids).toContain('apple') + expect(ids).not.toContain('banana') + expect(ids).not.toContain('cherry') + }) + it('shows all items when search is empty', () => { store.setSearch('app') store.setSearch('') @@ -383,6 +392,14 @@ describe('ListboxStore', () => { expect(store.state.filteredCount).toBe(1) }) + it('treats whitespace-only search as empty', () => { + store.setSearch(' ') + + const ids = store.getVisibleItemIds() + expect(ids).toEqual(['apple', 'banana', 'cherry']) + expect(store.state.filteredCount).toBe(3) + }) + it('filters with keywords', () => { store.context.items.clear() store.registerItem('item-1', { diff --git a/packages/react/src/internal/listbox/store/ListboxStore.ts b/packages/react/src/internal/listbox/store/ListboxStore.ts index d26c4ea5..efc2c424 100644 --- a/packages/react/src/internal/listbox/store/ListboxStore.ts +++ b/packages/react/src/internal/listbox/store/ListboxStore.ts @@ -14,6 +14,7 @@ import type { PopupMenuOpenChangeReason, } from '../../popup-menu/events.js' import { commandScore } from '../utils/command-score.js' +import { normalizeValue } from '../utils/normalize.js' // ============================================================================ // Types @@ -253,11 +254,12 @@ const selectors = { isGroupVisible: createSelector( (state: ListboxState, groupId: string) => - state.search.length === 0 || state.visibleGroups.has(groupId), + normalizeValue(state.search).length === 0 || + state.visibleGroups.has(groupId), ), getItemScore: createSelector((state: ListboxState, itemId: string) => { - if (state.search.length === 0) { + if (normalizeValue(state.search).length === 0) { return 1 // All items visible when no search } return state.filteredItems.get(itemId) ?? 0 @@ -265,7 +267,7 @@ const selectors = { hasSearchWithNoResults: createSelector((state: ListboxState) => { // Must have an active search - if (state.search.length === 0) return false + if (normalizeValue(state.search).length === 0) return false // In virtualized mode with items prop, check virtualItemsCount // (filteredCount won't be accurate since items aren't registered in DOM) @@ -278,6 +280,10 @@ const selectors = { }), } +const defaultFilter: FilterFn = (value, search, keywords) => { + return commandScore(value, normalizeValue(search), keywords) +} + // ============================================================================ // Store // ============================================================================ @@ -298,7 +304,7 @@ export class ListboxStore extends ReactStore< context?: Partial, ) { const defaultContext: ListboxContext = { - filter: commandScore, + filter: defaultFilter, loop: true, autoHighlightFirst: true, clearSearchOnClose: true, @@ -508,7 +514,7 @@ export class ListboxStore extends ReactStore< setHideUntilActive(enabled: boolean) { this.context.hideUntilActive = enabled // If enabling and there's already search content, activate immediately - if (enabled && this.state.search.length > 0) { + if (enabled && normalizeValue(this.state.search).length > 0) { this.setInputActive(true) } } @@ -976,7 +982,8 @@ export class ListboxStore extends ReactStore< // Check if item is visible (passes filter) const score = this.state.filteredItems.get(itemId) ?? 0 - const isVisible = this.state.search.length === 0 || score > 0 + const isVisible = + normalizeValue(this.state.search).length === 0 || score > 0 if (!isVisible) return false const onSelect = this.context.itemSelects.get(itemId) @@ -1081,7 +1088,7 @@ export class ListboxStore extends ReactStore< getVisibleItemIds(): string[] { const result: string[] = [] - const search = this.state.search + const search = normalizeValue(this.state.search) const filteredItems = this.state.filteredItems const virtualItems = this.context.virtualItems const orderedItems = this.context.orderedItems @@ -1186,10 +1193,16 @@ export class ListboxStore extends ReactStore< // Determine if search changed (requires reset to first item) // Use prevSearch from options if provided (from observer), otherwise fall back to state - const prevSearch = - optionsPrevSearch !== undefined ? optionsPrevSearch : this.state.search - const effectiveSearch = newSearch !== undefined ? newSearch : prevSearch - const searchChanged = newSearch !== undefined && newSearch !== prevSearch + const prevSearch = normalizeValue( + optionsPrevSearch !== undefined ? optionsPrevSearch : this.state.search, + ) + const effectiveSearch = normalizeValue( + newSearch !== undefined ? newSearch : prevSearch, + ) + const normalizedNewSearch = + newSearch !== undefined ? normalizeValue(newSearch) : undefined + const searchChanged = + normalizedNewSearch !== undefined && normalizedNewSearch !== prevSearch // If not open or autoHighlightFirst disabled, don't change anything if (!this.state.open || !this.context.autoHighlightFirst) { @@ -1286,6 +1299,7 @@ export class ListboxStore extends ReactStore< private recomputeFilteredItems(prevSearch?: string) { const { filter } = this.context const search = this.state.search + const normalizedSearch = normalizeValue(search) const items = this.context.items const groups = this.context.groups @@ -1294,7 +1308,7 @@ export class ListboxStore extends ReactStore< let filteredCount = 0 // If no search or filtering disabled, all items are visible - if (!search || filter === false) { + if (!normalizedSearch || filter === false) { // When virtualized with consumer-side filtering (filter === false), // use virtualItems as the source of truth for what's visible. // This ensures the scores match the consumer's filtered array, @@ -1319,7 +1333,7 @@ export class ListboxStore extends ReactStore< items.forEach((registration, id) => { const fuzzyScore = filterFn( registration.value, - search, + normalizedSearch, registration.keywords, ) const score = registration.forceScore ?? fuzzyScore @@ -1351,8 +1365,9 @@ export class ListboxStore extends ReactStore< // We pass filteredItems, newSearch, and prevSearch here because we need to detect search cleared const highlightedId = this.validateHighlight({ filteredItems, - newSearch: search, - prevSearch, + newSearch: normalizedSearch, + prevSearch: + prevSearch !== undefined ? normalizeValue(prevSearch) : undefined, }) this.update({ diff --git a/packages/react/src/internal/popup-menu/deep-search/__tests__/stateful-items.test.ts b/packages/react/src/internal/popup-menu/deep-search/__tests__/stateful-items.test.ts index 132ac61b..64d1d1f6 100644 --- a/packages/react/src/internal/popup-menu/deep-search/__tests__/stateful-items.test.ts +++ b/packages/react/src/internal/popup-menu/deep-search/__tests__/stateful-items.test.ts @@ -252,6 +252,20 @@ describe('CheckboxItemDef', () => { expect(scored).toHaveLength(1) expect(scored[0].score).toBeGreaterThan(0) }) + + it('should ignore trailing whitespace in the query', () => { + const nodes: NodeDef[] = [ + createCheckboxItemDef('cb1', 'Dark Mode', true), + createCheckboxItemDef('cb2', 'Light Theme', false), + ] + + const flattened = flattenNodes(nodes) + const scored = scoreNodes(flattened, 'dark ') + + expect(scored).toHaveLength(1) + expect(scored[0].node.id).toBe('cb1') + expect(scored[0].score).toBeGreaterThan(0) + }) }) describe('filterNodes', () => { @@ -275,6 +289,24 @@ describe('CheckboxItemDef', () => { expect(displayNodes[0].node.kind).toBe('checkbox-item') } }) + + it('should treat whitespace-only query as browse mode', () => { + const nodes: NodeDef[] = [ + createItemDef('item1', 'Regular Item'), + createCheckboxItemDef('cb1', 'Dark Mode', true), + ] + + const { displayNodes, isDeepSearching } = filterNodes({ + query: ' ', + nodes, + highlightedId: null, + }) + + expect(isDeepSearching).toBe(false) + expect(displayNodes).toHaveLength(2) + expect(isDisplayRowNode(displayNodes[0])).toBe(true) + expect(isDisplayRowNode(displayNodes[1])).toBe(true) + }) }) }) diff --git a/packages/react/src/internal/popup-menu/deep-search/utils.ts b/packages/react/src/internal/popup-menu/deep-search/utils.ts index ed44b0f4..d8c42f50 100644 --- a/packages/react/src/internal/popup-menu/deep-search/utils.ts +++ b/packages/react/src/internal/popup-menu/deep-search/utils.ts @@ -324,7 +324,9 @@ export function scoreNodes( flattenedNodes: FlattenedNode[], query: string, ): ScoredNode[] { - if (!query) { + const normalizedQuery = normalizeValue(query) + + if (!normalizedQuery) { // No query - return all nodes with score 1 return flattenedNodes.map( ({ node, breadcrumbs, group, radioGroup }): ScoredNode => ({ @@ -346,7 +348,11 @@ export function scoreNodes( ?.map((k) => normalizeValue(k)) .filter(Boolean) - const fuzzyScore = commandScore(normalizedValue, query, normalizedKeywords) + const fuzzyScore = commandScore( + normalizedValue, + normalizedQuery, + normalizedKeywords, + ) const score = node.forceScore ?? fuzzyScore if (score > 0) { @@ -1244,10 +1250,13 @@ export function filterNodes(options: FilterNodesOptions): { highlightedId, groupSearchBehavior = 'preserve', } = options + const normalizedQuery = normalizeValue(query) + const normalizedOptions = + normalizedQuery === query ? options : { ...options, query: normalizedQuery } // Browse mode - no query // Always preserve groups in browse mode (groupSearchBehavior only affects search) - if (!query) { + if (!normalizedQuery) { return { displayNodes: getBrowseNodesPreserve(nodes, highlightedId), isDeepSearching: false, @@ -1256,10 +1265,10 @@ export function filterNodes(options: FilterNodesOptions): { // Search mode - dispatch based on group search behavior if (groupSearchBehavior === 'preserve') { - return filterNodesPreserve(options) + return filterNodesPreserve(normalizedOptions) } - return filterNodesFlatten(options) + return filterNodesFlatten(normalizedOptions) } // ============================================================================