import { SelectInputItem, SelectInputOptionItem } from './SelectInput.types'; export const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50; /** * Converts a string to a normalized, searchable format by: * - Trimming whitespace * - Normalizing whitespace (convert multiple spaces to single space) * - Converting to NFD normalization form to handle diacritics * - Removing combining diacritical marks * - Converting to lowercase */ export function searchableString(value: string) { return ( value .trim() .replace(/\s+/gu, ' ') // NFD converts an Å to A + ̊ (and other special characters) .normalize('NFD') // and then this replaces the ̊ with nothing (and other special characters) .replace(/[\u0300-\u036f]/g, '') .toLowerCase() ); } /** * Extracts searchable strings from a value. * - If the value is a string, returns a normalized version. * - If the value is an object, extracts all string values and normalizes them. * - Otherwise returns an empty array. */ export function inferSearchableStrings(value: unknown) { if (typeof value === 'string') { return [searchableString(value)]; } if (typeof value === 'object' && value != null) { return Object.values(value) .filter((innerValue) => typeof innerValue === 'string') .map((innerValue) => searchableString(innerValue)); } return []; } /** * Sets the value of a duplicate option item to undefined, effectively hiding it when rendered. */ export function dedupeSelectInputOptionItem( item: SelectInputOptionItem, existingValues: Set, compareValues?: (a: T, b: T) => boolean, ): SelectInputOptionItem { const isDuplicate = compareValues ? Array.from(existingValues).some((existingValue) => compareValues(item.value, existingValue)) : existingValues.has(item.value); if (!isDuplicate) { existingValues.add(item.value); return item; } return { ...item, value: undefined }; } /** * Sets the `value` of duplicate option items to `undefined`, hiding them when * rendered. Indexes are kept intact within groups to preserve the active item * between filter changes when possible. */ export function dedupeSelectInputItems( items: readonly SelectInputItem[], compareValues?: (a: T, b: T) => boolean, ): SelectInputItem[] { const existingValues = new Set(); return items.map((item) => { switch (item.type) { case 'option': { return dedupeSelectInputOptionItem(item, existingValues, compareValues); } case 'group': { return { ...item, options: item.options.map((option) => dedupeSelectInputOptionItem(option, existingValues, compareValues), ), }; } default: } return item; }); } /** * Checks if a SelectInputOptionItem matches the search needle. */ export function selectInputOptionItemIncludesNeedle( item: SelectInputOptionItem, needle: string, ) { return inferSearchableStrings(item.filterMatchers ?? item.value).some((haystack) => haystack.includes(needle), ); } /** * Filters SelectInputItems based on the provided predicate function. * For group items, it checks if any of their options match the predicate. */ export function filterSelectInputItems( items: readonly SelectInputItem[], predicate: (item: SelectInputOptionItem) => boolean, ) { return items.filter((item) => { switch (item.type) { case 'option': { return predicate(item); } case 'group': { return item.options.some((option) => predicate(option)); } default: } return false; }); } /** * Flattens and sorts filtered options using the provided comparator. * Extracts all options from groups, filters out undefined values (deduplicated items), * sorts them, and returns as a flat list of option items. */ export function sortSelectInputItems( items: readonly SelectInputItem[], compareFn: ( a: SelectInputOptionItem>, b: SelectInputOptionItem>, searchQuery: string, ) => number, searchQuery: string, ): SelectInputItem>[] { const flattenedOption = items.flatMap((item) => { if (item.type === 'option') { return item.value !== undefined ? [item as SelectInputOptionItem>] : []; } if (item.type === 'group') { return item.options.filter( (option): option is SelectInputOptionItem> => option.value !== undefined, ); } return []; }); // eslint-disable-next-line functional/immutable-data return flattenedOption.sort((a, b) => compareFn(a, b, searchQuery)); } /** * A prebuilt sort function for `sortFilteredOptions` that sorts options by relevance to the search query. * Prioritizes: exact matches > starts with > contains > alphabetical. * * @param getLabel - Function to extract the label string from the option value. Defaults to using `title` property. * * @example * ```tsx * value.name)} * // ... * /> * ``` */ export function sortByRelevance( getLabel: (value: T) => string = (value) => (value as { title: string }).title, ): (a: SelectInputOptionItem, b: SelectInputOptionItem, searchQuery: string) => number { return (a, b, searchQuery) => { const normalizedQuery = searchQuery.toLowerCase(); const labelA = getLabel(a.value).toLowerCase(); const labelB = getLabel(b.value).toLowerCase(); // Prioritize exact matches const aExactMatch = labelA === normalizedQuery; const bExactMatch = labelB === normalizedQuery; if (aExactMatch && !bExactMatch) return -1; if (!aExactMatch && bExactMatch) return 1; // Then prioritize options where label starts with the search query const aStartsWith = labelA.startsWith(normalizedQuery); const bStartsWith = labelB.startsWith(normalizedQuery); if (aStartsWith && !bStartsWith) return -1; if (!aStartsWith && bStartsWith) return 1; // Then prioritize options where label contains the search query const aContains = labelA.includes(normalizedQuery); const bContains = labelB.includes(normalizedQuery); if (aContains && !bContains) return -1; if (!aContains && bContains) return 1; // Finally sort alphabetically return labelA.localeCompare(labelB); }; }