Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246<script lang="ts"> import { createBobbinClient } from "$lib/api/client"; import { searchActorsTypeahead, type ActorSuggestion } from "$lib/api/search"; import { onDestroy, type ComponentProps } from "svelte"; import Avatar from "$lib/components/ui/Avatar.svelte"; import Input from "$lib/components/ui/Input.svelte"; import { tagInput } from "$lib/components/ui/TagInput.svelte";
type InputProps = ComponentProps<typeof Input>; type Props = Omit< InputProps, | "value" | "loading" | "rich" | "tags" | "maxTags" | "commitOn" | "allowDuplicates" | "validate" | "suggestions" | "onSearch" | "onAdd" | "onRemove" | "hint" | "emptyLabel" | "panelClass" | "tag" | "suggestion" | "size" > & { bobbinUrl: string; value?: string; selected?: ActorSuggestion; onSelect?: (actor: ActorSuggestion) => void; loading?: boolean; size?: InputProps["size"]; panelClass?: string; };
let { bobbinUrl, value = $bindable(""), selected = $bindable(), onSelect, loading: inputLoading = false, size = "regular", panelClass, autocomplete = "off", spellcheck = false, element = $bindable(), oninput, onkeydown, onfocus, ...inputProps }: Props = $props();
const bobbin = $derived(createBobbinClient({ serviceUrl: bobbinUrl })); const resultListId = $props.id(); const anchorName = `--actor-input-${resultListId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; const slots = $derived(tagInput({ size }));
let actors = $state<ActorSuggestion[]>([]); let activeIndex = $state(-1); let open = $state(false); let status = $state<"idle" | "loading" | "ready" | "failed">("idle"); let timer: ReturnType<typeof setTimeout> | undefined; let request: AbortController | undefined; let panelEl = $state<HTMLElement>();
const showResults = $derived(open && value.trim() !== "" && status !== "idle");
const loadActors = async (query: string) => { const controller = new AbortController(); request = controller; try { const result = await searchActorsTypeahead( bobbin, { q: query, limit: 8 }, { signal: controller.signal } ); if (controller.signal.aborted) return; actors = result.actors; status = "ready"; } catch { if (controller.signal.aborted) return; actors = []; status = "failed"; } };
const handleInput: NonNullable<InputProps["oninput"]> = (event) => { value = event.currentTarget.value; if (selected !== undefined) selected = undefined; if (timer !== undefined) clearTimeout(timer); request?.abort(); actors = []; activeIndex = -1; status = "idle";
const query = value.trim(); if (query === "") { open = false; } else { open = true; status = "loading"; timer = setTimeout(() => void loadActors(query), 150); } oninput?.(event); };
const selectActor = (actor: ActorSuggestion) => { value = actor.handle; selected = actor; open = false; onSelect?.(actor); };
const handleKeydown: NonNullable<InputProps["onkeydown"]> = (event) => { switch (event.key) { case "ArrowDown": if (actors.length === 0) break; event.preventDefault(); open = true; activeIndex = activeIndex < actors.length - 1 ? activeIndex + 1 : 0; break; case "ArrowUp": if (actors.length === 0) break; event.preventDefault(); open = true; activeIndex = activeIndex > 0 ? activeIndex - 1 : actors.length - 1; break; case "Enter": { const actor = actors[activeIndex]; if (open && actor) { event.preventDefault(); selectActor(actor); } break; } case "Escape": open = false; activeIndex = -1; break; } onkeydown?.(event); };
const handleFocus: NonNullable<InputProps["onfocus"]> = (event) => { if (value.trim() !== "") open = true; onfocus?.(event); };
$effect(() => { if (selected !== undefined && selected.handle !== value) selected = undefined; });
$effect(() => { const panel = panelEl; if (!panel) return; const shown = panel.matches(":popover-open"); if (showResults && !shown) panel.showPopover(); else if (!showResults && shown) panel.hidePopover(); });
const closeWhenLeaving = (event: FocusEvent) => { const root = event.currentTarget as HTMLDivElement; if (!(event.relatedTarget instanceof Node) || !root.contains(event.relatedTarget)) { open = false; } };
onDestroy(() => { if (timer !== undefined) clearTimeout(timer); request?.abort(); });</script>
<div class="relative" onfocusout={closeWhenLeaving} style="anchor-name: {anchorName}"> <Input {...inputProps} bind:element {value} {size} loading={inputLoading || status === "loading"} oninput={handleInput} onkeydown={handleKeydown} onfocus={handleFocus} {autocomplete} {spellcheck} role="combobox" aria-autocomplete="list" aria-expanded={showResults} aria-controls={resultListId} aria-activedescendant={activeIndex >= 0 ? `${resultListId}-${activeIndex}` : undefined} />
<div bind:this={panelEl} popover="manual" style="position-anchor: {anchorName}" class={slots.panel({ class: ["actor-input-panel", "shadow-regular dark:shadow-none", panelClass] })} > <div id={resultListId} role="listbox" aria-label="Actors" class={slots.options()}> {#if actors.length > 0} {#each actors as actor, index (actor.did)} <button id={`${resultListId}-${index}`} type="button" role="option" tabindex={-1} aria-selected={index === activeIndex} class={slots.option()} data-active={index === activeIndex || undefined} onpointerdown={(event) => event.preventDefault()} onpointermove={() => (activeIndex = index)} onclick={() => selectActor(actor)} > <Avatar did={actor.did} handle={actor.handle} size="regular" /> <span class={slots.optionLabel()}>{actor.handle}</span> </button> {/each} {:else if status === "loading"} <p class={slots.empty()}>Searching…</p> {:else if status === "failed"} <p class={slots.empty()}>Couldn’t load actors</p> {:else} <p class={slots.empty()}>No actors found</p> {/if} </div> </div></div>
<style> @supports (position-area: bottom) and (position-try: flip-block) { .actor-input-panel { position: absolute; margin-top: 0.25rem; position-area: block-end span-inline-end; position-try: flip-block; width: anchor-size(width); } }</style>