@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,141 @@
|
|
|
1
|
+
<template>
|
|
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>
|
|
6
|
+
|
|
7
|
+
<Stack
|
|
8
|
+
direction="horizontal"
|
|
9
|
+
align="center"
|
|
10
|
+
gap="1"
|
|
11
|
+
padding="2"
|
|
12
|
+
wrap
|
|
13
|
+
class="cursor-text rounded-ui bg-secondary ring-1 ring-input ring-inset focus-within:ring-2 focus-within:ring-ring"
|
|
14
|
+
:class="[disabled ? 'cursor-not-allowed opacity-50' : '', invalid ? 'ring-2 ring-destructive' : '']"
|
|
15
|
+
@mousedown="focus"
|
|
16
|
+
>
|
|
17
|
+
<Badge
|
|
18
|
+
v-for="(tag, index) in modelValue"
|
|
19
|
+
:key="tag"
|
|
20
|
+
variant="outline"
|
|
21
|
+
:label="tag"
|
|
22
|
+
:remove-label="labels.remove(tag)"
|
|
23
|
+
:remove-disabled="disabled"
|
|
24
|
+
@remove="remove(index)"
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<input
|
|
28
|
+
:id="id"
|
|
29
|
+
ref="input"
|
|
30
|
+
v-model="draft"
|
|
31
|
+
type="text"
|
|
32
|
+
autocomplete="off"
|
|
33
|
+
class="min-w-24 flex-1 bg-transparent p-1 outline-none placeholder:opacity-50 pointer-coarse:text-[16px]/5"
|
|
34
|
+
:placeholder="modelValue.length === 0 ? placeholder : undefined"
|
|
35
|
+
:required="required && modelValue.length === 0"
|
|
36
|
+
:disabled="disabled"
|
|
37
|
+
:readonly="max !== undefined && modelValue.length >= max"
|
|
38
|
+
:aria-invalid="invalid || undefined"
|
|
39
|
+
@keydown="handleKeyDown"
|
|
40
|
+
@paste="paste"
|
|
41
|
+
@blur="add(draft)"
|
|
42
|
+
/>
|
|
43
|
+
</Stack>
|
|
44
|
+
|
|
45
|
+
<input v-for="tag in modelValue" :key="tag" type="hidden" :name="name" :value="tag" :disabled="disabled" />
|
|
46
|
+
</Stack>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import { ref, useId, useTemplateRef } from "vue";
|
|
51
|
+
import type { TagsInputProps } from "../../components/tags-input";
|
|
52
|
+
import { useUiLabels } from "../utilities/labels";
|
|
53
|
+
import Badge from "./badge.vue";
|
|
54
|
+
import Stack from "./stack.vue";
|
|
55
|
+
|
|
56
|
+
const {
|
|
57
|
+
modelValue = [],
|
|
58
|
+
label,
|
|
59
|
+
placeholder,
|
|
60
|
+
name,
|
|
61
|
+
max,
|
|
62
|
+
required = false,
|
|
63
|
+
disabled = false,
|
|
64
|
+
invalid = false,
|
|
65
|
+
} = defineProps<TagsInputProps>();
|
|
66
|
+
|
|
67
|
+
const emit = defineEmits<{
|
|
68
|
+
"update:modelValue": [value: Array<string>];
|
|
69
|
+
}>();
|
|
70
|
+
|
|
71
|
+
const labels = useUiLabels();
|
|
72
|
+
|
|
73
|
+
const id = useId();
|
|
74
|
+
|
|
75
|
+
const input = useTemplateRef("input");
|
|
76
|
+
|
|
77
|
+
const draft = ref("");
|
|
78
|
+
|
|
79
|
+
function add(text: string) {
|
|
80
|
+
const tags = [...modelValue];
|
|
81
|
+
|
|
82
|
+
for (const part of text.split(/[,\n]/)) {
|
|
83
|
+
const tag = part.trim();
|
|
84
|
+
|
|
85
|
+
if (tag !== "" && !tags.includes(tag) && (max === undefined || tags.length < max)) {
|
|
86
|
+
tags.push(tag);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
draft.value = "";
|
|
91
|
+
|
|
92
|
+
if (tags.length !== modelValue.length) {
|
|
93
|
+
emit("update:modelValue", tags);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function remove(index: number) {
|
|
98
|
+
if (disabled) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tags: Array<string> = [];
|
|
103
|
+
|
|
104
|
+
for (const [position, tag] of modelValue.entries()) {
|
|
105
|
+
if (position !== index) {
|
|
106
|
+
tags.push(tag);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
emit("update:modelValue", tags);
|
|
111
|
+
input.value?.focus();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function focus(event: MouseEvent) {
|
|
115
|
+
if (event.target !== input.value && !(event.target as HTMLElement).closest("button")) {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
input.value?.focus();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function paste(event: ClipboardEvent) {
|
|
122
|
+
const text = event.clipboardData?.getData("text") ?? "";
|
|
123
|
+
|
|
124
|
+
if (/[,\n]/.test(text)) {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
add(`${draft.value}${text}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
131
|
+
if (event.key === "Enter" || event.key === ",") {
|
|
132
|
+
if (draft.value.trim() !== "") {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
add(draft.value);
|
|
135
|
+
}
|
|
136
|
+
} else if (event.key === "Backspace" && draft.value === "" && modelValue.length > 0) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
remove(modelValue.length - 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
</script>
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
ref="region"
|
|
4
|
+
:aria-label="label ?? labels.notifications"
|
|
5
|
+
:popover="layered ? 'manual' : undefined"
|
|
6
|
+
class="pointer-events-none fixed inset-auto end-0 bottom-0 z-50 m-0 w-full overflow-visible border-0 bg-transparent p-0 sm:w-96"
|
|
7
|
+
>
|
|
8
|
+
<Stack
|
|
9
|
+
is="ol"
|
|
10
|
+
ref="list"
|
|
11
|
+
tabindex="-1"
|
|
12
|
+
aria-live="polite"
|
|
13
|
+
aria-relevant="additions"
|
|
14
|
+
padding="3"
|
|
15
|
+
gap="3"
|
|
16
|
+
class="outline-none"
|
|
17
|
+
@pointerenter="pause"
|
|
18
|
+
@pointerleave="resume"
|
|
19
|
+
@focusin="pause"
|
|
20
|
+
@focusout="resume"
|
|
21
|
+
>
|
|
22
|
+
<li
|
|
23
|
+
v-for="toast in toasts"
|
|
24
|
+
:key="toast.id"
|
|
25
|
+
class="pointer-events-auto touch-pan-y rounded-ui bg-popover shadow-xl"
|
|
26
|
+
:style="offsets[toast.id] ? `translate: ${offsets[toast.id]}px 0` : undefined"
|
|
27
|
+
@pointerdown="startSwipe($event, toast.id)"
|
|
28
|
+
@pointermove="swipe($event, toast.id)"
|
|
29
|
+
@pointerup="endSwipe(toast.id)"
|
|
30
|
+
@pointercancel="endSwipe(toast.id)"
|
|
31
|
+
>
|
|
32
|
+
<Notification :label="toast.label" :subtext="toast.subtext" :variant="toast.variant" :announce="false">
|
|
33
|
+
<template #actions>
|
|
34
|
+
<Button
|
|
35
|
+
v-if="toast.action"
|
|
36
|
+
size="xs"
|
|
37
|
+
variant="outline"
|
|
38
|
+
:label="toast.action.label"
|
|
39
|
+
@click="select(toast)"
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<Button
|
|
43
|
+
size="xs"
|
|
44
|
+
variant="ghost"
|
|
45
|
+
:icon-prefix="CloseIcon"
|
|
46
|
+
:aria-label="labels.close"
|
|
47
|
+
@click="remove(toast.id)"
|
|
48
|
+
/>
|
|
49
|
+
</template>
|
|
50
|
+
</Notification>
|
|
51
|
+
</li>
|
|
52
|
+
</Stack>
|
|
53
|
+
</section>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<script setup lang="ts">
|
|
57
|
+
import CloseIcon from "~icons/hugeicons/multiplication-sign";
|
|
58
|
+
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from "vue";
|
|
59
|
+
import type { Toast, ToastRegionProps } from "../../components/toast";
|
|
60
|
+
import { useUiLabels } from "../utilities/labels";
|
|
61
|
+
import { useToast } from "../utilities/use-toast";
|
|
62
|
+
import Button from "./button.vue";
|
|
63
|
+
import Notification from "./notification.vue";
|
|
64
|
+
import Stack from "./stack.vue";
|
|
65
|
+
|
|
66
|
+
const { label, hotkey = "F8" } = defineProps<ToastRegionProps>();
|
|
67
|
+
|
|
68
|
+
const labels = useUiLabels();
|
|
69
|
+
|
|
70
|
+
const { toasts, remove } = useToast();
|
|
71
|
+
|
|
72
|
+
const region = useTemplateRef("region");
|
|
73
|
+
|
|
74
|
+
const list = useTemplateRef<InstanceType<typeof Stack>>("list");
|
|
75
|
+
|
|
76
|
+
const layered = ref(false);
|
|
77
|
+
|
|
78
|
+
const offsets = ref<Record<string, number>>({});
|
|
79
|
+
|
|
80
|
+
const timers = new Map<string, { timer?: ReturnType<typeof setTimeout>; remaining: number; started: number }>();
|
|
81
|
+
|
|
82
|
+
const visible = computed(() => toasts.value.length > 0);
|
|
83
|
+
|
|
84
|
+
let paused = false;
|
|
85
|
+
|
|
86
|
+
let swiping: { id: string; x: number } | undefined;
|
|
87
|
+
|
|
88
|
+
function schedule(id: string) {
|
|
89
|
+
const entry = timers.get(id);
|
|
90
|
+
|
|
91
|
+
if (!entry || paused) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clearTimeout(entry.timer);
|
|
96
|
+
entry.started = Date.now();
|
|
97
|
+
entry.timer = setTimeout(() => remove(id), entry.remaining);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pause() {
|
|
101
|
+
paused = true;
|
|
102
|
+
|
|
103
|
+
for (const entry of timers.values()) {
|
|
104
|
+
if (entry.timer) {
|
|
105
|
+
clearTimeout(entry.timer);
|
|
106
|
+
entry.timer = undefined;
|
|
107
|
+
entry.remaining -= Date.now() - entry.started;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resume(event: Event) {
|
|
113
|
+
const element: HTMLElement | undefined = list.value?.$el;
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
(event.type === "focusout" && element?.contains((event as FocusEvent).relatedTarget as Node)) ||
|
|
117
|
+
(event.type === "pointerleave" && element?.contains(document.activeElement))
|
|
118
|
+
) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
paused = false;
|
|
123
|
+
|
|
124
|
+
for (const id of timers.keys()) {
|
|
125
|
+
schedule(id);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function select(toast: Toast) {
|
|
130
|
+
toast.action?.onSelect();
|
|
131
|
+
remove(toast.id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function startSwipe(event: PointerEvent, id: string) {
|
|
135
|
+
if ((event.target as HTMLElement).closest("button, a")) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
swiping = { id, x: event.clientX };
|
|
140
|
+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function swipe(event: PointerEvent, id: string) {
|
|
144
|
+
if (swiping?.id === id) {
|
|
145
|
+
offsets.value = { ...offsets.value, [id]: event.clientX - swiping.x };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function endSwipe(id: string) {
|
|
150
|
+
if (swiping?.id !== id) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
swiping = undefined;
|
|
155
|
+
|
|
156
|
+
if (Math.abs(offsets.value[id] ?? 0) > 80) {
|
|
157
|
+
remove(id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const next: Record<string, number> = {};
|
|
161
|
+
|
|
162
|
+
for (const [key, value] of Object.entries(offsets.value)) {
|
|
163
|
+
if (key !== id) {
|
|
164
|
+
next[key] = value;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
offsets.value = next;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
172
|
+
if (event.key === hotkey && visible.value) {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
(list.value?.$el as HTMLElement | undefined)?.focus();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
watch(
|
|
179
|
+
toasts,
|
|
180
|
+
(value, previous) => {
|
|
181
|
+
for (const toast of value) {
|
|
182
|
+
if (!timers.has(toast.id) && toast.duration) {
|
|
183
|
+
timers.set(toast.id, { remaining: toast.duration, started: Date.now() });
|
|
184
|
+
schedule(toast.id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const [id, entry] of timers) {
|
|
189
|
+
let present = false;
|
|
190
|
+
|
|
191
|
+
for (const toast of value) {
|
|
192
|
+
if (toast.id === id) {
|
|
193
|
+
present = true;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!present) {
|
|
199
|
+
clearTimeout(entry.timer);
|
|
200
|
+
timers.delete(id);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const element = region.value;
|
|
205
|
+
|
|
206
|
+
if (layered.value && element) {
|
|
207
|
+
const shown = element.matches(":popover-open");
|
|
208
|
+
|
|
209
|
+
if (shown && (value.length === 0 || value.length > previous.length)) {
|
|
210
|
+
element.hidePopover();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (value.length > 0 && (!shown || value.length > previous.length)) {
|
|
214
|
+
element.showPopover();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
{ flush: "post" },
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
onMounted(() => {
|
|
222
|
+
layered.value = typeof HTMLElement.prototype.showPopover === "function";
|
|
223
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
onUnmounted(() => {
|
|
227
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
228
|
+
|
|
229
|
+
for (const entry of timers.values()) {
|
|
230
|
+
clearTimeout(entry.timer);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
</script>
|
|
@@ -1,41 +1,96 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Stack
|
|
2
|
+
<Stack
|
|
3
|
+
ref="group"
|
|
4
|
+
role="group"
|
|
5
|
+
:aria-label="label"
|
|
6
|
+
:aria-orientation="orientation"
|
|
7
|
+
:direction="orientation"
|
|
8
|
+
gap="1"
|
|
9
|
+
:wrap="wrap"
|
|
10
|
+
@keydown="handleKeyDown"
|
|
11
|
+
@focusin="handleFocusIn"
|
|
12
|
+
>
|
|
3
13
|
<Button
|
|
4
14
|
v-for="item in items"
|
|
5
15
|
:key="item.value"
|
|
6
16
|
font="medium"
|
|
7
17
|
:size="size"
|
|
8
|
-
:variant="item.value
|
|
18
|
+
:variant="isSelected(item.value) ? 'secondary' : 'ghost'"
|
|
9
19
|
:icon-prefix="item.iconPrefix"
|
|
10
20
|
:disabled="item.disabled"
|
|
11
|
-
:aria-pressed="item.value
|
|
21
|
+
:aria-pressed="isSelected(item.value)"
|
|
22
|
+
:data-state="isSelected(item.value) ? 'on' : 'off'"
|
|
12
23
|
@click="select(item.value)"
|
|
13
24
|
>
|
|
14
|
-
<slot name="item" :item="item" :selected="item.value
|
|
25
|
+
<slot name="item" :item="item" :selected="isSelected(item.value)">
|
|
15
26
|
<span v-if="item.label" class="truncate px-1">{{ item.label }}</span>
|
|
16
27
|
</slot>
|
|
17
28
|
</Button>
|
|
18
29
|
</Stack>
|
|
19
30
|
</template>
|
|
20
31
|
|
|
21
|
-
<script
|
|
22
|
-
|
|
32
|
+
<script
|
|
33
|
+
setup
|
|
34
|
+
lang="ts"
|
|
35
|
+
generic="Value extends ToggleGroupValue = ToggleGroupValue, Model extends Value | Array<Value> = Value"
|
|
36
|
+
>
|
|
37
|
+
import { computed, ref, useTemplateRef, watch } from "vue";
|
|
23
38
|
import type { ToggleGroupProps, ToggleGroupValue } from "../../components/toggle-group";
|
|
39
|
+
import { useRovingFocus } from "../utilities/use-roving-focus";
|
|
24
40
|
import Button from "./button.vue";
|
|
25
41
|
import Stack from "./stack.vue";
|
|
26
42
|
|
|
27
|
-
const {
|
|
43
|
+
const {
|
|
44
|
+
modelValue,
|
|
45
|
+
items,
|
|
46
|
+
label,
|
|
47
|
+
size = "md",
|
|
48
|
+
wrap = true,
|
|
49
|
+
multiple = false,
|
|
50
|
+
orientation = "horizontal",
|
|
51
|
+
} = defineProps<ToggleGroupProps<Value, Model>>();
|
|
28
52
|
|
|
29
53
|
const emit = defineEmits<{
|
|
30
|
-
"update:modelValue": [value:
|
|
54
|
+
"update:modelValue": [value: Model];
|
|
31
55
|
}>();
|
|
32
56
|
|
|
57
|
+
const group = useTemplateRef<InstanceType<typeof Stack>>("group");
|
|
58
|
+
|
|
59
|
+
const { handleKeyDown, handleFocusIn } = useRovingFocus(
|
|
60
|
+
computed(() => group.value?.$el ?? null),
|
|
61
|
+
() => ({ selector: "button", orientation }),
|
|
62
|
+
);
|
|
63
|
+
|
|
33
64
|
const selected = ref(modelValue);
|
|
34
65
|
|
|
66
|
+
function isSelected(value: Value) {
|
|
67
|
+
if (Array.isArray(selected.value)) {
|
|
68
|
+
return selected.value.includes(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return selected.value === value;
|
|
72
|
+
}
|
|
73
|
+
|
|
35
74
|
function select(value: Value) {
|
|
36
|
-
|
|
75
|
+
if (multiple) {
|
|
76
|
+
const values: Array<Value> = [];
|
|
77
|
+
|
|
78
|
+
for (const entry of Array.isArray(selected.value) ? selected.value : []) {
|
|
79
|
+
if (entry !== value) {
|
|
80
|
+
values.push(entry);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!isSelected(value)) {
|
|
85
|
+
values.push(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
selected.value = values as Model;
|
|
89
|
+
} else {
|
|
90
|
+
selected.value = value as Model;
|
|
91
|
+
}
|
|
37
92
|
|
|
38
|
-
emit("update:modelValue", value);
|
|
93
|
+
emit("update:modelValue", selected.value as Model);
|
|
39
94
|
}
|
|
40
95
|
|
|
41
96
|
watch(
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Stack
|
|
3
|
+
ref="toolbar"
|
|
4
|
+
role="toolbar"
|
|
5
|
+
:aria-label="label"
|
|
6
|
+
:aria-orientation="orientation"
|
|
7
|
+
:direction="orientation"
|
|
8
|
+
:gap="gap"
|
|
9
|
+
:padding="padding"
|
|
10
|
+
:align="align"
|
|
11
|
+
:justify="justify"
|
|
12
|
+
:wrap="wrap"
|
|
13
|
+
@keydown="handleKeyDown"
|
|
14
|
+
@focusin="handleFocusIn"
|
|
15
|
+
>
|
|
16
|
+
<slot />
|
|
17
|
+
</Stack>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import { computed, useTemplateRef } from "vue";
|
|
22
|
+
import type { ToolbarProps } from "../../components/toolbar";
|
|
23
|
+
import { useRovingFocus } from "../utilities/use-roving-focus";
|
|
24
|
+
import Stack from "./stack.vue";
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
label,
|
|
28
|
+
orientation = "horizontal",
|
|
29
|
+
gap = "1",
|
|
30
|
+
padding,
|
|
31
|
+
align = "center",
|
|
32
|
+
justify,
|
|
33
|
+
wrap = false,
|
|
34
|
+
loop = true,
|
|
35
|
+
} = defineProps<ToolbarProps>();
|
|
36
|
+
|
|
37
|
+
const toolbar = useTemplateRef<InstanceType<typeof Stack>>("toolbar");
|
|
38
|
+
|
|
39
|
+
const { handleKeyDown, handleFocusIn } = useRovingFocus(
|
|
40
|
+
computed(() => toolbar.value?.$el ?? null),
|
|
41
|
+
() => ({ orientation, loop }),
|
|
42
|
+
);
|
|
43
|
+
</script>
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<span
|
|
3
3
|
class="group/tooltip relative inline-flex max-w-full"
|
|
4
|
-
|
|
5
|
-
@
|
|
6
|
-
@
|
|
7
|
-
@
|
|
4
|
+
:style="anchorStyle"
|
|
5
|
+
@pointerenter="enter"
|
|
6
|
+
@pointerleave="leave"
|
|
7
|
+
@focusin="focus"
|
|
8
|
+
@focusout="blur"
|
|
8
9
|
>
|
|
9
10
|
<Trigger />
|
|
10
11
|
|
|
11
12
|
<span
|
|
12
13
|
v-if="!disabled"
|
|
13
14
|
:id="id"
|
|
15
|
+
ref="layer"
|
|
14
16
|
role="tooltip"
|
|
15
|
-
|
|
16
|
-
:
|
|
17
|
+
:popover="anchored ? 'manual' : undefined"
|
|
18
|
+
:style="anchored ? layerStyle : undefined"
|
|
19
|
+
:data-state="visible ? 'open' : 'closed'"
|
|
20
|
+
class="w-max max-w-xs p-1"
|
|
21
|
+
:class="
|
|
22
|
+
anchored
|
|
23
|
+
? `inset-auto m-0 overflow-visible border-0 bg-transparent [position-try-fallbacks:flip-block] ${areas[`${placement}-${align}`]}`
|
|
24
|
+
: [
|
|
25
|
+
'invisible absolute z-20 group-hover/tooltip:visible group-has-[:focus-visible]/tooltip:visible',
|
|
26
|
+
placements[placement],
|
|
27
|
+
aligns[align],
|
|
28
|
+
dismissed ? 'hidden' : '',
|
|
29
|
+
]
|
|
30
|
+
"
|
|
17
31
|
>
|
|
18
32
|
<span class="block rounded-ui bg-primary p-1 text-xs/4 text-primary-foreground shadow-xl">
|
|
19
33
|
<span class="block px-1">{{ label }}</span>
|
|
@@ -23,32 +37,97 @@
|
|
|
23
37
|
</template>
|
|
24
38
|
|
|
25
39
|
<script setup lang="ts">
|
|
26
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
cloneVNode,
|
|
42
|
+
Comment,
|
|
43
|
+
computed,
|
|
44
|
+
isVNode,
|
|
45
|
+
onUnmounted,
|
|
46
|
+
ref,
|
|
47
|
+
useId,
|
|
48
|
+
useSlots,
|
|
49
|
+
useTemplateRef,
|
|
50
|
+
watch,
|
|
51
|
+
type VNodeChild,
|
|
52
|
+
} from "vue";
|
|
27
53
|
import type { TooltipAlign, TooltipPlacement, TooltipProps } from "../../components/tooltip";
|
|
54
|
+
import { tooltipState } from "../utilities/tooltip";
|
|
55
|
+
import { useAnchor } from "../utilities/use-anchor";
|
|
28
56
|
|
|
29
|
-
const { label, placement = "top", align = "center", disabled = false } = defineProps<TooltipProps>();
|
|
57
|
+
const { label, placement = "top", align = "center", delay = 400, disabled = false } = defineProps<TooltipProps>();
|
|
30
58
|
|
|
31
59
|
const slots = useSlots();
|
|
32
60
|
|
|
33
61
|
const id = useId();
|
|
34
62
|
|
|
63
|
+
const layer = useTemplateRef("layer");
|
|
64
|
+
|
|
35
65
|
const dismissed = ref(false);
|
|
36
66
|
|
|
37
67
|
const hovered = ref(false);
|
|
38
68
|
|
|
39
69
|
const focused = ref(false);
|
|
40
70
|
|
|
71
|
+
const visible = computed(() => (hovered.value || focused.value) && !dismissed.value);
|
|
72
|
+
|
|
73
|
+
const { anchored, anchorStyle, layerStyle } = useAnchor(layer, visible);
|
|
74
|
+
|
|
41
75
|
const placements: Record<TooltipPlacement, string> = {
|
|
42
|
-
top: "bottom-full
|
|
43
|
-
bottom: "top-full
|
|
76
|
+
top: "bottom-full",
|
|
77
|
+
bottom: "top-full",
|
|
44
78
|
};
|
|
45
79
|
|
|
46
80
|
const aligns: Record<TooltipAlign, string> = {
|
|
47
|
-
start: "
|
|
81
|
+
start: "start-0",
|
|
48
82
|
center: "left-1/2 -translate-x-1/2",
|
|
49
|
-
end: "
|
|
83
|
+
end: "end-0",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const areas: Record<`${TooltipPlacement}-${TooltipAlign}`, string> = {
|
|
87
|
+
"top-start": "[position-area:block-start_span-inline-end]",
|
|
88
|
+
"top-center": "[position-area:block-start]",
|
|
89
|
+
"top-end": "[position-area:block-start_span-inline-start]",
|
|
90
|
+
"bottom-start": "[position-area:block-end_span-inline-end]",
|
|
91
|
+
"bottom-center": "[position-area:block-end]",
|
|
92
|
+
"bottom-end": "[position-area:block-end_span-inline-start]",
|
|
50
93
|
};
|
|
51
94
|
|
|
95
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
96
|
+
|
|
97
|
+
function enter(event: PointerEvent) {
|
|
98
|
+
if (event.pointerType === "touch" || hovered.value) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
|
|
104
|
+
if (Date.now() - tooltipState.closedAt < 300) {
|
|
105
|
+
hovered.value = true;
|
|
106
|
+
} else {
|
|
107
|
+
timer = setTimeout(() => {
|
|
108
|
+
hovered.value = true;
|
|
109
|
+
}, delay);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function leave() {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
|
|
116
|
+
if (hovered.value) {
|
|
117
|
+
tooltipState.closedAt = Date.now();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
hovered.value = false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function focus(event: FocusEvent) {
|
|
124
|
+
focused.value = (event.target as HTMLElement).matches(":focus-visible");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function blur() {
|
|
128
|
+
focused.value = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
52
131
|
function handleKeyDown(event: KeyboardEvent) {
|
|
53
132
|
if (event.key === "Escape") {
|
|
54
133
|
dismissed.value = true;
|
|
@@ -71,20 +150,27 @@ function Trigger() {
|
|
|
71
150
|
|
|
72
151
|
return nodes;
|
|
73
152
|
}
|
|
74
|
-
|
|
153
|
+
|
|
154
|
+
watch(visible, (value) => {
|
|
75
155
|
if (typeof document === "undefined") {
|
|
76
156
|
return;
|
|
77
157
|
}
|
|
78
158
|
|
|
79
|
-
if (
|
|
159
|
+
if (value) {
|
|
80
160
|
document.addEventListener("keydown", handleKeyDown);
|
|
81
161
|
} else {
|
|
82
|
-
dismissed.value = false;
|
|
83
162
|
document.removeEventListener("keydown", handleKeyDown);
|
|
84
163
|
}
|
|
85
164
|
});
|
|
86
165
|
|
|
166
|
+
watch([hovered, focused], ([hover, focus]) => {
|
|
167
|
+
if (!hover && !focus) {
|
|
168
|
+
dismissed.value = false;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
87
172
|
onUnmounted(() => {
|
|
173
|
+
clearTimeout(timer);
|
|
88
174
|
document.removeEventListener("keydown", handleKeyDown);
|
|
89
175
|
});
|
|
90
176
|
</script>
|