@tunji_xy/ui 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/app/assets/css/main.css +193 -0
- package/app/components/Button.vue +126 -0
- package/app/components/Checkbox.vue +130 -0
- package/app/components/DropdownMenu.vue +444 -0
- package/app/components/Input.vue +172 -0
- package/app/components/Modal.vue +210 -0
- package/app/components/OtpInput.vue +251 -0
- package/app/components/ProgressBar.vue +122 -0
- package/app/components/Radio.vue +131 -0
- package/app/components/Select.vue +680 -0
- package/app/components/Switch.vue +141 -0
- package/app/components/Tabs.vue +288 -0
- package/app/components/Textarea.vue +188 -0
- package/app/components/Tooltip.vue +292 -0
- package/app/lib/cn.ts +25 -0
- package/nuxt.config.ts +22 -0
- package/package.json +57 -4
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
ref="root"
|
|
4
|
+
:class="cn('flex flex-col gap-1 text-foreground', attrs.class as string)"
|
|
5
|
+
@focusout="onFocusout"
|
|
6
|
+
>
|
|
7
|
+
<label
|
|
8
|
+
v-if="label"
|
|
9
|
+
:id="labelId"
|
|
10
|
+
class="text-sm font-semibold"
|
|
11
|
+
@click="triggerEl?.focus()"
|
|
12
|
+
>
|
|
13
|
+
{{ label }}
|
|
14
|
+
</label>
|
|
15
|
+
|
|
16
|
+
<button
|
|
17
|
+
:id="triggerId"
|
|
18
|
+
ref="trigger"
|
|
19
|
+
v-bind="triggerAttrs"
|
|
20
|
+
type="button"
|
|
21
|
+
role="combobox"
|
|
22
|
+
:class="triggerClasses"
|
|
23
|
+
:disabled="disabled"
|
|
24
|
+
aria-haspopup="listbox"
|
|
25
|
+
:aria-expanded="open"
|
|
26
|
+
:aria-controls="listId"
|
|
27
|
+
:aria-activedescendant="
|
|
28
|
+
open && activeIndex !== -1 ? optionId(activeIndex) : undefined
|
|
29
|
+
"
|
|
30
|
+
:aria-labelledby="label ? labelId : undefined"
|
|
31
|
+
:aria-required="required || undefined"
|
|
32
|
+
:aria-invalid="error ? true : undefined"
|
|
33
|
+
:aria-describedby="message ? messageId : undefined"
|
|
34
|
+
@click="toggle"
|
|
35
|
+
@keydown="onKeydown"
|
|
36
|
+
@keyup.space.prevent
|
|
37
|
+
>
|
|
38
|
+
<span
|
|
39
|
+
v-if="selected?.icon"
|
|
40
|
+
class="inline-flex shrink-0 text-muted-foreground [&>svg]:size-4"
|
|
41
|
+
>
|
|
42
|
+
<OptionIcon :icon="selected.icon" />
|
|
43
|
+
</span>
|
|
44
|
+
<span
|
|
45
|
+
:class="
|
|
46
|
+
cn('min-w-0 flex-1 truncate', !selected && 'text-muted-foreground/80')
|
|
47
|
+
"
|
|
48
|
+
>
|
|
49
|
+
{{ selected?.label ?? placeholder }}
|
|
50
|
+
</span>
|
|
51
|
+
<ChevronDownIcon
|
|
52
|
+
:class="
|
|
53
|
+
cn(
|
|
54
|
+
'size-4 shrink-0 text-muted-foreground transition-transform',
|
|
55
|
+
open && 'rotate-180',
|
|
56
|
+
)
|
|
57
|
+
"
|
|
58
|
+
/>
|
|
59
|
+
</button>
|
|
60
|
+
|
|
61
|
+
<input v-if="name" type="hidden" :name="name" :value="model ?? ''" />
|
|
62
|
+
|
|
63
|
+
<div
|
|
64
|
+
ref="popover"
|
|
65
|
+
popover="manual"
|
|
66
|
+
:class="popoverClasses"
|
|
67
|
+
:style="popoverStyle"
|
|
68
|
+
@mousedown="onPopoverMousedown"
|
|
69
|
+
>
|
|
70
|
+
<!-- Same height and padding as the trigger. -->
|
|
71
|
+
<div
|
|
72
|
+
v-if="searchable"
|
|
73
|
+
:class="
|
|
74
|
+
cn(
|
|
75
|
+
'flex shrink-0 items-center gap-2 border-b border-border',
|
|
76
|
+
sizes[size].trigger,
|
|
77
|
+
)
|
|
78
|
+
"
|
|
79
|
+
>
|
|
80
|
+
<SearchIcon class="size-4 shrink-0 text-muted-foreground" />
|
|
81
|
+
<input
|
|
82
|
+
ref="searchInput"
|
|
83
|
+
type="text"
|
|
84
|
+
role="combobox"
|
|
85
|
+
class="h-full min-w-0 flex-1 bg-transparent p-0 text-inherit outline-none placeholder:text-muted-foreground/80"
|
|
86
|
+
:value="query"
|
|
87
|
+
:placeholder="searchPlaceholder"
|
|
88
|
+
:aria-label="searchPlaceholder"
|
|
89
|
+
aria-autocomplete="list"
|
|
90
|
+
:aria-expanded="open"
|
|
91
|
+
:aria-controls="listId"
|
|
92
|
+
:aria-activedescendant="
|
|
93
|
+
activeIndex !== -1 ? optionId(activeIndex) : undefined
|
|
94
|
+
"
|
|
95
|
+
autocomplete="off"
|
|
96
|
+
spellcheck="false"
|
|
97
|
+
@input="onSearchInput"
|
|
98
|
+
@keydown="onListKeydown"
|
|
99
|
+
/>
|
|
100
|
+
<button
|
|
101
|
+
v-if="query"
|
|
102
|
+
type="button"
|
|
103
|
+
tabindex="-1"
|
|
104
|
+
class="inline-flex shrink-0 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
|
105
|
+
aria-label="Clear search"
|
|
106
|
+
@click="setQuery('')"
|
|
107
|
+
>
|
|
108
|
+
<XIcon class="size-4" />
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<!-- tabindex -1: browsers make scrolling areas Tab stops, but focus belongs on the trigger or search. -->
|
|
113
|
+
<div
|
|
114
|
+
:id="listId"
|
|
115
|
+
ref="list"
|
|
116
|
+
role="listbox"
|
|
117
|
+
tabindex="-1"
|
|
118
|
+
:aria-labelledby="label ? labelId : undefined"
|
|
119
|
+
:class="
|
|
120
|
+
cn(
|
|
121
|
+
'relative min-h-0 flex-1 overflow-y-auto overscroll-contain',
|
|
122
|
+
items.length > 0 && 'py-1',
|
|
123
|
+
)
|
|
124
|
+
"
|
|
125
|
+
>
|
|
126
|
+
<div
|
|
127
|
+
v-for="(section, s) in sections"
|
|
128
|
+
:key="section.name"
|
|
129
|
+
:role="section.name ? 'group' : undefined"
|
|
130
|
+
:aria-labelledby="section.name ? `${listId}-group-${s}` : undefined"
|
|
131
|
+
>
|
|
132
|
+
<div
|
|
133
|
+
v-if="section.name"
|
|
134
|
+
:id="`${listId}-group-${s}`"
|
|
135
|
+
role="presentation"
|
|
136
|
+
:class="
|
|
137
|
+
cn(
|
|
138
|
+
'pt-3 pb-1 text-xs font-semibold tracking-wider text-muted-foreground uppercase',
|
|
139
|
+
sizes[size].group,
|
|
140
|
+
)
|
|
141
|
+
"
|
|
142
|
+
>
|
|
143
|
+
{{ section.name }}
|
|
144
|
+
</div>
|
|
145
|
+
<div
|
|
146
|
+
v-for="{ option, index } in section.options"
|
|
147
|
+
:id="optionId(index)"
|
|
148
|
+
:key="option.value"
|
|
149
|
+
role="option"
|
|
150
|
+
:aria-selected="option.value === model"
|
|
151
|
+
:aria-disabled="option.disabled || undefined"
|
|
152
|
+
:class="optionClasses(option, index)"
|
|
153
|
+
@mousemove="hover(index)"
|
|
154
|
+
@click="select(index)"
|
|
155
|
+
>
|
|
156
|
+
<span
|
|
157
|
+
v-if="option.icon"
|
|
158
|
+
class="inline-flex shrink-0 text-muted-foreground [&>svg]:size-4"
|
|
159
|
+
>
|
|
160
|
+
<OptionIcon :icon="option.icon" />
|
|
161
|
+
</span>
|
|
162
|
+
<span class="flex min-w-0 flex-1 flex-col">
|
|
163
|
+
<span
|
|
164
|
+
:class="
|
|
165
|
+
cn('truncate', option.value === model && 'font-semibold')
|
|
166
|
+
"
|
|
167
|
+
>
|
|
168
|
+
{{ option.label }}
|
|
169
|
+
</span>
|
|
170
|
+
<span
|
|
171
|
+
v-if="option.description"
|
|
172
|
+
class="text-xs text-muted-foreground"
|
|
173
|
+
>
|
|
174
|
+
{{ option.description }}
|
|
175
|
+
</span>
|
|
176
|
+
</span>
|
|
177
|
+
<CheckIcon
|
|
178
|
+
v-if="option.value === model"
|
|
179
|
+
class="size-4 shrink-0"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<p
|
|
186
|
+
v-if="!items.length"
|
|
187
|
+
class="px-3 py-6 text-center text-sm text-muted-foreground"
|
|
188
|
+
>
|
|
189
|
+
{{ query ? noResultsText : "No results." }}
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<p
|
|
194
|
+
v-if="message"
|
|
195
|
+
:id="messageId"
|
|
196
|
+
:class="cn('text-sm', error ? 'text-danger' : 'text-muted-foreground')"
|
|
197
|
+
:role="error ? 'alert' : undefined"
|
|
198
|
+
>
|
|
199
|
+
{{ message }}
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
</template>
|
|
203
|
+
|
|
204
|
+
<script setup lang="ts">
|
|
205
|
+
import {
|
|
206
|
+
computed,
|
|
207
|
+
h,
|
|
208
|
+
onBeforeUnmount,
|
|
209
|
+
ref,
|
|
210
|
+
useAttrs,
|
|
211
|
+
useId,
|
|
212
|
+
useTemplateRef,
|
|
213
|
+
watch,
|
|
214
|
+
type Component,
|
|
215
|
+
type FunctionalComponent,
|
|
216
|
+
} from "vue";
|
|
217
|
+
import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "@lucide/vue";
|
|
218
|
+
import { cn } from "../lib/cn";
|
|
219
|
+
|
|
220
|
+
export interface SelectOption {
|
|
221
|
+
value: string | number;
|
|
222
|
+
label: string;
|
|
223
|
+
description?: string;
|
|
224
|
+
icon?: string | Component;
|
|
225
|
+
disabled?: boolean;
|
|
226
|
+
group?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface SelectProps {
|
|
230
|
+
options: SelectOption[];
|
|
231
|
+
label?: string;
|
|
232
|
+
placeholder?: string;
|
|
233
|
+
hint?: string;
|
|
234
|
+
error?: string;
|
|
235
|
+
variant?: "outline" | "filled" | "ghost";
|
|
236
|
+
size?: "sm" | "md" | "lg";
|
|
237
|
+
radius?: "none" | "sm" | "md" | "lg" | "full";
|
|
238
|
+
disabled?: boolean;
|
|
239
|
+
required?: boolean;
|
|
240
|
+
id?: string;
|
|
241
|
+
name?: string;
|
|
242
|
+
searchable?: boolean;
|
|
243
|
+
searchPlaceholder?: string;
|
|
244
|
+
noResultsText?: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const props = withDefaults(defineProps<SelectProps>(), {
|
|
248
|
+
placeholder: "Select…",
|
|
249
|
+
variant: "outline",
|
|
250
|
+
size: "md",
|
|
251
|
+
searchPlaceholder: "Search…",
|
|
252
|
+
noResultsText: "No results.",
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const model = defineModel<string | number | null>({ default: null });
|
|
256
|
+
|
|
257
|
+
// `class` styles the outer wrapper; every other attribute and listener
|
|
258
|
+
// (aria-label, @focus, data-*) goes to the trigger button.
|
|
259
|
+
defineOptions({ inheritAttrs: false });
|
|
260
|
+
const attrs = useAttrs();
|
|
261
|
+
const triggerAttrs = computed(() => {
|
|
262
|
+
const { class: _class, ...rest } = attrs;
|
|
263
|
+
return rest;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const autoId = useId();
|
|
267
|
+
const triggerId = computed(() => props.id ?? `ui-select-${autoId}`);
|
|
268
|
+
const labelId = computed(() => `${triggerId.value}-label`);
|
|
269
|
+
const listId = computed(() => `${triggerId.value}-listbox`);
|
|
270
|
+
const messageId = computed(() => `${triggerId.value}-message`);
|
|
271
|
+
const optionId = (index: number) => `${listId.value}-option-${index}`;
|
|
272
|
+
const message = computed(() => props.error || props.hint);
|
|
273
|
+
|
|
274
|
+
const query = ref("");
|
|
275
|
+
|
|
276
|
+
// With `searchable`, only the options whose label contains the search.
|
|
277
|
+
const visibleOptions = computed(() => {
|
|
278
|
+
const term = query.value.trim().toLowerCase();
|
|
279
|
+
if (!term) return props.options;
|
|
280
|
+
return props.options.filter((option) =>
|
|
281
|
+
option.label.toLowerCase().includes(term),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Options grouped by `group`, in order of first appearance. `index` is the
|
|
286
|
+
// option's position in the list as shown, which keyboard navigation follows.
|
|
287
|
+
const sections = computed(() => {
|
|
288
|
+
const groups = new Map<string, SelectOption[]>();
|
|
289
|
+
for (const option of visibleOptions.value) {
|
|
290
|
+
const name = option.group ?? "";
|
|
291
|
+
groups.set(name, [...(groups.get(name) ?? []), option]);
|
|
292
|
+
}
|
|
293
|
+
let index = 0;
|
|
294
|
+
return [...groups].map(([name, options]) => ({
|
|
295
|
+
name,
|
|
296
|
+
options: options.map((option) => ({ option, index: index++ })),
|
|
297
|
+
}));
|
|
298
|
+
});
|
|
299
|
+
const items = computed(() =>
|
|
300
|
+
sections.value.flatMap((section) => section.options.map((o) => o.option)),
|
|
301
|
+
);
|
|
302
|
+
const selected = computed(() =>
|
|
303
|
+
props.options.find((option) => option.value === model.value),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Renders an option's icon, whether it's a string or a component.
|
|
307
|
+
const OptionIcon: FunctionalComponent<{ icon: string | Component }> = ({
|
|
308
|
+
icon,
|
|
309
|
+
}) => (typeof icon === "string" ? icon : h(icon));
|
|
310
|
+
|
|
311
|
+
const rootEl = useTemplateRef<HTMLDivElement>("root");
|
|
312
|
+
const triggerEl = useTemplateRef<HTMLButtonElement>("trigger");
|
|
313
|
+
const popoverEl = useTemplateRef<HTMLDivElement>("popover");
|
|
314
|
+
const listEl = useTemplateRef<HTMLDivElement>("list");
|
|
315
|
+
// Not "search": a template ref sharing a name with a variable (the typeahead
|
|
316
|
+
// buffer below) binds to that variable in production builds, leaving this null.
|
|
317
|
+
const searchEl = useTemplateRef<HTMLInputElement>("searchInput");
|
|
318
|
+
const open = ref(false);
|
|
319
|
+
const activeIndex = ref(-1);
|
|
320
|
+
|
|
321
|
+
// The nearest enabled option from `from`, stepping by `step`, or -1.
|
|
322
|
+
function findEnabled(from: number, step: 1 | -1) {
|
|
323
|
+
for (let i = from; i >= 0 && i < items.value.length; i += step) {
|
|
324
|
+
if (!items.value[i]?.disabled) return i;
|
|
325
|
+
}
|
|
326
|
+
return -1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const firstEnabled = () => findEnabled(0, 1);
|
|
330
|
+
const lastEnabled = () => findEnabled(items.value.length - 1, -1);
|
|
331
|
+
|
|
332
|
+
function moveBy(delta: number) {
|
|
333
|
+
const target = Math.min(
|
|
334
|
+
Math.max(activeIndex.value + delta, 0),
|
|
335
|
+
items.value.length - 1,
|
|
336
|
+
);
|
|
337
|
+
const step = delta > 0 ? 1 : -1;
|
|
338
|
+
const next = findEnabled(target, step);
|
|
339
|
+
activeIndex.value = next !== -1 ? next : findEnabled(target, -step as -1);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function show(focus: "selected" | "first" | "last" = "selected") {
|
|
343
|
+
if (props.disabled || open.value) return;
|
|
344
|
+
query.value = "";
|
|
345
|
+
const selectedIndex = items.value.findIndex(
|
|
346
|
+
(option) => option.value === model.value,
|
|
347
|
+
);
|
|
348
|
+
activeIndex.value =
|
|
349
|
+
focus === "last"
|
|
350
|
+
? lastEnabled()
|
|
351
|
+
: focus === "selected" && selectedIndex !== -1
|
|
352
|
+
? selectedIndex
|
|
353
|
+
: firstEnabled();
|
|
354
|
+
open.value = true;
|
|
355
|
+
// Safari doesn't focus a button on click, and the keyboard and blur handling need it.
|
|
356
|
+
triggerEl.value?.focus();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function close(restoreFocus = true) {
|
|
360
|
+
if (!open.value) return;
|
|
361
|
+
open.value = false;
|
|
362
|
+
query.value = "";
|
|
363
|
+
// The search field is about to be hidden, so hand focus back to the trigger.
|
|
364
|
+
if (restoreFocus && popoverEl.value?.contains(document.activeElement)) {
|
|
365
|
+
triggerEl.value?.focus();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const toggle = () => (open.value ? close() : show());
|
|
370
|
+
|
|
371
|
+
function select(index: number) {
|
|
372
|
+
const option = items.value[index];
|
|
373
|
+
if (!option || option.disabled) return;
|
|
374
|
+
model.value = option.value;
|
|
375
|
+
close();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function pickActive() {
|
|
379
|
+
if (activeIndex.value === -1) close();
|
|
380
|
+
else select(activeIndex.value);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function hover(index: number) {
|
|
384
|
+
if (!items.value[index]?.disabled) activeIndex.value = index;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function setQuery(value: string) {
|
|
388
|
+
query.value = value;
|
|
389
|
+
activeIndex.value = firstEnabled();
|
|
390
|
+
if (listEl.value) listEl.value.scrollTop = 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const onSearchInput = (event: Event) =>
|
|
394
|
+
setQuery((event.target as HTMLInputElement).value);
|
|
395
|
+
|
|
396
|
+
// Close when focus leaves the component. Moving between the trigger and the
|
|
397
|
+
// search field (both inside it) keeps the list open.
|
|
398
|
+
function onFocusout(event: FocusEvent) {
|
|
399
|
+
if (!rootEl.value?.contains(event.relatedTarget as Node | null)) {
|
|
400
|
+
close(false);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Clicking in the list keeps focus where it is: on the trigger, or in the search field.
|
|
405
|
+
const onPopoverMousedown = (event: MouseEvent) => {
|
|
406
|
+
if (event.target !== searchEl.value) event.preventDefault();
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
let search = "";
|
|
410
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
411
|
+
|
|
412
|
+
// Jumps to the next option starting with what's been typed. Pressing the
|
|
413
|
+
// same letter again cycles through the options starting with it.
|
|
414
|
+
function typeahead(char: string) {
|
|
415
|
+
clearTimeout(searchTimer);
|
|
416
|
+
searchTimer = setTimeout(() => (search = ""), 500);
|
|
417
|
+
search += char.toLowerCase();
|
|
418
|
+
|
|
419
|
+
const repeated = [...search].every((c) => c === search[0]);
|
|
420
|
+
const prefix = repeated ? search[0]! : search;
|
|
421
|
+
const start = Math.max(activeIndex.value + (repeated ? 1 : 0), 0);
|
|
422
|
+
const count = items.value.length;
|
|
423
|
+
for (let i = 0; i < count; i++) {
|
|
424
|
+
const index = (start + i) % count;
|
|
425
|
+
const option = items.value[index]!;
|
|
426
|
+
if (!option.disabled && option.label.toLowerCase().startsWith(prefix)) {
|
|
427
|
+
activeIndex.value = index;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function onListKeydown(event: KeyboardEvent) {
|
|
434
|
+
switch (event.key) {
|
|
435
|
+
case "ArrowDown":
|
|
436
|
+
moveBy(1);
|
|
437
|
+
break;
|
|
438
|
+
case "ArrowUp":
|
|
439
|
+
if (event.altKey) pickActive();
|
|
440
|
+
else moveBy(-1);
|
|
441
|
+
break;
|
|
442
|
+
case "PageDown":
|
|
443
|
+
moveBy(10);
|
|
444
|
+
break;
|
|
445
|
+
case "PageUp":
|
|
446
|
+
moveBy(-10);
|
|
447
|
+
break;
|
|
448
|
+
case "Enter":
|
|
449
|
+
pickActive();
|
|
450
|
+
break;
|
|
451
|
+
case "Escape":
|
|
452
|
+
event.stopPropagation();
|
|
453
|
+
close();
|
|
454
|
+
break;
|
|
455
|
+
default:
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
event.preventDefault();
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function onKeydown(event: KeyboardEvent) {
|
|
463
|
+
const { key } = event;
|
|
464
|
+
const printable =
|
|
465
|
+
key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
466
|
+
|
|
467
|
+
if (!open.value) {
|
|
468
|
+
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(key)) show();
|
|
469
|
+
else if (key === "Home") show("first");
|
|
470
|
+
else if (key === "End") show("last");
|
|
471
|
+
else if (printable) {
|
|
472
|
+
show();
|
|
473
|
+
// Start the search with the key that opened the list.
|
|
474
|
+
if (props.searchable) setQuery(key);
|
|
475
|
+
else typeahead(key);
|
|
476
|
+
} else return;
|
|
477
|
+
event.preventDefault();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (onListKeydown(event)) return;
|
|
482
|
+
switch (key) {
|
|
483
|
+
case "Home":
|
|
484
|
+
activeIndex.value = firstEnabled();
|
|
485
|
+
break;
|
|
486
|
+
case "End":
|
|
487
|
+
activeIndex.value = lastEnabled();
|
|
488
|
+
break;
|
|
489
|
+
case " ":
|
|
490
|
+
// A space mid-search is part of the search, e.g. "united s".
|
|
491
|
+
if (search) typeahead(key);
|
|
492
|
+
else pickActive();
|
|
493
|
+
break;
|
|
494
|
+
default:
|
|
495
|
+
if (!printable) return;
|
|
496
|
+
typeahead(key);
|
|
497
|
+
}
|
|
498
|
+
event.preventDefault();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const MAX_HEIGHT = 288;
|
|
502
|
+
const GAP = 6;
|
|
503
|
+
const popoverStyle = ref<Record<string, string>>({});
|
|
504
|
+
|
|
505
|
+
// The list is a popover in the browser's top layer, so it's never clipped by
|
|
506
|
+
// a scrolling parent or a <UiModal>. It sits under the trigger, or above it
|
|
507
|
+
// when there's more room there.
|
|
508
|
+
function position() {
|
|
509
|
+
const trigger = triggerEl.value;
|
|
510
|
+
const popover = popoverEl.value;
|
|
511
|
+
const list = listEl.value;
|
|
512
|
+
if (!trigger || !popover || !list) return;
|
|
513
|
+
const rect = trigger.getBoundingClientRect();
|
|
514
|
+
const viewport = document.documentElement.clientHeight;
|
|
515
|
+
const below = viewport - rect.bottom - GAP * 2;
|
|
516
|
+
const above = rect.top - GAP * 2;
|
|
517
|
+
// The height it would be with the whole list showing.
|
|
518
|
+
const height = Math.min(
|
|
519
|
+
popover.offsetHeight - list.clientHeight + list.scrollHeight,
|
|
520
|
+
MAX_HEIGHT,
|
|
521
|
+
);
|
|
522
|
+
const flip = height > below && above > below;
|
|
523
|
+
popoverStyle.value = {
|
|
524
|
+
left: `${rect.left}px`,
|
|
525
|
+
width: `${rect.width}px`,
|
|
526
|
+
maxHeight: `${Math.min(MAX_HEIGHT, flip ? above : below)}px`,
|
|
527
|
+
top: flip ? "auto" : `${rect.bottom + GAP}px`,
|
|
528
|
+
bottom: flip ? `${viewport - rect.top + GAP}px` : "auto",
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const onScroll = (event: Event) => {
|
|
533
|
+
if (event.target !== listEl.value) position();
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
function stopTracking() {
|
|
537
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
538
|
+
window.removeEventListener("resize", position);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Keeps the active option (and its group heading) scrolled into view.
|
|
542
|
+
function scrollToActive() {
|
|
543
|
+
const list = listEl.value;
|
|
544
|
+
const option = document.getElementById(optionId(activeIndex.value));
|
|
545
|
+
if (!list || !option) return;
|
|
546
|
+
const heading = option.previousElementSibling as HTMLElement | null;
|
|
547
|
+
const top =
|
|
548
|
+
activeIndex.value === firstEnabled()
|
|
549
|
+
? 0
|
|
550
|
+
: heading?.getAttribute("role") === "presentation"
|
|
551
|
+
? heading.offsetTop
|
|
552
|
+
: option.offsetTop;
|
|
553
|
+
const bottom = option.offsetTop + option.offsetHeight;
|
|
554
|
+
if (top < list.scrollTop) list.scrollTop = top;
|
|
555
|
+
else if (bottom > list.scrollTop + list.clientHeight)
|
|
556
|
+
list.scrollTop = bottom - list.clientHeight;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
watch(
|
|
560
|
+
open,
|
|
561
|
+
(isOpen) => {
|
|
562
|
+
const popover = popoverEl.value;
|
|
563
|
+
if (!popover) return;
|
|
564
|
+
if (isOpen) {
|
|
565
|
+
if (!popover.matches(":popover-open")) popover.showPopover();
|
|
566
|
+
position();
|
|
567
|
+
searchEl.value?.focus({ preventScroll: true });
|
|
568
|
+
scrollToActive();
|
|
569
|
+
window.addEventListener("scroll", onScroll, {
|
|
570
|
+
capture: true,
|
|
571
|
+
passive: true,
|
|
572
|
+
});
|
|
573
|
+
window.addEventListener("resize", position);
|
|
574
|
+
} else {
|
|
575
|
+
if (popover.matches(":popover-open")) popover.hidePopover();
|
|
576
|
+
stopTracking();
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
{ flush: "post" },
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
watch(
|
|
583
|
+
activeIndex,
|
|
584
|
+
() => {
|
|
585
|
+
if (open.value) scrollToActive();
|
|
586
|
+
},
|
|
587
|
+
{ flush: "post" },
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
onBeforeUnmount(() => {
|
|
591
|
+
stopTracking();
|
|
592
|
+
clearTimeout(searchTimer);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const variants = {
|
|
596
|
+
outline: "border border-border bg-surface focus:ring-3",
|
|
597
|
+
filled: "border border-transparent bg-foreground/5 focus:ring-3",
|
|
598
|
+
ghost: "rounded-none border-b border-border px-0 focus:shadow-[0_1px_0_0]",
|
|
599
|
+
} as const;
|
|
600
|
+
|
|
601
|
+
// The variants' focus ring, applied while the list is open.
|
|
602
|
+
const rings = {
|
|
603
|
+
outline: "ring-3",
|
|
604
|
+
filled: "ring-3",
|
|
605
|
+
ghost: "shadow-[0_1px_0_0]",
|
|
606
|
+
} as const;
|
|
607
|
+
|
|
608
|
+
const hovers = {
|
|
609
|
+
outline: "hover:border-border-strong",
|
|
610
|
+
filled: "hover:bg-foreground/10",
|
|
611
|
+
ghost: "hover:not-focus:border-border-strong",
|
|
612
|
+
} as const;
|
|
613
|
+
|
|
614
|
+
const sizes = {
|
|
615
|
+
sm: {
|
|
616
|
+
trigger: "h-8 px-2 text-sm",
|
|
617
|
+
option: "px-2 py-1.5 text-sm",
|
|
618
|
+
group: "px-2",
|
|
619
|
+
},
|
|
620
|
+
md: {
|
|
621
|
+
trigger: "h-10 px-3 text-base sm:text-sm",
|
|
622
|
+
option: "px-3 py-2 text-base sm:text-sm",
|
|
623
|
+
group: "px-3",
|
|
624
|
+
},
|
|
625
|
+
lg: {
|
|
626
|
+
trigger: "h-12 px-4 text-base",
|
|
627
|
+
option: "px-4 py-2.5 text-base",
|
|
628
|
+
group: "px-4",
|
|
629
|
+
},
|
|
630
|
+
} as const;
|
|
631
|
+
|
|
632
|
+
const radii = {
|
|
633
|
+
none: "rounded-none",
|
|
634
|
+
sm: "rounded-sm",
|
|
635
|
+
md: "rounded-md",
|
|
636
|
+
lg: "rounded-lg",
|
|
637
|
+
full: "rounded-full",
|
|
638
|
+
} as const;
|
|
639
|
+
|
|
640
|
+
const triggerClasses = computed(() =>
|
|
641
|
+
cn(
|
|
642
|
+
"flex w-full cursor-pointer items-center gap-2 rounded-control text-left outline-none transition-[background-color,border-color,box-shadow]",
|
|
643
|
+
sizes[props.size].trigger,
|
|
644
|
+
props.radius && radii[props.radius],
|
|
645
|
+
variants[props.variant],
|
|
646
|
+
props.error
|
|
647
|
+
? "border-danger focus:ring-danger/30 focus:shadow-danger"
|
|
648
|
+
: "focus:border-primary focus:ring-primary/35 focus:shadow-primary",
|
|
649
|
+
open.value && rings[props.variant],
|
|
650
|
+
open.value &&
|
|
651
|
+
(props.error
|
|
652
|
+
? "ring-danger/30 shadow-danger"
|
|
653
|
+
: "border-primary ring-primary/35 shadow-primary"),
|
|
654
|
+
!props.error && !props.disabled && !open.value && hovers[props.variant],
|
|
655
|
+
props.disabled && "cursor-not-allowed opacity-60",
|
|
656
|
+
props.disabled && props.variant !== "ghost" && "bg-muted",
|
|
657
|
+
),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
const popoverClasses = computed(() =>
|
|
661
|
+
cn(
|
|
662
|
+
"inset-auto m-0 max-h-72 animate-fade-in flex-col overflow-hidden rounded-control border border-border bg-surface p-0 text-foreground shadow-lg outline-none open:flex dark:scheme-dark",
|
|
663
|
+
props.radius &&
|
|
664
|
+
(props.radius === "full" ? "rounded-lg" : radii[props.radius]),
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const optionClasses = (option: SelectOption, index: number) =>
|
|
669
|
+
cn(
|
|
670
|
+
"flex cursor-pointer items-center gap-2",
|
|
671
|
+
sizes[props.size].option,
|
|
672
|
+
index === activeIndex.value && "bg-muted",
|
|
673
|
+
option.disabled && "cursor-not-allowed opacity-50",
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
defineExpose({
|
|
677
|
+
focus: () => triggerEl.value?.focus(),
|
|
678
|
+
el: triggerEl,
|
|
679
|
+
});
|
|
680
|
+
</script>
|