@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,178 @@
1
+ <template>
2
+ <div
3
+ class="[-webkit-touch-callout:none]"
4
+ :data-state="isOpen ? 'open' : 'closed'"
5
+ @contextmenu="handleContextMenu"
6
+ @pointerdown="press"
7
+ @pointermove="move"
8
+ @pointerup="release"
9
+ @pointercancel="release"
10
+ @keydown="handleKeyDown"
11
+ >
12
+ <slot :open="isOpen" />
13
+
14
+ <span
15
+ v-if="isOpen"
16
+ aria-hidden="true"
17
+ class="pointer-events-none fixed size-0"
18
+ :style="`left: ${point.x}px; top: ${point.y}px; ${anchorStyle}`"
19
+ />
20
+
21
+ <Stack
22
+ v-if="isOpen"
23
+ ref="content"
24
+ role="menu"
25
+ :popover="anchored ? 'manual' : undefined"
26
+ :style="anchored ? layerStyle : `left: ${point.x}px; top: ${point.y}px`"
27
+ data-state="open"
28
+ padding="1"
29
+ gap="1"
30
+ class="w-max max-w-xs min-w-48 rounded-ui border border-border bg-popover text-popover-foreground shadow-xl"
31
+ :class="[
32
+ anchored
33
+ ? 'inset-auto m-0 overflow-visible [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]'
34
+ : 'fixed z-10',
35
+ contentClass,
36
+ ]"
37
+ >
38
+ <slot name="content" :close="close" />
39
+ </Stack>
40
+ </div>
41
+ </template>
42
+
43
+ <script setup lang="ts">
44
+ import { computed, nextTick, onUnmounted, provide, ref, useTemplateRef, watch } from "vue";
45
+ import type { ContextMenuProps } from "../../components/menu";
46
+ import { menuCloseKey, menuSizeKey } from "../utilities/menu";
47
+ import { useAnchor } from "../utilities/use-anchor";
48
+ import { useMenuNavigation } from "../utilities/use-menu-navigation";
49
+ import Stack from "./stack.vue";
50
+
51
+ const { itemSize = "md", contentClass, disabled = false } = defineProps<ContextMenuProps>();
52
+
53
+ const content = useTemplateRef<InstanceType<typeof Stack>>("content");
54
+
55
+ const contentElement = computed<HTMLElement | null>(() => content.value?.$el ?? null);
56
+
57
+ const { anchored, anchorStyle, layerStyle } = useAnchor(contentElement);
58
+
59
+ const { focusItem, handleKeyDown: navigate } = useMenuNavigation();
60
+
61
+ const isOpen = ref(false);
62
+
63
+ const point = ref({ x: 0, y: 0 });
64
+
65
+ let previous: HTMLElement | null = null;
66
+
67
+ let timer: ReturnType<typeof setTimeout> | undefined;
68
+
69
+ let start: { x: number; y: number } | undefined;
70
+
71
+ function openAt(x: number, y: number) {
72
+ if (!isOpen.value) {
73
+ previous = document.activeElement as HTMLElement | null;
74
+ }
75
+
76
+ point.value = { x, y };
77
+ isOpen.value = true;
78
+ }
79
+
80
+ async function close() {
81
+ const restore = contentElement.value?.contains(document.activeElement) ?? false;
82
+
83
+ isOpen.value = false;
84
+
85
+ if (restore) {
86
+ await nextTick();
87
+ previous?.focus();
88
+ }
89
+ }
90
+
91
+ function handleContextMenu(event: MouseEvent) {
92
+ if (disabled || contentElement.value?.contains(event.target as Node)) {
93
+ return;
94
+ }
95
+
96
+ event.preventDefault();
97
+
98
+ if (event.clientX === 0 && event.clientY === 0) {
99
+ const bounds = (event.target as HTMLElement).getBoundingClientRect();
100
+
101
+ openAt(bounds.left, bounds.bottom);
102
+ } else {
103
+ openAt(event.clientX, event.clientY);
104
+ }
105
+ }
106
+
107
+ function press(event: PointerEvent) {
108
+ if (event.pointerType !== "touch" || disabled) {
109
+ return;
110
+ }
111
+
112
+ start = { x: event.clientX, y: event.clientY };
113
+ clearTimeout(timer);
114
+ timer = setTimeout(() => {
115
+ if (start) {
116
+ openAt(start.x, start.y);
117
+ }
118
+ }, 500);
119
+ }
120
+
121
+ function move(event: PointerEvent) {
122
+ if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) > 10) {
123
+ release();
124
+ }
125
+ }
126
+
127
+ function release() {
128
+ clearTimeout(timer);
129
+ start = undefined;
130
+ }
131
+
132
+ function handlePointerDown(event: PointerEvent) {
133
+ if (!contentElement.value?.contains(event.target as Node)) {
134
+ isOpen.value = false;
135
+ }
136
+ }
137
+
138
+ function handleKeyDown(event: KeyboardEvent) {
139
+ if (!isOpen.value || !contentElement.value?.contains(event.target as Node)) {
140
+ return;
141
+ }
142
+
143
+ if (event.key === "Escape") {
144
+ event.preventDefault();
145
+ event.stopPropagation();
146
+ close();
147
+ } else if (event.key === "Tab") {
148
+ event.preventDefault();
149
+ close();
150
+ } else {
151
+ navigate(event);
152
+ }
153
+ }
154
+
155
+ watch(isOpen, async (value) => {
156
+ if (value) {
157
+ document.addEventListener("pointerdown", handlePointerDown, true);
158
+ await nextTick();
159
+ focusItem(contentElement.value, 0);
160
+ } else {
161
+ document.removeEventListener("pointerdown", handlePointerDown, true);
162
+ }
163
+ });
164
+
165
+ provide(
166
+ menuSizeKey,
167
+ computed(() => itemSize),
168
+ );
169
+
170
+ provide(menuCloseKey, close);
171
+
172
+ onUnmounted(() => {
173
+ clearTimeout(timer);
174
+ document.removeEventListener("pointerdown", handlePointerDown, true);
175
+ });
176
+
177
+ defineExpose({ openAt, close });
178
+ </script>
@@ -1,32 +1,54 @@
1
1
  <template>
