Add search box with results dropdown to the homepage
This commit is contained in:
parent
043be1d3fd
commit
a77b8a29c3
2 changed files with 181 additions and 1 deletions
173
src/lib/components/SearchBox.svelte
Normal file
173
src/lib/components/SearchBox.svelte
Normal 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>
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DiscussionsFeed from "$lib/components/DiscussionsFeed.svelte";
|
import DiscussionsFeed from "$lib/components/DiscussionsFeed.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
|
import SearchBox from "$lib/components/SearchBox.svelte";
|
||||||
import Tag from "$lib/components/Tag.svelte";
|
import Tag from "$lib/components/Tag.svelte";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
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 } from "$lib/config";
|
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
|
||||||
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"));
|
||||||
|
|
@ -51,6 +52,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#if SEARCH_ENABLED}
|
||||||
|
<div class="mt-2 mb-6">
|
||||||
|
<SearchBox />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if partial}
|
{#if partial}
|
||||||
<div class="mt-2 mb-8">
|
<div class="mt-2 mb-8">
|
||||||
<PostContent content={partial.content} headingOffset={0} />
|
<PostContent content={partial.content} headingOffset={0} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue