your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { AppBskyActorDefs } from '@atcute/bluesky';
3 import { searchActorsTypeahead } from '$lib/atproto';
4 import { Avatar } from '@foxui/core';
5
6 let results: AppBskyActorDefs.ProfileViewBasic[] = $state([]);
7 let highlightedIndex = $state(-1);
8 let dropdownVisible = $derived(results.length > 0);
9 let wrapperEl: HTMLDivElement | undefined = $state();
10
11 const listboxId = 'handle-input-listbox';
12
13 async function search(q: string) {
14 if (!q || q.length < 2) {
15 results = [];
16 return;
17 }
18 results = (await searchActorsTypeahead(q, 5)).actors;
19 highlightedIndex = -1;
20 }
21
22 function selectActor(actor: AppBskyActorDefs.ProfileViewBasic) {
23 value = actor.handle;
24 onselected?.(actor);
25 results = [];
26 highlightedIndex = -1;
27 }
28
29 function handleKeydown(e: KeyboardEvent) {
30 if (!dropdownVisible) {
31 if (e.key === 'Enter') {
32 (e.currentTarget as HTMLInputElement).form?.requestSubmit();
33 }
34 return;
35 }
36
37 if (e.key === 'ArrowUp') {
38 e.preventDefault();
39 highlightedIndex = highlightedIndex <= 0 ? results.length - 1 : highlightedIndex - 1;
40 } else if (e.key === 'ArrowDown') {
41 e.preventDefault();
42 highlightedIndex = highlightedIndex >= results.length - 1 ? 0 : highlightedIndex + 1;
43 } else if (e.key === 'Enter') {
44 if (highlightedIndex >= 0 && highlightedIndex < results.length) {
45 e.preventDefault();
46 selectActor(results[highlightedIndex]);
47 } else {
48 (e.currentTarget as HTMLInputElement).form?.requestSubmit();
49 }
50 } else if (e.key === 'Escape') {
51 results = [];
52 highlightedIndex = -1;
53 }
54 }
55
56 function handleClickOutside(e: MouseEvent) {
57 if (wrapperEl && !wrapperEl.contains(e.target as Node)) {
58 results = [];
59 highlightedIndex = -1;
60 }
61 }
62
63 $effect(() => {
64 if (dropdownVisible) {
65 document.addEventListener('click', handleClickOutside, true);
66 return () => document.removeEventListener('click', handleClickOutside, true);
67 }
68 });
69
70 let {
71 value = $bindable(),
72 onselected,
73 ref = $bindable()
74 }: {
75 value: string;
76 onselected: (actor: AppBskyActorDefs.ProfileViewBasic) => void;
77 ref?: HTMLInputElement | null;
78 } = $props();
79</script>
80
81<div class="relative w-full" bind:this={wrapperEl}>
82 <input
83 bind:this={ref}
84 type="text"
85 {value}
86 oninput={(e) => {
87 value = e.currentTarget.value;
88 search(e.currentTarget.value);
89 }}
90 onkeydown={handleKeydown}
91 class="focus-within:outline-accent-600 dark:focus-within:outline-accent-500 dark:placeholder:text-base-400 w-full touch-none rounded-full border-0 bg-white ring-0 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 dark:bg-white/5 dark:outline-white/10"
92 placeholder="handle"
93 id=""
94 aria-label="enter your handle"
95 role="combobox"
96 aria-expanded={dropdownVisible}
97 aria-controls={listboxId}
98 aria-autocomplete="list"
99 autocomplete="off"
100 />
101
102 {#if dropdownVisible}
103 <div
104 id={listboxId}
105 class="border-base-300 bg-base-50 dark:bg-base-900 dark:border-base-800 absolute bottom-full left-0 z-100 mb-2.5 max-h-[30dvh] w-full overflow-auto rounded-2xl border shadow-lg"
106 role="listbox"
107 >
108 <div class="w-full p-1">
109 {#each results as actor, i (actor.did)}
110 <button
111 type="button"
112 class="my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2 {i ===
113 highlightedIndex
114 ? 'bg-accent-100 dark:bg-accent-600/30'
115 : ''}"
116 role="option"
117 aria-selected={i === highlightedIndex}
118 onmouseenter={() => (highlightedIndex = i)}
119 onclick={() => selectActor(actor)}
120 >
121 <Avatar
122 src={actor.avatar?.replace('avatar', 'avatar_thumbnail')}
123 alt=""
124 class="size-6 rounded-full"
125 />
126 {actor.handle}
127 </button>
128 {/each}
129 </div>
130 </div>
131 {/if}
132</div>