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 { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
type Person = components['schemas']['Person'];
interface Props {
@@ -50,10 +51,23 @@ $effect(() => {
}
});
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
const typeahead = createTypeahead<Person>({
fetchUrl: async (term) => {
const personId = restrictToCorrespondentsOf;
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() {
if (value && searchTerm !== initialName) {
@@ -61,69 +75,38 @@ function handleInput() {
onchange?.('');
}
showDropdown = true;
clearTimeout(debounceTimer);
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);
const term = untrack(() => searchTerm);
typeahead.setQuery(term);
}
function handleFocus() {
onfocused?.();
showDropdown = true;
if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!;
loading = true;
(async () => {
try {
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) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
typeahead.openWith([]);
}
})();
} else {
typeahead.openWith(typeahead.results);
}
}
function selectPerson(person: Person) {
value = person.id!;
searchTerm = person.displayName;
showDropdown = false;
typeahead.close();
onchange?.(person.id!);
}
</script>
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
<label
for={name}
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'}
/>
{#if showDropdown && (results.length > 0 || loading)}
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
<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"
>
{#if loading}
{#if typeahead.loading}
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
{:else}
{#each results as person (person.id)}
{#each typeahead.results as person (person.id)}
<div
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
onclick={() => selectPerson(person)}

View File

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