@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.
- package/README.md +219 -59
- 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 +5222 -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 +109 -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 +105 -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,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
|
-
|
|
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
|
-
|
|
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) => {
|