@m3ui-vue/m3ui-vue 0.3.1 → 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.
- package/dist/{MMenuItem-C6RwHl-l.js → MMenuItem-FyttjOj_.js} +399 -207
- package/dist/MMenuItem-FyttjOj_.js.map +1 -0
- package/dist/components/MAutocomplete.vue.d.ts +2 -0
- package/dist/components/MBox.vue.d.ts +2 -2
- package/dist/components/MButton.vue.d.ts +4 -0
- package/dist/components/MButtonGroup.vue.d.ts +28 -0
- package/dist/components/MCarousel.vue.d.ts +21 -12
- package/dist/components/MColorPickerModal.vue.d.ts +18 -0
- package/dist/components/MDataTable.vue.d.ts +1 -1
- package/dist/components/MDatePickerModal.vue.d.ts +21 -0
- package/dist/components/MDateRangePickerModal.vue.d.ts +24 -0
- package/dist/components/MDialog.vue.d.ts +9 -1
- package/dist/components/MExpansionPanel.vue.d.ts +3 -2
- package/dist/components/MFab.vue.d.ts +14 -2
- package/dist/components/MFlex.vue.d.ts +2 -2
- package/dist/components/MIconButton.vue.d.ts +4 -2
- package/dist/components/MInfiniteScroll.vue.d.ts +1 -1
- package/dist/components/MList.vue.d.ts +2 -0
- package/dist/components/MLoadingOverlay.vue.d.ts +1 -1
- package/dist/components/MMenu.vue.d.ts +3 -1
- package/dist/components/MMenuItem.vue.d.ts +14 -3
- package/dist/components/MMultiAutocomplete.vue.d.ts +2 -0
- package/dist/components/MMultiSelect.vue.d.ts +2 -0
- package/dist/components/MNavigationDrawer.vue.d.ts +1 -1
- package/dist/components/MNotificationHost.vue.d.ts +3 -0
- package/dist/components/MSelect.vue.d.ts +2 -0
- package/dist/components/MSlider.vue.d.ts +15 -4
- package/dist/components/MSplitButton.vue.d.ts +39 -0
- package/dist/components/MSplitter.vue.d.ts +1 -1
- package/dist/components/MStack.vue.d.ts +2 -2
- package/dist/components/MTable.vue.d.ts +1 -1
- package/dist/components/MTimePickerModal.vue.d.ts +21 -0
- package/dist/components/MToolbar.vue.d.ts +30 -0
- package/dist/components/MTooltip.vue.d.ts +8 -2
- package/dist/components/MWindow.vue.d.ts +2 -2
- package/dist/composables/useDevice.d.ts +3 -0
- package/dist/composables/useNotification.d.ts +64 -0
- package/dist/composables/useToast.d.ts +8 -0
- package/dist/index.d.ts +12 -0
- package/dist/m3ui-vue.css +1 -1
- package/dist/m3ui.js +5220 -2994
- package/dist/m3ui.js.map +1 -1
- package/dist/rich-text-editor.js +10 -10
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/MAutocomplete.vue +108 -13
- package/src/components/MButton.vue +38 -16
- package/src/components/MButtonGroup.vue +158 -0
- package/src/components/MCarousel.vue +223 -101
- package/src/components/MColorPicker.vue +1 -4
- package/src/components/MColorPickerModal.vue +276 -0
- package/src/components/MDatePicker.vue +1 -4
- package/src/components/MDatePickerModal.vue +265 -0
- package/src/components/MDateRangePicker.vue +1 -4
- package/src/components/MDateRangePickerModal.vue +264 -0
- package/src/components/MDialog.vue +38 -1
- package/src/components/MExpansionPanel.vue +10 -32
- package/src/components/MFab.vue +92 -11
- package/src/components/MIconButton.vue +36 -6
- package/src/components/MList.vue +6 -3
- package/src/components/MListItem.vue +9 -3
- package/src/components/MMenu.vue +10 -5
- package/src/components/MMenuItem.vue +93 -10
- package/src/components/MMultiAutocomplete.vue +104 -10
- package/src/components/MMultiSelect.vue +106 -13
- package/src/components/MNotificationHost.vue +163 -0
- package/src/components/MSelect.vue +99 -16
- package/src/components/MSlider.vue +367 -134
- package/src/components/MSnackbar.vue +3 -0
- package/src/components/MSplitButton.vue +240 -0
- package/src/components/MTabs.vue +82 -39
- package/src/components/MTagInput.vue +11 -1
- package/src/components/MTimePicker.vue +1 -4
- package/src/components/MTimePickerModal.vue +458 -0
- package/src/components/MToolbar.vue +75 -0
- package/src/components/MTooltip.vue +60 -10
- package/src/components/_MContextMenuPanel.vue +2 -2
- package/src/composables/useDevice.ts +29 -0
- package/src/composables/useNotification.ts +93 -0
- package/src/composables/useToast.ts +42 -5
- package/src/index.ts +12 -0
- 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
|
|
5
|
+
text?: string
|
|
6
6
|
placement?: 'top' | 'bottom' | 'left' | 'right'
|
|
7
7
|
delay?: number
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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(() =>
|
|
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>
|
|
@@ -66,11 +66,11 @@ function onPanelMouseLeave(e: MouseEvent) {
|
|
|
66
66
|
<template>
|
|
67
67
|
<div
|
|
68
68
|
ref="panel"
|
|
69
|
-
class="m3-ctx-panel absolute z-201 min-w-[200px] rounded-
|
|
69
|
+
class="m3-ctx-panel absolute z-201 min-w-[200px] overflow-hidden rounded-lg bg-surface-container shadow-elevation-2"
|
|
70
70
|
:style="{ left: `${panelX}px`, top: `${panelY}px` }"
|
|
71
71
|
@mouseleave="onPanelMouseLeave"
|
|
72
72
|
>
|
|
73
|
-
<div class="
|
|
73
|
+
<div class="py-1">
|
|
74
74
|
<template v-for="(item, i) in items" :key="i">
|
|
75
75
|
<hr v-if="item.divider" class="my-1 border-outline-variant" />
|
|
76
76
|
|