2
- <Stack>
3
- <span v-if="label" class="text-sm font-normal">{{ label }}</span>
2
+ <Stack class="text-sm">
3
+ <label v-if="label" :for="id" class="font-normal">
4
+ {{ label }}<span v-if="required" aria-hidden="true" class="text-destructive"> *</span>
5
+ </label>
4
6
 
5
- <div class="relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center overflow-hidden rounded-ui">
7
+ <div
8
+ class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center rounded-ui bg-secondary ring-1 ring-input ring-inset focus-within:ring-2 focus-within:ring-ring"
9
+ :class="[disabled ? 'opacity-50' : '', invalid ? 'ring-2 ring-destructive' : '']"
10
+ >
6
11
  <Button
7
12
  size="md"
8
13
  variant="ghost"
14
+ tabindex="-1"
9
15
  :icon-prefix="MinusIcon"
10
- :disabled="disabled || modelValue <= min"
11
- aria-label="Decrease"
16
+ :aria-label="labels.decrease"
17
+ :aria-controls="id"
18
+ :disabled="disabled || (modelValue !== undefined && min !== undefined && modelValue <= min)"
12
19
  @click="change(-step)"
13
20
  />
14
21
 
15
- <output class="px-1 text-center tabular-nums">{{ modelValue }}</output>
22
+ <input
23
+ :id="id"
24
+ v-model="draft"
25
+ type="text"
26
+ inputmode="decimal"
27
+ role="spinbutton"
28
+ autocomplete="off"
29
+ class="w-full min-w-0 bg-transparent px-1 text-center tabular-nums outline-none placeholder:opacity-50 pointer-coarse:text-[16px]/5"
30
+ :name="name"
31
+ :placeholder="placeholder"
32
+ :required="required"
33
+ :disabled="disabled"
34
+ :aria-valuenow="modelValue"
35
+ :aria-valuemin="min"
36
+ :aria-valuemax="max"
37
+ :aria-invalid="invalid || undefined"
38
+ @blur="commit(draft)"
39
+ @keydown="handleKeyDown"
40
+ />
16
41
 
