@revojs/ui 0.2.61 → 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 +1 -1
  82. package/dist/vue/utilities/use-focus-trap.d.ts +0 -2
  83. package/dist/vue/utilities/use-focus-trap.js +0 -34
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <Button
3
+ role="menuitemradio"
4
+ tabindex="-1"
5
+ font="normal"
6
+ layout="left"
7
+ variant="ghost"
8
+ :aria-checked="checked"
9
+ :data-state="checked ? 'checked' : 'unchecked'"
10
+ :icon-prefix="iconPrefix"
11
+ :icon-suffix="checked ? TickIcon : undefined"
12
+ :size="size ?? menuSize ?? 'md'"
13
+ :disabled="disabled"
14
+ class="w-full"
15
+ @click="select"
16
+ >
17
+ <slot>
18
+ <span v-if="label" class="flex-1 truncate px-1">{{ label }}</span>
19
+ </slot>
20
+ </Button>
21
+ </template>
22
+
23
+ <script setup lang="ts" generic="Value extends MenuRadioValue = MenuRadioValue">
24
+ import TickIcon from "~icons/hugeicons/tick-02";
25
+ import { computed, inject } from "vue";
26
+ import type { MenuRadioItemProps, MenuRadioValue } from "../../components/menu";
27
+ import { menuCloseKey, menuRadioKey, menuSizeKey } from "../utilities/menu";
28
+ import Button from "./button.vue";
29
+
30
+ const { value, label, iconPrefix, size = undefined, disabled = false } = defineProps<MenuRadioItemProps<Value>>();
31
+
32
+ const emit = defineEmits<{ select: [event: MouseEvent] }>();
33
+
34
+ const close = inject(menuCloseKey, undefined);
35
+
36
+ const menuSize = inject(menuSizeKey, undefined);
37
+
38
+ const group = inject(menuRadioKey, undefined);
39
+
40
+ const checked = computed(() => group?.value.value === value);
41
+
42
+ function select(event: MouseEvent) {
43
+ emit("select", event);
44
+
45
+ if (!event.defaultPrevented) {
46
+ group?.select(value);
47
+ close?.();
48
+ }
49
+ }
50
+ </script>
@@ -3,7 +3,7 @@
3
3
  ref="popover"
4
4
  v-model="isOpen"
5
5
  content-role="menu"
6
- :trigger-label="triggerLabel"
6
+ :trigger-label="triggerLabel ?? labels.menu"
7
7
  :trigger-class="triggerClass"
8
8
  :trigger-layout="triggerLayout"
9
9
  :trigger-variant="triggerVariant"
@@ -31,11 +31,13 @@
31
31
  import { computed, nextTick, provide, ref, useTemplateRef, watch } from "vue";
32
32
  import type { MenuProps } from "../../components/menu";
33
33
  import { menuCloseKey, menuSizeKey } from "../utilities/menu";
34
+ import { useUiLabels } from "../utilities/labels";
35
+ import { useMenuNavigation } from "../utilities/use-menu-navigation";
34
36
  import Popover from "./popover.vue";
35
37
 
