Add search box with results dropdown to the homepage

This commit is contained in:
dtonon 2026-06-10 12:47:22 +01:00
parent 043be1d3fd
commit a77b8a29c3
2 changed files with 181 additions and 1 deletions

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { searchThreads, type SearchResult } from "$lib/search";
let query = $state("");
let results = $state<SearchResult[]>([]);
let open = $state(false);
let searching = $state(false);
let activeIndex = $state(-1);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
function schedule() {
const q = query.trim();
if (timer) clearTimeout(timer);
activeIndex = -1;
if (q.length < 2) {
seq++; // Supersede any in-flight query
open = false;
results = [];
searching = false;
return;
}
open = true;
searching = true;
timer = setTimeout(async () => {
const id = ++seq;
try {
const r = await searchThreads(q);
if (id !== seq) return;
results = r;
} finally {
if (id === seq) searching = false;
}
}, 300);
}
function close() {
open = false;
activeIndex = -1;
}
function select(r: SearchResult) {
close();
goto(`/thread/${r.threadId}`);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
close();
return;
}
if (!open || 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]);
}
}
function onFocus() {
if (query.trim().length >= 2) open = true;
}
function onBlur() {
// Delay so a mousedown on a result still lands
setTimeout(() => close(), 120);
}
</script>
<div class="relative">
<div class="relative">
<input
bind:value={query}
type="search"
placeholder="Search"
role="combobox"
aria-expanded={open}
aria-controls={open ? "search-results" : undefined}
aria-activedescendant={activeIndex >= 0
? `search-result-${activeIndex}`
: undefined}
aria-label="Search discussions"
autocomplete="off"
oninput={schedule}
onkeydown={onKeydown}
onfocus={onFocus}
onblur={onBlur}
class="w-full rounded-lg bg-neutral-100 py-2.5 pr-11 pl-4 text-neutral-800 placeholder-neutral-400 focus:ring-2 focus:ring-neutral-300 focus:outline-none dark:bg-neutral-800 dark:text-neutral-200 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="pointer-events-none absolute top-1/2 right-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>
</div>
{#if open}
<div
id="search-results"
role="listbox"
aria-label="Search results"
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"
>
{#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}
{#each results as r, i (r.threadId)}
<a
id="search-result-{i}"
href="/thread/{r.threadId}"
role="option"
aria-selected={i === activeIndex}
class="block 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"
>
{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"
>
{r.snippet}
</span>
{/if}
</a>
{/each}
</div>
{/if}
</div>

View file

@ -1,11 +1,12 @@
<script lang="ts">
import DiscussionsFeed from "$lib/components/DiscussionsFeed.svelte";
import PostContent from "$lib/components/PostContent.svelte";
import SearchBox from "$lib/components/SearchBox.svelte";
import Tag from "$lib/components/Tag.svelte";
import { groupsStore } from "$lib/groups.svelte";
import { overviewStore, loadOverview } from "$lib/overview.svelte";
import { partialsStore } from "$lib/partials.svelte";
import { MODE, GROUP_ID } from "$lib/config";
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
import type { NostrUser } from "@nostr/gadgets/metadata";
const partial = $derived(partialsStore.get("home"));
@ -51,6 +52,12 @@
{/if}
{/snippet}
{#if SEARCH_ENABLED}
<div class="mt-2 mb-6">
<SearchBox />
</div>
{/if}
{#if partial}
<div class="mt-2 mb-8">
<PostContent content={partial.content} headingOffset={0} />