johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<script lang="ts">
import Avatar from '#lib/atoms/Avatar.svelte';
import { type Actor, findActors } from '#lib/typeahead.ts';
/**
* A handle, with whoever on the Atmosphere matches what is typed so far
* dropping down over what is under it. Picking one fills it in and goes on (`onpick`),
* as the form's button would, or only fills it in, with none. Its own login's
* (`autocomplete` username) unless it names someone else.
*/
let {
id,
value = $bindable(''),
onpick,
name = 'handle',
label,
autocomplete = 'username'
}: {
id: string;
value?: string;
onpick?: (handle: string) => void;
name?: string;
label?: string;
autocomplete?: 'username' | 'off';
} = $props();
let found = $state<Actor[]>([]);
let open = $state(false);
// Which one Enter picks: none till the arrows choose one, so Enter goes on with what is typed.
let at = $state(-1);
const list = $derived(`${id}-list`);
// Looked up a moment after typing stops; a lookup overtaken by more typing is let go.
$effect(() => {
const q = value;
const ask = new AbortController();
const wait = setTimeout(() => {
findActors(q, ask.signal)
.then((a) => {
found = a;
at = -1;
})
.catch(() => {});
}, 150);
return () => {
clearTimeout(wait);
ask.abort();
};
});
const showing = $derived(open && found.length > 0);
function pick(a: Actor) {
value = a.handle;
open = false;
onpick?.(a.handle);
}
function keydown(e: KeyboardEvent) {
if (!showing) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const n = found.length;
at = e.key === 'ArrowDown' ? (at + 1) % n : (at - 1 + n) % n;
} else if (e.key === 'Enter' && found[at]) {
e.preventDefault();
pick(found[at]);
} else if (e.key === 'Escape') {
// Closes the list, not the dialog around it.
e.preventDefault();
e.stopPropagation();
open = false;
}
}
</script>
<div class="handle-input">
<input
{id}
{name}
aria-label={label}
placeholder="alice.bsky.social"
{autocomplete}
autocapitalize="none"
spellcheck="false"
required
role="combobox"
aria-expanded={showing}
aria-controls={list}
aria-autocomplete="list"
aria-activedescendant={showing && at >= 0 ? `${list}-${at}` : undefined}
bind:value
oninput={() => (open = true)}
onfocus={() => (open = true)}
onblur={() => (open = false)}
onkeydown={keydown}
/>
{#if showing}
<!-- Over the dialog, under the field, rather than stretching the dialog to hold it. -->
<div id={list} class="handle-list" role="listbox">
{#each found as a, i (a.did)}
<!-- Picked on the press, before the field loses focus and the list closes. -->
<div
id="{list}-{i}"
class="handle-option"
role="option"
tabindex="-1"
aria-selected={i === at}
onmousedown={(e) => {
e.preventDefault();
pick(a);
}}
onmouseenter={() => (at = i)}
>
<Avatar name={a.displayName || a.handle} did={a.did} src={a.avatar} size="lg" />
<span class="who"
>{#if a.displayName}<b>{a.displayName}</b>{/if}<small>@{a.handle}</small></span
>
</div>
{/each}
</div>
{/if}
</div>