Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291<script lang="ts"> import { createBobbinClient } from "#lib/api/client.ts"; import { searchActorsTypeahead, type ActorSuggestion } from "#lib/api/search.ts"; import { normalizeHandle } from "#lib/handle.ts"; 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; placement?: "below" | "above"; };
let { bobbinUrl, value = $bindable(""), selected = $bindable(), onSelect, loading: inputLoading = false, size = "regular", panelClass, placement = "below", 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 above = $derived(placement === "above"); const settledEmpty = $derived( (status === "ready" || status === "failed") && actors.length === 0 ); const showResults = $derived( open && value.trim() !== "" && status !== "idle" && !(above && settledEmpty) );
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(); activeIndex = -1;
const query = normalizeHandle(value); if (query === "") { actors = []; status = "idle"; 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 revealActive = () => document .getElementById(`${resultListId}-${activeIndex}`) ?.scrollIntoView({ block: "nearest" });
const handleKeydown: NonNullable<InputProps["onkeydown"]> = (event) => { const key = above && (event.key === "ArrowUp" || event.key === "ArrowDown") ? event.key === "ArrowUp" ? "ArrowDown" : "ArrowUp" : event.key; switch (key) { case "ArrowDown": if (actors.length === 0) break; event.preventDefault(); open = true; activeIndex = activeIndex < actors.length - 1 ? activeIndex + 1 : 0; revealActive(); break; case "ArrowUp": if (actors.length === 0) break; event.preventDefault(); open = true; activeIndex = activeIndex > 0 ? activeIndex - 1 : actors.length - 1; revealActive(); 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", above && "actor-input-panel-above", "shadow-regular dark:shadow-none", panelClass ] })} > <div id={resultListId} role="listbox" aria-label="Users" class={slots.options({ class: above && "flex flex-col-reverse" })} > {#if actors.length > 0} {#each actors as actor, index (actor.did)} <button data-handle={actor.handle} data-did={actor.did} 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 users…</p> {:else if status === "failed"} <p class={slots.empty()}>Couldn’t load users</p> {:else} <p class={slots.empty()}>No users 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); }
.actor-input-panel-above { margin-top: 0; margin-bottom: 0.25rem; position-area: block-start span-inline-end; position-try: none; max-height: min( calc(5 * (1.3125rem + 0.75rem) + 2 * var(--spacing-2xs)), calc(100% - 0.25rem - var(--spacing-xs)) ); } }</style>