@m3ui-vue/m3ui-vue 0.3.0 → 0.4.0

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 (84) hide show
  1. package/dist/{MMenuItem-C6RwHl-l.js → MMenuItem-FyttjOj_.js} +399 -207
  2. package/dist/MMenuItem-FyttjOj_.js.map +1 -0
  3. package/dist/components/MAutocomplete.vue.d.ts +2 -0
  4. package/dist/components/MBox.vue.d.ts +2 -2
  5. package/dist/components/MButton.vue.d.ts +4 -0
  6. package/dist/components/MButtonGroup.vue.d.ts +28 -0
  7. package/dist/components/MCarousel.vue.d.ts +21 -12
  8. package/dist/components/MChatBubble.vue.d.ts +32 -0
  9. package/dist/components/MColorPickerModal.vue.d.ts +18 -0
  10. package/dist/components/MDataTable.vue.d.ts +1 -1
  11. package/dist/components/MDatePickerModal.vue.d.ts +21 -0
  12. package/dist/components/MDateRangePickerModal.vue.d.ts +24 -0
  13. package/dist/components/MDialog.vue.d.ts +9 -1
  14. package/dist/components/MExpansionPanel.vue.d.ts +3 -2
  15. package/dist/components/MFab.vue.d.ts +14 -2
  16. package/dist/components/MFlex.vue.d.ts +2 -2
  17. package/dist/components/MIconButton.vue.d.ts +4 -2
  18. package/dist/components/MInfiniteScroll.vue.d.ts +1 -1
  19. package/dist/components/MList.vue.d.ts +2 -0
  20. package/dist/components/MLoadingOverlay.vue.d.ts +1 -1
  21. package/dist/components/MMenu.vue.d.ts +3 -1
  22. package/dist/components/MMenuItem.vue.d.ts +14 -3
  23. package/dist/components/MMultiAutocomplete.vue.d.ts +2 -0
  24. package/dist/components/MMultiSelect.vue.d.ts +2 -0
  25. package/dist/components/MNotificationHost.vue.d.ts +3 -0
  26. package/dist/components/MSelect.vue.d.ts +2 -0
  27. package/dist/components/MSlider.vue.d.ts +15 -4
  28. package/dist/components/MSplitButton.vue.d.ts +39 -0
  29. package/dist/components/MSplitter.vue.d.ts +1 -1
  30. package/dist/components/MStack.vue.d.ts +2 -2
  31. package/dist/components/MTable.vue.d.ts +1 -1
  32. package/dist/components/MTimePickerModal.vue.d.ts +21 -0
  33. package/dist/components/MToolbar.vue.d.ts +30 -0
  34. package/dist/components/MTooltip.vue.d.ts +8 -2
  35. package/dist/components/MWindow.vue.d.ts +4 -2
  36. package/dist/composables/useDevice.d.ts +3 -0
  37. package/dist/composables/useNotification.d.ts +64 -0
  38. package/dist/composables/useToast.d.ts +8 -0
  39. package/dist/index.d.ts +13 -0
  40. package/dist/m3ui-vue.css +1 -1
  41. package/dist/m3ui.js +5231 -2916
  42. package/dist/m3ui.js.map +1 -1
  43. package/dist/rich-text-editor.js +10 -10
  44. package/dist/styles.css +1 -1
  45. package/package.json +1 -1
  46. package/src/components/MAutocomplete.vue +108 -13
  47. package/src/components/MButton.vue +38 -16
  48. package/src/components/MButtonGroup.vue +158 -0
  49. package/src/components/MCarousel.vue +223 -101
  50. package/src/components/MChatBubble.vue +98 -0
  51. package/src/components/MColorPicker.vue +1 -4
  52. package/src/components/MColorPickerModal.vue +276 -0
  53. package/src/components/MDatePicker.vue +1 -4
  54. package/src/components/MDatePickerModal.vue +265 -0
  55. package/src/components/MDateRangePicker.vue +1 -4
  56. package/src/components/MDateRangePickerModal.vue +264 -0
  57. package/src/components/MDialog.vue +38 -1
  58. package/src/components/MExpansionPanel.vue +10 -32
  59. package/src/components/MFab.vue +92 -11
  60. package/src/components/MIconButton.vue +36 -6
  61. package/src/components/MList.vue +6 -3
  62. package/src/components/MListItem.vue +9 -3
  63. package/src/components/MMenu.vue +10 -5
  64. package/src/components/MMenuItem.vue +93 -10
  65. package/src/components/MMultiAutocomplete.vue +104 -10
  66. package/src/components/MMultiSelect.vue +106 -13
  67. package/src/components/MNotificationHost.vue +163 -0
  68. package/src/components/MSelect.vue +99 -16
  69. package/src/components/MSlider.vue +367 -134
  70. package/src/components/MSnackbar.vue +3 -0
  71. package/src/components/MSplitButton.vue +240 -0
  72. package/src/components/MTabs.vue +82 -39
  73. package/src/components/MTagInput.vue +11 -1
  74. package/src/components/MTimePicker.vue +1 -4
  75. package/src/components/MTimePickerModal.vue +458 -0
  76. package/src/components/MToolbar.vue +75 -0
  77. package/src/components/MTooltip.vue +60 -10
  78. package/src/components/MWindow.vue +83 -48
  79. package/src/components/_MContextMenuPanel.vue +2 -2
  80. package/src/composables/useDevice.ts +29 -0
  81. package/src/composables/useNotification.ts +93 -0
  82. package/src/composables/useToast.ts +42 -5
  83. package/src/index.ts +13 -0
  84. package/dist/MMenuItem-C6RwHl-l.js.map +0 -1
@@ -0,0 +1,458 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import MButton from './MButton.vue'
4
+ import MIconButton from './MIconButton.vue'
5
+ import { useLocale } from '../composables/useLocale'
6
+
7
+ const props = withDefaults(defineProps<{
8
+ modelValue: string | null
9
+ show?: boolean
10
+ use24h?: boolean
11
+ minuteStep?: number
12
+ title?: string
13
+ }>(), {
14
+ modelValue: null,
15
+ show: false,
16
+ use24h: false,
17
+ minuteStep: 5,
18
+ })
19
+
20
+ const locale = useLocale()
21
+ const emit = defineEmits<{
22
+ 'update:modelValue': [string | null]
23
+ 'update:show': [boolean]
24
+ }>()
25
+
26
+ const step = ref<'hour' | 'minute'>('hour')
27
+ const inputMode = ref<'dial' | 'input'>('dial')
28
+ const period = ref<'AM' | 'PM'>('AM')
29
+ const selectedHour = ref(12)
30
+ const selectedMinute = ref(0)
31
+ const dialEl = ref<HTMLElement | null>(null)
32
+ const inputHourEl = ref<HTMLInputElement | null>(null)
33
+ const inputMinuteEl = ref<HTMLInputElement | null>(null)
34
+
35
+ function pad(n: number) { return String(n).padStart(2, '0') }
36
+
37
+ function parseTime(t: string | null | undefined) {
38
+ if (!t) return { h: 12, m: 0 }
39
+ const [hh, mm] = t.split(':').map(Number)
40
+ return { h: hh ?? 12, m: mm ?? 0 }
41
+ }
42
+
43
+ watch(() => props.show, (open) => {
44
+ if (open) {
45
+ const { h, m } = parseTime(props.modelValue)
46
+ if (props.use24h) {
47
+ selectedHour.value = h
48
+ } else {
49
+ period.value = h >= 12 ? 'PM' : 'AM'
50
+ selectedHour.value = h % 12 || 12
51
+ }
52
+ selectedMinute.value = m
53
+ step.value = 'hour'
54
+ inputMode.value = 'dial'
55
+ document.addEventListener('keydown', onKeydown)
56
+ document.body.style.overflow = 'hidden'
57
+ } else {
58
+ document.removeEventListener('keydown', onKeydown)
59
+ document.body.style.overflow = ''
60
+ }
61
+ })
62
+
63
+ // ── Dial geometry ──────────────────────────────────────────────────
64
+ const SIZE = 256
65
+ const CX = SIZE / 2
66
+ const OUTER_R = 100
67
+ const INNER_R = 64
68
+ const NUM_R = 20
69
+
70
+ function posAt(idx: number, of: number, r: number) {
71
+ const a = (idx / of) * Math.PI * 2 - Math.PI / 2
72
+ return { x: CX + Math.cos(a) * r, y: CX + Math.sin(a) * r }
73
+ }
74
+
75
+ const hourItems = computed(() => {
76
+ const items: { value: number; label: string; x: number; y: number }[] = []
77
+ for (let i = 0; i < 12; i++) {
78
+ const h = i === 0 ? 12 : i
79
+ const p = posAt(i, 12, OUTER_R)
80
+ items.push({ value: h, label: String(h), ...p })
81
+ }
82
+ if (props.use24h) {
83
+ for (let i = 0; i < 12; i++) {
84
+ const h = i === 0 ? 0 : i + 12
85
+ const p = posAt(i, 12, INNER_R)
86
+ items.push({ value: h, label: pad(h), ...p })
87
+ }
88
+ }
89
+ return items
90
+ })
91
+
92
+ const minuteItems = computed(() =>
93
+ Array.from({ length: 12 }, (_, i) => {
94
+ const m = i * 5
95
+ const p = posAt(i, 12, OUTER_R)
96
+ return { value: m, label: pad(m), ...p }
97
+ }),
98
+ )
99
+
100
+ const handEnd = computed(() => {
101
+ if (step.value === 'hour') {
102
+ const h = selectedHour.value
103
+ const isInner = props.use24h && (h === 0 || h > 12)
104
+ const r = isInner ? INNER_R : OUTER_R
105
+ const a = ((h % 12) / 12) * Math.PI * 2 - Math.PI / 2
106
+ return { x: CX + Math.cos(a) * r, y: CX + Math.sin(a) * r }
107
+ }
108
+ const m = selectedMinute.value
109
+ const a = (m / 60) * Math.PI * 2 - Math.PI / 2
110
+ return { x: CX + Math.cos(a) * OUTER_R, y: CX + Math.sin(a) * OUTER_R }
111
+ })
112
+
113
+ // ── Dial pointer interaction ───────────────────────────────────────
114
+ function getDialAngle(e: MouseEvent | Touch) {
115
+ if (!dialEl.value) return null
116
+ const rect = dialEl.value.getBoundingClientRect()
117
+ const x = e.clientX - rect.left - rect.width / 2
118
+ const y = e.clientY - rect.top - rect.height / 2
119
+ let deg = Math.atan2(x, -y) * (180 / Math.PI)
120
+ if (deg < 0) deg += 360
121
+ const dist = Math.sqrt(x * x + y * y) / (rect.width / 2)
122
+ return { deg, dist }
123
+ }
124
+
125
+ function applyAngle(deg: number, dist: number) {
126
+ if (step.value === 'hour') {
127
+ let h = Math.round(deg / 30) % 12
128
+ if (props.use24h) {
129
+ const isInner = dist < 0.6
130
+ h = isInner ? (h === 0 ? 0 : h + 12) : (h === 0 ? 12 : h)
131
+ } else {
132
+ h = h === 0 ? 12 : h
133
+ }
134
+ selectedHour.value = h
135
+ } else {
136
+ let m = Math.round(deg / 6) % 60
137
+ m = Math.round(m / props.minuteStep) * props.minuteStep
138
+ if (m >= 60) m = 0
139
+ selectedMinute.value = m
140
+ }
141
+ }
142
+
143
+ function onDialDown(e: MouseEvent) {
144
+ e.preventDefault()
145
+ const info = getDialAngle(e)
146
+ if (!info) return
147
+ applyAngle(info.deg, info.dist)
148
+
149
+ const onMove = (ev: MouseEvent) => {
150
+ const i = getDialAngle(ev)
151
+ if (i) applyAngle(i.deg, i.dist)
152
+ }
153
+ const onUp = () => {
154
+ document.removeEventListener('mousemove', onMove)
155
+ document.removeEventListener('mouseup', onUp)
156
+ if (step.value === 'hour') step.value = 'minute'
157
+ }
158
+ document.addEventListener('mousemove', onMove)
159
+ document.addEventListener('mouseup', onUp)
160
+ }
161
+
162
+ function onDialTouch(e: TouchEvent) {
163
+ const touch = e.touches[0]
164
+ if (!touch) return
165
+ const info = getDialAngle(touch)
166
+ if (!info) return
167
+ applyAngle(info.deg, info.dist)
168
+
169
+ const svg = e.currentTarget as Element
170
+ const onMove = (ev: TouchEvent) => {
171
+ const t = ev.touches[0]
172
+ if (!t) return
173
+ const i = getDialAngle(t)
174
+ if (i) applyAngle(i.deg, i.dist)
175
+ }
176
+ const onEnd = () => {
177
+ svg.removeEventListener('touchmove', onMove as EventListener)
178
+ svg.removeEventListener('touchend', onEnd)
179
+ if (step.value === 'hour') step.value = 'minute'
180
+ }
181
+ svg.addEventListener('touchmove', onMove as EventListener, { passive: true })
182
+ svg.addEventListener('touchend', onEnd)
183
+ }
184
+
185
+ function selectHour(h: number) {
186
+ selectedHour.value = h
187
+ step.value = 'minute'
188
+ }
189
+
190
+ // ── Input mode ─────────────────────────────────────────────────────
191
+ const inputHour = ref('')
192
+ const inputMinute = ref('')
193
+
194
+ watch(inputMode, (mode) => {
195
+ if (mode === 'input') {
196
+ inputHour.value = pad(selectedHour.value)
197
+ inputMinute.value = pad(selectedMinute.value)
198
+ }
199
+ })
200
+
201
+ function clampInput() {
202
+ let h = parseInt(inputHour.value) || 0
203
+ let m = parseInt(inputMinute.value) || 0
204
+ if (props.use24h) {
205
+ h = Math.max(0, Math.min(23, h))
206
+ } else {
207
+ h = Math.max(1, Math.min(12, h))
208
+ }
209
+ m = Math.max(0, Math.min(59, m))
210
+ m = Math.round(m / props.minuteStep) * props.minuteStep
211
+ if (m >= 60) m = 0
212
+ selectedHour.value = h
213
+ selectedMinute.value = m
214
+ inputHour.value = pad(h)
215
+ inputMinute.value = pad(m)
216
+ }
217
+
218
+ // ── Actions ────────────────────────────────────────────────────────
219
+ function cancel() { emit('update:show', false) }
220
+
221
+ function confirm() {
222
+ if (inputMode.value === 'input') clampInput()
223
+ let h = selectedHour.value
224
+ if (!props.use24h) {
225
+ if (period.value === 'AM') h = h === 12 ? 0 : h
226
+ else h = h === 12 ? 12 : h + 12
227
+ }
228
+ emit('update:modelValue', `${pad(h)}:${pad(selectedMinute.value)}`)
229
+ emit('update:show', false)
230
+ }
231
+
232
+ function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') cancel() }
233
+ </script>
234
+
235
+ <template>
236
+ <Teleport to="body">
237
+ <Transition name="m3-tp-modal">
238
+ <div
239
+ v-if="show"
240
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
241
+ @click.self="cancel"
242
+ >
243
+ <div class="tp-box w-[328px] rounded-[28px] bg-surface-container-high px-6 pt-6 pb-4 shadow-elevation-3">
244
+ <!-- Title -->
245
+ <p class="mb-5 text-label-medium text-on-surface-variant">
246
+ {{ title || locale.selectTime }}
247
+ </p>
248
+
249
+ <!-- ── Dial mode ───────────────────────────────────── -->
250
+ <template v-if="inputMode === 'dial'">
251
+ <!-- Time display + AM/PM -->
252
+ <div class="mb-5 flex items-center justify-center gap-2">
253
+ <button
254
+ type="button"
255
+ class="flex h-[72px] min-w-[96px] items-center justify-center rounded-lg text-display-medium transition-colors"
256
+ :class="
257
+ step === 'hour'
258
+ ? 'bg-primary-container text-on-primary-container'
259
+ : 'bg-surface-container-highest text-on-surface cursor-pointer hover:bg-on-surface/8'
260
+ "
261
+ @click="step = 'hour'"
262
+ >
263
+ {{ pad(selectedHour) }}
264
+ </button>
265
+
266
+ <span class="text-display-medium text-on-surface">:</span>
267
+
268
+ <button
269
+ type="button"
270
+ class="flex h-[72px] min-w-[96px] items-center justify-center rounded-lg text-display-medium transition-colors"
271
+ :class="
272
+ step === 'minute'
273
+ ? 'bg-primary-container text-on-primary-container'
274
+ : 'bg-surface-container-highest text-on-surface cursor-pointer hover:bg-on-surface/8'
275
+ "
276
+ @click="step = 'minute'"
277
+ >
278
+ {{ pad(selectedMinute) }}
279
+ </button>
280
+
281
+ <!-- AM / PM toggle -->
282
+ <div
283
+ v-if="!use24h"
284
+ class="ml-2 flex h-[72px] flex-col overflow-hidden rounded-lg border border-outline"
285
+ >
286
+ <button
287
+ type="button"
288
+ class="flex flex-1 items-center justify-center px-3 text-label-large transition-colors"
289
+ :class="
290
+ period === 'AM'
291
+ ? 'bg-tertiary-container text-on-tertiary-container'
292
+ : 'text-on-surface-variant hover:bg-on-surface/8'
293
+ "
294
+ @click="period = 'AM'"
295
+ >AM</button>
296
+ <div class="h-px bg-outline" />
297
+ <button
298
+ type="button"
299
+ class="flex flex-1 items-center justify-center px-3 text-label-large transition-colors"
300
+ :class="
301
+ period === 'PM'
302
+ ? 'bg-tertiary-container text-on-tertiary-container'
303
+ : 'text-on-surface-variant hover:bg-on-surface/8'
304
+ "
305
+ @click="period = 'PM'"
306
+ >PM</button>
307
+ </div>
308
+ </div>
309
+
310
+ <!-- Clock dial -->
311
+ <div
312
+ ref="dialEl"
313
+ class="relative mx-auto h-[256px] w-[256px] select-none"
314
+ @mousedown="onDialDown"
315
+ @touchstart.prevent="onDialTouch"
316
+ >
317
+ <div class="absolute inset-0 rounded-full bg-surface-container-highest" />
318
+
319
+ <svg class="pointer-events-none absolute inset-0" viewBox="0 0 256 256">
320
+ <line
321
+ :x1="CX" :y1="CX"
322
+ :x2="handEnd.x" :y2="handEnd.y"
323
+ stroke="var(--color-primary)" stroke-width="2"
324
+ />
325
+ <circle :cx="CX" :cy="CX" r="4" fill="var(--color-primary)" />
326
+ <circle :cx="handEnd.x" :cy="handEnd.y" :r="NUM_R" fill="var(--color-primary)" />
327
+ </svg>
328
+
329
+ <template v-if="step === 'hour'">
330
+ <button
331
+ v-for="item in hourItems"
332
+ :key="'h' + item.value"
333
+ type="button"
334
+ class="pointer-events-auto absolute flex h-10 w-10 items-center justify-center rounded-full text-body-medium transition-colors"
335
+ :class="
336
+ item.value === selectedHour
337
+ ? 'text-on-primary font-medium'
338
+ : 'text-on-surface cursor-pointer'
339
+ "
340
+ :style="{
341
+ left: `${item.x}px`,
342
+ top: `${item.y}px`,
343
+ transform: 'translate(-50%, -50%)',
344
+ }"
345
+ @click.stop="selectHour(item.value)"
346
+ >
347
+ {{ item.label }}
348
+ </button>
349
+ </template>
350
+
351
+ <template v-else>
352
+ <button
353
+ v-for="item in minuteItems"
354
+ :key="'m' + item.value"
355
+ type="button"
356
+ class="pointer-events-auto absolute flex h-10 w-10 items-center justify-center rounded-full text-body-medium transition-colors"
357
+ :class="
358
+ item.value === selectedMinute
359
+ ? 'text-on-primary font-medium'
360
+ : 'text-on-surface cursor-pointer'
361
+ "
362
+ :style="{
363
+ left: `${item.x}px`,
364
+ top: `${item.y}px`,
365
+ transform: 'translate(-50%, -50%)',
366
+ }"
367
+ @click.stop="selectedMinute = item.value"
368
+ >
369
+ {{ item.label }}
370
+ </button>
371
+ </template>
372
+ </div>
373
+ </template>
374
+
375
+ <!-- ── Input mode ──────────────────────────────────── -->
376
+ <div v-else class="mx-auto flex items-center justify-center gap-2 py-8">
377
+ <input
378
+ ref="inputHourEl"
379
+ v-model="inputHour"
380
+ type="text"
381
+ maxlength="2"
382
+ class="h-[72px] w-[96px] rounded-lg border border-outline bg-surface-container-highest text-center text-display-medium text-on-surface outline-none transition-colors focus:border-2 focus:border-primary"
383
+ @blur="clampInput"
384
+ />
385
+ <span class="text-display-medium text-on-surface">:</span>
386
+ <input
387
+ ref="inputMinuteEl"
388
+ v-model="inputMinute"
389
+ type="text"
390
+ maxlength="2"
391
+ class="h-[72px] w-[96px] rounded-lg border border-outline bg-surface-container-highest text-center text-display-medium text-on-surface outline-none transition-colors focus:border-2 focus:border-primary"
392
+ @blur="clampInput"
393
+ />
394
+ <!-- AM / PM toggle -->
395
+ <div
396
+ v-if="!use24h"
397
+ class="ml-2 flex h-[72px] flex-col overflow-hidden rounded-lg border border-outline"
398
+ >
399
+ <button
400
+ type="button"
401
+ class="flex flex-1 items-center justify-center px-3 text-label-large transition-colors"
402
+ :class="
403
+ period === 'AM'
404
+ ? 'bg-tertiary-container text-on-tertiary-container'
405
+ : 'text-on-surface-variant hover:bg-on-surface/8'
406
+ "
407
+ @click="period = 'AM'"
408
+ >AM</button>
409
+ <div class="h-px bg-outline" />
410
+ <button
411
+ type="button"
412
+ class="flex flex-1 items-center justify-center px-3 text-label-large transition-colors"
413
+ :class="
414
+ period === 'PM'
415
+ ? 'bg-tertiary-container text-on-tertiary-container'
416
+ : 'text-on-surface-variant hover:bg-on-surface/8'
417
+ "
418
+ @click="period = 'PM'"
419
+ >PM</button>
420
+ </div>
421
+ </div>
422
+
423
+ <!-- Footer -->
424
+ <div class="mt-2 flex items-center justify-between">
425
+ <MIconButton
426
+ :icon="inputMode === 'dial' ? 'keyboard' : 'schedule'"
427
+ :label="inputMode === 'dial' ? 'Switch to keyboard' : 'Switch to dial'"
428
+ @click="inputMode = inputMode === 'dial' ? 'input' : 'dial'"
429
+ />
430
+ <div class="flex gap-2">
431
+ <MButton variant="text" @click="cancel">{{ locale.cancel }}</MButton>
432
+ <MButton variant="text" @click="confirm">OK</MButton>
433
+ </div>
434
+ </div>
435
+ </div>
436
+ </div>
437
+ </Transition>
438
+ </Teleport>
439
+ </template>
440
+
441
+ <style scoped>
442
+ .m3-tp-modal-enter-active,
443
+ .m3-tp-modal-leave-active {
444
+ transition: opacity 0.15s ease;
445
+ }
446
+ .m3-tp-modal-enter-from,
447
+ .m3-tp-modal-leave-to {
448
+ opacity: 0;
449
+ }
450
+ .m3-tp-modal-enter-active .tp-box,
451
+ .m3-tp-modal-leave-active .tp-box {
452
+ transition: transform 0.15s ease;
453
+ }
454
+ .m3-tp-modal-enter-from .tp-box,
455
+ .m3-tp-modal-leave-to .tp-box {
456
+ transform: scale(0.95);
457
+ }
458
+ </style>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ variant?: 'docked' | 'floating'
6
+ orientation?: 'horizontal' | 'vertical'
7
+ color?: 'surface' | 'primary' | 'secondary' | 'tertiary'
8
+ fabPosition?: 'start' | 'end' | 'none'
9
+ align?: 'start' | 'center' | 'end' | 'between'
10
+ }>(), {
11
+ variant: 'docked',
12
+ orientation: 'horizontal',
13
+ color: 'surface',
14
+ fabPosition: 'none',
15
+ align: 'center',
16
+ })
17
+
18
+ const colorClasses = computed(() => {
19
+ const map: Record<string, string> = {
20
+ surface: 'bg-surface-container text-on-surface',
21
+ primary: 'bg-primary-container text-on-primary-container',
22
+ secondary: 'bg-secondary-container text-on-secondary-container',
23
+ tertiary: 'bg-tertiary-container text-on-tertiary-container',
24
+ }
25
+ return map[props.color] ?? map.surface
26
+ })
27
+
28
+ const alignClass = computed(() => {
29
+ const map: Record<string, string> = {
30
+ start: 'justify-start',
31
+ center: 'justify-center',
32
+ end: 'justify-end',
33
+ between: 'justify-between',
34
+ }
35
+ return map[props.align] ?? 'justify-center'
36
+ })
37
+
38
+ const isVertical = computed(() => props.orientation === 'vertical')
39
+ </script>
40
+
41
+ <template>
42
+ <div
43
+ class="flex items-center gap-2"
44
+ :class="[
45
+ isVertical ? 'flex-col' : 'flex-row',
46
+ fabPosition === 'start' && !isVertical ? 'flex-row' : '',
47
+ fabPosition === 'start' && isVertical ? 'flex-col' : '',
48
+ ]"
49
+ >
50
+ <!-- FAB start -->
51
+ <div v-if="$slots.fab && fabPosition === 'start'" class="shrink-0">
52
+ <slot name="fab" />
53
+ </div>
54
+
55
+ <!-- Toolbar body -->
56
+ <div
57
+ class="flex items-center gap-1"
58
+ :class="[
59
+ isVertical ? 'flex-col py-3 px-2' : 'flex-row px-3 py-2',
60
+ alignClass,
61
+ colorClasses,
62
+ variant === 'floating' ? 'rounded-2xl shadow-elevation-2' : '',
63
+ variant === 'docked' ? 'w-full border-t border-outline-variant' : '',
64
+ variant === 'docked' && isVertical ? 'h-full w-auto border-t-0 border-r border-outline-variant' : '',
65
+ ]"
66
+ >
67
+ <slot />
68
+ </div>
69
+
70
+ <!-- FAB end -->
71
+ <div v-if="$slots.fab && fabPosition === 'end'" class="shrink-0">
72
+ <slot name="fab" />
73
+ </div>
74
+ </div>
75
+ </template>
@@ -2,20 +2,27 @@
2
2
  import { nextTick, onMounted, onUnmounted, ref } from 'vue'
3
3
 
4
4
  const props = withDefaults(defineProps<{
5
- text: string
5
+ text?: string
6
6
  placement?: 'top' | 'bottom' | 'left' | 'right'
7
7
  delay?: number
8
- }>(), { placement: 'top', delay: 600 })
8
+ rich?: boolean
9
+ }>(), { placement: 'top', delay: 600, rich: false })
9
10
 
10
11
  const visible = ref(false)
11
12
  const tipEl = ref<HTMLElement>()
12
13
  const triggerEl = ref<HTMLElement>()
13
14
  const tipStyle = ref<Record<string, string>>({})
14
- let timer: ReturnType<typeof setTimeout> | null = null
15
+ let showTimer: ReturnType<typeof setTimeout> | null = null
16
+ let hideTimer: ReturnType<typeof setTimeout> | null = null
17
+
18
+ function cancelTimers() {
19
+ if (showTimer) { clearTimeout(showTimer); showTimer = null }
20
+ if (hideTimer) { clearTimeout(hideTimer); hideTimer = null }
21
+ }
15
22
 
16
23
  async function show() {
17
- if (timer) clearTimeout(timer)
18
- timer = setTimeout(async () => {
24
+ cancelTimers()
25
+ showTimer = setTimeout(async () => {
19
26
  visible.value = true
20
27
  await nextTick()
21
28
  reposition()
@@ -23,19 +30,37 @@ async function show() {
23
30
  }
24
31
 
25
32
  function hide() {
26
- if (timer) { clearTimeout(timer); timer = null }
33
+ cancelTimers()
34
+ if (props.rich) {
35
+ hideTimer = setTimeout(() => { visible.value = false }, 120)
36
+ } else {
37
+ visible.value = false
38
+ }
39
+ }
40
+
41
+ function onTipEnter() {
42
+ if (!props.rich) return
43
+ cancelTimers()
44
+ }
45
+
46
+ function onTipLeave() {
47
+ if (!props.rich) return
48
+ cancelTimers()
27
49
  visible.value = false
28
50
  }
29
51
 
30
52
  function onScroll() {
31
- if (visible.value) hide()
53
+ if (visible.value) {
54
+ cancelTimers()
55
+ visible.value = false
56
+ }
32
57
  }
33
58
 
34
59
  function reposition() {
35
60
  if (!triggerEl.value || !tipEl.value) return
36
61
  const tr = triggerEl.value.getBoundingClientRect()
37
62
  const tt = tipEl.value.getBoundingClientRect()
38
- const GAP = 6
63
+ const GAP = 8
39
64
 
40
65
  let top = 0, left = 0
41
66
  switch (props.placement) {
@@ -51,7 +76,10 @@ function reposition() {
51
76
  }
52
77
 
53
78
  onMounted(() => window.addEventListener('scroll', onScroll, true))
54
- onUnmounted(() => window.removeEventListener('scroll', onScroll, true))
79
+ onUnmounted(() => {
80
+ window.removeEventListener('scroll', onScroll, true)
81
+ cancelTimers()
82
+ })
55
83
  </script>
56
84
 
57
85
  <template>
@@ -75,8 +103,9 @@ onUnmounted(() => window.removeEventListener('scroll', onScroll, true))
75
103
  leave-from-class="opacity-100"
76
104
  leave-to-class="opacity-0"
77
105
  >
106
+ <!-- Plain tooltip -->
78
107
  <div
79
- v-if="visible && text"
108
+ v-if="visible && !rich && text"
80
109
  ref="tipEl"
81
110
  class="pointer-events-none fixed z-400 max-w-[220px] rounded bg-inverse-surface px-3 py-1.5 text-label-medium text-inverse-on-surface shadow-elevation-2"
82
111
  :style="tipStyle"
@@ -84,6 +113,27 @@ onUnmounted(() => window.removeEventListener('scroll', onScroll, true))
84
113
  >
85
114
  {{ text }}
86
115
  </div>
116
+
117
+ <!-- Rich tooltip -->
118
+ <div
119
+ v-else-if="visible && rich"
120
+ ref="tipEl"
121
+ class="fixed z-400 max-w-[320px] rounded-xl bg-surface-container shadow-elevation-3 ring-1 ring-outline-variant"
122
+ :style="tipStyle"
123
+ role="tooltip"
124
+ @mouseenter="onTipEnter"
125
+ @mouseleave="onTipLeave"
126
+ >
127
+ <div v-if="text" class="px-4 pt-3 pb-1 text-title-small font-medium text-on-surface">
128
+ {{ text }}
129
+ </div>
130
+ <div class="px-4 py-2 text-body-medium text-on-surface-variant">
131
+ <slot name="content" />
132
+ </div>
133
+ <div v-if="$slots.actions" class="flex justify-end gap-2 px-4 pb-3">
134
+ <slot name="actions" />
135
+ </div>
136
+ </div>
87
137
  </Transition>
88
138
  </Teleport>
89
139
  </template>