@revojs/ui 0.2.61 → 0.2.63
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/dist/components/accordion.d.ts +13 -0
- package/dist/components/accordion.js +1 -0
- package/dist/components/checkbox.d.ts +1 -0
- package/dist/components/collapsible.d.ts +11 -0
- package/dist/components/collapsible.js +1 -0
- package/dist/components/confirm.d.ts +12 -0
- package/dist/components/confirm.js +1 -0
- package/dist/components/counter.d.ts +5 -0
- package/dist/components/labels.d.ts +24 -0
- package/dist/components/labels.js +1 -0
- package/dist/components/menu.d.ts +34 -0
- package/dist/components/radio-group.d.ts +17 -0
- package/dist/components/radio-group.js +1 -0
- package/dist/components/range.d.ts +2 -0
- package/dist/components/roving-focus.d.ts +7 -0
- package/dist/components/roving-focus.js +1 -0
- package/dist/components/select.d.ts +1 -0
- package/dist/components/stepper.d.ts +16 -0
- package/dist/components/stepper.js +1 -0
- package/dist/components/tabs.d.ts +5 -1
- package/dist/components/tags-input.d.ts +10 -0
- package/dist/components/tags-input.js +1 -0
- package/dist/components/toast.d.ts +19 -0
- package/dist/components/toast.js +1 -0
- package/dist/components/toggle-group.d.ts +6 -3
- package/dist/components/toolbar.d.ts +12 -0
- package/dist/components/toolbar.js +1 -0
- package/dist/components/tooltip.d.ts +1 -0
- package/dist/index.d.ts +15 -5
- package/dist/style.css +4 -0
- package/dist/vue/components/accordion.vue +57 -0
- package/dist/vue/components/checkbox.vue +24 -4
- package/dist/vue/components/collapsible.vue +72 -0
- package/dist/vue/components/collection.vue +5 -2
- package/dist/vue/components/confirm-dialog.vue +45 -0
- package/dist/vue/components/context-menu.vue +178 -0
- package/dist/vue/components/counter.vue +124 -18
- package/dist/vue/components/dialog.vue +5 -2
- package/dist/vue/components/menu-checkbox-item.vue +52 -0
- package/dist/vue/components/menu-group.vue +37 -0
- package/dist/vue/components/menu-layer.vue +152 -0
- package/dist/vue/components/menu-radio-group.vue +36 -0
- package/dist/vue/components/menu-radio-item.vue +50 -0
- package/dist/vue/components/menu.vue +16 -46
- package/dist/vue/components/pagination.vue +26 -6
- package/dist/vue/components/popover.vue +62 -13
- package/dist/vue/components/radio-group.vue +46 -0
- package/dist/vue/components/range.vue +31 -7
- package/dist/vue/components/rating.vue +35 -4
- package/dist/vue/components/select.vue +170 -52
- package/dist/vue/components/sheet.vue +5 -2
- package/dist/vue/components/shell.vue +5 -2
- package/dist/vue/components/stepper.vue +96 -0
- package/dist/vue/components/tabs.vue +37 -43
- package/dist/vue/components/tags-input.vue +141 -0
- package/dist/vue/components/toast-region.vue +233 -0
- package/dist/vue/components/toggle-group.vue +65 -10
- package/dist/vue/components/toolbar.vue +43 -0
- package/dist/vue/components/tooltip.vue +101 -15
- package/dist/vue/components/upload.vue +7 -4
- package/dist/vue/index.d.ts +28 -7
- package/dist/vue/index.js +29 -8
- package/dist/vue/utilities/labels.d.ts +7 -0
- package/dist/vue/utilities/labels.js +62 -0
- package/dist/vue/utilities/menu.d.ts +6 -0
- package/dist/vue/utilities/menu.js +2 -1
- package/dist/vue/utilities/tooltip.d.ts +3 -0
- package/dist/vue/utilities/tooltip.js +4 -0
- package/dist/vue/utilities/use-anchor.d.ts +6 -0
- package/dist/vue/utilities/use-anchor.js +26 -0
- package/dist/vue/utilities/use-confirm.d.ts +24 -0
- package/dist/vue/utilities/use-confirm.js +27 -0
- package/dist/vue/utilities/use-menu-navigation.d.ts +6 -0
- package/dist/vue/utilities/use-menu-navigation.js +48 -0
- package/dist/vue/utilities/use-roving-focus.d.ts +7 -0
- package/dist/vue/utilities/use-roving-focus.js +73 -0
- package/dist/vue/utilities/use-toast.d.ts +27 -0
- package/dist/vue/utilities/use-toast.js +33 -0
- package/dist/vue/utilities/use-typeahead.d.ts +5 -0
- package/dist/vue/utilities/use-typeahead.js +37 -0
- package/package.json +1 -1
- package/dist/vue/utilities/use-focus-trap.d.ts +0 -2
- package/dist/vue/utilities/use-focus-trap.js +0 -34
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<input v-if="name" type="hidden" :name="name" :value="modelValue" :disabled="disabled" />
|
|
8
8
|
|
|
9
|
-
<div ref="container" class="relative">
|
|
9
|
+
<div ref="container" class="relative" :style="anchorStyle">
|
|
10
10
|
<Stack
|
|
11
11
|
direction="horizontal"
|
|
12
12
|
align="center"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
v-if="searchable"
|
|
40
40
|
class="grid min-w-0 flex-1"
|
|
41
41
|
:class="[controlTexts[text], searchWidths[width]]"
|
|
42
|
-
:data-value="query || (placeholder ??
|
|
42
|
+
:data-value="query || (placeholder ?? labels.select)"
|
|
43
43
|
>
|
|
44
44
|
<input
|
|
45
45
|
v-bind="controlAttributes"
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
role="combobox"
|
|
50
50
|
size="1"
|
|
51
51
|
class="w-full min-w-0 bg-transparent px-1 outline-none [grid-area:1/1] placeholder:opacity-50"
|
|
52
|
-
:placeholder="placeholder ??
|
|
52
|
+
:placeholder="placeholder ?? labels.select"
|
|
53
53
|
:aria-expanded="open"
|
|
54
54
|
:aria-controls="listboxId"
|
|
55
55
|
:aria-activedescendant="activeId"
|
|
@@ -62,8 +62,7 @@
|
|
|
62
62
|
@keydown.enter.prevent="confirm"
|
|
63
63
|
@keydown.escape="escape"
|
|
64
64
|
@keydown.tab="open = false"
|
|
65
|
-
@keydown
|
|
66
|
-
@keydown.arrow-up.prevent="stepHighlight(-1)"
|
|
65
|
+
@keydown="navigate"
|
|
67
66
|
/>
|
|
68
67
|
</span>
|
|
69
68
|
|
|
@@ -77,6 +76,7 @@
|
|
|
77
76
|
class="min-w-0 flex-1 cursor-pointer truncate bg-transparent px-1 text-left outline-none disabled:cursor-not-allowed"
|
|
78
77
|
:class="selected ? '' : 'opacity-50'"
|
|
79
78
|
:aria-expanded="open"
|
|
79
|
+
:data-state="open ? 'open' : 'closed'"
|
|
80
80
|
:aria-controls="listboxId"
|
|
81
81
|
:aria-activedescendant="activeId"
|
|
82
82
|
:aria-invalid="invalid ?? controlAttributes['aria-invalid']"
|
|
@@ -86,10 +86,9 @@
|
|
|
86
86
|
@keydown.space.prevent="confirm"
|
|
87
87
|
@keydown.escape="escape"
|
|
88
88
|
@keydown.tab="open = false"
|
|
89
|
-
@keydown
|
|
90
|
-
@keydown.arrow-up.prevent="stepHighlight(-1)"
|
|
89
|
+
@keydown="navigate"
|
|
91
90
|
>
|
|
92
|
-
{{ selected?.label ?? placeholder ??
|
|
91
|
+
{{ selected?.label ?? placeholder ?? labels.select }}
|
|
93
92
|
</button>
|
|
94
93
|
|
|
95
94
|
<button
|
|
@@ -118,39 +117,70 @@
|
|
|
118
117
|
<Stack
|
|
119
118
|
v-if="open"
|
|
120
119
|
:id="listboxId"
|
|
120
|
+
ref="listbox"
|
|
121
121
|
role="listbox"
|
|
122
122
|
:aria-label="label"
|
|
123
|
+
:popover="anchored ? 'manual' : undefined"
|
|
124
|
+
:style="anchored ? layerStyle : undefined"
|
|
125
|
+
data-state="open"
|
|
123
126
|
padding="1"
|
|
124
|
-
|
|
125
|
-
class="
|
|
127
|
+
class="max-h-[min(16rem,50vh)] overflow-y-auto rounded-ui border border-border bg-popover text-popover-foreground shadow-xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
128
|
+
:class="
|
|
129
|
+
anchored
|
|
130
|
+
? `inset-auto mx-0 my-1 [position-try-fallbacks:flip-block] ${areas[placement]} ${layerWidths[width]}`
|
|
131
|
+
: `absolute z-10 ${placements[placement]} ${listWidths[width]}`
|
|
132
|
+
"
|
|
126
133
|
>
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
134
|
+
<template v-for="section in sections" :key="section.group ?? ''">
|
|
135
|
+
<Stack
|
|
136
|
+
:role="section.group ? 'group' : undefined"
|
|
137
|
+
:aria-labelledby="section.group ? `${listboxId}-group-${section.start}` : undefined"
|
|
138
|
+
gap="0"
|
|
139
|
+
>
|
|
140
|
+
<span
|
|
141
|
+
v-if="section.group"
|
|
142
|
+
:id="`${listboxId}-group-${section.start}`"
|
|
143
|
+
class="p-2 text-xs font-medium text-muted-foreground"
|
|
144
|
+
>
|
|
145
|
+
<span class="block truncate px-1">{{ section.group }}</span>
|
|
146
|
+
</span>
|
|
147
|
+
|
|
148
|
+
<Button
|
|
149
|
+
v-for="{ option, index } in section.options"
|
|
150
|
+
:key="option.value"
|
|
151
|
+
:id="`${listboxId}-${index}`"
|
|
152
|
+
:class="['w-full', highlighted === index ? 'bg-accent!' : '']"
|
|
153
|
+
variant="ghost"
|
|
154
|
+
layout="left"
|
|
155
|
+
font="normal"
|
|
156
|
+
:text="text"
|
|
157
|
+
:size="size"
|
|
158
|
+
:disabled="option.disabled"
|
|
159
|
+
:data-highlighted="highlighted === index ? '' : undefined"
|
|
160
|
+
tabindex="-1"
|
|
161
|
+
role="option"
|
|
162
|
+
:aria-selected="option.value === modelValue"
|
|
163
|
+
@click="select(option)"
|
|
164
|
+
>
|
|
165
|
+
<Stack
|
|
166
|
+
direction="horizontal"
|
|
167
|
+
align="center"
|
|
168
|
+
justify="between"
|
|
169
|
+
gap="1"
|
|
170
|
+
class="w-full text-left"
|
|
171
|
+
>
|
|
172
|
+
<slot name="option" :option="option" :selected="option.value === modelValue">
|
|
173
|
+
<span class="min-w-0 flex-1 truncate px-1">{{ option.label }}</span>
|
|
174
|
+
</slot>
|
|
175
|
+
|
|
176
|
+
<Icon v-if="option.value === modelValue" :is="TickIcon" :size="iconSize" />
|
|
177
|
+
</Stack>
|
|
178
|
+
</Button>
|
|
149
179
|
</Stack>
|
|
150
|
-
</
|
|
180
|
+
</template>
|
|
151
181
|
|
|
152
182
|
<p v-if="filtered.length === 0" class="px-1 py-5 text-center opacity-60">
|
|
153
|
-
{{ emptyLabel }}
|
|
183
|
+
{{ emptyLabel ?? labels.noResults }}
|
|
154
184
|
</p>
|
|
155
185
|
</Stack>
|
|
156
186
|
</div>
|
|
@@ -175,7 +205,10 @@ import type {
|
|
|
175
205
|
import Button from "./button.vue";
|
|
176
206
|
import Icon from "./icon.vue";
|
|
177
207
|
import Stack from "./stack.vue";
|
|
208
|
+
import { useUiLabels } from "../utilities/labels";
|
|
209
|
+
import { useAnchor } from "../utilities/use-anchor";
|
|
178
210
|
import { useControlAttributes } from "../utilities/use-control-attributes";
|
|
211
|
+
import { isTypeaheadKey, useTypeahead } from "../utilities/use-typeahead";
|
|
179
212
|
|
|
180
213
|
defineOptions({ inheritAttrs: false });
|
|
181
214
|
|
|
@@ -189,19 +222,21 @@ const {
|
|
|
189
222
|
placement = "bottom",
|
|
190
223
|
width = "full",
|
|
191
224
|
placeholder,
|
|
192
|
-
emptyLabel
|
|
225
|
+
emptyLabel,
|
|
193
226
|
name,
|
|
194
227
|
required = false,
|
|
195
228
|
disabled = false,
|
|
196
229
|
searchable = true,
|
|
197
230
|
invalid = undefined,
|
|
198
|
-
requiredLabel
|
|
231
|
+
requiredLabel,
|
|
199
232
|
} = defineProps<SelectProps<Value>>();
|
|
200
233
|
|
|
201
234
|
const emit = defineEmits<{
|
|
202
235
|
"update:modelValue": [value: Value];
|
|
203
236
|
}>();
|
|
204
237
|
|
|
238
|
+
const labels = useUiLabels();
|
|
239
|
+
|
|
205
240
|
const open = ref(false);
|
|
206
241
|
|
|
207
242
|
const container = useTemplateRef("container");
|
|
@@ -214,6 +249,12 @@ const trigger = useTemplateRef("trigger");
|
|
|
214
249
|
|
|
215
250
|
const validity = useTemplateRef("validity");
|
|
216
251
|
|
|
252
|
+
const listbox = useTemplateRef<InstanceType<typeof Stack>>("listbox");
|
|
253
|
+
|
|
254
|
+
const { anchored, anchorStyle, layerStyle } = useAnchor(computed(() => listbox.value?.$el ?? null));
|
|
255
|
+
|
|
256
|
+
const { search: typeahead } = useTypeahead();
|
|
257
|
+
|
|
217
258
|
const listboxId = `select-${useId()}`;
|
|
218
259
|
|
|
219
260
|
const controlId = `${listboxId}-control`;
|
|
@@ -260,7 +301,17 @@ const searchWidths: Record<SelectWidth, string> = {
|
|
|
260
301
|
|
|
261
302
|
const listWidths: Record<SelectWidth, string> = {
|
|
262
303
|
full: "inset-x-0",
|
|
263
|
-
fit: "
|
|
304
|
+
fit: "start-0 w-max max-w-xs min-w-full",
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const layerWidths: Record<SelectWidth, string> = {
|
|
308
|
+
full: "[width:anchor-size(width)]",
|
|
309
|
+
fit: "w-max max-w-xs [min-width:anchor-size(width)]",
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const areas: Record<SelectPlacement, string> = {
|
|
313
|
+
bottom: "[position-area:block-end_span-inline-end]",
|
|
314
|
+
top: "[position-area:block-start_span-inline-end]",
|
|
264
315
|
};
|
|
265
316
|
|
|
266
317
|
const selected = computed(() => options.find((option) => option.value === modelValue));
|
|
@@ -275,6 +326,27 @@ const filtered = computed(() => {
|
|
|
275
326
|
return term === "" ? options : options.filter((option) => option.label.toLowerCase().includes(term));
|
|
276
327
|
});
|
|
277
328
|
|
|
329
|
+
const sections = computed(() => {
|
|
330
|
+
const list: Array<{
|
|
331
|
+
group?: string;
|
|
332
|
+
start: number;
|
|
333
|
+
options: Array<{ option: SelectOption<Value>; index: number }>;
|
|
334
|
+
}> = [];
|
|
335
|
+
|
|
336
|
+
for (const [index, option] of filtered.value.entries()) {
|
|
337
|
+
const last = list[list.length - 1];
|
|
338
|
+
|
|
339
|
+
if (last && last.group === option.group) {
|
|
340
|
+
last.options.push({ option, index });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
list.push({ group: option.group, start: index, options: [{ option, index }] });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return list;
|
|
348
|
+
});
|
|
349
|
+
|
|
278
350
|
const highlighted = ref(-1);
|
|
279
351
|
|
|
280
352
|
const activeId = computed(() =>
|
|
@@ -307,17 +379,11 @@ function focusControl() {
|
|
|
307
379
|
(search.value ?? trigger.value)?.focus();
|
|
308
380
|
}
|
|
309
381
|
|
|
310
|
-
function highlight(step: 1 | -1) {
|
|
382
|
+
function highlight(start: number, step: 1 | -1) {
|
|
311
383
|
const count = filtered.value.length;
|
|
312
384
|
|
|
313
|
-
if (count === 0) {
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
let index = highlighted.value;
|
|
318
|
-
|
|
319
385
|
for (let round = 0; round < count; round++) {
|
|
320
|
-
index = (
|
|
386
|
+
const index = Math.min(count - 1, Math.max(0, start + step * round));
|
|
321
387
|
|
|
322
388
|
if (!filtered.value[index].disabled) {
|
|
323
389
|
highlighted.value = index;
|
|
@@ -329,14 +395,62 @@ function highlight(step: 1 | -1) {
|
|
|
329
395
|
}
|
|
330
396
|
}
|
|
331
397
|
|
|
332
|
-
function
|
|
333
|
-
|
|
398
|
+
function navigate(event: KeyboardEvent) {
|
|
399
|
+
const count = filtered.value.length;
|
|
400
|
+
const current = highlighted.value;
|
|
401
|
+
|
|
402
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
403
|
+
event.preventDefault();
|
|
404
|
+
|
|
405
|
+
if (!open.value) {
|
|
406
|
+
open.value = true;
|
|
407
|
+
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
412
|
+
let index = current;
|
|
413
|
+
|
|
414
|
+
for (let round = 0; round < count; round++) {
|
|
415
|
+
index = (index + step + count) % count;
|
|
416
|
+
|
|
417
|
+
if (!filtered.value[index].disabled) {
|
|
418
|
+
highlight(index, step);
|
|
419
|
+
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} else if (open.value && (event.key === "Home" || event.key === "End") && (!searchable || event.altKey)) {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
|
|
426
|
+
if (event.key === "Home") {
|
|
427
|
+
highlight(0, 1);
|
|
428
|
+
} else {
|
|
429
|
+
highlight(count - 1, -1);
|
|
430
|
+
}
|
|
431
|
+
} else if (open.value && (event.key === "PageDown" || event.key === "PageUp")) {
|
|
432
|
+
event.preventDefault();
|
|
433
|
+
|
|
434
|
+
if (event.key === "PageDown") {
|
|
435
|
+
highlight(Math.min(count - 1, Math.max(0, current) + 10), -1);
|
|
436
|
+
} else {
|
|
437
|
+
highlight(Math.max(0, current - 10), 1);
|
|
438
|
+
}
|
|
439
|
+
} else if (!searchable && isTypeaheadKey(event) && event.key !== " ") {
|
|
440
|
+
const options: Array<string> = [];
|
|
441
|
+
|
|
442
|
+
for (const option of filtered.value) {
|
|
443
|
+
options.push(option.label);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const index = typeahead(event.key, options, current);
|
|
447
|
+
|
|
334
448
|
open.value = true;
|
|
335
449
|
|
|
336
|
-
|
|
450
|
+
if (index !== -1) {
|
|
451
|
+
highlight(index, 1);
|
|
452
|
+
}
|
|
337
453
|
}
|
|
338
|
-
|
|
339
|
-
highlight(step);
|
|
340
454
|
}
|
|
341
455
|
|
|
342
456
|
function confirm() {
|
|
@@ -372,7 +486,9 @@ watch(selected, (value) => {
|
|
|
372
486
|
});
|
|
373
487
|
|
|
374
488
|
watchEffect(() => {
|
|
375
|
-
(search.value ?? validity.value)?.setCustomValidity(
|
|
489
|
+
(search.value ?? validity.value)?.setCustomValidity(
|
|
490
|
+
required && !disabled && !selected.value ? (requiredLabel ?? labels.selectRequired) : "",
|
|
491
|
+
);
|
|
376
492
|
|
|
377
493
|
if (disabled) {
|
|
378
494
|
open.value = false;
|
|
@@ -381,7 +497,9 @@ watchEffect(() => {
|
|
|
381
497
|
|
|
382
498
|
watch(open, (value) => {
|
|
383
499
|
if (value) {
|
|
384
|
-
|
|
500
|
+
if (highlighted.value === -1) {
|
|
501
|
+
highlighted.value = filtered.value.findIndex((option) => option.value === modelValue);
|
|
502
|
+
}
|
|
385
503
|
|
|
386
504
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
387
505
|
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
size="md"
|
|
28
28
|
variant="ghost"
|
|
29
29
|
:icon-prefix="CloseIcon"
|
|
30
|
-
:aria-label="closeLabel"
|
|
30
|
+
:aria-label="closeLabel ?? labels.close"
|
|
31
31
|
:disabled="!dismissible"
|
|
32
32
|
@click="dismiss"
|
|
33
33
|
/>
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
import { useId, useTemplateRef, type HTMLAttributes } from "vue";
|
|
56
56
|
import CloseIcon from "~icons/hugeicons/multiplication-sign";
|
|
57
57
|
import type { SheetProps, SheetSide } from "../../components/sheet";
|
|
58
|
+
import { useUiLabels } from "../utilities/labels";
|
|
58
59
|
import { useModalDialog } from "../utilities/use-modal-dialog";
|
|
59
60
|
import Button from "./button.vue";
|
|
60
61
|
import Card from "./card.vue";
|
|
@@ -71,7 +72,7 @@ const {
|
|
|
71
72
|
bodyAlign,
|
|
72
73
|
bodyJustify,
|
|
73
74
|
bodyWrap,
|
|
74
|
-
closeLabel
|
|
75
|
+
closeLabel,
|
|
75
76
|
dismissible = true,
|
|
76
77
|
} = defineProps<SheetProps>();
|
|
77
78
|
|
|
@@ -79,6 +80,8 @@ const emit = defineEmits<{
|
|
|
79
80
|
"update:modelValue": [value: boolean];
|
|
80
81
|
}>();
|
|
81
82
|
|
|
83
|
+
const labels = useUiLabels();
|
|
84
|
+
|
|
82
85
|
const sheet = useTemplateRef("sheet");
|
|
83
86
|
|
|
84
87
|
const id = `sheet-${useId()}`;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<Stack
|
|
20
20
|
v-if="$slots.navigation"
|
|
21
21
|
is="nav"
|
|
22
|
-
:aria-label="navigationLabel"
|
|
22
|
+
:aria-label="navigationLabel ?? labels.pageNavigation"
|
|
23
23
|
direction="horizontal"
|
|
24
24
|
gap="1"
|
|
25
25
|
padding="1"
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
|
|
33
33
|
<script setup lang="ts">
|
|
34
34
|
import type { ShellProps } from "../../components/shell";
|
|
35
|
+
import { useUiLabels } from "../utilities/labels";
|
|
35
36
|
import Stack from "./stack.vue";
|
|
36
37
|
|
|
37
|
-
const { label, headingLabel, sticky = true, navigationLabel
|
|
38
|
+
const { label, headingLabel, sticky = true, navigationLabel } = defineProps<ShellProps>();
|
|
39
|
+
|
|
40
|
+
const labels = useUiLabels();
|
|
38
41
|
</script>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav :aria-label="label" class="w-full">
|
|
3
|
+
<Stack is="ol" :direction="orientation" gap="1" class="m-0 list-none p-0">
|
|
4
|
+
<li
|
|
5
|
+
v-for="(item, index) in items"
|
|
6
|
+
:key="item.value"
|
|
7
|
+
class="min-w-0"
|
|
8
|
+
:class="orientation === 'horizontal' ? 'flex-1' : ''"
|
|
9
|
+
>
|
|
10
|
+
<Button
|
|
11
|
+
variant="ghost"
|
|
12
|
+
layout="left"
|
|
13
|
+
font="normal"
|
|
14
|
+
class="w-full"
|
|
15
|
+
:aria-current="index === currentIndex ? 'step' : undefined"
|
|
16
|
+
:data-state="state(index)"
|
|
17
|
+
:disabled="item.disabled || (linear && index > currentIndex)"
|
|
18
|
+
@click="select(item.value)"
|
|
19
|
+
>
|
|
20
|
+
<span
|
|
21
|
+
class="shrink-0 rounded-ui p-1 text-xs/4 font-medium tabular-nums"
|
|
22
|
+
:class="markers[state(index)]"
|
|
23
|
+
>
|
|
24
|
+
<Icon v-if="state(index) === 'complete'" :is="TickIcon" :size="16" />
|
|
25
|
+
<span v-else class="block px-1">{{ index + 1 }}</span>
|
|
26
|
+
</span>
|
|
27
|
+
|
|
28
|
+
<Stack gap="0" class="min-w-0 flex-1 px-1">
|
|
29
|
+
<span class="truncate" :class="state(index) === 'current' ? 'font-medium' : ''">
|
|
30
|
+
{{ item.label }}
|
|
31
|
+
</span>
|
|
32
|
+
|
|
33
|
+
<span v-if="item.description" class="truncate text-xs text-muted-foreground">
|
|
34
|
+
{{ item.description }}
|
|
35
|
+
</span>
|
|
36
|
+
</Stack>
|
|
37
|
+
</Button>
|
|
38
|
+
</li>
|
|
39
|
+
</Stack>
|
|
40
|
+
</nav>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<script setup lang="ts" generic="Value extends StepperValue = StepperValue">
|
|
44
|
+
import TickIcon from "~icons/hugeicons/tick-02";
|
|
45
|
+
import { computed, ref, watch } from "vue";
|
|
46
|
+
import type { StepperProps, StepperState, StepperValue } from "../../components/stepper";
|
|
47
|
+
import Button from "./button.vue";
|
|
48
|
+
import Icon from "./icon.vue";
|
|
49
|
+
import Stack from "./stack.vue";
|
|
50
|
+
|
|
51
|
+
const { modelValue, items, label, orientation = "horizontal", linear = false } = defineProps<StepperProps<Value>>();
|
|
52
|
+
|
|
53
|
+
const emit = defineEmits<{
|
|
54
|
+
"update:modelValue": [value: Value];
|
|
55
|
+
}>();
|
|
56
|
+
|
|
57
|
+
const selected = ref(modelValue);
|
|
58
|
+
|
|
59
|
+
const currentIndex = computed(() =>
|
|
60
|
+
Math.max(
|
|
61
|
+
0,
|
|
62
|
+
items.findIndex((item) => item.value === selected.value),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const markers: Record<StepperState, string> = {
|
|
67
|
+
complete: "bg-primary text-primary-foreground",
|
|
68
|
+
current: "bg-primary text-primary-foreground",
|
|
69
|
+
upcoming: "bg-secondary text-secondary-foreground",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function state(index: number): StepperState {
|
|
73
|
+
if (index < currentIndex.value) {
|
|
74
|
+
return "complete";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (index === currentIndex.value) {
|
|
78
|
+
return "current";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return "upcoming";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function select(value: Value) {
|
|
85
|
+
selected.value = value;
|
|
86
|
+
|
|
87
|
+
emit("update:modelValue", value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
watch(
|
|
91
|
+
() => modelValue,
|
|
92
|
+
(value) => {
|
|
93
|
+
selected.value = value;
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
</script>
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Stack gap="3">
|
|
2
|
+
<Stack :direction="orientation === 'vertical' ? 'horizontal' : 'vertical'" gap="3">
|
|
3
3
|
<Stack
|
|
4
|
-
|
|
4
|
+
ref="tablist"
|
|
5
|
+
:direction="orientation === 'vertical' ? 'vertical' : 'horizontal'"
|
|
5
6
|
gap="1"
|
|
6
7
|
role="tablist"
|
|
7
8
|
:aria-label="label"
|
|
9
|
+
:aria-orientation="orientation"
|
|
8
10
|
class="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
11
|
+
:class="orientation === 'vertical' ? 'shrink-0' : ''"
|
|
9
12
|
@keydown="handleKeyDown"
|
|
13
|
+
@focusin="handleFocusIn"
|
|
10
14
|
>
|
|
11
15
|
<Button
|
|
12
16
|
v-for="(item, index) in items"
|
|
@@ -15,13 +19,15 @@
|
|
|
15
19
|
role="tab"
|
|
16
20
|
font="medium"
|
|
17
21
|
:size="size"
|
|
22
|
+
:layout="orientation === 'vertical' ? 'left' : 'center'"
|
|
18
23
|
:variant="item.value === active ? 'secondary' : 'ghost'"
|
|
19
24
|
:icon-prefix="item.iconPrefix"
|
|
20
25
|
:label="item.label"
|
|
21
26
|
:disabled="item.disabled"
|
|
22
27
|
:aria-selected="item.value === active"
|
|
23
28
|
:aria-controls="$slots.default ? `${id}-panel` : undefined"
|
|
24
|
-
:
|
|
29
|
+
:data-state="item.value === active ? 'active' : 'inactive'"
|
|
30
|
+
:data-value="index"
|
|
25
31
|
@click="select(item.value)"
|
|
26
32
|
/>
|
|
27
33
|
</Stack>
|
|
@@ -33,7 +39,7 @@
|
|
|
33
39
|
:aria-labelledby="`${id}-tab-${activeIndex}`"
|
|
34
40
|
tabindex="0"
|
|
35
41
|
gap="3"
|
|
36
|
-
class="rounded-ui outline-ring focus-visible:outline-2 focus-visible:outline-offset-[-2px]"
|
|
42
|
+
class="min-w-0 flex-1 rounded-ui outline-ring focus-visible:outline-2 focus-visible:outline-offset-[-2px]"
|
|
37
43
|
>
|
|
38
44
|
<slot :value="active" />
|
|
39
45
|
</Stack>
|
|
@@ -41,12 +47,20 @@
|
|
|
41
47
|
</template>
|
|
42
48
|
|
|
43
49
|
<script setup lang="ts" generic="Value extends TabsValue = TabsValue">
|
|
44
|
-
import { computed,
|
|
50
|
+
import { computed, ref, useId, useTemplateRef, watch } from "vue";
|
|
45
51
|
import type { TabsProps, TabsValue } from "../../components/tabs";
|
|
52
|
+
import { useRovingFocus } from "../utilities/use-roving-focus";
|
|
46
53
|
import Button from "./button.vue";
|
|
47
54
|
import Stack from "./stack.vue";
|
|
48
55
|
|
|
49
|
-
const {
|
|
56
|
+
const {
|
|
57
|
+
modelValue,
|
|
58
|
+
items,
|
|
59
|
+
label,
|
|
60
|
+
size = "md",
|
|
61
|
+
orientation = "horizontal",
|
|
62
|
+
activation = "automatic",
|
|
63
|
+
} = defineProps<TabsProps<Value>>();
|
|
50
64
|
|
|
51
65
|
const emit = defineEmits<{
|
|
52
66
|
"update:modelValue": [value: Value];
|
|
@@ -54,6 +68,8 @@ const emit = defineEmits<{
|
|
|
54
68
|
|
|
55
69
|
const id = useId();
|
|
56
70
|
|
|
71
|
+
const tablist = useTemplateRef<InstanceType<typeof Stack>>("tablist");
|
|
72
|
+
|
|
57
73
|
const selected = ref(modelValue);
|
|
58
74
|
|
|
59
75
|
const active = computed(() => {
|
|
@@ -79,49 +95,27 @@ const activeIndex = computed(() =>
|
|
|
79
95
|
),
|
|
80
96
|
);
|
|
81
97
|
|
|
98
|
+
const { handleKeyDown, handleFocusIn } = useRovingFocus(
|
|
99
|
+
computed(() => tablist.value?.$el ?? null),
|
|
100
|
+
() => ({
|
|
101
|
+
selector: "[role='tab']",
|
|
102
|
+
orientation,
|
|
103
|
+
onMove: (element: HTMLElement) => {
|
|
104
|
+
const item = items[Number(element.dataset.value)];
|
|
105
|
+
|
|
106
|
+
if (activation === "automatic" && item) {
|
|
107
|
+
select(item.value);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
82
113
|
function select(value: Value) {
|
|
83
114
|
selected.value = value;
|
|
84
115
|
|
|
85
116
|
emit("update:modelValue", value);
|
|
86
117
|
}
|
|
87
118
|
|
|
88
|
-
async function handleKeyDown(event: KeyboardEvent) {
|
|
89
|
-
const enabled: Array<number> = [];
|
|
90
|
-
|
|
91
|
-
for (const [index, item] of items.entries()) {
|
|
92
|
-
if (!item.disabled) {
|
|
93
|
-
enabled.push(index);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const position = enabled.indexOf(activeIndex.value);
|
|
98
|
-
let next = position;
|
|
99
|
-
|
|
100
|
-
if (event.key === "ArrowRight") {
|
|
101
|
-
next = (position + 1) % enabled.length;
|
|
102
|
-
} else if (event.key === "ArrowLeft") {
|
|
103
|
-
next = (position - 1 + enabled.length) % enabled.length;
|
|
104
|
-
} else if (event.key === "Home") {
|
|
105
|
-
next = 0;
|
|
106
|
-
} else if (event.key === "End") {
|
|
107
|
-
next = enabled.length - 1;
|
|
108
|
-
} else {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
event.preventDefault();
|
|
113
|
-
|
|
114
|
-
const index = enabled[next];
|
|
115
|
-
|
|
116
|
-
if (index === undefined) {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
select(items[index].value);
|
|
121
|
-
await nextTick();
|
|
122
|
-
document.getElementById(`${id}-tab-${index}`)?.focus();
|
|
123
|
-
}
|
|
124
|
-
|
|
125
119
|
watch(
|
|
126
120
|
() => modelValue,
|
|
127
121
|
(value) => {
|