36
38
  const {
37
39
  modelValue = false,
38
- triggerLabel = "Menu",
40
+ triggerLabel,
39
41
  triggerClass,
40
42
  triggerLayout = "center",
41
43
  triggerVariant = "ghost",
@@ -54,25 +56,20 @@ const emit = defineEmits<{
54
56
  "update:modelValue": [value: boolean];
55
57
  }>();
56
58
 
59
+ const labels = useUiLabels();
60
+
57
61
  const popover = useTemplateRef("popover");
58
62
 
63
+ const { focusItem, handleKeyDown: navigate } = useMenuNavigation();
64
+
59
65
  const isOpen = ref(modelValue);
60
66
 
61
67
  let initialFocus: "first" | "last" = "first";
62
68
 
63
- function items() {
69
+ function menu() {
64
70
  const element: HTMLElement | undefined = popover.value?.$el;
65
71
 
66
- return [
67
- ...(element?.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]:not([aria-disabled="true"])') ??
68
- []),
69
- ];
70
- }
71
-
72
- function focusItem(index: number) {
73
- const list = items();
74
-
75
- list[(index + list.length) % list.length]?.focus();
72
+ return element?.querySelector("[role='menu']");
76
73
  }
77
74
 
78
75
  function close() {
@@ -82,13 +79,13 @@ function close() {
82
79
  function handleKeyDown(event: KeyboardEvent) {
83
80
  const target = event.target as HTMLElement;
84
81
 
85
- if (target.getAttribute("aria-haspopup") === "menu") {
82
+ if (target.getAttribute("aria-haspopup") === "menu" && !target.closest("[role='menu']")) {
86
83
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
87
84
  event.preventDefault();
88
85
  initialFocus = event.key === "ArrowDown" ? "first" : "last";
89
86
 
90
87
  if (isOpen.value) {
91
- focusItem(initialFocus === "first" ? 0 : -1);
88
+ focusItem(menu(), initialFocus === "first" ? 0 : -1);
92
89
  } else {
93
90
  isOpen.value = true;
94
91
  }
@@ -97,40 +94,13 @@ function handleKeyDown(event: KeyboardEvent) {
97
94
  return;
98
95
  }
99
96
 
100
- const list = items();
101
- const index = list.indexOf(target);
97
+ if (event.key === "Tab") {
98
+ close();
102
99
 
103
- if (index === -1) {
104
100
  return;
105
101
  }
106
102
 
107
- if (event.key === "ArrowDown") {
108
- event.preventDefault();
109
- focusItem(index + 1);
110
- } else if (event.key === "ArrowUp") {
111
- event.preventDefault();
112
- focusItem(index - 1);
113
- } else if (event.key === "Home") {
114
- event.preventDefault();
115
- focusItem(0);
116
- } else if (event.key === "End") {
117
- event.preventDefault();
118
- focusItem(-1);
119
- } else if (event.key === "Tab") {
120
- close();
121
- } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
122
- const character = event.key.toLowerCase();
123
-
124
- for (let offset = 1; offset <= list.length; offset++) {
125
- const item = list[(index + offset) % list.length];
126
-
127
- if (item.textContent?.trim().toLowerCase().startsWith(character)) {
128
- event.preventDefault();
129
- item.focus();
130
- break;
131
- }
132
- }
133
- }
103
+ navigate(event);
134
104
  }
135
105
 
136
106
  watch(
@@ -145,7 +115,7 @@ watch(isOpen, async (value) => {
145
115
 
146
116
  if (value) {
147
117
  await nextTick();
148
- focusItem(initialFocus === "first" ? 0 : -1);
118
+ focusItem(menu(), initialFocus === "first" ? 0 : -1);
149
119
  initialFocus = "first";
150
120
  }
151
121
  });
@@ -1,13 +1,31 @@
1
1
  <template>
2
- <Stack is="nav" :aria-label="label" direction="horizontal" justify="end" gap="1" :aria-busy="loading || undefined">
3
- <Button :label="previousLabel" variant="ghost" :disabled="!hasPrevious || loading" @click="$emit('previous')" />
2
+ <Stack
3
+ is="nav"
4
+ :aria-label="label ?? labels.pagination"
5
+ direction="horizontal"
6
+ justify="end"
7
+ gap="1"
8
+ :aria-busy="loading || undefined"
9
+ >
10
+ <Button
11
+ :label="previousLabel ?? labels.previous"
12
+ variant="ghost"
13
+ :disabled="!hasPrevious || loading"
14
+ @click="$emit('previous')"
15
+ />
4
16
 
5
- <Button :label="nextLabel" variant="ghost" :disabled="!hasNext || loading" @click="$emit('next')" />
17
+ <Button
18
+ :label="nextLabel ?? labels.next"
19
+ variant="ghost"
20
+ :disabled="!hasNext || loading"
21
+ @click="$emit('next')"
22
+ />
6
23
  </Stack>
7
24
  </template>
8
25
 
9
26
  <script setup lang="ts">
10
27
  import type { PaginationProps } from "../../components/pagination";
28
+ import { useUiLabels } from "../utilities/labels";
11
29
  import Button from "./button.vue";
12
30
  import Stack from "./stack.vue";
13
31
 
@@ -15,10 +33,12 @@ const {
15
33
  hasPrevious = false,
16
34
  hasNext = false,
17
35
  loading = false,
18
- previousLabel = "Previous",
19
- nextLabel = "Next",
20
- label = "Pagination",
36
+ previousLabel,
37
+ nextLabel,
38
+ label,
21
39
  } = defineProps<PaginationProps>();
22
40
 
23
41
  defineEmits<{ previous: []; next: [] }>();
42
+
43
+ const labels = useUiLabels();
24
44
  </script>
@@ -3,8 +3,9 @@
3
3
  ref="container"
4
4
  class="relative"
5
5
  :class="matchTriggerWidth ? 'w-full' : 'w-fit'"
6
+ :style="anchorStyle"
6
7
  @keydown="handleKeyDown"
7
- @focusout="$event.relatedTarget && !container?.contains($event.relatedTarget as Node) && close()"
8
+ @focusout="$event.relatedTarget && !container?.contains($event.relatedTarget as Node) && setOpen(false)"
8
9
  >
9
10
  <Button
10
11
  :id="triggerId"
@@ -16,11 +17,12 @@
16
17
  :aria-haspopup="contentRole"
17
18
  :aria-expanded="isOpen"
18
19
  :aria-controls="isOpen ? contentId : undefined"
20
+ :data-state="isOpen ? 'open' : 'closed'"
19
21
  @click="toggle"
20
22
  >
21
23
  <slot name="trigger" :open="open" :close="close" :toggle="toggle">
22
24
  <span class="truncate px-1" :class="triggerLayout === 'center' ? '' : 'flex-1'">{{
23
- triggerLabel
25
+ triggerLabel ?? labels.open
24
26
  }}</span>
25
27
  </slot>
26
28
  </Button>
@@ -28,16 +30,27 @@
28
30
  <Stack
29
31
  v-if="isOpen"
30
32
  :id="contentId"
33
+ ref="content"
31
34
  :role="contentRole"
32
35
  :aria-labelledby="triggerId"
36
+ :popover="anchored ? 'manual' : undefined"
37
+ :style="anchored ? layerStyle : undefined"
38
+ :tabindex="contentRole === 'dialog' ? -1 : undefined"
33
39
  :padding="contentPadding"
34
40
  :gap="contentGap"
35
41
  :direction="contentDirection"
36
42
  :align="contentAlign"
37
43
  :justify="contentJustify"
38
44
  :wrap="contentWrap"
39
- class="absolute z-10 max-w-xs rounded-ui border border-border bg-popover text-popover-foreground shadow-xl"
40
- :class="[placements[placement], aligns[align], contentClass, matchTriggerWidth ? 'w-full' : '']"
45
+ data-state="open"
46
+ class="max-w-xs rounded-ui border border-border bg-popover text-popover-foreground shadow-xl outline-none"
47
+ :class="[
48
+ anchored
49
+ ? `inset-auto mx-0 my-1 overflow-visible [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline] ${areas[`${placement}-${align}`]}`
50
+ : `absolute z-10 ${placements[placement]} ${aligns[align]}`,
51
+ contentClass,
52
+ matchTriggerWidth ? (anchored ? '[width:anchor-size(width)]' : 'w-full') : '',
53
+ ]"
41
54
  >
42
55
  <slot :open="open" :close="close" :toggle="toggle" />
43
56
  </Stack>
@@ -45,14 +58,16 @@
45
58
  </template>
46
59
 
47
60
  <script setup lang="ts">
48
- import { onUnmounted, ref, useId, useTemplateRef, watch } from "vue";
61
+ import { computed, nextTick, onUnmounted, ref, useId, useTemplateRef, watch } from "vue";
49
62
  import type { PopoverAlign, PopoverPlacement, PopoverProps } from "../../components/popover";
63
+ import { useUiLabels } from "../utilities/labels";
64
+ import { useAnchor } from "../utilities/use-anchor";
50
65
  import Button from "./button.vue";
51
66
  import Stack from "./stack.vue";
52
67
 
53
68
  const {
54
69
  modelValue = false,
55
- triggerLabel = "Open",
70
+ triggerLabel,
56
71
  triggerClass,
57
72
  triggerLayout = "center",
58
73
  triggerVariant = "outline",
@@ -75,8 +90,16 @@ const emit = defineEmits<{
75
90
  "update:modelValue": [value: boolean];
76
91
  }>();
77
92
 
93
+ const labels = useUiLabels();
94
+
78
95
  const container = useTemplateRef("container");
79
96
 
97
+ const content = useTemplateRef<InstanceType<typeof Stack>>("content");
98
+
99
+ const contentElement = computed<HTMLElement | null>(() => content.value?.$el ?? null);
100
+
101
+ const { anchored, anchorStyle, layerStyle } = useAnchor(contentElement);
102
+
80
103
  const triggerId = useId();
81
104
 
82
105
  const contentId = useId();
@@ -87,8 +110,15 @@ const placements: Record<PopoverPlacement, string> = {
87
110
  };
88
111
 
89
112
  const aligns: Record<PopoverAlign, string> = {
90
- start: "left-0",
91
- end: "right-0",
113
+ start: "start-0",
114
+ end: "end-0",
115
+ };
116
+
117
+ const areas: Record<`${PopoverPlacement}-${PopoverAlign}`, string> = {
118
+ "bottom-start": "[position-area:block-end_span-inline-end]",
119
+ "bottom-end": "[position-area:block-end_span-inline-start]",
120
+ "top-start": "[position-area:block-start_span-inline-end]",
121
+ "top-end": "[position-area:block-start_span-inline-start]",
92
122
  };
93
123
 
94
124
  const isOpen = ref(modelValue);
@@ -103,17 +133,28 @@ function open() {
103
133
  setOpen(true);
104
134
  }
105
135
 
106
- function close() {
136
+ async function close() {
137
+ const restore = contentElement.value?.contains(document.activeElement) ?? false;
138
+
107
139
  setOpen(false);
140
+
141
+ if (restore) {
142
+ await nextTick();
143
+ document.getElementById(triggerId)?.focus();
144
+ }
108
145
  }
109
146
 
110
147
  function toggle() {
111
- setOpen(!isOpen.value);
148
+ if (isOpen.value) {
149
+ close();
150
+ } else {
151
+ open();
152
+ }
112
153
  }
113
154
 
114
155
  function handlePointerDown(event: PointerEvent) {
115
156
  if (!container.value?.contains(event.target as Node)) {
116
- close();
157
+ setOpen(false);
117
158
  }
118
159
  }
119
160
 
@@ -121,20 +162,28 @@ function handleKeyDown(event: KeyboardEvent) {
121
162
  if (event.key === "Escape" && isOpen.value) {
122
163
  event.preventDefault();
123
164
  event.stopPropagation();
124
- close();
165
+ setOpen(false);
125
166
  document.getElementById(triggerId)?.focus();
126
167
  }
127
168
  }
128
169
 
129
170
  watch(
130
171
  isOpen,
131
- (value) => {
172
+ async (value) => {
132
173
  if (typeof document === "undefined") {
133
174
  return;
134
175
  }
135
176
 
136
177
  if (value) {
137
178
  document.addEventListener("pointerdown", handlePointerDown);
179
+
180
+ if (contentRole === "dialog") {
181
+ await nextTick();
182
+
183
+ const element = contentElement.value;
184
+
185
+ (element?.querySelector<HTMLElement>("[autofocus]") ?? element)?.focus({ preventScroll: true });
186
+ }
138
187
  } else {
139
188
  document.removeEventListener("pointerdown", handlePointerDown);
140
189
  }
@@ -0,0 +1,46 @@
1
+ <template>
2
+ <fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0" :disabled="disabled">
3
+ <legend v-if="label" class="p-0 text-sm font-normal">
4
+ {{ label }}<span v-if="required" aria-hidden="true" class="text-destructive"> *</span>
5
+ </legend>
6
+
7
+ <Stack :direction="orientation" :gap="orientation === 'horizontal' ? '5' : '3'" wrap>
8
+ <Radio
9
+ v-for="option in options"
10
+ :key="option.value"
11
+ :model-value="modelValue"
12
+ :value="option.value"
13
+ :label="option.label"
14
+ :description="option.description"
15
+ :name="name ?? id"
16
+ :required="required"
17
+ :disabled="disabled || option.disabled"
18
+ @update:model-value="$emit('update:modelValue', $event as Value)"
19
+ />
20
+ </Stack>
21
+ </fieldset>
22
+ </template>
23
+
24
+ <script setup lang="ts" generic="Value extends RadioValue = RadioValue">
25
+ import { useId } from "vue";
26
+ import type { RadioValue } from "../../components/radio";
27
+ import type { RadioGroupProps } from "../../components/radio-group";
28
+ import Radio from "./radio.vue";
29
+ import Stack from "./stack.vue";
30
+
31
+ const {
32
+ modelValue,
33
+ options,
34
+ label,
35
+ name,
36
+ orientation = "vertical",
37
+ required = false,
38
+ disabled = false,
39
+ } = defineProps<RadioGroupProps<Value>>();
40
+
41
+ defineEmits<{
42
+ "update:modelValue": [value: Value];
43
+ }>();
44
+
45
+ const id = useId();
46
+ </script>
@@ -1,10 +1,10 @@
1
1
  <template>
2
2
  <Stack :class="interactionVariants[interaction]">
3
3
  <Stack v-if="label" direction="horizontal" align="center" justify="between" gap="1" class="text-sm">
4
- <span class="font-normal">{{ label }}</span>
4
+ <span :id="labelId" class="font-normal">{{ label }}</span>
5
5
 
6
6
  <output class="tabular-nums opacity-60">
7
- {{ modelValue }}
7
+ {{ valueText ? valueText(modelValue) : modelValue }}
8
8
  </output>
9
9
  </Stack>
10
10
 
@@ -17,8 +17,11 @@
17
17
  :aria-valuemin="min"
18
18
  :aria-valuemax="max"
19
19
  :aria-valuenow="modelValue"
20
- :aria-label="label"
20
+ :aria-valuetext="valueText ? valueText(modelValue) : undefined"
21
+ :aria-labelledby="label ? labelId : undefined"
22
+ aria-orientation="horizontal"
21
23
  :aria-disabled="disabled"
24
+ :data-disabled="disabled ? '' : undefined"
22
25
  @pointerdown="onPointerDown"
23
26
  @pointermove="onPointerMove"
24
27
  @pointerup="onPointerUp"
@@ -36,12 +39,21 @@
36
39
  </template>
37
40
 
38
41
  <script setup lang="ts">
39
- import { computed, useTemplateRef } from "vue";
42
+ import { computed, useId, useTemplateRef } from "vue";
40
43
  import type { ControlInteraction } from "../../components/control.ts";
41
44
  import type { RangeProps } from "../../components/range";
42
45
  import Stack from "./stack.vue";
43
46
 
44
- const { modelValue = 0, label, min = 0, max = 100, step = 1, disabled = false } = defineProps<RangeProps>();
47
+ const {
48
+ modelValue = 0,
49
+ label,
50
+ min = 0,
51
+ max = 100,
52
+ step = 1,
53
+ largeStep,
54
+ valueText,
55
+ disabled = false,
56
+ } = defineProps<RangeProps>();
45
57
 
46
58
  const emit = defineEmits<{
47
59
  "update:modelValue": [value: number];
@@ -56,6 +68,10 @@ const interactionVariants: Record<ControlInteraction, string> = {
56
68
 
57
69
  const track = useTemplateRef("track");
58
70
 
71
+ const labelId = useId();
72
+
73
+ const jump = computed(() => largeStep ?? Math.max(step, (max - min) / 10));
74
+
59
75
  const percent = computed(() => (max > min ? Math.min(100, Math.max(0, ((modelValue - min) / (max - min)) * 100)) : 0));
60
76
 
61
77
  let dragging = false;
@@ -108,11 +124,19 @@ function onKeyDown(event: KeyboardEvent) {
108
124
  return;
109
125
  }
110
126
 
127
+ const amount = event.shiftKey ? jump.value : step;
128
+
111
129
  if (event.key === "ArrowRight" || event.key === "ArrowUp") {
112
- commit(modelValue + step);
130
+ commit(modelValue + amount);
113
131
  event.preventDefault();
114
132
  } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
115
- commit(modelValue - step);
133
+ commit(modelValue - amount);
134
+ event.preventDefault();
135
+ } else if (event.key === "PageUp") {
136
+ commit(modelValue + jump.value);
137
+ event.preventDefault();
138
+ } else if (event.key === "PageDown") {
139
+ commit(modelValue - jump.value);
116
140
  event.preventDefault();
117
141
  } else if (event.key === "Home") {
118
142
  commit(min);
@@ -2,7 +2,16 @@
2
2
  <Stack>
3
3
  <span v-if="label" class="text-sm font-normal">{{ label }}</span>
4
4
 
5
- <Stack direction="horizontal" align="center" gap="0" role="radiogroup" :aria-label="label">
5
+ <Stack
6
+ ref="group"
7
+ direction="horizontal"
8
+ align="center"
9
+ gap="0"
10
+ role="radiogroup"
11
+ :aria-label="label"
12
+ @keydown="handleKeyDown"
13
+ @focusin="handleFocusIn"
14
+ >
6
15
  <Button
7
16
  v-for="value in max"
8
17
  :key="value"
@@ -11,9 +20,10 @@
11
20
  variant="ghost"
12
21
  role="radio"
13
22
  :aria-checked="value === modelValue"
14
- :aria-label="`${value} of ${max}`"
23
+ :aria-label="labels.rating(value, max)"
24
+ :data-value="value"
15
25
  :disabled="readonly || disabled"
16
- @click="$emit('update:modelValue', value)"
26
+ @click="select(value)"
17
27
  >
18
28
  <Icon :is="StarIcon" :size="20" :class="{ '[&_path]:fill-current': value <= modelValue }" />
19
29
  </Button>
@@ -23,6 +33,9 @@
23
33
 
24
34
  <script setup lang="ts">
25
35
  import StarIcon from "~icons/hugeicons/star";
36
+ import { computed, useTemplateRef } from "vue";
37
+ import { useUiLabels } from "../utilities/labels";
38
+ import { useRovingFocus } from "../utilities/use-roving-focus";
26
39
  import Button from "./button.vue";
27
40
  import Icon from "./icon.vue";
28
41
  import Stack from "./stack.vue";
@@ -30,10 +43,28 @@ import type { RatingProps, RatingState } from "../../components/rating";
30
43
 
31
44
  const { modelValue = 0, max = 5, label, readonly = false, disabled = false } = defineProps<RatingProps>();
32
45
 
33
- defineEmits<{
46
+ const emit = defineEmits<{
34
47
  "update:modelValue": [value: number];
35
48
  }>();
36
49
 
50
+ const labels = useUiLabels();
51
+
52
+ const group = useTemplateRef<InstanceType<typeof Stack>>("group");
53
+
54
+ const { handleKeyDown, handleFocusIn } = useRovingFocus(
55
+ computed(() => group.value?.$el ?? null),
56
+ () => ({
57
+ selector: "[role='radio']",
58
+ onMove: (element: HTMLElement) => select(Number(element.dataset.value)),
59
+ }),
60
+ );
61
+
62
+ function select(value: number) {
63
+ if (!readonly && !disabled) {
64
+ emit("update:modelValue", value);
65
+ }
66
+ }
67
+
37
68
  function state(value: number): RatingState {
38
69
  return value <= modelValue ? "active" : "inactive";
39
70
  }