@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
|
@@ -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
|
-
<
|
|
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
|
|
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
|
-
:
|
|
11
|
-
aria-
|
|
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
|
-
<
|
|
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
|
-
:
|
|
22
|
-
aria-
|
|
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
|
|
66
|
+
modelValue,
|
|
43
67
|
label,
|
|
44
|
-
min
|
|
45
|
-
max
|
|
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
|
-
|
|
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
|
|
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>
|