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:
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user