@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,306 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, useId, onMounted, onUnmounted, nextTick } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+ import MCheckbox from './MCheckbox.vue'
5
+ import { useFieldBg } from '../composables/useFieldBg'
6
+
7
+ export interface MultiSelectOption {
8
+ label: string
9
+ value: string | number
10
+ disabled?: boolean
11
+ }
12
+
13
+ const props = withDefaults(
14
+ defineProps<{
15
+ modelValue: (string | number)[]
16
+ options: MultiSelectOption[]
17
+ label?: string
18
+ placeholder?: string
19
+ variant?: 'filled' | 'outlined'
20
+ disabled?: boolean
21
+ error?: string
22
+ hint?: string
23
+ required?: boolean
24
+ leadingIcon?: string
25
+ fieldBg?: string
26
+ searchable?: boolean
27
+ maxChips?: number
28
+ }>(),
29
+ {
30
+ modelValue: () => [],
31
+ variant: 'filled',
32
+ disabled: false,
33
+ required: false,
34
+ searchable: true,
35
+ maxChips: 3,
36
+ },
37
+ )
38
+
39
+ const emit = defineEmits<{ 'update:modelValue': [(string | number)[]] }>()
40
+
41
+ const id = useId()
42
+ const open = ref(false)
43
+ const search = ref('')
44
+ const fieldEl = ref<HTMLElement | null>(null)
45
+ const { resolvedFieldBg } = useFieldBg(fieldEl, () => props.fieldBg)
46
+ const dropdownEl = ref<HTMLElement | null>(null)
47
+ const searchInput = ref<HTMLInputElement | null>(null)
48
+ const dropPos = ref({ top: '0px', left: '0px', width: '0px' })
49
+
50
+ const hasValue = computed(() => props.modelValue.length > 0)
51
+
52
+ const filteredOptions = computed(() => {
53
+ if (!search.value) return props.options
54
+ const q = search.value.toLowerCase()
55
+ return props.options.filter((o) => o.label.toLowerCase().includes(q))
56
+ })
57
+
58
+ const visibleChips = computed(() =>
59
+ props.modelValue.slice(0, props.maxChips).map((v) => ({
60
+ value: v,
61
+ label: props.options.find((o) => o.value === v)?.label ?? String(v),
62
+ })),
63
+ )
64
+
65
+ const overflowCount = computed(() => Math.max(0, props.modelValue.length - props.maxChips))
66
+
67
+ function toggle(value: string | number) {
68
+ const current = props.modelValue
69
+ if (current.includes(value)) {
70
+ emit('update:modelValue', current.filter((v) => v !== value))
71
+ } else {
72
+ emit('update:modelValue', [...current, value])
73
+ }
74
+ }
75
+
76
+ function removeChip(value: string | number, e: Event) {
77
+ e.stopPropagation()
78
+ emit('update:modelValue', props.modelValue.filter((v) => v !== value))
79
+ }
80
+
81
+ function computeDropPos() {
82
+ if (!fieldEl.value) return
83
+ const rect = fieldEl.value.getBoundingClientRect()
84
+ dropPos.value = {
85
+ top: `${rect.bottom + 4}px`,
86
+ left: `${rect.left}px`,
87
+ width: `${rect.width}px`,
88
+ }
89
+ }
90
+
91
+ async function openDropdown() {
92
+ if (props.disabled) return
93
+ computeDropPos()
94
+ open.value = true
95
+ search.value = ''
96
+ await nextTick()
97
+ searchInput.value?.focus()
98
+ }
99
+
100
+ function close() {
101
+ open.value = false
102
+ search.value = ''
103
+ }
104
+
105
+ function onOutsideClick(e: MouseEvent) {
106
+ const t = e.target as Node
107
+ if (!fieldEl.value?.contains(t) && !dropdownEl.value?.contains(t)) close()
108
+ }
109
+
110
+ function onScroll(e: Event) {
111
+ if (!open.value) return
112
+ if (dropdownEl.value?.contains(e.target as Node)) return
113
+ if (!fieldEl.value) return
114
+ const rect = fieldEl.value.getBoundingClientRect()
115
+ if (rect.bottom < 0 || rect.top > window.innerHeight) {
116
+ close()
117
+ return
118
+ }
119
+ computeDropPos()
120
+ }
121
+
122
+ onMounted(() => {
123
+ document.addEventListener('mousedown', onOutsideClick)
124
+ window.addEventListener('scroll', onScroll, true)
125
+ })
126
+ onUnmounted(() => {
127
+ document.removeEventListener('mousedown', onOutsideClick)
128
+ window.removeEventListener('scroll', onScroll, true)
129
+ })
130
+
131
+ const triggerClasses = computed(() => {
132
+ const base = [
133
+ 'flex min-h-[56px] w-full cursor-pointer items-center gap-1.5 flex-wrap',
134
+ 'transition-[border-color,border-width] duration-150',
135
+ props.leadingIcon ? 'pl-12 pr-10' : 'pl-4 pr-10',
136
+ ]
137
+
138
+ if (props.variant === 'outlined') {
139
+ return [
140
+ ...base,
141
+ 'rounded-sm border bg-transparent py-2',
142
+ open.value
143
+ ? (props.error ? 'border-2 border-error' : 'border-2 border-primary')
144
+ : (props.error ? 'border-error' : 'border-outline hover:border-on-surface'),
145
+ ].join(' ')
146
+ }
147
+
148
+ return [
149
+ ...base,
150
+ 'rounded-t-sm bg-surface-container-highest border-b pt-7 pb-2',
151
+ open.value
152
+ ? (props.error ? 'border-b-2 border-error' : 'border-b-2 border-primary')
153
+ : (props.error ? 'border-error' : 'border-on-surface-variant hover:border-on-surface'),
154
+ ].join(' ')
155
+ })
156
+
157
+ const labelClasses = computed(() => {
158
+ const left = props.leadingIcon
159
+ ? (props.variant === 'outlined' ? 'left-11' : 'left-12')
160
+ : (props.variant === 'outlined' ? 'left-3' : 'left-4')
161
+
162
+ const floated = props.variant === 'outlined'
163
+ ? '-top-2.5 translate-y-0 text-label-small bg-[var(--field-bg)] px-1 right-auto max-w-[calc(100%-1.5rem)]'
164
+ : 'top-3.5 translate-y-0 text-label-small'
165
+
166
+ const unFloated = 'top-1/2 -translate-y-1/2 text-body-large'
167
+ const active = open.value || hasValue.value
168
+
169
+ return [
170
+ 'pointer-events-none absolute right-10 truncate transition-all duration-200',
171
+ left,
172
+ active ? floated : unFloated,
173
+ open.value
174
+ ? (props.error ? 'text-error' : 'text-primary')
175
+ : (props.error ? 'text-error' : 'text-on-surface-variant'),
176
+ ].join(' ')
177
+ })
178
+ </script>
179
+
180
+ <template>
181
+ <div class="flex flex-col gap-1">
182
+ <div
183
+ ref="fieldEl"
184
+ class="relative"
185
+ :class="variant === 'outlined' ? 'mt-2' : ''"
186
+ :style="variant === 'outlined' ? { '--field-bg': resolvedFieldBg } : undefined"
187
+ >
188
+ <div
189
+ v-if="leadingIcon"
190
+ class="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-on-surface-variant"
191
+ >
192
+ <MIcon :name="leadingIcon" :size="20" />
193
+ </div>
194
+
195
+ <!-- Trigger field -->
196
+ <div
197
+ :id="id"
198
+ :class="triggerClasses"
199
+ role="button"
200
+ :tabindex="disabled ? -1 : 0"
201
+ :aria-expanded="open"
202
+ :aria-haspopup="true"
203
+ @click="open ? close() : openDropdown()"
204
+ @keydown.enter.prevent="open ? close() : openDropdown()"
205
+ @keydown.space.prevent="open ? close() : openDropdown()"
206
+ @keydown.escape="close()"
207
+ >
208
+ <template v-if="hasValue">
209
+ <span
210
+ v-for="chip in visibleChips"
211
+ :key="chip.value"
212
+ class="inline-flex items-center gap-1 rounded-full bg-secondary-container px-2 py-0.5 text-label-small text-on-secondary-container"
213
+ >
214
+ {{ chip.label }}
215
+ <button
216
+ type="button"
217
+ class="flex h-4 w-4 items-center justify-center rounded-full hover:bg-on-secondary-container/20"
218
+ @click="removeChip(chip.value, $event)"
219
+ >
220
+ <MIcon name="close" :size="12" />
221
+ </button>
222
+ </span>
223
+ <span
224
+ v-if="overflowCount > 0"
225
+ class="rounded-full bg-surface-container-high px-2 py-0.5 text-label-small text-on-surface-variant"
226
+ >
227
+ +{{ overflowCount }}
228
+ </span>
229
+ </template>
230
+ <span v-else-if="!open" class="text-body-large text-on-surface-variant opacity-0">
231
+ {{ placeholder }}
232
+ </span>
233
+ </div>
234
+
235
+ <label :class="labelClasses">
236
+ {{ label }}<span v-if="required" class="text-error">&nbsp;*</span>
237
+ </label>
238
+
239
+ <div class="pointer-events-none absolute right-2 top-7 -translate-y-1/2">
240
+ <MIcon
241
+ :name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
242
+ :size="24"
243
+ class="text-on-surface-variant"
244
+ />
245
+ </div>
246
+ </div>
247
+
248
+ <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
249
+ <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
250
+ </div>
251
+
252
+ <!-- Dropdown teleported to body to escape overflow clipping -->
253
+ <Teleport to="body">
254
+ <Transition
255
+ enter-active-class="transition-[opacity,transform] duration-150"
256
+ enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
257
+ enter-to-class="opacity-100 translate-y-0 scale-100"
258
+ leave-active-class="transition-[opacity,transform] duration-100"
259
+ leave-from-class="opacity-100 translate-y-0 scale-100"
260
+ leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
261
+ >
262
+ <div
263
+ v-if="open"
264
+ ref="dropdownEl"
265
+ class="fixed z-[500] max-h-60 overflow-auto rounded-sm bg-surface-container shadow-elevation-2"
266
+ :style="dropPos"
267
+ >
268
+ <!-- Search -->
269
+ <div v-if="searchable" class="sticky top-0 bg-surface-container px-3 py-2">
270
+ <div class="flex items-center gap-2 rounded-full bg-surface-container-high px-3 py-1.5">
271
+ <MIcon name="search" :size="16" class="shrink-0 text-on-surface-variant" />
272
+ <input
273
+ ref="searchInput"
274
+ v-model="search"
275
+ type="text"
276
+ placeholder="Buscar..."
277
+ class="w-full bg-transparent text-body-medium text-on-surface outline-none placeholder:text-on-surface-variant"
278
+ />
279
+ </div>
280
+ </div>
281
+
282
+ <div class="flex flex-col py-1">
283
+ <label
284
+ v-for="opt in filteredOptions"
285
+ :key="opt.value"
286
+ class="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-on-surface/8"
287
+ :class="opt.disabled ? 'cursor-not-allowed opacity-38' : ''"
288
+ >
289
+ <MCheckbox
290
+ :model-value="modelValue.includes(opt.value)"
291
+ :disabled="opt.disabled"
292
+ @update:model-value="!opt.disabled && toggle(opt.value)"
293
+ />
294
+ <span class="text-body-large text-on-surface">{{ opt.label }}</span>
295
+ </label>
296
+ <p
297
+ v-if="filteredOptions.length === 0"
298
+ class="px-4 py-3 text-center text-body-small text-on-surface-variant"
299
+ >
300
+ Sin resultados
301
+ </p>
302
+ </div>
303
+ </div>
304
+ </Transition>
305
+ </Teleport>
306
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import MIcon from './MIcon.vue'
3
+ import MBadge from './MBadge.vue'
4
+
5
+ export interface NavBarItem {
6
+ value: string | number
7
+ label: string
8
+ icon: string
9
+ badge?: number
10
+ badgeDot?: boolean
11
+ }
12
+
13
+ withDefaults(defineProps<{
14
+ modelValue: string | number
15
+ items: NavBarItem[]
16
+ }>(), {})
17
+
18
+ defineEmits<{ 'update:modelValue': [string | number] }>()
19
+ </script>
20
+
21
+ <template>
22
+ <nav class="flex h-20 w-full items-center justify-around border-t border-outline-variant bg-surface-container">
23
+ <button
24
+ v-for="item in items"
25
+ :key="item.value"
26
+ type="button"
27
+ class="group flex flex-1 cursor-pointer flex-col items-center justify-center gap-1 self-stretch transition-colors focus-visible:outline-none"
28
+ :class="
29
+ item.value === modelValue
30
+ ? 'text-on-secondary-container'
31
+ : 'text-on-surface-variant'
32
+ "
33
+ @click="$emit('update:modelValue', item.value)"
34
+ >
35
+ <!-- Pill indicator with icon -->
36
+ <span
37
+ class="inline-flex h-8 items-center justify-center rounded-2xl transition-all duration-200"
38
+ :class="
39
+ item.value === modelValue
40
+ ? 'w-16 bg-secondary-container'
41
+ : 'w-0 bg-secondary-container/0 group-hover:w-16 group-hover:bg-on-surface/8'
42
+ "
43
+ >
44
+ <MBadge v-if="item.badge != null" :count="item.badge">
45
+ <MIcon :name="item.icon" :size="24" />
46
+ </MBadge>
47
+ <MBadge v-else-if="item.badgeDot" dot>
48
+ <MIcon :name="item.icon" :size="24" />
49
+ </MBadge>
50
+ <MIcon v-else :name="item.icon" :size="24" />
51
+ </span>
52
+
53
+ <!-- Label -->
54
+ <span
55
+ class="text-label-medium transition-[font-weight] duration-150"
56
+ :class="item.value === modelValue ? 'font-bold' : 'font-medium'"
57
+ >
58
+ {{ item.label }}
59
+ </span>
60
+ </button>
61
+ </nav>
62
+ </template>
@@ -0,0 +1,157 @@
1
+ <script setup lang="ts">
2
+ import { watch } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+
5
+ export interface DrawerItem {
6
+ value: string | number
7
+ label: string
8
+ icon?: string
9
+ badge?: string | number
10
+ disabled?: boolean
11
+ }
12
+
13
+ export interface DrawerSection {
14
+ title?: string
15
+ items: DrawerItem[]
16
+ }
17
+
18
+ const props = withDefaults(defineProps<{
19
+ modelValue: boolean
20
+ selected?: string | number
21
+ sections: DrawerSection[]
22
+ title?: string
23
+ modal?: boolean
24
+ }>(), { modal: true })
25
+
26
+ const emit = defineEmits<{
27
+ 'update:modelValue': [boolean]
28
+ select: [string | number]
29
+ }>()
30
+
31
+ function close() { emit('update:modelValue', false) }
32
+ function select(item: DrawerItem) {
33
+ if (item.disabled) return
34
+ emit('select', item.value)
35
+ if (props.modal) close()
36
+ }
37
+
38
+ watch(() => props.modelValue, (open) => {
39
+ if (open) document.body.style.overflow = 'hidden'
40
+ else document.body.style.overflow = ''
41
+ })
42
+ </script>
43
+
44
+ <template>
45
+ <!-- Modal variant -->
46
+ <Teleport v-if="modal" to="body">
47
+ <Transition name="nd" :duration="{ enter: 300, leave: 280 }">
48
+ <div v-if="modelValue" class="fixed inset-0 z-[100] flex">
49
+ <!-- Scrim -->
50
+ <div class="nd-scrim absolute inset-0 bg-black/40" @click="close" />
51
+
52
+ <!-- Panel -->
53
+ <nav class="nd-panel relative flex h-full w-72 max-w-[85vw] flex-col bg-surface-container shadow-elevation-3">
54
+ <!-- Header -->
55
+ <div v-if="title || $slots.header" class="shrink-0 px-5 pt-6 pb-2">
56
+ <slot name="header">
57
+ <h2 class="text-title-small font-medium text-on-surface-variant">{{ title }}</h2>
58
+ </slot>
59
+ </div>
60
+
61
+ <!-- Sections -->
62
+ <div class="flex-1 overflow-y-auto px-3 py-2">
63
+ <template v-for="(section, si) in sections" :key="si">
64
+ <div v-if="si > 0" class="my-2 border-t border-outline-variant" />
65
+ <p v-if="section.title" class="px-4 pt-4 pb-2 text-title-small font-medium text-on-surface-variant">
66
+ {{ section.title }}
67
+ </p>
68
+ <button
69
+ v-for="item in section.items"
70
+ :key="item.value"
71
+ type="button"
72
+ class="flex w-full items-center gap-3 rounded-full px-4 py-3 text-left transition-colors focus-visible:outline-none"
73
+ :class="[
74
+ item.disabled
75
+ ? 'cursor-not-allowed opacity-[0.38]'
76
+ : item.value === selected
77
+ ? 'bg-secondary-container text-on-secondary-container'
78
+ : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
79
+ ]"
80
+ :disabled="item.disabled"
81
+ @click="select(item)"
82
+ >
83
+ <MIcon v-if="item.icon" :name="item.icon" :size="24" />
84
+ <span class="flex-1 text-label-large font-medium">{{ item.label }}</span>
85
+ <span
86
+ v-if="item.badge != null"
87
+ class="text-label-medium text-on-surface-variant"
88
+ >
89
+ {{ item.badge }}
90
+ </span>
91
+ </button>
92
+ </template>
93
+ </div>
94
+ </nav>
95
+ </div>
96
+ </Transition>
97
+ </Teleport>
98
+
99
+ <!-- Standard (inline) variant -->
100
+ <nav
101
+ v-else
102
+ class="flex h-full w-72 flex-col border-r border-outline-variant bg-surface"
103
+ >
104
+ <div v-if="title || $slots.header" class="shrink-0 px-5 pt-6 pb-2">
105
+ <slot name="header">
106
+ <h2 class="text-title-small font-medium text-on-surface-variant">{{ title }}</h2>
107
+ </slot>
108
+ </div>
109
+ <div class="flex-1 overflow-y-auto px-3 py-2">
110
+ <template v-for="(section, si) in sections" :key="si">
111
+ <div v-if="si > 0" class="my-2 border-t border-outline-variant" />
112
+ <p v-if="section.title" class="px-4 pt-4 pb-2 text-title-small font-medium text-on-surface-variant">
113
+ {{ section.title }}
114
+ </p>
115
+ <button
116
+ v-for="item in section.items"
117
+ :key="item.value"
118
+ type="button"
119
+ class="flex w-full items-center gap-3 rounded-full px-4 py-3 text-left transition-colors focus-visible:outline-none"
120
+ :class="[
121
+ item.disabled
122
+ ? 'cursor-not-allowed opacity-[0.38]'
123
+ : item.value === selected
124
+ ? 'bg-secondary-container text-on-secondary-container'
125
+ : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
126
+ ]"
127
+ :disabled="item.disabled"
128
+ @click="select(item)"
129
+ >
130
+ <MIcon v-if="item.icon" :name="item.icon" :size="24" />
131
+ <span class="flex-1 text-label-large font-medium">{{ item.label }}</span>
132
+ <span v-if="item.badge != null" class="text-label-medium text-on-surface-variant">
133
+ {{ item.badge }}
134
+ </span>
135
+ </button>
136
+ </template>
137
+ </div>
138
+ </nav>
139
+ </template>
140
+
141
+ <style scoped>
142
+ .nd-scrim {
143
+ transition: opacity 280ms ease;
144
+ }
145
+ .nd-enter-from .nd-scrim,
146
+ .nd-leave-to .nd-scrim {
147
+ opacity: 0;
148
+ }
149
+
150
+ .nd-panel {
151
+ transition: transform 300ms cubic-bezier(0.2, 0, 0, 1);
152
+ }
153
+ .nd-enter-from .nd-panel,
154
+ .nd-leave-to .nd-panel {
155
+ transform: translateX(-100%);
156
+ }
157
+ </style>
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ import MIcon from './MIcon.vue'
3
+ import MBadge from './MBadge.vue'
4
+
5
+ export interface NavRailItem {
6
+ value: string | number
7
+ label: string
8
+ icon: string
9
+ badge?: number
10
+ badgeDot?: boolean
11
+ disabled?: boolean
12
+ }
13
+
14
+ withDefaults(defineProps<{
15
+ modelValue: string | number
16
+ items: NavRailItem[]
17
+ alignment?: 'top' | 'center' | 'bottom'
18
+ }>(), { alignment: 'top' })
19
+
20
+ defineEmits<{ 'update:modelValue': [string | number] }>()
21
+ </script>
22
+
23
+ <template>
24
+ <nav class="flex h-full w-20 flex-col items-center border-r border-outline-variant bg-surface">
25
+ <!-- FAB slot -->
26
+ <div v-if="$slots.fab" class="flex shrink-0 items-center justify-center pt-3 pb-2">
27
+ <slot name="fab" />
28
+ </div>
29
+
30
+ <!-- Items -->
31
+ <div
32
+ class="flex flex-1 flex-col items-center gap-1 py-3"
33
+ :class="{
34
+ 'justify-start': alignment === 'top',
35
+ 'justify-center': alignment === 'center',
36
+ 'justify-end': alignment === 'bottom',
37
+ }"
38
+ >
39
+ <button
40
+ v-for="item in items"
41
+ :key="item.value"
42
+ type="button"
43
+ class="group flex w-full cursor-pointer flex-col items-center justify-center gap-1 px-3 py-2 focus-visible:outline-none"
44
+ :class="item.disabled ? 'cursor-not-allowed opacity-[0.38]' : ''"
45
+ :disabled="item.disabled"
46
+ @click="!item.disabled && $emit('update:modelValue', item.value)"
47
+ >
48
+ <!-- Pill indicator -->
49
+ <span
50
+ class="inline-flex h-8 items-center justify-center rounded-2xl transition-all duration-200"
51
+ :class="
52
+ item.value === modelValue
53
+ ? 'w-14 bg-secondary-container text-on-secondary-container'
54
+ : 'w-14 bg-transparent text-on-surface-variant group-hover:bg-on-surface/8'
55
+ "
56
+ >
57
+ <MBadge v-if="item.badge != null" :count="item.badge">
58
+ <MIcon :name="item.icon" :size="24" />
59
+ </MBadge>
60
+ <MBadge v-else-if="item.badgeDot" dot>
61
+ <MIcon :name="item.icon" :size="24" />
62
+ </MBadge>
63
+ <MIcon v-else :name="item.icon" :size="24" />
64
+ </span>
65
+
66
+ <!-- Label -->
67
+ <span
68
+ class="max-w-[56px] truncate text-center text-label-medium"
69
+ :class="
70
+ item.value === modelValue
71
+ ? 'font-bold text-on-surface'
72
+ : 'font-medium text-on-surface-variant'
73
+ "
74
+ >
75
+ {{ item.label }}
76
+ </span>
77
+ </button>
78
+ </div>
79
+ </nav>
80
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import MIconButton from './MIconButton.vue'
4
+
5
+ const props = defineProps<{ page: number; perPage: number; total: number }>()
6
+ const emit = defineEmits<{ 'update:page': [number] }>()
7
+
8
+ const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.perPage)))
9
+
10
+ const rangeLabel = computed(() => {
11
+ if (props.total === 0) return '0 resultados'
12
+ const from = (props.page - 1) * props.perPage + 1
13
+ const to = Math.min(props.page * props.perPage, props.total)
14
+ return `${from}-${to} de ${props.total}`
15
+ })
16
+ </script>
17
+
18
+ <template>
19
+ <div class="flex flex-wrap items-center justify-between gap-4 text-body-medium text-on-surface-variant">
20
+ <span>{{ rangeLabel }}</span>
21
+ <div class="flex items-center gap-2">
22
+ <span>Página {{ page }} de {{ totalPages }}</span>
23
+ <MIconButton
24
+ icon="chevron_left"
25
+ label="Página anterior"
26
+ :disabled="page <= 1"
27
+ @click="emit('update:page', page - 1)"
28
+ />
29
+ <MIconButton
30
+ icon="chevron_right"
31
+ label="Página siguiente"
32
+ :disabled="page >= totalPages"
33
+ @click="emit('update:page', page + 1)"
34
+ />
35
+ </div>
36
+ </div>
37
+ </template>