/** * Copyright Aquera Inc 2025 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import type { ComboboxDataItem, ComboboxGroupItem, ComboboxOptionItem, ComboboxRow, ComboboxHeaderRow, ComboboxOptionRow, } from './types'; export function isGroup(item: any): item is ComboboxGroupItem { return !!item && typeof item === 'object' && item.type === 'group' && Array.isArray(item.options); } export function hasGroups(data: any[]): boolean { if (!Array.isArray(data)) return false; for (const item of data) { if (isGroup(item)) return true; } return false; } export function countOptionsDeep(group: ComboboxGroupItem): number { let n = 0; for (const child of group.options) { if (isGroup(child)) n += countOptionsDeep(child); else n += 1; } return n; } export function flattenRows(data: ComboboxDataItem[]): ComboboxRow[] { const rows: ComboboxRow[] = []; const walk = (items: ComboboxDataItem[], depth: number, parentIds: string[]) => { for (const item of items) { if (isGroup(item)) { rows.push({ kind: 'header', id: item.id, label: item.label, prefix: item.prefix, depth, optionCount: countOptionsDeep(item), } as ComboboxHeaderRow); walk(item.options, depth + 1, [...parentIds, item.id]); } else { rows.push({ kind: 'option', item: item as ComboboxOptionItem, depth, parentIds, } as ComboboxOptionRow); } } }; walk(Array.isArray(data) ? data : [], 0, []); return rows; } export function getOptionRows(rows: ComboboxRow[]): ComboboxOptionRow[] { const out: ComboboxOptionRow[] = []; for (const r of rows) if (r.kind === 'option') out.push(r); return out; } /** * Filter rows by a query. * * Rules: * - An option row is kept if its searchText matches. * - If a group's label matches, the entire subtree (header + all descendant * options + nested headers) is kept. * - Otherwise a header is kept only if at least one descendant option matches. * - Empty groups (no surviving descendants) are dropped. */ export function filterRows( data: ComboboxDataItem[], query: string, getSearchText: (item: any) => string, ): { rows: ComboboxRow[]; visibleOptionCount: number } { const q = (query || '').trim().toLowerCase(); if (!q) { const rows = flattenRows(data); return { rows, visibleOptionCount: getOptionRows(rows).length }; } const matchedOption = (item: ComboboxOptionItem): boolean => { const text = (getSearchText(item) || '').toString().toLowerCase(); return text.includes(q); }; // Walk the tree, returning a filtered subtree (or null when nothing survives). type FilterResult = | { kind: 'option'; item: ComboboxOptionItem } | { kind: 'group'; group: ComboboxGroupItem; children: FilterResult[] } | null; const walk = (item: ComboboxDataItem, ancestorLabelMatched: boolean): FilterResult => { if (isGroup(item)) { const labelMatched = item.label.toLowerCase().includes(q); const keepAll = labelMatched || ancestorLabelMatched; const children: FilterResult[] = []; for (const child of item.options) { const sub = walk(child, keepAll); if (sub) children.push(sub); } if (children.length === 0 && !keepAll) return null; // If keepAll but nothing came back (empty group), still emit so user sees label. return { kind: 'group', group: item, children }; } if (ancestorLabelMatched || matchedOption(item as ComboboxOptionItem)) { return { kind: 'option', item: item as ComboboxOptionItem }; } return null; }; const tops: FilterResult[] = []; for (const top of data) { const r = walk(top, false); if (r) tops.push(r); } // Materialize back to flat rows. const rows: ComboboxRow[] = []; const emit = (results: FilterResult[], depth: number, parentIds: string[]) => { for (const r of results) { if (!r) continue; if (r.kind === 'group') { rows.push({ kind: 'header', id: r.group.id, label: r.group.label, prefix: r.group.prefix, depth, optionCount: countOptionsDeep(r.group), }); emit(r.children, depth + 1, [...parentIds, r.group.id]); } else { rows.push({ kind: 'option', item: r.item, depth, parentIds, }); } } }; emit(tops, 0, []); return { rows, visibleOptionCount: getOptionRows(rows).length }; }