17
42
  <Button
18
43
  size="md"
19
44
  variant="ghost"
45
+ tabindex="-1"
20
46
  :icon-prefix="AddIcon"
21
- :disabled="disabled || modelValue >= max"
22
- aria-label="Increase"
47
+ :aria-label="labels.increase"
48
+ :aria-controls="id"
49
+ :disabled="disabled || (modelValue !== undefined && max !== undefined && modelValue >= max)"
23
50
  @click="change(step)"
24
51
  />
25
-
26
- <span
27
- aria-hidden="true"
28
- class="pointer-events-none absolute inset-0 rounded-ui ring-1 ring-input ring-inset"
29
- />
30
52
  </div>
31
53
  </Stack>
32
54
  </template>
@@ -34,24 +56,108 @@
34
56
  <script setup lang="ts">
35
57
  import AddIcon from "~icons/hugeicons/add-01";
36
58
  import MinusIcon from "~icons/hugeicons/minus-sign";
59
+ import { computed, ref, useId, watch } from "vue";
37
60
  import type { CounterProps } from "../../components/counter";
61
+ import { useUiLabels } from "../utilities/labels";
38
62
  import Button from "./button.vue";
39
63
  import Stack from "./stack.vue";
40
64
 
41
65
  const {
42
- modelValue = 0,
66
+ modelValue,
43
67
  label,
44
- min = -Infinity,
45
- max = Infinity,
68
+ min,
69
+ max,
46
70
  step = 1,
71
+ largeStep,
72
+ name,
73
+ placeholder,
74
+ required = false,
47
75
  disabled = false,
76
+ invalid = false,
48
77
  } = defineProps<CounterProps>();
49
78
 
50
79
  const emit = defineEmits<{
51
- "update:modelValue": [value: number];
80
+ "update:modelValue": [value: number | undefined];
52
81
  }>();
53
82
 
83
+ const labels = useUiLabels();
84
+
85
+ const id = useId();
86
+
87
+ const draft = ref(modelValue === undefined ? "" : String(modelValue));
88
+
89
+ const jump = computed(() => largeStep ?? step * 10);
90
+
91
+ function update(raw: number) {
92
+ const decimals = (String(step).split(".")[1] ?? "").length;
93
+ const base = min ?? 0;
94
+ let value = Number((base + Math.round((raw - base) / step) * step).toFixed(decimals));
95
+
96
+ if (min !== undefined) {
97
+ value = Math.max(min, value);
98
+ }
99
+
100
+ if (max !== undefined) {
101
+ value = Math.min(max, value);
102
+ }
103
+
104
+ draft.value = String(value);
105
+
106
+ if (value !== modelValue) {
107
+ emit("update:modelValue", value);
108
+ }
109
+ }
110
+
111
+ function commit(text: string) {
112
+ const value = Number(text.trim().replace(",", "."));
113
+
114
+ if (text.trim() === "") {
115
+ draft.value = "";
116
+
117
+ if (modelValue !== undefined) {
118
+ emit("update:modelValue", undefined);
119
+ }
120
+ } else if (Number.isNaN(value)) {
121
+ draft.value = modelValue === undefined ? "" : String(modelValue);
122
+ } else {
123
+ update(value);
124
+ }
125
+ }
126
+
54
127
  function change(amount: number) {
55
- emit("update:modelValue", Math.min(max, Math.max(min, modelValue + amount)));
128
+ if (!disabled) {
129
+ update((modelValue ?? min ?? 0) + amount);
130
+ }
56
131
  }
