import { createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js"; import { Portal } from "solid-js/web"; // ============================================================================ // Fuzzy matching (Sublime-style subsequence scoring) // ============================================================================ // Port of Forrest Smith's fts_fuzzy_match. Every query char must appear in the // target in order; the match is scored so that word-boundary / acronym hits // ("nfcu" -> "Navy Federal Credit Union") outrank scattered ones. When a query // char matches, we also recurse past it in case a later occurrence scores higher. // The matcher functions are exported so other parts of the UI can rank/highlight // without mounting the component. export interface FuzzyMatchResult { score: number; positions: number[]; } export interface FuzzySegment { text: string; match: boolean; } export interface FuzzyRankedItem { value: string; score: number; segments: FuzzySegment[]; } const FUZZY_SEQUENTIAL_BONUS = 15; const FUZZY_SEPARATOR_BONUS = 30; const FUZZY_CAMEL_BONUS = 30; const FUZZY_FIRST_LETTER_BONUS = 15; const FUZZY_LEADING_PENALTY = -5; const FUZZY_MAX_LEADING_PENALTY = -15; const FUZZY_UNMATCHED_PENALTY = -1; const FUZZY_RECURSION_LIMIT = 10; const FUZZY_TRANSPOSE_PENALTY = -20; const FUZZY_EXACT_SUBSTRING_BONUS = 100; const isLower = (c: string) => c >= "a" && c <= "z"; const isUpper = (c: string) => c >= "A" && c <= "Z"; const isSeparator = (c: string) => c === " " || c === "_" || c === "-"; function fuzzyScore(target: string, matches: number[]): number { let score = 100; score += Math.max(FUZZY_MAX_LEADING_PENALTY, FUZZY_LEADING_PENALTY * matches[0]); score += FUZZY_UNMATCHED_PENALTY * (target.length - matches.length); for (let i = 0; i < matches.length; i++) { const curr = matches[i]; if (i > 0 && curr === matches[i - 1] + 1) score += FUZZY_SEQUENTIAL_BONUS; if (curr === 0) { score += FUZZY_FIRST_LETTER_BONUS; } else { const prev = target[curr - 1]; if (isLower(prev) && isUpper(target[curr])) score += FUZZY_CAMEL_BONUS; if (isSeparator(prev)) score += FUZZY_SEPARATOR_BONUS; } } return score; } function fuzzyRecurse(query: string, target: string, qi: number, ti: number, matches: number[], rec: { count: number }): number[] | null { if (++rec.count >= FUZZY_RECURSION_LIMIT) return null; let best: number[] | null = null; while (qi < query.length && ti < target.length) { if (query[qi].toLowerCase() === target[ti].toLowerCase()) { const skipped = fuzzyRecurse(query, target, qi, ti + 1, matches.slice(), rec); if (skipped && (!best || fuzzyScore(target, skipped) > fuzzyScore(target, best))) best = skipped; matches.push(ti); qi++; } ti++; } if (qi < query.length) return best; // query not fully consumed -> this path failed if (!best || fuzzyScore(target, matches) > fuzzyScore(target, best)) return matches; return best; } export function fuzzyMatch(query: string, target: string): FuzzyMatchResult | null { if (!query) return null; const matches = fuzzyRecurse(query, target, 0, 0, [], { count: 0 }); if (!matches) return null; let score = fuzzyScore(target, matches); // A contiguous substring hit ("bankof" in "Bankof") should outrank a // word-boundary match split across tokens ("Bank of America"). The bonus is // constant per query/target, so it lives here rather than in the per- // alignment scorer the recursion uses to pick match positions. if (target.toLowerCase().includes(query.toLowerCase())) score += FUZZY_EXACT_SUBSTRING_BONUS; return { score, positions: matches }; } // Subsequence matching can't tolerate a transposed typo ("teh" vs "the") because // the swapped letters violate ordering. So also try every single adjacent-swap // variant of the query and keep the best, penalizing transposed hits so exact // matches still rank first. export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatchResult | null { let best = fuzzyMatch(query, target); for (let i = 0; i < query.length - 1; i++) { const swapped = query.slice(0, i) + query[i + 1] + query[i] + query.slice(i + 2); const m = fuzzyMatch(swapped, target); if (!m) continue; const score = m.score + FUZZY_TRANSPOSE_PENALTY; if (!best || score > best.score) best = { score, positions: m.positions }; } return best; } // Split `text` into alternating matched / unmatched runs for highlighting. export function fuzzySegments(text: string, positions: number[]): FuzzySegment[] { const matched = new Set(positions); const segments: FuzzySegment[] = []; let buf = ""; let bufMatch = matched.has(0); for (let i = 0; i < text.length; i++) { const isMatch = matched.has(i); if (isMatch !== bufMatch) { if (buf) segments.push({ text: buf, match: bufMatch }); buf = ""; bufMatch = isMatch; } buf += text[i]; } if (buf) segments.push({ text: buf, match: bufMatch }); return segments; } // Rank `options` against `query`, best score first, with highlight segments. // Returns [] for an empty query. This is the headless entry point. export function rankFuzzyMatches(query: string, options: string[], maxResults?: number): FuzzyRankedItem[] { const q = query.trim(); if (!q) return []; const out: FuzzyRankedItem[] = []; for (const value of options) { const m = fuzzyMatchTypoTolerant(q, value); if (m) out.push({ value, score: m.score, segments: fuzzySegments(value, m.positions) }); } out.sort((a, b) => b.score - a.score); return maxResults != null ? out.slice(0, maxResults) : out; } // ============================================================================ // Component // ============================================================================ export type FuzzyMatchDisplay = "list" | "dropdown" | "none"; export interface FuzzyMatchProps { options: string[]; // "list": inline highlighted results below the input (default). // "dropdown": ComboBox-style autocomplete popover. // "none": render only the input and emit via onResults (headless). display?: FuzzyMatchDisplay; // Show each result's match score. Debug aid — off by default. showScores?: boolean; maxResults?: number; placeholder?: string; class?: string; listClass?: string; // Emit the ranked results on every change so other UI can consume them. onResults?: (results: FuzzyRankedItem[]) => void; onSelect?: (value: string, item: FuzzyRankedItem) => void; onQueryChange?: (query: string) => void; } const INPUT_CLS = "bg-surface block w-full border border-line-strong rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"; // Mirror FormCombobox's dropdown styling (Forms.ts): neutral hover / highlight, // not a colored one. const DROPDOWN_CLS = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"; const DROPDOWN_OPTION_CLS = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-surface-raised whitespace-nowrap"; const DROPDOWN_OPTION_HIGHLIGHT_CLS = "bg-surface-raised"; function Highlight(props: { segments: FuzzySegment[] }) { return {(seg) => seg.match ? {seg.text} : {seg.text}} ; } export function FuzzyMatch(props: FuzzyMatchProps) { const [query, setQuery] = createSignal(""); const [open, setOpen] = createSignal(false); const [highlighted, setHighlighted] = createSignal(0); const [pos, setPos] = createSignal({ top: 0, left: 0, width: 0 }); let containerRef: HTMLDivElement | undefined; let inputRef: HTMLInputElement | undefined; let dropdownRef: HTMLDivElement | undefined; const display = () => props.display ?? "list"; const results = createMemo(() => rankFuzzyMatches(query(), props.options, props.maxResults)); // Show all options (unranked) in list mode before the user types anything, // so the searchable set is visible up front. const listItems = createMemo(() => query().trim() ? results() : props.options.map((value) => ({ value, score: 0, segments: [{ text: value, match: false }] })) ); // Emit results to the parent whenever they change (headless usage). createEffect(() => props.onResults?.(results())); const setQ = (v: string) => { setQuery(v); setHighlighted(0); props.onQueryChange?.(v); }; const select = (item: FuzzyRankedItem) => { props.onSelect?.(item.value, item); if (display() === "dropdown") { setQ(item.value); setOpen(false); } }; const updatePos = () => { if (!inputRef) return; const r = inputRef.getBoundingClientRect(); setPos({ top: r.bottom + 4, left: r.left, width: r.width }); }; // Position tracking + outside-click, only while the dropdown is open. createEffect(() => { if (display() !== "dropdown" || !open()) return; updatePos(); const onDown = (e: MouseEvent) => { const t = e.target as Node; if (!containerRef?.contains(t) && !dropdownRef?.contains(t)) setOpen(false); }; document.addEventListener("mousedown", onDown); onCleanup(() => document.removeEventListener("mousedown", onDown)); }); const onKeyDown = (e: KeyboardEvent) => { if (display() !== "dropdown") return; const list = results(); if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setHighlighted((i) => Math.min(i + 1, list.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlighted((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); const it = list[highlighted()]; if (it) select(it); } else if (e.key === "Escape") { setOpen(false); } }; const ScoreBadge = (p: { score: number }) => {p.score} ; return
{ setQ(e.currentTarget.value); if (display() === "dropdown") setOpen(true); }} onFocus={() => { if (display() === "dropdown" && results().length) setOpen(true); }} onKeyDown={onKeyDown} />
0} fallback={

No matches for "{query()}".

}>
    {(r) =>
  • select(r)} >
  • }
0}>
{(r, i) => }
; }