Split search into shared state with inline homepage input and modal
This commit is contained in:
parent
b18b201fbb
commit
d256abb42f
6 changed files with 316 additions and 172 deletions
107
src/lib/searchState.svelte.ts
Normal file
107
src/lib/searchState.svelte.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { searchThreads, type SearchResult } from "$lib/search";
|
||||
|
||||
// Debounced-search state machine shared by the inline (homepage) and modal
|
||||
// search shells, so behavior lives in one place and the shells only differ
|
||||
// in chrome and positioning.
|
||||
export function createSearchState() {
|
||||
let query = $state("");
|
||||
let results = $state<SearchResult[]>([]);
|
||||
let resultsQuery = $state(""); // The query that produced the current results
|
||||
let searching = $state(false);
|
||||
let activeIndex = $state(-1);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const terms = $derived(
|
||||
resultsQuery.split(/\s+/).filter((t) => t.length >= 2),
|
||||
);
|
||||
|
||||
function schedule() {
|
||||
const q = query.trim();
|
||||
if (timer) clearTimeout(timer);
|
||||
activeIndex = -1;
|
||||
if (q.length < 2) {
|
||||
seq++; // Supersede any in-flight query
|
||||
results = [];
|
||||
resultsQuery = "";
|
||||
searching = false;
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
timer = setTimeout(async () => {
|
||||
const id = ++seq;
|
||||
try {
|
||||
const r = await searchThreads(q);
|
||||
if (id !== seq) return;
|
||||
results = r;
|
||||
resultsQuery = q;
|
||||
} finally {
|
||||
if (id === seq) searching = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Split into alternating plain/matched segments for <mark> rendering
|
||||
function highlight(text: string): { text: string; hit: boolean }[] {
|
||||
if (!text || terms.length === 0) return [{ text, hit: false }];
|
||||
const alts = terms.map(escapeRe).join("|");
|
||||
const exact = new RegExp(`^(${alts})$`, "i");
|
||||
return text
|
||||
.split(new RegExp(`(${alts})`, "gi"))
|
||||
.filter((s) => s !== "")
|
||||
.map((s) => ({ text: s, hit: exact.test(s) }));
|
||||
}
|
||||
|
||||
// Arrow/Enter handling; returns true when the event was consumed
|
||||
function navigate(
|
||||
e: KeyboardEvent,
|
||||
pick: (r: SearchResult) => void,
|
||||
): boolean {
|
||||
if (results.length === 0) return false;
|
||||
if (e.key === "ArrowDown") {
|
||||
activeIndex = (activeIndex + 1) % results.length;
|
||||
} else if (e.key === "ArrowUp") {
|
||||
activeIndex = (activeIndex - 1 + results.length) % results.length;
|
||||
} else if (e.key === "Enter" && activeIndex >= 0) {
|
||||
pick(results[activeIndex]);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
get query() {
|
||||
return query;
|
||||
},
|
||||
set query(v: string) {
|
||||
query = v;
|
||||
},
|
||||
get results() {
|
||||
return results;
|
||||
},
|
||||
get searching() {
|
||||
return searching;
|
||||
},
|
||||
get activeIndex() {
|
||||
return activeIndex;
|
||||
},
|
||||
set activeIndex(v: number) {
|
||||
activeIndex = v;
|
||||
},
|
||||
// True once a query has produced (or is about to produce) results
|
||||
get active() {
|
||||
return resultsQuery.trim().length >= 2;
|
||||
},
|
||||
schedule,
|
||||
highlight,
|
||||
navigate,
|
||||
};
|
||||
}
|
||||
|
||||
export type SearchState = ReturnType<typeof createSearchState>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue