@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,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 role="group" :aria-label="label" direction="horizontal" gap="1" :wrap="wrap">
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 === selected ? 'secondary' : 'ghost'"
18
+ :variant="isSelected(item.value) ? 'secondary' : 'ghost'"
9
19
  :icon-prefix="item.iconPrefix"
10
20
  :disabled="item.disabled"
11
- :aria-pressed="item.value === selected"
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 === selected">
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 setup lang="ts" generic="Value extends ToggleGroupValue = ToggleGroupValue">
22
- import { ref, watch } from "vue";
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 { modelValue, items, label, size = "md", wrap = true } = defineProps<ToggleGroupProps<Value>>();
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: 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
- selected.value = value;
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
- @pointerenter="hovered = true"
5
- @pointerleave="hovered = false"
6
- @focusin="focused = true"
7
- @focusout="focused = false"
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
- class="invisible absolute z-20 w-max max-w-xs group-hover/tooltip:visible group-has-[:focus-visible]/tooltip:visible"
16
- :class="[placements[placement], aligns[align], dismissed ? 'hidden' : '']"
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 { cloneVNode, Comment, isVNode, onUnmounted, ref, useId, useSlots, watch, type VNodeChild } from "vue";
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 p-1",
43
- bottom: "top-full p-1",
76
+ top: "bottom-full",
77
+ bottom: "top-full",
44
78
  };
45
79
 
46
80
  const aligns: Record<TooltipAlign, string> = {
47
- start: "left-0",
81
+ start: "start-0",
48
82
  center: "left-1/2 -translate-x-1/2",
49
- end: "right-0",
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
- watch([hovered, focused], ([hover, focus]) => {
153
+
154
+ watch(visible, (value) => {
75
155
  if (typeof document === "undefined") {
76
156
  return;
77
157
  }
78
158
 
79
- if (hover || focus) {
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>