refactor(#248): extract typeahead logic into createTypeahead composable, use in PersonTypeahead

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 23:07:59 +02:00
parent 83629e0c6e
commit 53d89a44fc
2 changed files with 38 additions and 48 deletions

View File

@@ -3,6 +3,7 @@ import { untrack } from 'svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside'; import { clickOutside } from '$lib/actions/clickOutside';
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
interface Props { interface Props {
@@ -50,10 +51,23 @@ $effect(() => {
} }
}); });
let results: Person[] = $state([]); const typeahead = createTypeahead<Person>({
let showDropdown = $state(false); fetchUrl: async (term) => {
let loading = $state(false); const personId = restrictToCorrespondentsOf;
let debounceTimer: ReturnType<typeof setTimeout>; if (personId) {
const url =
term.length >= 1
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
: `/api/persons/${personId}/correspondents`;
const res = await fetch(url);
return res.ok ? await res.json() : [];
}
if (term.length < 1) return [];
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
return res.ok ? await res.json() : [];
},
debounceMs: 300
});
function handleInput() { function handleInput() {
if (value && searchTerm !== initialName) { if (value && searchTerm !== initialName) {
@@ -61,69 +75,38 @@ function handleInput() {
onchange?.(''); onchange?.('');
} }
showDropdown = true; const term = untrack(() => searchTerm);
clearTimeout(debounceTimer); typeahead.setQuery(term);
debounceTimer = setTimeout(async () => {
const term = untrack(() => searchTerm);
const correspondentsOf = untrack(() => restrictToCorrespondentsOf);
loading = true;
try {
let url: string;
if (correspondentsOf) {
if (term.length >= 1) {
url = `/api/persons/${correspondentsOf}/correspondents?q=${encodeURIComponent(term)}`;
} else {
url = `/api/persons/${correspondentsOf}/correspondents`;
}
} else {
if (term.length < 1) {
results = [];
loading = false;
return;
}
url = `/api/persons?q=${encodeURIComponent(term)}`;
}
const res = await fetch(url);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
}, 300);
} }
function handleFocus() { function handleFocus() {
onfocused?.(); onfocused?.();
showDropdown = true;
if (restrictToCorrespondentsOf) { if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!; const personId = untrack(() => restrictToCorrespondentsOf)!;
loading = true;
(async () => { (async () => {
try { try {
const res = await fetch(`/api/persons/${personId}/correspondents`); const res = await fetch(`/api/persons/${personId}/correspondents`);
results = res.ok ? await res.json() : []; const persons: Person[] = res.ok ? await res.json() : [];
typeahead.openWith(persons);
} catch (e) { } catch (e) {
console.error('Suche fehlgeschlagen', e); console.error('Suche fehlgeschlagen', e);
results = []; typeahead.openWith([]);
} finally {
loading = false;
} }
})(); })();
} else {
typeahead.openWith(typeahead.results);
} }
} }
function selectPerson(person: Person) { function selectPerson(person: Person) {
value = person.id!; value = person.id!;
searchTerm = person.displayName; searchTerm = person.displayName;
showDropdown = false; typeahead.close();
onchange?.(person.id!); onchange?.(person.id!);
} }
</script> </script>
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}> <div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
<label <label
for={name} for={name}
class={compact class={compact
@@ -149,14 +132,14 @@ function selectPerson(person: Person) {
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'} : 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
/> />
{#if showDropdown && (results.length > 0 || loading)} {#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
<div <div
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm" class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
> >
{#if loading} {#if typeahead.loading}
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div> <div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
{:else} {:else}
{#each results as person (person.id)} {#each typeahead.results as person (person.id)}
<div <div
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg" class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
onclick={() => selectPerson(person)} onclick={() => selectPerson(person)}

View File

@@ -41,6 +41,12 @@ export function createTypeahead<T>(options: Options<T>) {
close(); close();
} }
/** Directly populate results without going through the debounce (e.g. on-focus preload). */
function openWith(items: T[]) {
results = items;
isOpen = true;
}
return { return {
get query() { get query() {
return query; return query;
@@ -59,6 +65,7 @@ export function createTypeahead<T>(options: Options<T>) {
}, },
setQuery, setQuery,
close, close,
select select,
openWith
}; };
} }