diff --git a/frontend/islands/PackageSearch.tsx b/frontend/islands/PackageSearch.tsx index 320eee00..d9cdcb5b 100644 --- a/frontend/islands/PackageSearch.tsx +++ b/frontend/islands/PackageSearch.tsx @@ -1,5 +1,5 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. -import { computed, Signal, useSignal } from "@preact/signals"; +import { batch, computed, Signal, useSignal } from "@preact/signals"; import { useEffect, useMemo, useRef } from "preact/hooks"; import { JSX } from "preact/jsx-runtime"; import { OramaClient } from "@oramacloud/client"; @@ -15,22 +15,27 @@ interface PackageSearchProps { jumbo?: boolean; } -// 450ms is a suggestion from Michele at Orama. -const TYPING_DEBOUNCE = 450; +// The maximum time between a query and the result for that query being +// displayed, if there is a more recent pending query. +const MAX_STALE_RESULT_MS = 200; export function PackageSearch( { query, indexId, apiKey, jumbo }: PackageSearchProps, ) { - const suggestions = useSignal<(OramaPackageHit[] | Package[])>([]); - const pending = useSignal(false); - const debounceRef = useRef(-1); + const suggestions = useSignal(null); + const searchNRef = useRef({ started: 0, displayed: 0 }); const abort = useRef(null); const selectionIdx = useSignal(-1); const ref = useRef(null); - const showSuggestions = useSignal(true); + const isFocused = useSignal(false); + const search = useSignal(query ?? ""); const btnSubmit = useSignal(false); const sizeClasses = jumbo ? "py-3 px-4 text-lg" : "py-1 px-2 text-base"; + const showSuggestions = computed(() => + isFocused.value && search.value.length > 0 + ); + const orama = useMemo(() => { if (IS_BROWSER && indexId) { return new OramaClient({ @@ -43,7 +48,7 @@ export function PackageSearch( useEffect(() => { const outsideClick = (e: Event) => { if (!ref.current) return; - showSuggestions.value = ref.current.contains(e.target as Element); + isFocused.value = ref.current.contains(e.target as Element); }; document.addEventListener("click", outsideClick); @@ -52,56 +57,69 @@ export function PackageSearch( const onInput = (ev: JSX.TargetedEvent) => { const value = ev.currentTarget!.value as string; - if (value.length > 1) { - showSuggestions.value = true; - pending.value = true; - selectionIdx.value = -1; - abort.current?.abort(); + search.value = value; + if (value.length >= 1) { + const searchN = ++searchNRef.current.started; + const oldAborter = abort.current; abort.current = new AbortController(); - clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(async () => { - selectionIdx.value = -1; + setTimeout(() => { + oldAborter?.abort(); + if (searchNRef.current.displayed < searchN) { + selectionIdx.value = -1; + suggestions.value = null; + } + }, MAX_STALE_RESULT_MS); + + (async () => { try { if (orama) { const res = await orama.search({ term: value, limit: 5, mode: "fulltext", - }, { - // @ts-ignore same named AbortController, but different? - abortController: abort.current, + }, { abortController: abort.current! }); + if ( + abort.current?.signal.aborted || + searchNRef.current.displayed > searchN + ) return; + searchNRef.current.displayed = searchN; + batch(() => { + selectionIdx.value = -1; + suggestions.value = res?.hits.map((hit) => hit.document) ?? []; }); - suggestions.value = res?.hits.map((hit) => hit.document) ?? []; } else { const res = await api.get>(path`/packages`, { query: value, limit: 5, }); - pending.value = false; if (res.ok) { - suggestions.value = res.data.items; + if ( + abort.current?.signal.aborted || + searchNRef.current.displayed > searchN + ) return; + searchNRef.current.displayed = searchN; + batch(() => { + selectionIdx.value = -1; + suggestions.value = res.data.items; + }); } else { throw res; } } } catch (_e) { - suggestions.value = []; + if (abort.current?.signal.aborted) return; + suggestions.value = null; } - - pending.value = false; - }, TYPING_DEBOUNCE); + })(); } else { abort.current?.abort(); abort.current = new AbortController(); - clearTimeout(debounceRef.current); - pending.value = false; - suggestions.value = []; + suggestions.value = null; } }; function onKeyUp(e: KeyboardEvent) { - if (pending.value) return; - + if (suggestions.value === null) return; if (e.key === "ArrowDown") { selectionIdx.value = Math.min( suggestions.value.length - 1, @@ -113,7 +131,9 @@ export function PackageSearch( } function onSubmit(e: JSX.TargetedEvent) { - if (!btnSubmit.value && selectionIdx.value > -1) { + if ( + !btnSubmit.value && selectionIdx.value > -1 && suggestions.value !== null + ) { const item = suggestions.value[selectionIdx.value]; if (item !== undefined) { e.preventDefault(); @@ -140,7 +160,7 @@ export function PackageSearch( value={query} onInput={onInput} onKeyUp={onKeyUp} - onFocus={() => showSuggestions.value = true} + onFocus={() => isFocused.value = true} autoComplete="off" aria-expanded="false" /> @@ -176,7 +196,6 @@ export function PackageSearch( @@ -185,43 +204,43 @@ export function PackageSearch( } function SuggestionList( - { suggestions, pending, selectionIdx, showSuggestions }: { - suggestions: Signal; + { suggestions, selectionIdx, showSuggestions }: { + suggestions: Signal; showSuggestions: Signal; - pending: Signal; selectionIdx: Signal; }, ) { - if ( - !showSuggestions.value || !pending.value && suggestions.value.length == 0 - ) return null; + if (!showSuggestions.value) return null; return (
- {pending.value ?
...
: null} - {!pending.value && ( - - )} + {suggestions.value === null + ?
...
+ : suggestions.value?.length === 0 + ?
No results
+ : ( + + )}
); }