@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.
Files changed (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/dist/components/MAlert.vue.d.ts +27 -0
  4. package/dist/components/MAppBar.vue.d.ts +24 -0
  5. package/dist/components/MAvatar.vue.d.ts +9 -0
  6. package/dist/components/MBadge.vue.d.ts +22 -0
  7. package/dist/components/MBottomSheet.vue.d.ts +26 -0
  8. package/dist/components/MBreadcrumbs.vue.d.ts +19 -0
  9. package/dist/components/MButton.vue.d.ts +32 -0
  10. package/dist/components/MCalendar.vue.d.ts +23 -0
  11. package/dist/components/MCard.vue.d.ts +28 -0
  12. package/dist/components/MChart.vue.d.ts +13 -0
  13. package/dist/components/MCheckbox.vue.d.ts +26 -0
  14. package/dist/components/MChip.vue.d.ts +33 -0
  15. package/dist/components/MCodeEditor.vue.d.ts +35 -0
  16. package/dist/components/MColorPicker.vue.d.ts +18 -0
  17. package/dist/components/MCommandPalette.vue.d.ts +29 -0
  18. package/dist/components/MConfirmDialog.vue.d.ts +23 -0
  19. package/dist/components/MContainer.vue.d.ts +24 -0
  20. package/dist/components/MContextMenu.vue.d.ts +35 -0
  21. package/dist/components/MDataTable.vue.d.ts +83 -0
  22. package/dist/components/MDatePicker.vue.d.ts +21 -0
  23. package/dist/components/MDateRangePicker.vue.d.ts +24 -0
  24. package/dist/components/MDialog.vue.d.ts +30 -0
  25. package/dist/components/MDivider.vue.d.ts +11 -0
  26. package/dist/components/MDragDropList.vue.d.ts +40 -0
  27. package/dist/components/MEmptyState.vue.d.ts +21 -0
  28. package/dist/components/MExpansionPanel.vue.d.ts +28 -0
  29. package/dist/components/MFab.vue.d.ts +28 -0
  30. package/dist/components/MFileUpload.vue.d.ts +25 -0
  31. package/dist/components/MGrid.vue.d.ts +26 -0
  32. package/dist/components/MHotkeys.vue.d.ts +16 -0
  33. package/dist/components/MIcon.vue.d.ts +9 -0
  34. package/dist/components/MIconButton.vue.d.ts +14 -0
  35. package/dist/components/MInfiniteScroll.vue.d.ts +34 -0
  36. package/dist/components/MJsonEditor.vue.d.ts +17 -0
  37. package/dist/components/MJsonViewer.vue.d.ts +14 -0
  38. package/dist/components/MKanban.vue.d.ts +53 -0
  39. package/dist/components/MLoadingOverlay.vue.d.ts +28 -0
  40. package/dist/components/MMarkdown.vue.d.ts +11 -0
  41. package/dist/components/MMasonry.vue.d.ts +23 -0
  42. package/dist/components/MMenu.vue.d.ts +27 -0
  43. package/dist/components/MMenuItem.vue.d.ts +16 -0
  44. package/dist/components/MMultiSelect.vue.d.ts +34 -0
  45. package/dist/components/MNavigationBar.vue.d.ts +18 -0
  46. package/dist/components/MNavigationDrawer.vue.d.ts +41 -0
  47. package/dist/components/MNavigationRail.vue.d.ts +32 -0
  48. package/dist/components/MPagination.vue.d.ts +12 -0
  49. package/dist/components/MProgressBar.vue.d.ts +13 -0
  50. package/dist/components/MRadio.vue.d.ts +17 -0
  51. package/dist/components/MRadioGroup.vue.d.ts +24 -0
  52. package/dist/components/MRating.vue.d.ts +23 -0
  53. package/dist/components/MResult.vue.d.ts +20 -0
  54. package/dist/components/MRichTextEditor.vue.d.ts +17 -0
  55. package/dist/components/MScheduler.vue.d.ts +35 -0
  56. package/dist/components/MSegmentedButton.vue.d.ts +24 -0
  57. package/dist/components/MSelect.vue.d.ts +29 -0
  58. package/dist/components/MSideSheet.vue.d.ts +28 -0
  59. package/dist/components/MSkeleton.vue.d.ts +14 -0
  60. package/dist/components/MSlider.vue.d.ts +24 -0
  61. package/dist/components/MSnackbar.vue.d.ts +3 -0
  62. package/dist/components/MSpinner.vue.d.ts +10 -0
  63. package/dist/components/MSplitter.vue.d.ts +26 -0
  64. package/dist/components/MSpotlightSearch.vue.d.ts +34 -0
  65. package/dist/components/MStack.vue.d.ts +30 -0
  66. package/dist/components/MStatCard.vue.d.ts +24 -0
  67. package/dist/components/MStepper.vue.d.ts +33 -0
  68. package/dist/components/MSwitch.vue.d.ts +14 -0
  69. package/dist/components/MTable.vue.d.ts +73 -0
  70. package/dist/components/MTabs.vue.d.ts +20 -0
  71. package/dist/components/MTerminal.vue.d.ts +25 -0
  72. package/dist/components/MTextField.vue.d.ts +41 -0
  73. package/dist/components/MTimePicker.vue.d.ts +20 -0
  74. package/dist/components/MTimeline.vue.d.ts +31 -0
  75. package/dist/components/MTooltip.vue.d.ts +21 -0
  76. package/dist/components/MTopAppBar.vue.d.ts +29 -0
  77. package/dist/components/MTour.vue.d.ts +19 -0
  78. package/dist/components/MTransferList.vue.d.ts +23 -0
  79. package/dist/components/MTree.vue.d.ts +68 -0
  80. package/dist/components/MTreeTable.vue.d.ts +57 -0
  81. package/dist/components/MVirtualTable.vue.d.ts +40 -0
  82. package/dist/components/_MContextMenuPanel.vue.d.ts +13 -0
  83. package/dist/components/_MTreeNode.vue.d.ts +26 -0
  84. package/dist/composables/useColorPalette.d.ts +11 -0
  85. package/dist/composables/useFieldBg.d.ts +13 -0
  86. package/dist/composables/useTheme.d.ts +5 -0
  87. package/dist/composables/useToast.d.ts +59 -0
  88. package/dist/index.d.ts +112 -0
  89. package/dist/m3ui.css +2 -0
  90. package/dist/m3ui.js +7432 -0
  91. package/dist/m3ui.js.map +1 -0
  92. package/dist/plugin.d.ts +9 -0
  93. package/dist/styles/palettes.css +1253 -0
  94. package/dist/styles/theme.css +249 -0
  95. package/package.json +166 -0
  96. package/src/components/MAlert.vue +69 -0
  97. package/src/components/MAppBar.vue +40 -0
  98. package/src/components/MAvatar.vue +21 -0
  99. package/src/components/MBadge.vue +46 -0
  100. package/src/components/MBottomSheet.vue +113 -0
  101. package/src/components/MBreadcrumbs.vue +52 -0
  102. package/src/components/MButton.vue +111 -0
  103. package/src/components/MCalendar.vue +173 -0
  104. package/src/components/MCard.vue +56 -0
  105. package/src/components/MChart.vue +158 -0
  106. package/src/components/MCheckbox.vue +48 -0
  107. package/src/components/MChip.vue +87 -0
  108. package/src/components/MCodeEditor.vue +179 -0
  109. package/src/components/MColorPicker.vue +305 -0
  110. package/src/components/MCommandPalette.vue +213 -0
  111. package/src/components/MConfirmDialog.vue +43 -0
  112. package/src/components/MContainer.vue +36 -0
  113. package/src/components/MContextMenu.vue +66 -0
  114. package/src/components/MDataTable.vue +376 -0
  115. package/src/components/MDatePicker.vue +253 -0
  116. package/src/components/MDateRangePicker.vue +265 -0
  117. package/src/components/MDialog.vue +90 -0
  118. package/src/components/MDivider.vue +26 -0
  119. package/src/components/MDragDropList.vue +111 -0
  120. package/src/components/MEmptyState.vue +40 -0
  121. package/src/components/MExpansionPanel.vue +112 -0
  122. package/src/components/MFab.vue +220 -0
  123. package/src/components/MFileUpload.vue +206 -0
  124. package/src/components/MGrid.vue +99 -0
  125. package/src/components/MHotkeys.vue +122 -0
  126. package/src/components/MIcon.vue +9 -0
  127. package/src/components/MIconButton.vue +49 -0
  128. package/src/components/MInfiniteScroll.vue +68 -0
  129. package/src/components/MJsonEditor.vue +118 -0
  130. package/src/components/MJsonViewer.vue +106 -0
  131. package/src/components/MKanban.vue +147 -0
  132. package/src/components/MLoadingOverlay.vue +52 -0
  133. package/src/components/MMarkdown.vue +123 -0
  134. package/src/components/MMasonry.vue +87 -0
  135. package/src/components/MMenu.vue +113 -0
  136. package/src/components/MMenuItem.vue +15 -0
  137. package/src/components/MMultiSelect.vue +306 -0
  138. package/src/components/MNavigationBar.vue +62 -0
  139. package/src/components/MNavigationDrawer.vue +157 -0
  140. package/src/components/MNavigationRail.vue +80 -0
  141. package/src/components/MPagination.vue +37 -0
  142. package/src/components/MProgressBar.vue +200 -0
  143. package/src/components/MRadio.vue +89 -0
  144. package/src/components/MRadioGroup.vue +41 -0
  145. package/src/components/MRating.vue +108 -0
  146. package/src/components/MResult.vue +62 -0
  147. package/src/components/MRichTextEditor.vue +199 -0
  148. package/src/components/MScheduler.vue +225 -0
  149. package/src/components/MSegmentedButton.vue +75 -0
  150. package/src/components/MSelect.vue +259 -0
  151. package/src/components/MSideSheet.vue +112 -0
  152. package/src/components/MSkeleton.vue +60 -0
  153. package/src/components/MSlider.vue +188 -0
  154. package/src/components/MSnackbar.vue +244 -0
  155. package/src/components/MSpinner.vue +122 -0
  156. package/src/components/MSplitter.vue +97 -0
  157. package/src/components/MSpotlightSearch.vue +244 -0
  158. package/src/components/MStack.vue +67 -0
  159. package/src/components/MStatCard.vue +56 -0
  160. package/src/components/MStepper.vue +161 -0
  161. package/src/components/MSwitch.vue +63 -0
  162. package/src/components/MTable.vue +404 -0
  163. package/src/components/MTabs.vue +97 -0
  164. package/src/components/MTerminal.vue +146 -0
  165. package/src/components/MTextField.vue +180 -0
  166. package/src/components/MTimePicker.vue +227 -0
  167. package/src/components/MTimeline.vue +117 -0
  168. package/src/components/MTooltip.vue +82 -0
  169. package/src/components/MTopAppBar.vue +62 -0
  170. package/src/components/MTour.vue +226 -0
  171. package/src/components/MTransferList.vue +181 -0
  172. package/src/components/MTree.vue +164 -0
  173. package/src/components/MTreeTable.vue +159 -0
  174. package/src/components/MVirtualTable.vue +155 -0
  175. package/src/components/_MContextMenuPanel.vue +129 -0
  176. package/src/components/_MTreeNode.vue +171 -0
  177. package/src/composables/useColorPalette.ts +60 -0
  178. package/src/composables/useFieldBg.ts +91 -0
  179. package/src/composables/useTheme.ts +55 -0
  180. package/src/composables/useToast.ts +51 -0
  181. package/src/env.d.ts +1 -0
  182. package/src/index.ts +119 -0
  183. package/src/plugin.ts +18 -0
  184. package/src/styles/palettes.css +1253 -0
  185. 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>