132
+
133
+ function handleKeyDown(event: KeyboardEvent) {
134
+ if (event.key === "ArrowUp") {
135
+ event.preventDefault();
136
+ change(event.shiftKey ? jump.value : step);
137
+ } else if (event.key === "ArrowDown") {
138
+ event.preventDefault();
139
+ change(event.shiftKey ? -jump.value : -step);
140
+ } else if (event.key === "PageUp") {
141
+ event.preventDefault();
142
+ change(jump.value);
143
+ } else if (event.key === "PageDown") {
144
+ event.preventDefault();
145
+ change(-jump.value);
146
+ } else if (event.key === "Home" && min !== undefined) {
147
+ event.preventDefault();
148
+ update(min);
149
+ } else if (event.key === "End" && max !== undefined) {
150
+ event.preventDefault();
151
+ update(max);
152
+ } else if (event.key === "Enter") {
153
+ commit(draft.value);
154
+ }
155
+ }
156
+
157
+ watch(
158
+ () => modelValue,
159
+ (value) => {
160
+ draft.value = value === undefined ? "" : String(value);
161
+ },
162
+ );
57
163
  </script>
@@ -30,7 +30,7 @@
30
30
  size="md"
31
31
  variant="ghost"
32
32
  :icon-prefix="CloseIcon"
33
- :aria-label="closeLabel"
33
+ :aria-label="closeLabel ?? labels.close"
34
34
  :disabled="!dismissible"
35
35
  @click="dismiss"
36
36
  />
@@ -51,6 +51,7 @@ import { useId, useTemplateRef, type HTMLAttributes } from "vue";
51
51
  import type { DialogProps } from "../../components/dialog";
52
52
  import Button from "./button.vue";
53
53
  import Card from "./card.vue";
54
+ import { useUiLabels } from "../utilities/labels";
54
55
  import { useModalDialog } from "../utilities/use-modal-dialog";
55
56
 
