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
1
<script lang="ts">
2
2
import { AppBskyActorDefs } from '@atcute/bluesky';
3
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
7
+
let highlightedIndex = $state(-1);
8
8
+
let dropdownVisible = $derived(results.length > 0);
9
9
+
let wrapperEl: HTMLDivElement | undefined = $state();
10
10
+
11
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
19
+
highlightedIndex = -1;
15
20
}
16
16
-
let open = $state(false);
21
21
+
22
22
+
function selectActor(actor: AppBskyActorDefs.ProfileViewBasic) {
23
23
+
value = actor.handle;
24
24
+
onselected?.(actor);
25
25
+
results = [];
26
26
+
highlightedIndex = -1;
27
27
+
}
28
28
+
29
29
+
function handleKeydown(e: KeyboardEvent) {
30
30
+
if (!dropdownVisible) {
31
31
+
if (e.key === 'Enter') {
32
32
+
(e.currentTarget as HTMLInputElement).form?.requestSubmit();
33
33
+
}
34
34
+
return;
35
35
+
}
36
36
+
37
37
+
if (e.key === 'ArrowUp') {
38
38
+
e.preventDefault();
39
39
+
highlightedIndex = highlightedIndex <= 0 ? results.length - 1 : highlightedIndex - 1;
40
40
+
} else if (e.key === 'ArrowDown') {
41
41
+
e.preventDefault();
42
42
+
highlightedIndex = highlightedIndex >= results.length - 1 ? 0 : highlightedIndex + 1;
43
43
+
} else if (e.key === 'Enter') {
44
44
+
if (highlightedIndex >= 0 && highlightedIndex < results.length) {
45
45
+
e.preventDefault();
46
46
+
selectActor(results[highlightedIndex]);
47
47
+
} else {
48
48
+
(e.currentTarget as HTMLInputElement).form?.requestSubmit();
49
49
+
}
50
50
+
} else if (e.key === 'Escape') {
51
51
+
results = [];
52
52
+
highlightedIndex = -1;
53
53
+
}
54
54
+
}
55
55
+
56
56
+
function handleClickOutside(e: MouseEvent) {
57
57
+
if (wrapperEl && !wrapperEl.contains(e.target as Node)) {
58
58
+
results = [];
59
59
+
highlightedIndex = -1;
60
60
+
}
61
61
+
}
62
62
+
63
63
+
$effect(() => {
64
64
+
if (dropdownVisible) {
65
65
+
document.addEventListener('click', handleClickOutside, true);
66
66
+
return () => document.removeEventListener('click', handleClickOutside, true);
67
67
+
}
68
68
+
});
17
69
18
70
let {
19
71
value = $bindable(),
···
26
78
} = $props();
27
79
</script>
28
80
29
29
-
<Combobox.Root
30
30
-
type="single"
31
31
-
onOpenChangeComplete={(o) => {
32
32
-
if (!o) results = [];
33
33
-
}}
34
34
-
bind:value={
35
35
-
() => {
36
36
-
return value;
37
37
-
},
38
38
-
(val) => {
39
39
-
const profile = results.find((v) => v.handle === val);
40
40
-
if (profile) onselected?.(profile);
41
41
-
// Only update if val has content - prevents Combobox from clearing on Enter
42
42
-
if (val) value = val;
43
43
-
}
44
44
-
}
45
45
-
bind:open={
46
46
-
() => {
47
47
-
return open && results.length > 0;
48
48
-
},
49
49
-
(val) => {
50
50
-
open = val;
51
51
-
}
52
52
-
}
53
53
-
>
54
54
-
<Combobox.Input
55
55
-
bind:ref
81
81
+
<div class="relative w-full" bind:this={wrapperEl}>
82
82
+
<input
83
83
+
bind:this={ref}
84
84
+
type="text"
85
85
+
{value}
56
86
oninput={(e) => {
57
87
value = e.currentTarget.value;
58
88
search(e.currentTarget.value);
59
89
}}
60
60
-
onkeydown={(e) => {
61
61
-
if (e.key === 'Enter') e.currentTarget.form?.requestSubmit();
62
62
-
}}
90
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
95
+
role="combobox"
96
96
+
aria-expanded={dropdownVisible}
97
97
+
aria-controls={listboxId}
98
98
+
aria-autocomplete="list"
99
99
+
autocomplete="off"
67
100
/>
68
68
-
<Combobox.Content
69
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
70
-
sideOffset={10}
71
71
-
align="start"
72
72
-
side="top"
73
73
-
>
74
74
-
<Combobox.Viewport class="w-full p-1">
75
75
-
{#each results as actor (actor.did)}
76
76
-
<Combobox.Item
77
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
78
-
value={actor.handle}
79
79
-
label={actor.handle}
80
80
-
>
81
81
-
<Avatar
82
82
-
src={actor.avatar?.replace('avatar', 'avatar_thumbnail')}
83
83
-
alt=""
84
84
-
class="size-6 rounded-full"
85
85
-
/>
86
86
-
{actor.handle}
87
87
-
</Combobox.Item>
88
88
-
{/each}
89
89
-
</Combobox.Viewport>
90
90
-
</Combobox.Content>
91
91
-
</Combobox.Root>
101
101
+
102
102
+
{#if dropdownVisible}
103
103
+
<div
104
104
+
id={listboxId}
105
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
106
+
role="listbox"
107
107
+
>
108
108
+
<div class="w-full p-1">
109
109
+
{#each results as actor, i (actor.did)}
110
110
+
<button
111
111
+
type="button"
112
112
+
class="my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2 {i ===
113
113
+
highlightedIndex
114
114
+
? 'bg-accent-100 dark:bg-accent-600/30'
115
115
+
: ''}"
116
116
+
role="option"
117
117
+
aria-selected={i === highlightedIndex}
118
118
+
onmouseenter={() => (highlightedIndex = i)}
119
119
+
onclick={() => selectActor(actor)}
120
120
+
>
121
121
+
<Avatar
122
122
+
src={actor.avatar?.replace('avatar', 'avatar_thumbnail')}
123
123
+
alt=""
124
124
+
class="size-6 rounded-full"
125
125
+
/>
126
126
+
{actor.handle}
127
127
+
</button>
128
128
+
{/each}
129
129
+
</div>
130
130
+
</div>
131
131
+
{/if}
132
132
+
</div>