@m3ui-vue/m3ui-vue 0.3.1 → 0.4.1

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/README.md +219 -59
  2. package/dist/{MMenuItem-C6RwHl-l.js → MMenuItem-FyttjOj_.js} +399 -207
  3. package/dist/MMenuItem-FyttjOj_.js.map +1 -0
  4. package/dist/components/MAutocomplete.vue.d.ts +2 -0
  5. package/dist/components/MBox.vue.d.ts +2 -2
  6. package/dist/components/MButton.vue.d.ts +4 -0
  7. package/dist/components/MButtonGroup.vue.d.ts +28 -0
  8. package/dist/components/MCarousel.vue.d.ts +21 -12
  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/MNavigationDrawer.vue.d.ts +1 -1
  26. package/dist/components/MNotificationHost.vue.d.ts +3 -0
  27. package/dist/components/MSelect.vue.d.ts +2 -0
  28. package/dist/components/MSlider.vue.d.ts +15 -4
  29. package/dist/components/MSplitButton.vue.d.ts +39 -0
  30. package/dist/components/MSplitter.vue.d.ts +1 -1
  31. package/dist/components/MStack.vue.d.ts +2 -2
  32. package/dist/components/MTable.vue.d.ts +1 -1
  33. package/dist/components/MTimePickerModal.vue.d.ts +21 -0
  34. package/dist/components/MToolbar.vue.d.ts +30 -0
  35. package/dist/components/MTooltip.vue.d.ts +8 -2
  36. package/dist/components/MWindow.vue.d.ts +2 -2
  37. package/dist/composables/useDevice.d.ts +3 -0
  38. package/dist/composables/useNotification.d.ts +64 -0
  39. package/dist/composables/useToast.d.ts +8 -0
  40. package/dist/index.d.ts +12 -0
  41. package/dist/m3ui-vue.css +1 -1
  42. package/dist/m3ui.js +5222 -2994
  43. package/dist/m3ui.js.map +1 -1
  44. package/dist/rich-text-editor.js +10 -10
  45. package/dist/styles.css +1 -1
  46. package/package.json +1 -1
  47. package/src/components/MAutocomplete.vue +109 -13
  48. package/src/components/MButton.vue +38 -16
  49. package/src/components/MButtonGroup.vue +158 -0
  50. package/src/components/MCarousel.vue +223 -101
  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 +105 -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/_MContextMenuPanel.vue +2 -2
  79. package/src/composables/useDevice.ts +29 -0
  80. package/src/composables/useNotification.ts +93 -0
  81. package/src/composables/useToast.ts +42 -5
  82. package/src/index.ts +12 -0
  83. package/dist/MMenuItem-C6RwHl-l.js.map +0 -1