56
57
  const {
@@ -63,7 +64,7 @@ const {
63
64
  bodyAlign,
64
65
  bodyJustify,
65
66
  bodyWrap,
66
- closeLabel = "Close",
67
+ closeLabel,
67
68
  dismissible = true,
68
69
  } = defineProps<DialogProps>();
69
70
 
@@ -71,6 +72,8 @@ const emit = defineEmits<{
71
72
  "update:modelValue": [value: boolean];
72
73
  }>();
73
74
 
75
+ const labels = useUiLabels();
76
+
74
77
  const dialog = useTemplateRef("dialog");
75
78
 
76
79
  const id = `dialog-${useId()}`;
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <Button
3
+ role="menuitemcheckbox"
4
+ tabindex="-1"
5
+ font="normal"
6
+ layout="left"
7
+ variant="ghost"
8
+ :aria-checked="modelValue"
9
+ :data-state="modelValue ? 'checked' : 'unchecked'"
10
+ :icon-prefix="iconPrefix"
11
+ :icon-suffix="modelValue ? TickIcon : undefined"
12
+ :size="size ?? menuSize ?? 'md'"
13
+ :disabled="disabled"
14
+ class="w-full"
15
+ @click="toggle"
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">
24
+ import TickIcon from "~icons/hugeicons/tick-02";
25
+ import { inject } from "vue";
26
+ import type { MenuCheckboxItemProps } from "../../components/menu";
27
+ import { menuSizeKey } from "../utilities/menu";
28
+ import Button from "./button.vue";
29
+
30
+ const {
31
+ modelValue = false,
32
+ label,
33
+ iconPrefix,
34
+ size = undefined,
35
+ disabled = false,
36
+ } = defineProps<MenuCheckboxItemProps>();
37
+
38
+ const emit = defineEmits<{
39
+ "update:modelValue": [value: boolean];
40
+ select: [event: MouseEvent];
41
+ }>();
42
+
43
+ const menuSize = inject(menuSizeKey, undefined);
44
+
45
+ function toggle(event: MouseEvent) {
46
+ emit("select", event);
47
+
48
+ if (!event.defaultPrevented) {
49
+ emit("update:modelValue", !modelValue);
50
+ }
51
+ }
52
+ </script>
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <Stack role="group" :aria-labelledby="label ? id : undefined" gap="1">
3
+ <span v-if="label" :id="id" class="text-xs font-medium text-muted-foreground" :class="sizes[selected]">
4
+ <span class="block truncate" :class="insets[selected]">{{ label }}</span>
5
+ </span>
6
+
7
+ <slot />
8
+ </Stack>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ import { computed, inject, useId } from "vue";
13
+ import type { ButtonSize } from "../../components/button";
14
+ import type { MenuGroupProps } from "../../components/menu";
15
+ import { menuSizeKey } from "../utilities/menu";
16
+ import Stack from "./stack.vue";
17
+
18
+ const { label, size = undefined } = defineProps<MenuGroupProps>();
19
+
20
+ const id = useId();
21
+
22
+ const menuSize = inject(menuSizeKey, undefined);
23
+
24
+ const selected = computed(() => size ?? menuSize?.value ?? "md");
25
+
26
+ const sizes: Record<ButtonSize, string> = {
27
+ md: "p-2",
28
+ sm: "p-1",
29
+ xs: "p-1",
30
+ };
31
+
32
+ const insets: Record<ButtonSize, string> = {
33
+ md: "px-2",
34
+ sm: "px-2",
35
+ xs: "px-1",
36
+ };
37
+ </script>
@@ -0,0 +1,152 @@
1
+ <template>
2
+ <div ref="wrapper" class="relative w-full" :style="anchorStyle" @keydown="handleKeyDown">
3
+ <Button
4
+ :id="triggerId"
5
+ role="menuitem"
6
+ tabindex="-1"
7
+ font="normal"
8
+ layout="left"
9
+ variant="ghost"
10
+ aria-haspopup="menu"
11
+ :aria-expanded="isOpen"
12
+ :aria-controls="isOpen ? contentId : undefined"
13
+ :data-state="isOpen ? 'open' : 'closed'"
14
+ :icon-prefix="iconPrefix"
15
+ :icon-suffix="ArrowRightIcon"
16
+ :size="size ?? menuSize ?? 'md'"
17
+ :disabled="disabled"
18
+ class="w-full"
19
+ :class="isOpen ? 'bg-ghost-hover!' : ''"
20
+ @click="show(true)"
21
+ @pointerenter="$event.pointerType !== 'touch' && show(false)"
22
+ >
23
+ <span class="flex-1 truncate px-1">{{ label }}</span>
24
+ </Button>
25
+
26
+ <Stack
27
+ v-if="isOpen"
28
+ :id="contentId"
29
+ ref="content"
30
+ role="menu"
31
+ :aria-labelledby="triggerId"
32
+ :popover="anchored ? 'manual' : undefined"
33
+ :style="anchored ? layerStyle : undefined"
34
+ data-state="open"
35
+ padding="1"
36
+ gap="1"
37
+ class="w-max max-w-xs min-w-48 rounded-ui border border-border bg-popover text-popover-foreground shadow-xl"
38
+ :class="
39
+ anchored
40
+ ? 'inset-auto mx-1 my-0 overflow-visible [position-area:inline-end_span-block-end] [position-try-fallbacks:flip-inline,block-end_span-inline-end,block-start_span-inline-end]'
41
+ : 'absolute start-full top-0 z-10 mx-1'
42
+ "
43
+ >
44
+ <slot />
45
+ </Stack>
46
+ </div>
47
+ </template>
48
+
49
+ <script setup lang="ts">
50
+ import ArrowRightIcon from "~icons/hugeicons/arrow-right-01";
51
+ import { computed, inject, nextTick, onMounted, onUnmounted, ref, useId, useTemplateRef } from "vue";
52
+ import type { MenuLayerProps } from "../../components/menu";
53
+ import { menuSizeKey } from "../utilities/menu";
54
+ import { useAnchor } from "../utilities/use-anchor";
55
+ import { useMenuNavigation } from "../utilities/use-menu-navigation";
56
+ import Button from "./button.vue";
57
+ import Stack from "./stack.vue";
58
+
59
+ const { label, iconPrefix, size = undefined, disabled = false } = defineProps<MenuLayerProps>();
60
+
61
+ const menuSize = inject(menuSizeKey, undefined);
62
+
63
+ const wrapper = useTemplateRef("wrapper");
64
+
65
+ const content = useTemplateRef<InstanceType<typeof Stack>>("content");
66
+
67
+ const contentElement = computed<HTMLElement | null>(() => content.value?.$el ?? null);
68
+
69
+ const { anchored, anchorStyle, layerStyle } = useAnchor(contentElement);
70
+
71
+ const { focusItem } = useMenuNavigation();
72
+
73
+ const triggerId = useId();
74
+
75
+ const contentId = useId();
76
+
77
+ const isOpen = ref(false);
78
+
79
+ let parent: Element | null | undefined;
80
+
81
+ let timer: ReturnType<typeof setTimeout> | undefined;
82
+
83
+ async function show(focus: boolean) {
84
+ clearTimeout(timer);
85
+
86
+ if (disabled) {
87
+ return;
88
+ }
89
+
90
+ isOpen.value = true;
91
+
92
+ if (focus) {
93
+ await nextTick();
94
+ focusItem(contentElement.value, 0);
95
+ }
96
+ }
97
+
98
+ function hide(focus: boolean) {
99
+ clearTimeout(timer);
100
+ isOpen.value = false;
101
+
102
+ if (focus) {
103
+ document.getElementById(triggerId)?.focus();
104
+ }
105
+ }
106
+
107
+ function handleKeyDown(event: KeyboardEvent) {
108
+ const target = event.target as HTMLElement;
109
+ const rtl = getComputedStyle(target).direction === "rtl";
110
+ const forward = rtl ? "ArrowLeft" : "ArrowRight";
111
+ const backward = rtl ? "ArrowRight" : "ArrowLeft";
112
+
113
+ if (target.id === triggerId) {
114
+ if (event.key === forward) {
115
+ event.preventDefault();
116
+ event.stopPropagation();
117
+ show(true);
118
+ }
119
+
120
+ return;
121
+ }
122
+
123
+ if (isOpen.value && (event.key === backward || event.key === "Escape")) {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ hide(true);
127
+ }
128
+ }
129
+
130
+ function handlePointerOver(event: PointerEvent) {
131
+ if (wrapper.value?.contains(event.target as Node)) {
132
+ clearTimeout(timer);
133
+
134
+ return;
135
+ }
136
+
137
+ if (isOpen.value) {
138
+ clearTimeout(timer);
139
+ timer = setTimeout(() => hide(false), 300);
140
+ }
141
+ }
142
+
143
+ onMounted(() => {
144
+ parent = wrapper.value?.parentElement?.closest("[role='menu']");
145
+ parent?.addEventListener("pointerover", handlePointerOver as EventListener);
146
+ });
147
+
148
+ onUnmounted(() => {
149
+ clearTimeout(timer);
150
+ parent?.removeEventListener("pointerover", handlePointerOver as EventListener);
151
+ });
152
+ </script>
@@ -0,0 +1,36 @@
1
+ <template>
2
+ <Stack role="group" :aria-label="label" gap="1">
3
+ <slot />
4
+ </Stack>
5
+ </template>
6
+
7
+ <script setup lang="ts" generic="Value extends MenuRadioValue = MenuRadioValue">
8
+ import { computed, provide, ref, watch } from "vue";
9
+ import type { MenuRadioGroupProps, MenuRadioValue } from "../../components/menu";
10
+ import { menuRadioKey } from "../utilities/menu";
11
+ import Stack from "./stack.vue";
12
+
13
+ const { modelValue, label } = defineProps<MenuRadioGroupProps<Value>>();
14
+
15
+ const emit = defineEmits<{
16
+ "update:modelValue": [value: Value];
17
+ }>();
18
+
19
+ const selected = ref<MenuRadioValue | undefined>(modelValue);
20
+
21
+ provide(menuRadioKey, {
22
+ value: computed(() => selected.value),
23
+ select: (value) => {
24
+ selected.value = value;
25
+
26
+ emit("update:modelValue", value as Value);
27
+ },
28
+ });
29
+
30
+ watch(
31
+ () => modelValue,
32
+ (value) => {
33
+ selected.value = value;
34
+ },
35
+ );
36
+ </script>