tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
pulls
pipelines
handle input remove combobox
Florian
4 weeks ago
aed4e1c7
dd0079fd
+97
-56
1 changed file
expand all
collapse all
unified
split
src
lib
atproto
UI
HandleInput.svelte
+97
-56
src/lib/atproto/UI/HandleInput.svelte
···
1
<script lang="ts">
2
import { AppBskyActorDefs } from '@atcute/bluesky';
3
-
import { Combobox } from 'bits-ui';
4
import { searchActorsTypeahead } from '$lib/atproto';
5
import { Avatar } from '@foxui/core';
6
7
let results: AppBskyActorDefs.ProfileViewBasic[] = $state([]);
0
0
0
0
0
8
9
async function search(q: string) {
10
if (!q || q.length < 2) {
···
12
return;
13
}
14
results = (await searchActorsTypeahead(q, 5)).actors;
0
15
}
16
-
let open = $state(false);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
17
18
let {
19
value = $bindable(),
···
26
} = $props();
27
</script>
28
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
56
oninput={(e) => {
57
value = e.currentTarget.value;
58
search(e.currentTarget.value);
59
}}
60
-
onkeydown={(e) => {
61
-
if (e.key === 'Enter') e.currentTarget.form?.requestSubmit();
62
-
}}
63
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
placeholder="handle"
65
id=""
66
aria-label="enter your handle"
0
0
0
0
0
67
/>
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>
0
0
0
0
0
0
0
0
···
1
<script lang="ts">
2
import { AppBskyActorDefs } from '@atcute/bluesky';
0
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) {
···
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(),
···
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}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
86
oninput={(e) => {
87
value = e.currentTarget.value;
88
search(e.currentTarget.value);
89
}}
90
+
onkeydown={handleKeydown}
0
0
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>