your personal website on atproto - mirror blento.app
at handle-input 132 lines 3.8 kB view raw
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>