/** * `Intl.Collator`-backed case/diacritics-insensitive filter used by the * combobox for client-side item filtering. Port of the runtime logic in * `packages/react/src/internals/filter.ts` + the `createCollatorItemFilter` * / `createSingleSelectionCollatorFilter` helpers from * `packages/react/src/combobox/root/utils/index.ts`. No framework-specific * shape — the React hook (`useCoreFilter`) collapses to a plain factory in * Solid since `getFilter` itself is already memoised via a module-level * cache. */ import { stringifyAsLabel } from "./resolve-value-label"; const filterCache = new Map(); export interface Filter { contains: ( item: Item, query: string, itemToString?: (item: Item) => string, ) => boolean; startsWith: ( item: Item, query: string, itemToString?: (item: Item) => string, ) => boolean; endsWith: ( item: Item, query: string, itemToString?: (item: Item) => string, ) => boolean; } export interface GetFilterParameters extends Intl.CollatorOptions { locale?: Intl.LocalesArgument | undefined; } function stringifyLocale(locale?: Intl.LocalesArgument): string { if (Array.isArray(locale)) { return locale.map((value) => stringifyLocale(value)).join(","); } if (locale == null) return ""; return String(locale); } export function getFilter(options: GetFilterParameters = {}): Filter { const mergedOptions: Intl.CollatorOptions = { usage: "search", sensitivity: "base", ignorePunctuation: true, ...options, }; const cacheKey = `${stringifyLocale(options.locale)}|${JSON.stringify(mergedOptions)}`; const cached = filterCache.get(cacheKey); if (cached) return cached; const collator = new Intl.Collator(options.locale, mergedOptions); const filter: Filter = { contains( item: Item, query: string, itemToString?: (item: Item) => string, ) { if (!query) return true; const itemString = stringifyAsLabel( item, itemToString as ((item: unknown) => string) | undefined, ); for (let i = 0; i <= itemString.length - query.length; i += 1) { if ( collator.compare(itemString.slice(i, i + query.length), query) === 0 ) { return true; } } return false; }, startsWith( item: Item, query: string, itemToString?: (item: Item) => string, ) { if (!query) return true; const itemString = stringifyAsLabel( item, itemToString as ((item: unknown) => string) | undefined, ); return collator.compare(itemString.slice(0, query.length), query) === 0; }, endsWith( item: Item, query: string, itemToString?: (item: Item) => string, ) { if (!query) return true; const itemString = stringifyAsLabel( item, itemToString as ((item: unknown) => string) | undefined, ); const queryLength = query.length; return ( itemString.length >= queryLength && collator.compare( itemString.slice(itemString.length - queryLength), query, ) === 0 ); }, }; filterCache.set(cacheKey, filter); return filter; } /** * Case-insensitive "item contains query" filter — matches the React * `createCollatorItemFilter` helper. */ export function createCollatorItemFilter( collatorFilter: Filter, itemToStringLabel?: (item: unknown) => string, ): (item: unknown, query: string) => boolean { return (item, query) => { if (item == null) return false; const itemString = stringifyAsLabel(item, itemToStringLabel); return collatorFilter.contains(itemString, query); }; } /** * Single-select filter that shows all items when the query is empty or * matches the current selection. Mirrors React's * `createSingleSelectionCollatorFilter`. */ export function createSingleSelectionCollatorFilter( collatorFilter: Filter, itemToStringLabel?: (item: unknown) => string, selectedValue?: unknown, ): (item: unknown, query: string) => boolean { return (item, query) => { if (item == null) return false; if (!query) return true; const itemString = stringifyAsLabel(item, itemToStringLabel); const selectedString = selectedValue != null ? stringifyAsLabel(selectedValue, itemToStringLabel) : ""; if ( selectedString && collatorFilter.contains(selectedString, query) && selectedString.length === query.length ) { return true; } return collatorFilter.contains(itemString, query); }; }