Split search into shared state with inline homepage input and modal

This commit is contained in:
dtonon 2026-07-16 13:23:28 +01:00
parent b18b201fbb
commit d256abb42f
6 changed files with 316 additions and 172 deletions

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { SearchResult } from "$lib/search";
import { createSearchState } from "$lib/searchState.svelte";
import { registerInlineSearch } from "$lib/searchModal.svelte";
import SearchResults from "$lib/components/SearchResults.svelte";
const search = createSearchState();
let open = $state(false);
let inputEl = $state<HTMLInputElement | null>(null);
// Route the global "/" shortcut and the sidebar/menu triggers here while
// this input is on screen
$effect(() => registerInlineSearch(() => inputEl?.focus()));
function onInput() {
search.schedule();
open = search.query.trim().length >= 2;
}
function select(r: SearchResult) {
open = false;
goto(`/thread/${r.threadId}`);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
open = false;
return;
}
if (!open) return;
search.navigate(e, select);
}
function onFocus() {
if (search.query.trim().length >= 2) open = true;
}
function onBlur() {
// Delay so a mousedown on a result still lands
setTimeout(() => (open = false), 120);
}
</script>
<div class="relative">
<div class="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
class="pointer-events-none absolute top-1/2 left-4 h-5 w-5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<input
bind:this={inputEl}
bind:value={search.query}
type="search"
placeholder="Search"
role="combobox"
aria-expanded={open}
aria-controls={open ? "search-results-inline" : undefined}
aria-activedescendant={search.activeIndex >= 0
? `search-results-inline-${search.activeIndex}`
: undefined}
aria-label="Search discussions"
autocomplete="off"
oninput={onInput}
onkeydown={onKeydown}
onfocus={onFocus}
onblur={onBlur}
class="focus:ring-accent focus:dark:ring-accent w-full rounded border border-neutral-200 bg-neutral-100 py-2 pr-12 pl-11 text-neutral-800 placeholder-neutral-400 focus:bg-neutral-50 focus:ring-1 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:placeholder-neutral-500 focus:dark:bg-neutral-950"
/>
<kbd
class="pointer-events-none absolute top-1/2 right-3 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md bg-neutral-200 font-sans text-sm text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
aria-hidden="true">/</kbd
>
</div>
{#if open}
<SearchResults
{search}
idBase="search-results-inline"
class="absolute right-0 left-0 z-30 mt-2 max-h-96 overflow-auto rounded-lg border border-neutral-200 bg-white py-2 shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
onselect={select}
/>
{/if}
</div>

View file

@ -1,19 +1,14 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { searchThreads, type SearchResult } from "$lib/search"; import type { SearchResult } from "$lib/search";
import { createSearchState } from "$lib/searchState.svelte";
import { searchModal, openSearch, closeSearch } from "$lib/searchModal.svelte"; import { searchModal, openSearch, closeSearch } from "$lib/searchModal.svelte";
import SearchResults from "$lib/components/SearchResults.svelte";
const search = 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 inputEl = $state<HTMLInputElement | null>(null); let inputEl = $state<HTMLInputElement | null>(null);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
const active = $derived(resultsQuery.trim().length >= 2);
// Focus (and select, so a stale query is typed over) when the modal opens // Focus (and select, so a stale query is typed over) when the modal opens
$effect(() => { $effect(() => {
@ -24,67 +19,11 @@
}); });
}); });
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 select(r: SearchResult) { function select(r: SearchResult) {
closeSearch(); closeSearch();
goto(`/thread/${r.threadId}`); goto(`/thread/${r.threadId}`);
} }
function onInputKeydown(e: KeyboardEvent) {
if (results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
activeIndex = (activeIndex + 1) % results.length;
} else if (e.key === "ArrowUp") {
e.preventDefault();
activeIndex = (activeIndex - 1 + results.length) % results.length;
} else if (e.key === "Enter" && activeIndex >= 0) {
e.preventDefault();
select(results[activeIndex]);
}
}
const terms = $derived(resultsQuery.split(/\s+/).filter((t) => t.length >= 2));
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) }));
}
// Global "/" opens the search from any page, unless typing somewhere else // Global "/" opens the search from any page, unless typing somewhere else
function onWindowKeydown(e: KeyboardEvent) { function onWindowKeydown(e: KeyboardEvent) {
if (searchModal.open) { if (searchModal.open) {
@ -110,7 +49,7 @@
<button <button
type="button" type="button"
aria-label="Close search" aria-label="Close search"
class="absolute inset-0 bg-black/40" class="absolute inset-0 bg-black/40 dark:bg-black/65"
onclick={closeSearch} onclick={closeSearch}
></button> ></button>
<div <div
@ -137,19 +76,19 @@
</svg> </svg>
<input <input
bind:this={inputEl} bind:this={inputEl}
bind:value={query} bind:value={search.query}
type="search" type="search"
placeholder="Search" placeholder="Search"
role="combobox" role="combobox"
aria-expanded={active} aria-expanded={search.active}
aria-controls={active ? "search-results" : undefined} aria-controls={search.active ? "search-results-modal" : undefined}
aria-activedescendant={activeIndex >= 0 aria-activedescendant={search.activeIndex >= 0
? `search-result-${activeIndex}` ? `search-results-modal-${search.activeIndex}`
: undefined} : undefined}
aria-label="Search discussions" aria-label="Search discussions"
autocomplete="off" autocomplete="off"
oninput={schedule} oninput={search.schedule}
onkeydown={onInputKeydown} onkeydown={(e) => search.navigate(e, select)}
class="focus:ring-accent focus:dark:ring-accent w-full rounded border border-neutral-200 bg-neutral-100 py-2 pr-14 pl-11 text-neutral-800 placeholder-neutral-400 focus:bg-neutral-50 focus:ring-1 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:placeholder-neutral-500 focus:dark:bg-neutral-950" class="focus:ring-accent focus:dark:ring-accent w-full rounded border border-neutral-200 bg-neutral-100 py-2 pr-14 pl-11 text-neutral-800 placeholder-neutral-400 focus:bg-neutral-50 focus:ring-1 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:placeholder-neutral-500 focus:dark:bg-neutral-950"
/> />
<kbd <kbd
@ -158,75 +97,13 @@
> >
</div> </div>
{#if active || searching} {#if search.active || search.searching}
<div <SearchResults
id="search-results" {search}
role="listbox" idBase="search-results-modal"
aria-label="Search results"
class="mt-3 max-h-[50vh] overflow-auto border-t border-neutral-100 pt-2 dark:border-neutral-800" class="mt-3 max-h-[50vh] overflow-auto border-t border-neutral-100 pt-2 dark:border-neutral-800"
> onselect={select}
{#if searching} />
<div
class="flex items-center gap-2 px-4 py-2 text-sm text-neutral-400 dark:text-neutral-500"
aria-live="polite"
>
<span
class="inline-block h-3 w-3 animate-spin rounded-full border border-neutral-300 border-t-neutral-600 dark:border-neutral-600"
aria-hidden="true"
></span>
<span>Searching…</span>
</div>
{:else if results.length === 0}
<div
class="px-4 py-2 text-sm text-neutral-400 dark:text-neutral-500"
>
No results
</div>
{/if}
{#snippet marked(text: string)}
{#each highlight(text) as p}
{#if p.hit}<mark
class="bg-secondary/40 dark:bg-secondary/30 rounded-sm text-inherit"
>{p.text}</mark
>{:else}{p.text}{/if}
{/each}
{/snippet}
{#each results as r, i (r.threadId)}
<a
id="search-result-{i}"
href="/thread/{r.threadId}"
role="option"
aria-selected={i === activeIndex}
class="block rounded px-4 py-2 {i === activeIndex
? 'bg-neutral-100 dark:bg-neutral-800'
: ''}"
onmousedown={(e) => {
e.preventDefault();
select(r);
}}
onmouseenter={() => (activeIndex = i)}
>
<span
class="block truncate font-medium text-neutral-800 dark:text-neutral-200"
>
{@render marked(r.title)}
{#if r.matchKind === "reply"}
<span
class="ml-1 text-xs font-normal text-neutral-400 dark:text-neutral-500"
>reply</span
>
{/if}
</span>
{#if r.snippet}
<span
class="block truncate text-sm text-neutral-500 dark:text-neutral-400"
>
{@render marked(r.snippet)}
</span>
{/if}
</a>
{/each}
</div>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import type { SearchState } from "$lib/searchState.svelte";
import type { SearchResult } from "$lib/search";
type Props = {
search: SearchState;
// Base for the listbox and option element ids (aria-activedescendant)
idBase: string;
class?: string;
onselect: (r: SearchResult) => void;
};
let { search, idBase, class: cls = "", onselect }: Props = $props();
</script>
<div id={idBase} role="listbox" aria-label="Search results" class={cls}>
{#if search.searching}
<div
class="flex items-center gap-2 px-4 py-2 text-sm text-neutral-400 dark:text-neutral-500"
aria-live="polite"
>
<span
class="inline-block h-3 w-3 animate-spin rounded-full border border-neutral-300 border-t-neutral-600 dark:border-neutral-600"
aria-hidden="true"
></span>
<span>Searching…</span>
</div>
{:else if search.results.length === 0}
<div class="px-4 py-2 text-sm text-neutral-400 dark:text-neutral-500">
No results
</div>
{/if}
{#snippet marked(text: string)}
{#each search.highlight(text) as p}
{#if p.hit}<mark
class="bg-secondary/40 dark:bg-secondary/30 rounded-sm text-inherit"
>{p.text}</mark
>{:else}{p.text}{/if}
{/each}
{/snippet}
{#each search.results as r, i (r.threadId)}
<a
id="{idBase}-{i}"
href="/thread/{r.threadId}"
role="option"
aria-selected={i === search.activeIndex}
class="block rounded px-4 py-2 {i === search.activeIndex
? 'bg-neutral-100 dark:bg-neutral-800'
: ''}"
onmousedown={(e) => {
e.preventDefault();
onselect(r);
}}
onmouseenter={() => (search.activeIndex = i)}
>
<span
class="block truncate font-medium text-neutral-800 dark:text-neutral-200"
>
{@render marked(r.title)}
{#if r.matchKind === "reply"}
<span
class="ml-1 text-xs font-normal text-neutral-400 dark:text-neutral-500"
>reply</span
>
{/if}
</span>
{#if r.snippet}
<span
class="block truncate text-sm text-neutral-500 dark:text-neutral-400"
>
{@render marked(r.snippet)}
</span>
{/if}
</a>
{/each}
</div>

View file

@ -1,13 +1,26 @@
let open = $state(false); let open = $state(false);
// When a page hosts an inline search input (the homepage), it registers a
// focus function here and every search trigger routes to it instead of the
// modal, so a modal input never opens on top of an inline one.
let inlineFocus: (() => void) | null = null;
export const searchModal = { export const searchModal = {
get open() { get open() {
return open; return open;
}, },
}; };
export function registerInlineSearch(focus: () => void): () => void {
inlineFocus = focus;
return () => {
if (inlineFocus === focus) inlineFocus = null;
};
}
export function openSearch() { export function openSearch() {
open = true; if (inlineFocus) inlineFocus();
else open = true;
} }
export function closeSearch() { export function closeSearch() {

View 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>;

View file

@ -6,7 +6,7 @@
import { overviewStore, loadOverview } from "$lib/overview.svelte"; import { overviewStore, loadOverview } from "$lib/overview.svelte";
import { partialsStore } from "$lib/partials.svelte"; import { partialsStore } from "$lib/partials.svelte";
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config"; import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
import { openSearch } from "$lib/searchModal.svelte"; import SearchInline from "$lib/components/SearchInline.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
const partial = $derived(partialsStore.get("home")); const partial = $derived(partialsStore.get("home"));
@ -52,35 +52,10 @@
{/if} {/if}
{/snippet} {/snippet}
<!-- Looks like the search input but only opens the global search modal, so
there is a single search implementation. -->
{#if SEARCH_ENABLED} {#if SEARCH_ENABLED}
<button <div class="mt-2 mb-6">
type="button" <SearchInline />
onclick={openSearch} </div>
class="focus:ring-accent relative mt-2 mb-6 flex w-full items-center gap-3 rounded border border-neutral-200 bg-neutral-100 px-4 py-2 text-neutral-400 focus:ring-1 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
Search
<kbd
class="pointer-events-none absolute top-1/2 right-3 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md bg-neutral-200 font-sans text-sm text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
aria-hidden="true">/</kbd
>
</button>
{/if} {/if}
{#if partial} {#if partial}