@revojs/ui 0.2.60 → 0.2.62

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.
Files changed (83) hide show
  1. package/dist/components/accordion.d.ts +13 -0
  2. package/dist/components/accordion.js +1 -0
  3. package/dist/components/checkbox.d.ts +1 -0
  4. package/dist/components/collapsible.d.ts +11 -0
  5. package/dist/components/collapsible.js +1 -0
  6. package/dist/components/confirm.d.ts +12 -0
  7. package/dist/components/confirm.js +1 -0
  8. package/dist/components/counter.d.ts +5 -0
  9. package/dist/components/labels.d.ts +24 -0
  10. package/dist/components/labels.js +1 -0
  11. package/dist/components/menu.d.ts +34 -0
  12. package/dist/components/radio-group.d.ts +17 -0
  13. package/dist/components/radio-group.js +1 -0
  14. package/dist/components/range.d.ts +2 -0
  15. package/dist/components/roving-focus.d.ts +7 -0
  16. package/dist/components/roving-focus.js +1 -0
  17. package/dist/components/select.d.ts +1 -0
  18. package/dist/components/stepper.d.ts +16 -0
  19. package/dist/components/stepper.js +1 -0
  20. package/dist/components/tabs.d.ts +4 -0
  21. package/dist/components/tags-input.d.ts +10 -0
  22. package/dist/components/tags-input.js +1 -0
  23. package/dist/components/toast.d.ts +19 -0
  24. package/dist/components/toast.js +1 -0
  25. package/dist/components/toggle-group.d.ts +5 -2
  26. package/dist/components/toolbar.d.ts +12 -0
  27. package/dist/components/toolbar.js +1 -0
  28. package/dist/components/tooltip.d.ts +1 -0
  29. package/dist/index.d.ts +15 -5
  30. package/dist/style.css +4 -0
  31. package/dist/vue/components/accordion.vue +57 -0
  32. package/dist/vue/components/checkbox.vue +24 -4
  33. package/dist/vue/components/collapsible.vue +72 -0
  34. package/dist/vue/components/collection.vue +5 -2
  35. package/dist/vue/components/confirm-dialog.vue +45 -0
  36. package/dist/vue/components/context-menu.vue +178 -0
  37. package/dist/vue/components/counter.vue +124 -18
  38. package/dist/vue/components/dialog.vue +5 -2
  39. package/dist/vue/components/menu-checkbox-item.vue +52 -0
  40. package/dist/vue/components/menu-group.vue +37 -0
  41. package/dist/vue/components/menu-layer.vue +152 -0
  42. package/dist/vue/components/menu-radio-group.vue +36 -0
  43. package/dist/vue/components/menu-radio-item.vue +50 -0
  44. package/dist/vue/components/menu.vue +16 -46
  45. package/dist/vue/components/pagination.vue +26 -6
  46. package/dist/vue/components/popover.vue +62 -13
  47. package/dist/vue/components/radio-group.vue +46 -0
  48. package/dist/vue/components/range.vue +31 -7
  49. package/dist/vue/components/rating.vue +35 -4
  50. package/dist/vue/components/select.vue +170 -52
  51. package/dist/vue/components/sheet.vue +5 -2
  52. package/dist/vue/components/shell.vue +5 -2
  53. package/dist/vue/components/stepper.vue +96 -0
  54. package/dist/vue/components/tabs.vue +37 -43
  55. package/dist/vue/components/tags-input.vue +141 -0
  56. package/dist/vue/components/toast-region.vue +233 -0
  57. package/dist/vue/components/toggle-group.vue +65 -10
  58. package/dist/vue/components/toolbar.vue +43 -0
  59. package/dist/vue/components/tooltip.vue +101 -15
  60. package/dist/vue/components/upload.vue +7 -4
  61. package/dist/vue/index.d.ts +28 -7
  62. package/dist/vue/index.js +29 -8
  63. package/dist/vue/utilities/labels.d.ts +7 -0
  64. package/dist/vue/utilities/labels.js +62 -0
  65. package/dist/vue/utilities/menu.d.ts +6 -0
  66. package/dist/vue/utilities/menu.js +2 -1
  67. package/dist/vue/utilities/tooltip.d.ts +3 -0
  68. package/dist/vue/utilities/tooltip.js +4 -0
  69. package/dist/vue/utilities/use-anchor.d.ts +6 -0
  70. package/dist/vue/utilities/use-anchor.js +26 -0
  71. package/dist/vue/utilities/use-confirm.d.ts +24 -0
  72. package/dist/vue/utilities/use-confirm.js +27 -0
  73. package/dist/vue/utilities/use-menu-navigation.d.ts +6 -0
  74. package/dist/vue/utilities/use-menu-navigation.js +48 -0
  75. package/dist/vue/utilities/use-roving-focus.d.ts +7 -0
  76. package/dist/vue/utilities/use-roving-focus.js +73 -0
  77. package/dist/vue/utilities/use-toast.d.ts +27 -0
  78. package/dist/vue/utilities/use-toast.js +33 -0
  79. package/dist/vue/utilities/use-typeahead.d.ts +5 -0
  80. package/dist/vue/utilities/use-typeahead.js +37 -0
  81. package/package.json +2 -2
  82. package/dist/vue/utilities/use-focus-trap.d.ts +0 -2
  83. 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 ?? 'Select')"
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 ?? 'Select'"
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.arrow-down.prevent="stepHighlight(1)"
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.arrow-down.prevent="stepHighlight(1)"
90
- @keydown.arrow-up.prevent="stepHighlight(-1)"
89
+ @keydown="navigate"
91
90
  >
92
- {{ selected?.label ?? placeholder ?? "Select" }}
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
- :class="[placements[placement], listWidths[width]]"
125
- class="absolute z-10 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"
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
- <Button
128
- v-for="(option, index) in filtered"
129
- :key="option.value"
130
- :id="`${listboxId}-${index}`"
131
- :class="['w-full', highlighted === index ? 'bg-accent!' : '']"
132
- variant="ghost"
133
- layout="left"
134
- font="normal"
135
- :text="text"
136
- :size="size"
137
- :disabled="option.disabled"
138
- tabindex="-1"
139
- role="option"
140
- :aria-selected="option.value === modelValue"
141
- @click="select(option)"
142
- >
143
- <Stack direction="horizontal" align="center" justify="between" gap="1" class="w-full text-left">
144
- <slot name="option" :option="option" :selected="option.value === modelValue">
145
- <span class="min-w-0 flex-1 truncate px-1">{{ option.label }}</span>
146
- </slot>
147
-
148
- <Icon v-if="option.value === modelValue" :is="TickIcon" :size="iconSize" />
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
- </Button>
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 = "No results found",
225
+ emptyLabel,
193
226
  name,
194
227
  required = false,
195
228
  disabled = false,
196
229
  searchable = true,
197
230
  invalid = undefined,
198
- requiredLabel = "Please select an option",
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: "left-0 w-max max-w-xs min-w-full",
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 = (index + step + count) % count;
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 stepHighlight(step: 1 | -1) {
333
- if (!open.value) {
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
- return;
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(required && !disabled && !selected.value ? requiredLabel : "");
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
- highlighted.value = filtered.value.findIndex((option) => option.value === modelValue);
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 = "Close",
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 = "Page navigation" } = defineProps<ShellProps>();
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
- direction="horizontal"
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
- :tabindex="item.value === active ? 0 : -1"
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, nextTick, ref, useId, watch } from "vue";
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 { modelValue, items, label, size = "md" } = defineProps<TabsProps<Value>>();
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) => {