~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
117 lines · 3.1 KB Raw History
<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>