your personal website on atproto - mirror blento.app

handle input remove combobox

Florian aed4e1c7 dd0079fd

+97 -56
+97 -56
src/lib/atproto/UI/HandleInput.svelte
··· 1 1 <script lang="ts"> 2 2 import { AppBskyActorDefs } from '@atcute/bluesky'; 3 - import { Combobox } from 'bits-ui'; 4 3 import { searchActorsTypeahead } from '$lib/atproto'; 5 4 import { Avatar } from '@foxui/core'; 6 5 7 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'; 8 12 9 13 async function search(q: string) { 10 14 if (!q || q.length < 2) { ··· 12 16 return; 13 17 } 14 18 results = (await searchActorsTypeahead(q, 5)).actors; 19 + highlightedIndex = -1; 15 20 } 16 - let open = $state(false); 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 + }); 17 69 18 70 let { 19 71 value = $bindable(), ··· 26 78 } = $props(); 27 79 </script> 28 80 29 - <Combobox.Root 30 - type="single" 31 - onOpenChangeComplete={(o) => { 32 - if (!o) results = []; 33 - }} 34 - bind:value={ 35 - () => { 36 - return value; 37 - }, 38 - (val) => { 39 - const profile = results.find((v) => v.handle === val); 40 - if (profile) onselected?.(profile); 41 - // Only update if val has content - prevents Combobox from clearing on Enter 42 - if (val) value = val; 43 - } 44 - } 45 - bind:open={ 46 - () => { 47 - return open && results.length > 0; 48 - }, 49 - (val) => { 50 - open = val; 51 - } 52 - } 53 - > 54 - <Combobox.Input 55 - bind:ref 81 + <div class="relative w-full" bind:this={wrapperEl}> 82 + <input 83 + bind:this={ref} 84 + type="text" 85 + {value} 56 86 oninput={(e) => { 57 87 value = e.currentTarget.value; 58 88 search(e.currentTarget.value); 59 89 }} 60 - onkeydown={(e) => { 61 - if (e.key === 'Enter') e.currentTarget.form?.requestSubmit(); 62 - }} 90 + onkeydown={handleKeydown} 63 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" 64 92 placeholder="handle" 65 93 id="" 66 94 aria-label="enter your handle" 95 + role="combobox" 96 + aria-expanded={dropdownVisible} 97 + aria-controls={listboxId} 98 + aria-autocomplete="list" 99 + autocomplete="off" 67 100 /> 68 - <Combobox.Content 69 - class="border-base-300 bg-base-50 dark:bg-base-900 dark:border-base-800 z-100 max-h-[30dvh] w-full rounded-2xl border shadow-lg" 70 - sideOffset={10} 71 - align="start" 72 - side="top" 73 - > 74 - <Combobox.Viewport class="w-full p-1"> 75 - {#each results as actor (actor.did)} 76 - <Combobox.Item 77 - class="rounded-button data-highlighted:bg-accent-100 dark:data-highlighted:bg-accent-600/30 my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2" 78 - value={actor.handle} 79 - label={actor.handle} 80 - > 81 - <Avatar 82 - src={actor.avatar?.replace('avatar', 'avatar_thumbnail')} 83 - alt="" 84 - class="size-6 rounded-full" 85 - /> 86 - {actor.handle} 87 - </Combobox.Item> 88 - {/each} 89 - </Combobox.Viewport> 90 - </Combobox.Content> 91 - </Combobox.Root> 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>