@@ -0,0 +1,276 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import MButton from './MButton.vue'
4
+ import MIcon from './MIcon.vue'
5
+ import { useLocale } from '../composables/useLocale'
6
+
7
+ const props = withDefaults(defineProps<{
8
+ modelValue: string
9
+ show?: boolean
10
+ presets?: string[]
11
+ title?: string
12
+ }>(), {
13
+ show: false,
14
+ presets: () => [
15
+ '#f44336', '#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3',
16
+ '#03a9f4', '#00bcd4', '#009688', '#4caf50', '#8bc34a', '#cddc39',
17
+ '#ffeb3b', '#ffc107', '#ff9800', '#ff5722', '#795548', '#607d8b',
18
+ ],
19
+ })
20
+
21
+ const locale = useLocale()
22
+ const emit = defineEmits<{
23
+ 'update:modelValue': [string]
24
+ 'update:show': [boolean]
25
+ }>()
26
+
27
+ const satBrightEl = ref<HTMLElement>()
28
+ const draggingSB = ref(false)
29
+ const hue = ref(0)
30
+ const sat = ref(100)
31
+ const bright = ref(100)
32
+ const hexInput = ref('')
33
+
34
+ function hexToHsv(hex: string) {
35
+ let c = hex.replace('#', '')
36
+ if (c.length === 3) c = c[0]! + c[0]! + c[1]! + c[1]! + c[2]! + c[2]!
37
+ const r = parseInt(c.substring(0, 2), 16) / 255
38
+ const g = parseInt(c.substring(2, 4), 16) / 255
39
+ const b = parseInt(c.substring(4, 6), 16) / 255
40
+ const max = Math.max(r, g, b), min = Math.min(r, g, b)
41
+ const d = max - min
42
+ let h = 0
43
+ if (d !== 0) {
44
+ if (max === r) h = ((g - b) / d + 6) % 6
45
+ else if (max === g) h = (b - r) / d + 2
46
+ else h = (r - g) / d + 4
47
+ h *= 60
48
+ }
49
+ return { h, s: max === 0 ? 0 : (d / max) * 100, v: max * 100 }
50
+ }
51
+
52
+ function hsvToHex(h: number, s: number, v: number) {
53
+ s /= 100; v /= 100
54
+ const c = v * s
55
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1))
56
+ const m = v - c
57
+ let r = 0, g = 0, b = 0
58
+ if (h < 60) { r = c; g = x }
59
+ else if (h < 120) { r = x; g = c }
60
+ else if (h < 180) { g = c; b = x }
61
+ else if (h < 240) { g = x; b = c }
62
+ else if (h < 300) { r = x; b = c }
63
+ else { r = c; b = x }
64
+ const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0')
65
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
66
+ }
67
+
68
+ const currentHex = computed(() => hsvToHex(hue.value, sat.value, bright.value))
69
+ const hueColor = computed(() => `hsl(${hue.value}, 100%, 50%)`)
70
+
71
+ watch(() => props.show, (open) => {
72
+ if (open) {
73
+ const hsv = hexToHsv(props.modelValue)
74
+ hue.value = hsv.h
75
+ sat.value = hsv.s
76
+ bright.value = hsv.v
77
+ hexInput.value = props.modelValue
78
+ document.addEventListener('keydown', onKeydown)
79
+ document.body.style.overflow = 'hidden'
80
+ } else {
81
+ document.removeEventListener('keydown', onKeydown)
82
+ document.body.style.overflow = ''
83
+ }
84
+ })
85
+
86
+ function syncHex() {
87
+ hexInput.value = currentHex.value
88
+ }
89
+
90
+ // ── Saturation / Brightness ────────────────────────────────────────
91
+ function onSBPointerDown(e: PointerEvent) {
92
+ draggingSB.value = true
93
+ updateSB(e)
94
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
95
+ }
96
+ function onSBPointerMove(e: PointerEvent) {
97
+ if (!draggingSB.value) return
98
+ updateSB(e)
99
+ }
100
+ function onSBPointerUp() {
101
+ draggingSB.value = false
102
+ syncHex()
103
+ }
104
+ function updateSB(e: PointerEvent) {
105
+ if (!satBrightEl.value) return
106
+ const rect = satBrightEl.value.getBoundingClientRect()
107
+ sat.value = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
108
+ bright.value = Math.max(0, Math.min(100, (1 - (e.clientY - rect.top) / rect.height) * 100))
109
+ syncHex()
110
+ }
111
+
112
+ function onHueInput(e: Event) {
113
+ hue.value = Number((e.target as HTMLInputElement).value)
114
+ syncHex()
115
+ }
116
+
117
+ function selectPreset(color: string) {
118
+ const hsv = hexToHsv(color)
119
+ hue.value = hsv.h
120
+ sat.value = hsv.s
121
+ bright.value = hsv.v
122
+ hexInput.value = color
123
+ }
124
+
125
+ function onHexFieldInput(e: Event) {
126
+ const v = (e.target as HTMLInputElement).value
127
+ hexInput.value = v
128
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) {
129
+ const hsv = hexToHsv(v)
130
+ hue.value = hsv.h
131
+ sat.value = hsv.s
132
+ bright.value = hsv.v
133
+ }
134
+ }
135
+
136
+ // ── Actions ────────────────────────────────────────────────────────
137
+ function cancel() { emit('update:show', false) }
138
+
139
+ function confirm() {
140
+ emit('update:modelValue', currentHex.value)
141
+ emit('update:show', false)
142
+ }
143
+
144
+ function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') cancel() }
145
+ </script>
146
+
147
+ <template>
148
+ <Teleport to="body">
149
+ <Transition name="m3-cp-modal">
150
+ <div
151
+ v-if="show"
152
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
153
+ @click.self="cancel"
154
+ >
155
+ <div class="cp-box w-[328px] rounded-[28px] bg-surface-container-high px-6 pt-6 pb-4 shadow-elevation-3">
156
+ <!-- Header -->
157
+ <div class="mb-4 flex items-center gap-3">
158
+ <span
159
+ class="h-10 w-10 shrink-0 rounded-full border border-outline-variant"
160
+ :style="{ backgroundColor: currentHex }"
161
+ />
162
+ <div>
163
+ <p class="text-label-medium text-on-surface-variant">
164
+ {{ title || 'Select color' }}
165
+ </p>
166
+ <p class="font-mono text-title-medium text-on-surface">{{ currentHex }}</p>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- Saturation / Brightness area -->
171
+ <div
172
+ ref="satBrightEl"
173
+ class="relative mb-3 h-44 w-full cursor-crosshair overflow-hidden rounded-lg"
174
+ :style="{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${hueColor})` }"
175
+ @pointerdown="onSBPointerDown"
176
+ @pointermove="onSBPointerMove"
177
+ @pointerup="onSBPointerUp"
178
+ >
179
+ <div
180
+ class="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-elevation-1"
181
+ :style="{
182
+ left: `${sat}%`,
183
+ top: `${100 - bright}%`,
184
+ backgroundColor: currentHex,
185
+ }"
186
+ />
187
+ </div>
188
+
189
+ <!-- Hue slider -->
190
+ <div class="mb-3">
191
+ <input
192
+ type="range"
193
+ min="0"
194
+ max="360"
195
+ :value="hue"
196
+ class="hue-slider h-3 w-full cursor-pointer appearance-none rounded-full outline-none"
197
+ @input="onHueInput"
198
+ />
199
+ </div>
200
+
201
+ <!-- Hex input -->
202
+ <div class="mb-3">
203
+ <input
204
+ type="text"
205
+ :value="hexInput"
206
+ maxlength="7"
207
+ class="w-full rounded-sm border border-outline bg-transparent px-3 py-2 font-mono text-body-medium text-on-surface outline-none transition-colors focus:border-primary"
208
+ @input="onHexFieldInput"
209
+ />
210
+ </div>
211
+
212
+ <!-- Presets -->
213
+ <div class="mb-4 flex flex-wrap gap-1.5">
214
+ <button
215
+ v-for="color in presets"
216
+ :key="color"
217
+ type="button"
218
+ class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border transition-transform duration-100 hover:scale-110"
219
+ :class="color === currentHex ? 'border-on-surface' : 'border-transparent'"
220
+ :style="{ backgroundColor: color }"
221
+ @click="selectPreset(color)"
222
+ >
223
+ <MIcon v-if="color === currentHex" name="check" :size="14" class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]" />
224
+ </button>
225
+ </div>
226
+
227
+ <!-- Footer -->
228
+ <div class="flex justify-end gap-2">
229
+ <MButton variant="text" @click="cancel">{{ locale.cancel }}</MButton>
230
+ <MButton variant="text" @click="confirm">OK</MButton>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ </Transition>
235
+ </Teleport>
236
+ </template>
237
+
238
+ <style scoped>
239
+ .hue-slider {
240
+ background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
241
+ }
242
+ .hue-slider::-webkit-slider-thumb {
243
+ -webkit-appearance: none;
244
+ width: 16px;
245
+ height: 16px;
246
+ border-radius: 50%;
247
+ background: white;
248
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
249
+ cursor: pointer;
250
+ }
251
+ .hue-slider::-moz-range-thumb {
252
+ width: 16px;
253
+ height: 16px;
254
+ border-radius: 50%;
255
+ background: white;
256
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
257
+ border: none;
258
+ cursor: pointer;
259
+ }
260
+ .m3-cp-modal-enter-active,
261
+ .m3-cp-modal-leave-active {
262
+ transition: opacity 0.15s ease;
263
+ }
264
+ .m3-cp-modal-enter-from,
265
+ .m3-cp-modal-leave-to {
266
+ opacity: 0;
267
+ }
268
+ .m3-cp-modal-enter-active .cp-box,
269
+ .m3-cp-modal-leave-active .cp-box {
270
+ transition: transform 0.15s ease;
271
+ }
272
+ .m3-cp-modal-enter-from .cp-box,
273
+ .m3-cp-modal-leave-to .cp-box {
274
+ transform: scale(0.95);
275
+ }
276
+ </style>
@@ -141,10 +141,7 @@ function onClickOutside(e: MouseEvent) {
141
141
  function onScroll(e: Event) {
142
142
  if (!open.value) return
143
143
  if (panelEl.value?.contains(e.target as Node)) return
144
- if (!triggerEl.value) return
145
- const rect = triggerEl.value.getBoundingClientRect()
146
- if (rect.bottom < 0 || rect.top > window.innerHeight) { open.value = false; return }
147
- computeDropPos()
144
+ open.value = false
148
145
  }
149
146
 
150
147
  watch(open, (v) => {
@@ -0,0 +1,265 @@
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 MIcon from './MIcon.vue'
6
+ import { useLocale } from '../composables/useLocale'
7
+
8
+ const props = withDefaults(defineProps<{
9
+ modelValue: string | null
10
+ show?: boolean
11
+ min?: string
12
+ max?: string
13
+ locale?: string
14
+ title?: string
15
+ }>(), {
16
+ modelValue: null,
17
+ show: false,
18
+ locale: 'es-ES',
19
+ })
20
+
21
+ const locale = useLocale()
22
+ const emit = defineEmits<{
23
+ 'update:modelValue': [string | null]
24
+ 'update:show': [boolean]
25
+ }>()
26
+
27
+ const inputMode = ref<'calendar' | 'input'>('calendar')
28
+ const selected = ref<string | null>(null)
29
+ const viewDate = ref(new Date())
30
+ const dateInput = ref('')
31
+
32
+ watch(() => props.show, (open) => {
33
+ if (open) {
34
+ selected.value = props.modelValue ?? null
35
+ viewDate.value = props.modelValue ? new Date(props.modelValue + 'T00:00:00') : new Date()
36
+ inputMode.value = 'calendar'
37
+ dateInput.value = props.modelValue ? isoToDisplay(props.modelValue) : ''
38
+ document.addEventListener('keydown', onKeydown)
39
+ document.body.style.overflow = 'hidden'
40
+ } else {
41
+ document.removeEventListener('keydown', onKeydown)
42
+ document.body.style.overflow = ''
43
+ }
44
+ })
45
+
46
+ // ── Calendar logic ─────────────────────────────────────────────────
47
+ const WEEKDAYS = computed(() => {
48
+ const f = new Intl.DateTimeFormat(props.locale, { weekday: 'narrow' })
49
+ return Array.from({ length: 7 }, (_, i) => f.format(new Date(2024, 0, i + 1)))
50
+ })
51
+
52
+ const monthLabel = computed(() =>
53
+ new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }).format(viewDate.value),
54
+ )
55
+
56
+ const calendarDays = computed(() => {
57
+ const y = viewDate.value.getFullYear()
58
+ const m = viewDate.value.getMonth()
59
+ const first = new Date(y, m, 1)
60
+ const startDay = (first.getDay() + 6) % 7
61
+ const daysInMonth = new Date(y, m + 1, 0).getDate()
62
+ const days: { date: number; current: boolean; iso: string; disabled: boolean }[] = []
63
+
64
+ const prevDays = new Date(y, m, 0).getDate()
65
+ for (let i = startDay - 1; i >= 0; i--) {
66
+ const d = prevDays - i
67
+ const iso = fmt(y, m - 1, d)
68
+ days.push({ date: d, current: false, iso, disabled: isOOR(iso) })
69
+ }
70
+ for (let d = 1; d <= daysInMonth; d++) {
71
+ const iso = fmt(y, m, d)
72
+ days.push({ date: d, current: true, iso, disabled: isOOR(iso) })
73
+ }
74
+ const remaining = 42 - days.length
75
+ for (let d = 1; d <= remaining; d++) {
76
+ const iso = fmt(y, m + 1, d)
77
+ days.push({ date: d, current: false, iso, disabled: isOOR(iso) })
78
+ }
79
+ return days
80
+ })
81
+
82
+ function fmt(y: number, m: number, d: number) {
83
+ const dt = new Date(y, m, d)
84
+ return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
85
+ }
86
+
87
+ function isOOR(iso: string) {
88
+ if (props.min && iso < props.min) return true
89
+ if (props.max && iso > props.max) return true
90
+ return false
91
+ }
92
+
93
+ const todayIso = fmt(new Date().getFullYear(), new Date().getMonth(), new Date().getDate())
94
+
95
+ function prevMonth() {
96
+ const d = new Date(viewDate.value)
97
+ d.setMonth(d.getMonth() - 1)
98
+ viewDate.value = d
99
+ }
100
+ function nextMonth() {
101
+ const d = new Date(viewDate.value)
102
+ d.setMonth(d.getMonth() + 1)
103
+ viewDate.value = d
104
+ }
105
+
106
+ function selectDay(day: typeof calendarDays.value[0]) {
107
+ if (day.disabled) return
108
+ selected.value = day.iso
109
+ }
110
+
111
+ // ── Display ────────────────────────────────────────────────────────
112
+ const headerDate = computed(() => {
113
+ if (!selected.value) return '—'
114
+ const d = new Date(selected.value + 'T00:00:00')
115
+ return new Intl.DateTimeFormat(props.locale, { weekday: 'short', month: 'short', day: 'numeric' }).format(d)
116
+ })
117
+
118
+ // ── Input mode ─────────────────────────────────────────────────────
119
+ function isoToDisplay(iso: string) {
120
+ const [y, m, d] = iso.split('-')
121
+ return `${d}/${m}/${y}`
122
+ }
123
+
124
+ function displayToIso(display: string) {
125
+ const match = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
126
+ if (!match) return null
127
+ return `${match[3]}-${match[2]}-${match[1]}`
128
+ }
129
+
130
+ function applyInput() {
131
+ const iso = displayToIso(dateInput.value)
132
+ if (!iso) return
133
+ if (!isOOR(iso)) {
134
+ selected.value = iso
135
+ viewDate.value = new Date(iso + 'T00:00:00')
136
+ }
137
+ }
138
+
139
+ // ── Actions ────────────────────────────────────────────────────────
140
+ function cancel() { emit('update:show', false) }
141
+
142
+ function confirm() {
143
+ if (inputMode.value === 'input') applyInput()
144
+ if (selected.value) {
145
+ emit('update:modelValue', selected.value)
146
+ emit('update:show', false)
147
+ }
148
+ }
149
+
150
+ function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') cancel() }
151
+ </script>
152
+
153
+ <template>
154
+ <Teleport to="body">
155
+ <Transition name="m3-dp-modal">
156
+ <div
157
+ v-if="show"
158
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
159
+ @click.self="cancel"
160
+ >
161
+ <div class="dp-box w-[360px] rounded-[28px] bg-surface-container-high shadow-elevation-3">
162
+ <!-- Header -->
163
+ <div class="px-6 pt-6 pb-4">
164
+ <p class="mb-3 text-label-medium text-on-surface-variant">
165
+ {{ title || locale.selectDate }}
166
+ </p>
167
+ <div class="flex items-center justify-between">
168
+ <span class="text-headline-medium font-medium capitalize text-on-surface">
169
+ {{ headerDate }}
170
+ </span>
171
+ <MIconButton
172
+ :icon="inputMode === 'calendar' ? 'edit' : 'calendar_today'"
173
+ :label="inputMode === 'calendar' ? 'Switch to input' : 'Switch to calendar'"
174
+ @click="inputMode = inputMode === 'calendar' ? 'input' : 'calendar'"
175
+ />
176
+ </div>
177
+ </div>
178
+
179
+ <div class="h-px bg-outline-variant" />
180
+
181
+ <!-- Calendar mode -->
182
+ <div v-if="inputMode === 'calendar'" class="px-4 pt-3 pb-2">
183
+ <!-- Month nav -->
184
+ <div class="mb-2 flex items-center justify-between">
185
+ <MIconButton icon="chevron_left" :label="locale.previousMonth" :size="36" @click="prevMonth" />
186
+ <span class="text-title-small font-medium capitalize text-on-surface">{{ monthLabel }}</span>
187
+ <MIconButton icon="chevron_right" :label="locale.nextMonth" :size="36" @click="nextMonth" />
188
+ </div>
189
+
190
+ <!-- Weekday headers -->
191
+ <div class="mb-1 grid grid-cols-7 gap-0.5 text-center">
192
+ <span v-for="wd in WEEKDAYS" :key="wd" class="py-1 text-label-small font-medium text-on-surface-variant">
193
+ {{ wd }}
194
+ </span>
195
+ </div>
196
+
197
+ <!-- Days grid -->
198
+ <div class="grid grid-cols-7 gap-0.5">
199
+ <button
200
+ v-for="(day, i) in calendarDays"
201
+ :key="i"
202
+ type="button"
203
+ class="flex h-10 w-full items-center justify-center rounded-full text-body-medium transition-colors duration-100"
204
+ :class="[
205
+ day.disabled
206
+ ? 'cursor-not-allowed text-on-surface/25'
207
+ : day.iso === selected
208
+ ? 'bg-primary text-on-primary'
209
+ : day.iso === todayIso
210
+ ? 'border border-primary text-primary cursor-pointer hover:bg-primary/8'
211
+ : day.current
212
+ ? 'cursor-pointer text-on-surface hover:bg-on-surface/8'
213
+ : 'cursor-pointer text-on-surface-variant/50 hover:bg-on-surface/4',
214
+ ]"
215
+ :disabled="day.disabled"
216
+ @click="selectDay(day)"
217
+ >
218
+ {{ day.date }}
219
+ </button>
220
+ </div>
221
+ </div>
222
+
223
+ <!-- Input mode -->
224
+ <div v-else class="px-6 py-6">
225
+ <label class="mb-2 block text-label-medium text-on-surface-variant">Date (DD/MM/YYYY)</label>
226
+ <input
227
+ v-model="dateInput"
228
+ type="text"
229
+ placeholder="23/06/2026"
230
+ maxlength="10"
231
+ class="h-14 w-full rounded-sm border border-outline bg-transparent px-4 text-body-large text-on-surface outline-none transition-colors focus:border-2 focus:border-primary"
232
+ @keydown.enter="applyInput"
233
+ @blur="applyInput"
234
+ />
235
+ </div>
236
+
237
+ <!-- Footer -->
238
+ <div class="flex justify-end gap-2 px-6 pb-4">
239
+ <MButton variant="text" @click="cancel">{{ locale.cancel }}</MButton>
240
+ <MButton variant="text" :disabled="!selected" @click="confirm">OK</MButton>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </Transition>
245
+ </Teleport>
246
+ </template>
247
+
248
+ <style scoped>
249
+ .m3-dp-modal-enter-active,
250
+ .m3-dp-modal-leave-active {
251
+ transition: opacity 0.15s ease;
252
+ }
253
+ .m3-dp-modal-enter-from,
254
+ .m3-dp-modal-leave-to {
255
+ opacity: 0;
256
+ }
257
+ .m3-dp-modal-enter-active .dp-box,
258
+ .m3-dp-modal-leave-active .dp-box {
259
+ transition: transform 0.15s ease;
260
+ }
261
+ .m3-dp-modal-enter-from .dp-box,
262
+ .m3-dp-modal-leave-to .dp-box {
263
+ transform: scale(0.95);
264
+ }
265
+ </style>
@@ -155,10 +155,7 @@ function onOut(e: MouseEvent) {
155
155
  function onScroll(e: Event) {
156
156
  if (!open.value) return
157
157
  if (panelEl.value?.contains(e.target as Node)) return
158
- if (!triggerEl.value) return
159
- const rect = triggerEl.value.getBoundingClientRect()
160
- if (rect.bottom < 0 || rect.top > window.innerHeight) { open.value = false; return }
161
- computeDropPos()
158
+ open.value = false
162
159
  }
163
160
 
164
161
  watch(open, (v) => {