@m3ui-vue/m3ui-vue 0.1.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/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/components/MAlert.vue.d.ts +27 -0
- package/dist/components/MAppBar.vue.d.ts +24 -0
- package/dist/components/MAvatar.vue.d.ts +9 -0
- package/dist/components/MBadge.vue.d.ts +22 -0
- package/dist/components/MBottomSheet.vue.d.ts +26 -0
- package/dist/components/MBreadcrumbs.vue.d.ts +19 -0
- package/dist/components/MButton.vue.d.ts +32 -0
- package/dist/components/MCalendar.vue.d.ts +23 -0
- package/dist/components/MCard.vue.d.ts +28 -0
- package/dist/components/MChart.vue.d.ts +13 -0
- package/dist/components/MCheckbox.vue.d.ts +26 -0
- package/dist/components/MChip.vue.d.ts +33 -0
- package/dist/components/MCodeEditor.vue.d.ts +35 -0
- package/dist/components/MColorPicker.vue.d.ts +18 -0
- package/dist/components/MCommandPalette.vue.d.ts +29 -0
- package/dist/components/MConfirmDialog.vue.d.ts +23 -0
- package/dist/components/MContainer.vue.d.ts +24 -0
- package/dist/components/MContextMenu.vue.d.ts +35 -0
- package/dist/components/MDataTable.vue.d.ts +83 -0
- package/dist/components/MDatePicker.vue.d.ts +21 -0
- package/dist/components/MDateRangePicker.vue.d.ts +24 -0
- package/dist/components/MDialog.vue.d.ts +30 -0
- package/dist/components/MDivider.vue.d.ts +11 -0
- package/dist/components/MDragDropList.vue.d.ts +40 -0
- package/dist/components/MEmptyState.vue.d.ts +21 -0
- package/dist/components/MExpansionPanel.vue.d.ts +28 -0
- package/dist/components/MFab.vue.d.ts +28 -0
- package/dist/components/MFileUpload.vue.d.ts +25 -0
- package/dist/components/MGrid.vue.d.ts +26 -0
- package/dist/components/MHotkeys.vue.d.ts +16 -0
- package/dist/components/MIcon.vue.d.ts +9 -0
- package/dist/components/MIconButton.vue.d.ts +14 -0
- package/dist/components/MInfiniteScroll.vue.d.ts +34 -0
- package/dist/components/MJsonEditor.vue.d.ts +17 -0
- package/dist/components/MJsonViewer.vue.d.ts +14 -0
- package/dist/components/MKanban.vue.d.ts +53 -0
- package/dist/components/MLoadingOverlay.vue.d.ts +28 -0
- package/dist/components/MMarkdown.vue.d.ts +11 -0
- package/dist/components/MMasonry.vue.d.ts +23 -0
- package/dist/components/MMenu.vue.d.ts +27 -0
- package/dist/components/MMenuItem.vue.d.ts +16 -0
- package/dist/components/MMultiSelect.vue.d.ts +34 -0
- package/dist/components/MNavigationBar.vue.d.ts +18 -0
- package/dist/components/MNavigationDrawer.vue.d.ts +41 -0
- package/dist/components/MNavigationRail.vue.d.ts +32 -0
- package/dist/components/MPagination.vue.d.ts +12 -0
- package/dist/components/MProgressBar.vue.d.ts +13 -0
- package/dist/components/MRadio.vue.d.ts +17 -0
- package/dist/components/MRadioGroup.vue.d.ts +24 -0
- package/dist/components/MRating.vue.d.ts +23 -0
- package/dist/components/MResult.vue.d.ts +20 -0
- package/dist/components/MRichTextEditor.vue.d.ts +17 -0
- package/dist/components/MScheduler.vue.d.ts +35 -0
- package/dist/components/MSegmentedButton.vue.d.ts +24 -0
- package/dist/components/MSelect.vue.d.ts +29 -0
- package/dist/components/MSideSheet.vue.d.ts +28 -0
- package/dist/components/MSkeleton.vue.d.ts +14 -0
- package/dist/components/MSlider.vue.d.ts +24 -0
- package/dist/components/MSnackbar.vue.d.ts +3 -0
- package/dist/components/MSpinner.vue.d.ts +10 -0
- package/dist/components/MSplitter.vue.d.ts +26 -0
- package/dist/components/MSpotlightSearch.vue.d.ts +34 -0
- package/dist/components/MStack.vue.d.ts +30 -0
- package/dist/components/MStatCard.vue.d.ts +24 -0
- package/dist/components/MStepper.vue.d.ts +33 -0
- package/dist/components/MSwitch.vue.d.ts +14 -0
- package/dist/components/MTable.vue.d.ts +73 -0
- package/dist/components/MTabs.vue.d.ts +20 -0
- package/dist/components/MTerminal.vue.d.ts +25 -0
- package/dist/components/MTextField.vue.d.ts +41 -0
- package/dist/components/MTimePicker.vue.d.ts +20 -0
- package/dist/components/MTimeline.vue.d.ts +31 -0
- package/dist/components/MTooltip.vue.d.ts +21 -0
- package/dist/components/MTopAppBar.vue.d.ts +29 -0
- package/dist/components/MTour.vue.d.ts +19 -0
- package/dist/components/MTransferList.vue.d.ts +23 -0
- package/dist/components/MTree.vue.d.ts +68 -0
- package/dist/components/MTreeTable.vue.d.ts +57 -0
- package/dist/components/MVirtualTable.vue.d.ts +40 -0
- package/dist/components/_MContextMenuPanel.vue.d.ts +13 -0
- package/dist/components/_MTreeNode.vue.d.ts +26 -0
- package/dist/composables/useColorPalette.d.ts +11 -0
- package/dist/composables/useFieldBg.d.ts +13 -0
- package/dist/composables/useTheme.d.ts +5 -0
- package/dist/composables/useToast.d.ts +59 -0
- package/dist/index.d.ts +112 -0
- package/dist/m3ui.css +2 -0
- package/dist/m3ui.js +7432 -0
- package/dist/m3ui.js.map +1 -0
- package/dist/plugin.d.ts +9 -0
- package/dist/styles/palettes.css +1253 -0
- package/dist/styles/theme.css +249 -0
- package/package.json +166 -0
- package/src/components/MAlert.vue +69 -0
- package/src/components/MAppBar.vue +40 -0
- package/src/components/MAvatar.vue +21 -0
- package/src/components/MBadge.vue +46 -0
- package/src/components/MBottomSheet.vue +113 -0
- package/src/components/MBreadcrumbs.vue +52 -0
- package/src/components/MButton.vue +111 -0
- package/src/components/MCalendar.vue +173 -0
- package/src/components/MCard.vue +56 -0
- package/src/components/MChart.vue +158 -0
- package/src/components/MCheckbox.vue +48 -0
- package/src/components/MChip.vue +87 -0
- package/src/components/MCodeEditor.vue +179 -0
- package/src/components/MColorPicker.vue +305 -0
- package/src/components/MCommandPalette.vue +213 -0
- package/src/components/MConfirmDialog.vue +43 -0
- package/src/components/MContainer.vue +36 -0
- package/src/components/MContextMenu.vue +66 -0
- package/src/components/MDataTable.vue +376 -0
- package/src/components/MDatePicker.vue +253 -0
- package/src/components/MDateRangePicker.vue +265 -0
- package/src/components/MDialog.vue +90 -0
- package/src/components/MDivider.vue +26 -0
- package/src/components/MDragDropList.vue +111 -0
- package/src/components/MEmptyState.vue +40 -0
- package/src/components/MExpansionPanel.vue +112 -0
- package/src/components/MFab.vue +220 -0
- package/src/components/MFileUpload.vue +206 -0
- package/src/components/MGrid.vue +99 -0
- package/src/components/MHotkeys.vue +122 -0
- package/src/components/MIcon.vue +9 -0
- package/src/components/MIconButton.vue +49 -0
- package/src/components/MInfiniteScroll.vue +68 -0
- package/src/components/MJsonEditor.vue +118 -0
- package/src/components/MJsonViewer.vue +106 -0
- package/src/components/MKanban.vue +147 -0
- package/src/components/MLoadingOverlay.vue +52 -0
- package/src/components/MMarkdown.vue +123 -0
- package/src/components/MMasonry.vue +87 -0
- package/src/components/MMenu.vue +113 -0
- package/src/components/MMenuItem.vue +15 -0
- package/src/components/MMultiSelect.vue +306 -0
- package/src/components/MNavigationBar.vue +62 -0
- package/src/components/MNavigationDrawer.vue +157 -0
- package/src/components/MNavigationRail.vue +80 -0
- package/src/components/MPagination.vue +37 -0
- package/src/components/MProgressBar.vue +200 -0
- package/src/components/MRadio.vue +89 -0
- package/src/components/MRadioGroup.vue +41 -0
- package/src/components/MRating.vue +108 -0
- package/src/components/MResult.vue +62 -0
- package/src/components/MRichTextEditor.vue +199 -0
- package/src/components/MScheduler.vue +225 -0
- package/src/components/MSegmentedButton.vue +75 -0
- package/src/components/MSelect.vue +259 -0
- package/src/components/MSideSheet.vue +112 -0
- package/src/components/MSkeleton.vue +60 -0
- package/src/components/MSlider.vue +188 -0
- package/src/components/MSnackbar.vue +244 -0
- package/src/components/MSpinner.vue +122 -0
- package/src/components/MSplitter.vue +97 -0
- package/src/components/MSpotlightSearch.vue +244 -0
- package/src/components/MStack.vue +67 -0
- package/src/components/MStatCard.vue +56 -0
- package/src/components/MStepper.vue +161 -0
- package/src/components/MSwitch.vue +63 -0
- package/src/components/MTable.vue +404 -0
- package/src/components/MTabs.vue +97 -0
- package/src/components/MTerminal.vue +146 -0
- package/src/components/MTextField.vue +180 -0
- package/src/components/MTimePicker.vue +227 -0
- package/src/components/MTimeline.vue +117 -0
- package/src/components/MTooltip.vue +82 -0
- package/src/components/MTopAppBar.vue +62 -0
- package/src/components/MTour.vue +226 -0
- package/src/components/MTransferList.vue +181 -0
- package/src/components/MTree.vue +164 -0
- package/src/components/MTreeTable.vue +159 -0
- package/src/components/MVirtualTable.vue +155 -0
- package/src/components/_MContextMenuPanel.vue +129 -0
- package/src/components/_MTreeNode.vue +171 -0
- package/src/composables/useColorPalette.ts +60 -0
- package/src/composables/useFieldBg.ts +91 -0
- package/src/composables/useTheme.ts +55 -0
- package/src/composables/useToast.ts +51 -0
- package/src/env.d.ts +1 -0
- package/src/index.ts +119 -0
- package/src/plugin.ts +18 -0
- package/src/styles/palettes.css +1253 -0
- package/src/styles/theme.css +249 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
import MIconButton from './MIconButton.vue'
|
|
5
|
+
import { useFieldBg } from '../composables/useFieldBg'
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<{
|
|
8
|
+
modelValue: string | null
|
|
9
|
+
label?: string
|
|
10
|
+
placeholder?: string
|
|
11
|
+
min?: string
|
|
12
|
+
max?: string
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
error?: string
|
|
15
|
+
hint?: string
|
|
16
|
+
locale?: string
|
|
17
|
+
fieldBg?: string
|
|
18
|
+
}>(), { locale: 'es-ES' })
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{ 'update:modelValue': [string | null] }>()
|
|
21
|
+
|
|
22
|
+
const open = ref(false)
|
|
23
|
+
const triggerEl = ref<HTMLElement | null>(null)
|
|
24
|
+
const panelEl = ref<HTMLElement | null>(null)
|
|
25
|
+
const dropPos = ref({ top: '0px', left: '0px' })
|
|
26
|
+
const { resolvedFieldBg } = useFieldBg(triggerEl, () => props.fieldBg)
|
|
27
|
+
|
|
28
|
+
const viewDate = ref(props.modelValue ? new Date(props.modelValue + 'T00:00:00') : new Date())
|
|
29
|
+
watch(() => props.modelValue, (v) => {
|
|
30
|
+
if (v) viewDate.value = new Date(v + 'T00:00:00')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const WEEKDAYS = (() => {
|
|
34
|
+
const f = new Intl.DateTimeFormat(props.locale, { weekday: 'narrow' })
|
|
35
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
36
|
+
const d = new Date(2024, 0, i + 1) // Mon=1 Jan 2024
|
|
37
|
+
return f.format(d)
|
|
38
|
+
})
|
|
39
|
+
})()
|
|
40
|
+
|
|
41
|
+
const monthLabel = computed(() => {
|
|
42
|
+
const f = new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' })
|
|
43
|
+
return f.format(viewDate.value)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const calendarDays = computed(() => {
|
|
47
|
+
const y = viewDate.value.getFullYear()
|
|
48
|
+
const m = viewDate.value.getMonth()
|
|
49
|
+
const first = new Date(y, m, 1)
|
|
50
|
+
const startDay = (first.getDay() + 6) % 7
|
|
51
|
+
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
|
52
|
+
const days: { date: number; current: boolean; iso: string; disabled: boolean }[] = []
|
|
53
|
+
|
|
54
|
+
const prevMonth = new Date(y, m, 0).getDate()
|
|
55
|
+
for (let i = startDay - 1; i >= 0; i--) {
|
|
56
|
+
const d = prevMonth - i
|
|
57
|
+
const iso = fmt(y, m - 1, d)
|
|
58
|
+
days.push({ date: d, current: false, iso, disabled: isOutOfRange(iso) })
|
|
59
|
+
}
|
|
60
|
+
for (let d = 1; d <= daysInMonth; d++) {
|
|
61
|
+
const iso = fmt(y, m, d)
|
|
62
|
+
days.push({ date: d, current: true, iso, disabled: isOutOfRange(iso) })
|
|
63
|
+
}
|
|
64
|
+
const remaining = 42 - days.length
|
|
65
|
+
for (let d = 1; d <= remaining; d++) {
|
|
66
|
+
const iso = fmt(y, m + 1, d)
|
|
67
|
+
days.push({ date: d, current: false, iso, disabled: isOutOfRange(iso) })
|
|
68
|
+
}
|
|
69
|
+
return days
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
function fmt(y: number, m: number, d: number) {
|
|
73
|
+
const dt = new Date(y, m, d)
|
|
74
|
+
const yy = dt.getFullYear()
|
|
75
|
+
const mm = String(dt.getMonth() + 1).padStart(2, '0')
|
|
76
|
+
const dd = String(dt.getDate()).padStart(2, '0')
|
|
77
|
+
return `${yy}-${mm}-${dd}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isOutOfRange(iso: string) {
|
|
81
|
+
if (props.min && iso < props.min) return true
|
|
82
|
+
if (props.max && iso > props.max) return true
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isToday = (iso: string) => iso === fmt(new Date().getFullYear(), new Date().getMonth(), new Date().getDate())
|
|
87
|
+
|
|
88
|
+
function prevMonth() {
|
|
89
|
+
const d = new Date(viewDate.value)
|
|
90
|
+
d.setMonth(d.getMonth() - 1)
|
|
91
|
+
viewDate.value = d
|
|
92
|
+
}
|
|
93
|
+
function nextMonth() {
|
|
94
|
+
const d = new Date(viewDate.value)
|
|
95
|
+
d.setMonth(d.getMonth() + 1)
|
|
96
|
+
viewDate.value = d
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function selectDay(day: typeof calendarDays.value[0]) {
|
|
100
|
+
if (day.disabled) return
|
|
101
|
+
emit('update:modelValue', day.iso)
|
|
102
|
+
open.value = false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clear() {
|
|
106
|
+
emit('update:modelValue', null)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const displayValue = computed(() => {
|
|
110
|
+
if (!props.modelValue) return ''
|
|
111
|
+
const d = new Date(props.modelValue + 'T00:00:00')
|
|
112
|
+
return new Intl.DateTimeFormat(props.locale, { day: 'numeric', month: 'short', year: 'numeric' }).format(d)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
function computeDropPos() {
|
|
116
|
+
if (!triggerEl.value) return
|
|
117
|
+
const rect = triggerEl.value.getBoundingClientRect()
|
|
118
|
+
const panelH = 380
|
|
119
|
+
const spaceBelow = window.innerHeight - rect.bottom - 8
|
|
120
|
+
const above = spaceBelow < panelH && rect.top > panelH
|
|
121
|
+
dropPos.value = {
|
|
122
|
+
top: above ? `${rect.top - 4 - panelH}px` : `${rect.bottom + 4}px`,
|
|
123
|
+
left: `${rect.left}px`,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function onClickOutside(e: MouseEvent) {
|
|
128
|
+
const t = e.target as Node
|
|
129
|
+
if (triggerEl.value?.contains(t)) return
|
|
130
|
+
if (panelEl.value?.contains(t)) return
|
|
131
|
+
open.value = false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onScroll(e: Event) {
|
|
135
|
+
if (!open.value) return
|
|
136
|
+
if (panelEl.value?.contains(e.target as Node)) return
|
|
137
|
+
if (!triggerEl.value) return
|
|
138
|
+
const rect = triggerEl.value.getBoundingClientRect()
|
|
139
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight) { open.value = false; return }
|
|
140
|
+
computeDropPos()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
watch(open, (v) => {
|
|
144
|
+
if (v) {
|
|
145
|
+
computeDropPos()
|
|
146
|
+
setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0)
|
|
147
|
+
} else {
|
|
148
|
+
document.removeEventListener('mousedown', onClickOutside)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
onMounted(() => window.addEventListener('scroll', onScroll, true))
|
|
153
|
+
onUnmounted(() => {
|
|
154
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
155
|
+
document.removeEventListener('mousedown', onClickOutside)
|
|
156
|
+
})
|
|
157
|
+
</script>
|
|
158
|
+
|
|
159
|
+
<template>
|
|
160
|
+
<div class="flex flex-col gap-1">
|
|
161
|
+
<!-- Trigger -->
|
|
162
|
+
<div ref="triggerEl" class="relative mt-2" :style="{ '--field-bg': resolvedFieldBg }">
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="flex h-14 w-full items-center gap-2 rounded-sm border bg-transparent px-4 text-left text-body-large transition-[border-color,border-width] duration-150"
|
|
166
|
+
:class="[
|
|
167
|
+
disabled ? 'pointer-events-none opacity-[0.38]' : 'cursor-pointer',
|
|
168
|
+
open
|
|
169
|
+
? error ? 'border-2 border-error' : 'border-2 border-primary'
|
|
170
|
+
: error ? 'border-error' : 'border-outline hover:border-on-surface',
|
|
171
|
+
]"
|
|
172
|
+
@click="!disabled && (open = !open)"
|
|
173
|
+
>
|
|
174
|
+
<MIcon name="calendar_today" :size="20" class="shrink-0 text-on-surface-variant" />
|
|
175
|
+
<span v-if="displayValue" class="flex-1 text-on-surface">{{ displayValue }}</span>
|
|
176
|
+
<span v-else class="flex-1 text-on-surface-variant">{{ placeholder || label || 'Seleccionar fecha' }}</span>
|
|
177
|
+
<MIcon
|
|
178
|
+
v-if="modelValue"
|
|
179
|
+
name="close"
|
|
180
|
+
:size="18"
|
|
181
|
+
class="shrink-0 cursor-pointer text-on-surface-variant hover:text-on-surface"
|
|
182
|
+
@click.stop="clear"
|
|
183
|
+
/>
|
|
184
|
+
</button>
|
|
185
|
+
<label
|
|
186
|
+
v-if="label"
|
|
187
|
+
class="pointer-events-none absolute -top-2.5 left-3 bg-[var(--field-bg)] px-1 text-label-small transition-colors"
|
|
188
|
+
:class="open ? (error ? 'text-error' : 'text-primary') : error ? 'text-error' : 'text-on-surface-variant'"
|
|
189
|
+
>
|
|
190
|
+
{{ label }}
|
|
191
|
+
</label>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
|
|
195
|
+
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
196
|
+
|
|
197
|
+
<!-- Calendar dropdown -->
|
|
198
|
+
<Teleport to="body">
|
|
199
|
+
<Transition
|
|
200
|
+
enter-active-class="transition-[opacity,transform] duration-150"
|
|
201
|
+
enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
202
|
+
leave-active-class="transition-[opacity,transform] duration-100"
|
|
203
|
+
leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
204
|
+
>
|
|
205
|
+
<div
|
|
206
|
+
v-if="open"
|
|
207
|
+
ref="panelEl"
|
|
208
|
+
class="fixed z-[500] w-[320px] rounded-lg bg-surface-container p-4 shadow-elevation-3"
|
|
209
|
+
:style="dropPos"
|
|
210
|
+
>
|
|
211
|
+
<!-- Header -->
|
|
212
|
+
<div class="mb-3 flex items-center justify-between">
|
|
213
|
+
<MIconButton icon="chevron_left" label="Mes anterior" :size="36" @click="prevMonth" />
|
|
214
|
+
<span class="text-title-small font-medium capitalize text-on-surface">{{ monthLabel }}</span>
|
|
215
|
+
<MIconButton icon="chevron_right" label="Mes siguiente" :size="36" @click="nextMonth" />
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<!-- Weekday headers -->
|
|
219
|
+
<div class="mb-1 grid grid-cols-7 gap-0.5 text-center">
|
|
220
|
+
<span v-for="wd in WEEKDAYS" :key="wd" class="py-1 text-label-small font-medium text-on-surface-variant">
|
|
221
|
+
{{ wd }}
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<!-- Days grid -->
|
|
226
|
+
<div class="grid grid-cols-7 gap-0.5">
|
|
227
|
+
<button
|
|
228
|
+
v-for="(day, i) in calendarDays"
|
|
229
|
+
:key="i"
|
|
230
|
+
type="button"
|
|
231
|
+
class="flex h-9 w-full items-center justify-center rounded-full text-body-medium transition-colors duration-100"
|
|
232
|
+
:class="[
|
|
233
|
+
day.disabled
|
|
234
|
+
? 'cursor-not-allowed text-on-surface/25'
|
|
235
|
+
: day.iso === modelValue
|
|
236
|
+
? 'bg-primary text-on-primary'
|
|
237
|
+
: isToday(day.iso)
|
|
238
|
+
? 'border border-primary text-primary cursor-pointer hover:bg-primary/8'
|
|
239
|
+
: day.current
|
|
240
|
+
? 'cursor-pointer text-on-surface hover:bg-on-surface/8'
|
|
241
|
+
: 'cursor-pointer text-on-surface-variant/50 hover:bg-on-surface/4',
|
|
242
|
+
]"
|
|
243
|
+
:disabled="day.disabled"
|
|
244
|
+
@click="selectDay(day)"
|
|
245
|
+
>
|
|
246
|
+
{{ day.date }}
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</Transition>
|
|
251
|
+
</Teleport>
|
|
252
|
+
</div>
|
|
253
|
+
</template>
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
import MIconButton from './MIconButton.vue'
|
|
5
|
+
import { useFieldBg } from '../composables/useFieldBg'
|
|
6
|
+
|
|
7
|
+
export interface DateRange {
|
|
8
|
+
start: string | null
|
|
9
|
+
end: string | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<{
|
|
13
|
+
modelValue: DateRange
|
|
14
|
+
label?: string
|
|
15
|
+
min?: string
|
|
16
|
+
max?: string
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
error?: string
|
|
19
|
+
hint?: string
|
|
20
|
+
locale?: string
|
|
21
|
+
fieldBg?: string
|
|
22
|
+
}>(), { locale: 'es-ES' })
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits<{ 'update:modelValue': [DateRange] }>()
|
|
25
|
+
|
|
26
|
+
const open = ref(false)
|
|
27
|
+
const triggerEl = ref<HTMLElement | null>(null)
|
|
28
|
+
const panelEl = ref<HTMLElement | null>(null)
|
|
29
|
+
const picking = ref<'start' | 'end'>('start')
|
|
30
|
+
const hovered = ref<string | null>(null)
|
|
31
|
+
const dropPos = ref({ top: '0px', left: '0px' })
|
|
32
|
+
const { resolvedFieldBg } = useFieldBg(triggerEl, () => props.fieldBg)
|
|
33
|
+
|
|
34
|
+
const viewDate = ref(
|
|
35
|
+
props.modelValue.start ? new Date(props.modelValue.start + 'T00:00:00') : new Date()
|
|
36
|
+
)
|
|
37
|
+
watch(() => props.modelValue.start, (v) => {
|
|
38
|
+
if (v) viewDate.value = new Date(v + 'T00:00:00')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const WEEKDAYS = (() => {
|
|
42
|
+
const f = new Intl.DateTimeFormat(props.locale, { weekday: 'narrow' })
|
|
43
|
+
return Array.from({ length: 7 }, (_, i) => f.format(new Date(2024, 0, i + 1)))
|
|
44
|
+
})()
|
|
45
|
+
|
|
46
|
+
const monthLabel = computed(() =>
|
|
47
|
+
new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }).format(viewDate.value)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const calendarDays = computed(() => {
|
|
51
|
+
const y = viewDate.value.getFullYear()
|
|
52
|
+
const m = viewDate.value.getMonth()
|
|
53
|
+
const first = new Date(y, m, 1)
|
|
54
|
+
const startDay = (first.getDay() + 6) % 7
|
|
55
|
+
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
|
56
|
+
const days: { date: number; current: boolean; iso: string; disabled: boolean }[] = []
|
|
57
|
+
|
|
58
|
+
const prevMonth = new Date(y, m, 0).getDate()
|
|
59
|
+
for (let i = startDay - 1; i >= 0; i--) {
|
|
60
|
+
const d = prevMonth - i
|
|
61
|
+
const iso = fmt(y, m - 1, d)
|
|
62
|
+
days.push({ date: d, current: false, iso, disabled: isOOR(iso) })
|
|
63
|
+
}
|
|
64
|
+
for (let d = 1; d <= daysInMonth; d++) {
|
|
65
|
+
const iso = fmt(y, m, d)
|
|
66
|
+
days.push({ date: d, current: true, iso, disabled: isOOR(iso) })
|
|
67
|
+
}
|
|
68
|
+
const remaining = 42 - days.length
|
|
69
|
+
for (let d = 1; d <= remaining; d++) {
|
|
70
|
+
const iso = fmt(y, m + 1, d)
|
|
71
|
+
days.push({ date: d, current: false, iso, disabled: isOOR(iso) })
|
|
72
|
+
}
|
|
73
|
+
return days
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
function fmt(y: number, m: number, d: number) {
|
|
77
|
+
const dt = new Date(y, m, d)
|
|
78
|
+
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
|
|
79
|
+
}
|
|
80
|
+
function isOOR(iso: string) {
|
|
81
|
+
if (props.min && iso < props.min) return true
|
|
82
|
+
if (props.max && iso > props.max) return true
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
const todayIso = fmt(new Date().getFullYear(), new Date().getMonth(), new Date().getDate())
|
|
86
|
+
|
|
87
|
+
function selectDay(day: typeof calendarDays.value[0]) {
|
|
88
|
+
if (day.disabled) return
|
|
89
|
+
if (picking.value === 'start') {
|
|
90
|
+
emit('update:modelValue', { start: day.iso, end: null })
|
|
91
|
+
picking.value = 'end'
|
|
92
|
+
} else {
|
|
93
|
+
const s = props.modelValue.start!
|
|
94
|
+
if (day.iso < s) {
|
|
95
|
+
emit('update:modelValue', { start: day.iso, end: null })
|
|
96
|
+
} else {
|
|
97
|
+
emit('update:modelValue', { start: s, end: day.iso })
|
|
98
|
+
picking.value = 'start'
|
|
99
|
+
open.value = false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isInRange(iso: string) {
|
|
105
|
+
const { start, end } = props.modelValue
|
|
106
|
+
const effectiveEnd = end ?? hovered.value
|
|
107
|
+
if (!start || !effectiveEnd) return false
|
|
108
|
+
const lo = start < effectiveEnd ? start : effectiveEnd
|
|
109
|
+
const hi = start < effectiveEnd ? effectiveEnd : start
|
|
110
|
+
return iso > lo && iso < hi
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function prevMonth() { const d = new Date(viewDate.value); d.setMonth(d.getMonth() - 1); viewDate.value = d }
|
|
114
|
+
function nextMonth() { const d = new Date(viewDate.value); d.setMonth(d.getMonth() + 1); viewDate.value = d }
|
|
115
|
+
|
|
116
|
+
const displayValue = computed(() => {
|
|
117
|
+
const f = new Intl.DateTimeFormat(props.locale, { day: 'numeric', month: 'short' })
|
|
118
|
+
const s = props.modelValue.start ? f.format(new Date(props.modelValue.start + 'T00:00:00')) : '—'
|
|
119
|
+
const e = props.modelValue.end ? f.format(new Date(props.modelValue.end + 'T00:00:00')) : '—'
|
|
120
|
+
if (!props.modelValue.start && !props.modelValue.end) return ''
|
|
121
|
+
return `${s} → ${e}`
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
function clear() { emit('update:modelValue', { start: null, end: null }); picking.value = 'start' }
|
|
125
|
+
|
|
126
|
+
function computeDropPos() {
|
|
127
|
+
if (!triggerEl.value) return
|
|
128
|
+
const rect = triggerEl.value.getBoundingClientRect()
|
|
129
|
+
const panelH = 400
|
|
130
|
+
const spaceBelow = window.innerHeight - rect.bottom - 8
|
|
131
|
+
const above = spaceBelow < panelH && rect.top > panelH
|
|
132
|
+
dropPos.value = {
|
|
133
|
+
top: above ? `${rect.top - 4 - panelH}px` : `${rect.bottom + 4}px`,
|
|
134
|
+
left: `${rect.left}px`,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function onOut(e: MouseEvent) {
|
|
139
|
+
const t = e.target as Node
|
|
140
|
+
if (triggerEl.value?.contains(t)) return
|
|
141
|
+
if (panelEl.value?.contains(t)) return
|
|
142
|
+
open.value = false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onScroll(e: Event) {
|
|
146
|
+
if (!open.value) return
|
|
147
|
+
if (panelEl.value?.contains(e.target as Node)) return
|
|
148
|
+
if (!triggerEl.value) return
|
|
149
|
+
const rect = triggerEl.value.getBoundingClientRect()
|
|
150
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight) { open.value = false; return }
|
|
151
|
+
computeDropPos()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
watch(open, (v) => {
|
|
155
|
+
if (v) {
|
|
156
|
+
picking.value = props.modelValue.start && !props.modelValue.end ? 'end' : 'start'
|
|
157
|
+
computeDropPos()
|
|
158
|
+
setTimeout(() => document.addEventListener('mousedown', onOut), 0)
|
|
159
|
+
} else {
|
|
160
|
+
document.removeEventListener('mousedown', onOut)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
onMounted(() => window.addEventListener('scroll', onScroll, true))
|
|
165
|
+
onUnmounted(() => {
|
|
166
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
167
|
+
document.removeEventListener('mousedown', onOut)
|
|
168
|
+
})
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<template>
|
|
172
|
+
<div class="flex flex-col gap-1">
|
|
173
|
+
<div ref="triggerEl" class="relative mt-2" :style="{ '--field-bg': resolvedFieldBg }">
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
class="flex h-14 w-full items-center gap-2 rounded-sm border bg-transparent px-4 text-left text-body-large transition-[border-color,border-width] duration-150"
|
|
177
|
+
:class="[
|
|
178
|
+
disabled ? 'pointer-events-none opacity-[0.38]' : 'cursor-pointer',
|
|
179
|
+
open
|
|
180
|
+
? error ? 'border-2 border-error' : 'border-2 border-primary'
|
|
181
|
+
: error ? 'border-error' : 'border-outline hover:border-on-surface',
|
|
182
|
+
]"
|
|
183
|
+
@click="!disabled && (open = !open)"
|
|
184
|
+
>
|
|
185
|
+
<MIcon name="date_range" :size="20" class="shrink-0 text-on-surface-variant" />
|
|
186
|
+
<span v-if="displayValue" class="flex-1 text-on-surface">{{ displayValue }}</span>
|
|
187
|
+
<span v-else class="flex-1 text-on-surface-variant">{{ label || 'Seleccionar rango' }}</span>
|
|
188
|
+
<MIcon
|
|
189
|
+
v-if="modelValue.start || modelValue.end"
|
|
190
|
+
name="close"
|
|
191
|
+
:size="18"
|
|
192
|
+
class="shrink-0 cursor-pointer text-on-surface-variant hover:text-on-surface"
|
|
193
|
+
@click.stop="clear"
|
|
194
|
+
/>
|
|
195
|
+
</button>
|
|
196
|
+
<label
|
|
197
|
+
v-if="label"
|
|
198
|
+
class="pointer-events-none absolute -top-2.5 left-3 bg-[var(--field-bg)] px-1 text-label-small transition-colors"
|
|
199
|
+
:class="open ? (error ? 'text-error' : 'text-primary') : error ? 'text-error' : 'text-on-surface-variant'"
|
|
200
|
+
>
|
|
201
|
+
{{ label }}
|
|
202
|
+
</label>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
|
|
206
|
+
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
207
|
+
|
|
208
|
+
<Teleport to="body">
|
|
209
|
+
<Transition
|
|
210
|
+
enter-active-class="transition-[opacity,transform] duration-150"
|
|
211
|
+
enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
212
|
+
leave-active-class="transition-[opacity,transform] duration-100"
|
|
213
|
+
leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
214
|
+
>
|
|
215
|
+
<div
|
|
216
|
+
v-if="open"
|
|
217
|
+
ref="panelEl"
|
|
218
|
+
class="fixed z-[500] w-[320px] rounded-lg bg-surface-container p-4 shadow-elevation-3"
|
|
219
|
+
:style="dropPos"
|
|
220
|
+
>
|
|
221
|
+
<p class="mb-2 text-center text-label-medium text-on-surface-variant">
|
|
222
|
+
{{ picking === 'start' ? 'Selecciona inicio' : 'Selecciona fin' }}
|
|
223
|
+
</p>
|
|
224
|
+
|
|
225
|
+
<div class="mb-3 flex items-center justify-between">
|
|
226
|
+
<MIconButton icon="chevron_left" label="Anterior" :size="36" @click="prevMonth" />
|
|
227
|
+
<span class="text-title-small font-medium capitalize text-on-surface">{{ monthLabel }}</span>
|
|
228
|
+
<MIconButton icon="chevron_right" label="Siguiente" :size="36" @click="nextMonth" />
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div class="mb-1 grid grid-cols-7 gap-0.5 text-center">
|
|
232
|
+
<span v-for="wd in WEEKDAYS" :key="wd" class="py-1 text-label-small font-medium text-on-surface-variant">{{ wd }}</span>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div class="grid grid-cols-7 gap-0.5">
|
|
236
|
+
<button
|
|
237
|
+
v-for="(day, i) in calendarDays"
|
|
238
|
+
:key="i"
|
|
239
|
+
type="button"
|
|
240
|
+
class="flex h-9 w-full items-center justify-center text-body-medium transition-colors duration-100"
|
|
241
|
+
:class="[
|
|
242
|
+
day.disabled
|
|
243
|
+
? 'cursor-not-allowed text-on-surface/25 rounded-full'
|
|
244
|
+
: day.iso === modelValue.start || day.iso === modelValue.end
|
|
245
|
+
? 'bg-primary text-on-primary rounded-full'
|
|
246
|
+
: isInRange(day.iso)
|
|
247
|
+
? 'bg-primary/12 text-on-surface cursor-pointer'
|
|
248
|
+
: day.iso === todayIso
|
|
249
|
+
? 'border border-primary text-primary rounded-full cursor-pointer hover:bg-primary/8'
|
|
250
|
+
: day.current
|
|
251
|
+
? 'cursor-pointer text-on-surface rounded-full hover:bg-on-surface/8'
|
|
252
|
+
: 'cursor-pointer text-on-surface-variant/50 rounded-full hover:bg-on-surface/4',
|
|
253
|
+
]"
|
|
254
|
+
:disabled="day.disabled"
|
|
255
|
+
@mouseenter="picking === 'end' && (hovered = day.iso)"
|
|
256
|
+
@click="selectDay(day)"
|
|
257
|
+
>
|
|
258
|
+
{{ day.date }}
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</Transition>
|
|
263
|
+
</Teleport>
|
|
264
|
+
</div>
|
|
265
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { watch } from 'vue'
|
|
3
|
+
import MIconButton from './MIconButton.vue'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
modelValue: boolean
|
|
8
|
+
title?: string
|
|
9
|
+
maxWidth?: string
|
|
10
|
+
persistent?: boolean
|
|
11
|
+
}>(),
|
|
12
|
+
{
|
|
13
|
+
maxWidth: 'max-w-md',
|
|
14
|
+
persistent: false,
|
|
15
|
+
},
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
|
19
|
+
|
|
20
|
+
function close() {
|
|
21
|
+
if (props.persistent) return
|
|
22
|
+
emit('update:modelValue', false)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function onKeydown(event: KeyboardEvent) {
|
|
26
|
+
if (event.key === 'Escape') close()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
watch(
|
|
30
|
+
() => props.modelValue,
|
|
31
|
+
(open) => {
|
|
32
|
+
if (open) {
|
|
33
|
+
document.addEventListener('keydown', onKeydown)
|
|
34
|
+
document.body.style.overflow = 'hidden'
|
|
35
|
+
} else {
|
|
36
|
+
document.removeEventListener('keydown', onKeydown)
|
|
37
|
+
document.body.style.overflow = ''
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<Teleport to="body">
|
|
45
|
+
<Transition name="m3-dialog">
|
|
46
|
+
<div
|
|
47
|
+
v-if="modelValue"
|
|
48
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
49
|
+
@click.self="close"
|
|
50
|
+
>
|
|
51
|
+
<div
|
|
52
|
+
class="dialog-box flex max-h-[90vh] w-full flex-col rounded-xl bg-surface-container-high shadow-elevation-3"
|
|
53
|
+
:class="maxWidth"
|
|
54
|
+
>
|
|
55
|
+
<div class="flex items-start justify-between gap-4 px-6 pt-6 pb-2">
|
|
56
|
+
<h2 class="text-headline-small text-on-surface">
|
|
57
|
+
<slot name="title">{{ title }}</slot>
|
|
58
|
+
</h2>
|
|
59
|
+
<MIconButton v-if="!persistent" icon="close" label="Cerrar" @click="close" />
|
|
60
|
+
</div>
|
|
61
|
+
<div class="overflow-y-auto px-6 py-2 text-body-medium text-on-surface-variant">
|
|
62
|
+
<slot />
|
|
63
|
+
</div>
|
|
64
|
+
<div v-if="$slots.actions" class="flex justify-end gap-2 px-6 py-4">
|
|
65
|
+
<slot name="actions" />
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</Transition>
|
|
70
|
+
</Teleport>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<style scoped>
|
|
74
|
+
.m3-dialog-enter-active,
|
|
75
|
+
.m3-dialog-leave-active {
|
|
76
|
+
transition: opacity 0.15s ease;
|
|
77
|
+
}
|
|
78
|
+
.m3-dialog-enter-from,
|
|
79
|
+
.m3-dialog-leave-to {
|
|
80
|
+
opacity: 0;
|
|
81
|
+
}
|
|
82
|
+
.m3-dialog-enter-active .dialog-box,
|
|
83
|
+
.m3-dialog-leave-active .dialog-box {
|
|
84
|
+
transition: transform 0.15s ease;
|
|
85
|
+
}
|
|
86
|
+
.m3-dialog-enter-from .dialog-box,
|
|
87
|
+
.m3-dialog-leave-to .dialog-box {
|
|
88
|
+
transform: scale(0.95);
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(defineProps<{
|
|
3
|
+
vertical?: boolean
|
|
4
|
+
label?: string
|
|
5
|
+
inset?: boolean
|
|
6
|
+
}>(), { vertical: false, inset: false })
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div
|
|
11
|
+
v-if="!vertical"
|
|
12
|
+
class="flex items-center gap-3"
|
|
13
|
+
:class="inset && 'ml-16'"
|
|
14
|
+
role="separator"
|
|
15
|
+
>
|
|
16
|
+
<div class="h-px flex-1 bg-outline-variant" />
|
|
17
|
+
<span v-if="label" class="shrink-0 text-label-small text-on-surface-variant">{{ label }}</span>
|
|
18
|
+
<div v-if="label" class="h-px flex-1 bg-outline-variant" />
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div
|
|
22
|
+
v-else
|
|
23
|
+
class="w-px self-stretch bg-outline-variant"
|
|
24
|
+
role="separator"
|
|
25
|
+
/>
|
|
26
|
+
</template>
|