@m3ui-vue/m3ui-vue 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/{MMenuItem-_n5OG5MT.js → MMenuItem-DpoEsH91.js} +215 -122
  2. package/dist/MMenuItem-DpoEsH91.js.map +1 -0
  3. package/dist/components/MAlert.vue.d.ts +1 -0
  4. package/dist/components/MButton.vue.d.ts +1 -1
  5. package/dist/components/MCalendar.vue.d.ts +3 -0
  6. package/dist/components/MCarousel.vue.d.ts +36 -0
  7. package/dist/components/MChip.vue.d.ts +3 -3
  8. package/dist/components/MCommandPalette.vue.d.ts +3 -2
  9. package/dist/components/MConfirmDialog.vue.d.ts +0 -2
  10. package/dist/components/MDataTable.vue.d.ts +25 -2
  11. package/dist/components/MDatePicker.vue.d.ts +2 -0
  12. package/dist/components/MDateRangePicker.vue.d.ts +5 -0
  13. package/dist/components/MDialog.vue.d.ts +1 -0
  14. package/dist/components/MFab.vue.d.ts +1 -1
  15. package/dist/components/MFileUpload.vue.d.ts +15 -1
  16. package/dist/components/MFlex.vue.d.ts +1 -1
  17. package/dist/components/MInfiniteScroll.vue.d.ts +7 -5
  18. package/dist/components/MMaskField.vue.d.ts +24 -0
  19. package/dist/components/MMultiSelect.vue.d.ts +20 -6
  20. package/dist/components/MNavigationDrawer.vue.d.ts +9 -3
  21. package/dist/components/MNumberField.vue.d.ts +27 -0
  22. package/dist/components/MRichTextEditor.vue.d.ts +28 -1
  23. package/dist/components/MScheduler.vue.d.ts +5 -0
  24. package/dist/components/MSelect.vue.d.ts +12 -9
  25. package/dist/components/MSnackbar.vue.d.ts +4 -1
  26. package/dist/components/MSpinner.vue.d.ts +1 -0
  27. package/dist/components/MSplitter.vue.d.ts +1 -1
  28. package/dist/components/MSpotlightSearch.vue.d.ts +3 -2
  29. package/dist/components/MStack.vue.d.ts +1 -1
  30. package/dist/components/MTable.vue.d.ts +3 -2
  31. package/dist/components/MTextField.vue.d.ts +2 -5
  32. package/dist/components/MTimePicker.vue.d.ts +1 -0
  33. package/dist/components/MTour.vue.d.ts +3 -0
  34. package/dist/components/MTransferList.vue.d.ts +19 -3
  35. package/dist/components/MTree.vue.d.ts +1 -1
  36. package/dist/components/_MDrawerItemList.vue.d.ts +57 -0
  37. package/dist/composables/useLocale.d.ts +74 -0
  38. package/dist/index.d.ts +10 -1
  39. package/dist/locales/de.d.ts +2 -0
  40. package/dist/locales/es.d.ts +2 -0
  41. package/dist/locales/fr.d.ts +2 -0
  42. package/dist/locales/index.d.ts +8 -0
  43. package/dist/locales/ja.d.ts +2 -0
  44. package/dist/locales/ko.d.ts +2 -0
  45. package/dist/locales/pt.d.ts +2 -0
  46. package/dist/locales/zh.d.ts +2 -0
  47. package/dist/m3ui-vue.css +1 -1
  48. package/dist/m3ui.js +3278 -2489
  49. package/dist/m3ui.js.map +1 -1
  50. package/dist/plugin.d.ts +3 -1
  51. package/dist/rich-text-editor.d.ts +1 -0
  52. package/dist/rich-text-editor.js +141 -113
  53. package/dist/rich-text-editor.js.map +1 -1
  54. package/dist/styles/palettes.css +660 -0
  55. package/dist/styles.css +1 -1
  56. package/package.json +6 -2
  57. package/src/components/MAlert.vue +5 -1
  58. package/src/components/MCalendar.vue +14 -5
  59. package/src/components/MCarousel.vue +203 -0
  60. package/src/components/MCommandPalette.vue +11 -7
  61. package/src/components/MConfirmDialog.vue +6 -5
  62. package/src/components/MDataTable.vue +78 -14
  63. package/src/components/MDatePicker.vue +11 -4
  64. package/src/components/MDateRangePicker.vue +15 -5
  65. package/src/components/MDialog.vue +5 -1
  66. package/src/components/MFileUpload.vue +22 -10
  67. package/src/components/MInfiniteScroll.vue +14 -9
  68. package/src/components/MMaskField.vue +198 -0
  69. package/src/components/MMultiSelect.vue +53 -24
  70. package/src/components/MNavigationDrawer.vue +282 -107
  71. package/src/components/MNumberField.vue +176 -0
  72. package/src/components/MRichTextEditor.vue +95 -34
  73. package/src/components/MScheduler.vue +12 -4
  74. package/src/components/MSelect.vue +41 -14
  75. package/src/components/MSnackbar.vue +8 -1
  76. package/src/components/MSpinner.vue +5 -1
  77. package/src/components/MSpotlightSearch.vue +11 -7
  78. package/src/components/MTable.vue +8 -4
  79. package/src/components/MTextField.vue +19 -10
  80. package/src/components/MTimePicker.vue +5 -1
  81. package/src/components/MTour.vue +12 -9
  82. package/src/components/MTransferList.vue +30 -15
  83. package/src/components/_MDrawerItemList.vue +105 -0
  84. package/src/composables/useColorPalette.ts +30 -20
  85. package/src/composables/useLocale.ts +181 -0
  86. package/src/index.ts +10 -1
  87. package/src/locales/de.ts +93 -0
  88. package/src/locales/es.ts +93 -0
  89. package/src/locales/fr.ts +93 -0
  90. package/src/locales/index.ts +8 -0
  91. package/src/locales/ja.ts +93 -0
  92. package/src/locales/ko.ts +93 -0
  93. package/src/locales/pt.ts +93 -0
  94. package/src/locales/zh.ts +93 -0
  95. package/src/plugin.ts +7 -1
  96. package/src/rich-text-editor.ts +1 -0
  97. package/src/styles/palettes.css +660 -0
  98. package/dist/MMenuItem-_n5OG5MT.js.map +0 -1
@@ -1,10 +1,13 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, useSlots, watch } from 'vue'
2
+ import { computed, ref, useSlots, watch, onMounted } from 'vue'
3
3
  import MCheckbox from './MCheckbox.vue'
4
4
  import MIcon from './MIcon.vue'
5
5
  import MIconButton from './MIconButton.vue'
6
6
  import MPagination from './MPagination.vue'
7
7
  import MChip from './MChip.vue'
8
+ import { useLocale } from '../composables/useLocale'
9
+
10
+ const locale = useLocale()
8
11
 
9
12
  export interface DataTableColumn {
10
13
  key: string
@@ -43,9 +46,18 @@ const props = withDefaults(defineProps<{
43
46
  groupBy?: string
44
47
  columnToggle?: boolean
45
48
  exportable?: boolean
49
+ serverSide?: boolean
50
+ total?: number
51
+ page?: number
52
+ searchPlaceholder?: string
53
+ selectedText?: string
54
+ recordsText?: string
55
+ expandLabel?: string
56
+ columnsLabel?: string
57
+ exportLabel?: string
58
+ noGroupText?: string
46
59
  }>(), {
47
60
  loading: false,
48
- emptyText: 'Sin resultados',
49
61
  rowKey: 'id',
50
62
  selectable: false,
51
63
  modelValue: () => [],
@@ -57,11 +69,24 @@ const props = withDefaults(defineProps<{
57
69
  stickyHeader: false,
58
70
  columnToggle: false,
59
71
  exportable: false,
72
+ serverSide: false,
73
+ total: 0,
74
+ page: 1,
60
75
  })
61
76
 
77
+ export interface DataTableFetchParams {
78
+ page: number
79
+ perPage: number
80
+ search: string
81
+ sortKey: string
82
+ sortDir: 'asc' | 'desc' | ''
83
+ }
84
+
62
85
  const emit = defineEmits<{
63
86
  'update:modelValue': [Record<string, any>[]]
87
+ 'update:page': [number]
64
88
  rowClick: [Record<string, any>]
89
+ fetch: [DataTableFetchParams]
65
90
  }>()
66
91
 
67
92
  const slots = useSlots()
@@ -87,7 +112,16 @@ function toggleSort(key: string) {
87
112
  else { sortKey.value = ''; sortDir.value = '' }
88
113
  }
89
114
 
115
+ const currentPage = computed({
116
+ get: () => props.serverSide ? (props.page ?? 1) : internalPage.value,
117
+ set: (val: number) => {
118
+ if (props.serverSide) emit('update:page', val)
119
+ else internalPage.value = val
120
+ },
121
+ })
122
+
90
123
  const processedRows = computed(() => {
124
+ if (props.serverSide) return props.rows
91
125
  let result = props.rows
92
126
  if (search.value.trim()) {
93
127
  const q = search.value.toLowerCase()
@@ -112,20 +146,50 @@ const groupedRows = computed(() => {
112
146
  if (!props.groupBy) return null
113
147
  const map = new Map<string, Record<string, any>[]>()
114
148
  for (const row of processedRows.value) {
115
- const key = String(row[props.groupBy] ?? 'Sin grupo')
149
+ const key = String(row[props.groupBy] ?? (props.noGroupText ?? locale.noGroup))
116
150
  if (!map.has(key)) map.set(key, [])
117
151
  map.get(key)!.push(row)
118
152
  }
119
153
  return map
120
154
  })
121
155
 
122
- const totalCount = computed(() => processedRows.value.length)
156
+ const totalCount = computed(() => props.serverSide ? (props.total ?? 0) : processedRows.value.length)
123
157
  const visibleRows = computed(() => {
124
- const start = (internalPage.value - 1) * props.perPage
158
+ if (props.serverSide) return props.rows
159
+ const start = (currentPage.value - 1) * props.perPage
125
160
  return processedRows.value.slice(start, start + props.perPage)
126
161
  })
127
162
 
128
- watch([search, sortKey, sortDir], () => { internalPage.value = 1 })
163
+ watch([search, sortKey, sortDir], () => {
164
+ if (!props.serverSide) internalPage.value = 1
165
+ })
166
+
167
+ function emitFetch() {
168
+ emit('fetch', {
169
+ page: currentPage.value,
170
+ perPage: props.perPage,
171
+ search: search.value,
172
+ sortKey: sortKey.value,
173
+ sortDir: sortDir.value,
174
+ })
175
+ }
176
+
177
+ const mounted = ref(false)
178
+ onMounted(() => {
179
+ mounted.value = true
180
+ if (props.serverSide) emitFetch()
181
+ })
182
+
183
+ watch([search, sortKey, sortDir], () => {
184
+ if (!props.serverSide || !mounted.value) return
185
+ internalPage.value = 1
186
+ emitFetch()
187
+ })
188
+
189
+ watch(currentPage, () => {
190
+ if (!props.serverSide || !mounted.value) return
191
+ emitFetch()
192
+ })
129
193
 
130
194
  const selected = computed({
131
195
  get: () => props.modelValue ?? [],
@@ -214,7 +278,7 @@ function colStyle(col: DataTableColumn) {
214
278
  >
215
279
  <div v-if="searchable" class="flex min-w-48 flex-1 items-center gap-2 rounded-full border border-outline-variant bg-surface-container px-3 py-1.5 transition-[border-color,box-shadow] duration-150 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30">
216
280
  <MIcon name="search" :size="16" class="shrink-0 text-on-surface-variant" />
217
- <input v-model="search" type="text" placeholder="Buscar..." class="w-full bg-transparent text-body-medium text-on-surface outline-none placeholder:text-on-surface-variant" />
281
+ <input v-model="search" type="text" :placeholder="searchPlaceholder ?? locale.search" class="w-full bg-transparent text-body-medium text-on-surface outline-none placeholder:text-on-surface-variant" />
218
282
  <button v-if="search" class="text-on-surface-variant transition-colors hover:text-on-surface" @click="search = ''">
219
283
  <MIcon name="close" :size="14" />
220
284
  </button>
@@ -224,13 +288,13 @@ function colStyle(col: DataTableColumn) {
224
288
 
225
289
  <Transition enter-active-class="transition-[opacity,transform] duration-150" enter-from-class="opacity-0 scale-90" leave-active-class="transition-[opacity,transform] duration-100" leave-to-class="opacity-0 scale-90">
226
290
  <span v-if="selectable && selected.length > 0" class="rounded-full bg-primary/12 px-3 py-1 text-label-small font-medium text-primary">
227
- {{ selected.length }} seleccionado{{ selected.length !== 1 ? 's' : '' }}
291
+ {{ selected.length }} {{ selectedText ?? locale.selectedCount }}{{ selected.length !== 1 ? 's' : '' }}
228
292
  </span>
229
293
  </Transition>
230
294
 
231
295
  <!-- Column toggle -->
232
296
  <div v-if="columnToggle" class="relative">
233
- <MIconButton icon="view_column" label="Columnas" :size="36" @click="showColMenu = !showColMenu" />
297
+ <MIconButton icon="view_column" :label="columnsLabel ?? locale.columns" :size="36" @click="showColMenu = !showColMenu" />
234
298
  <div v-if="showColMenu" class="absolute right-0 top-full z-10 mt-1 min-w-40 rounded-lg bg-surface-container py-2 shadow-elevation-3">
235
299
  <label v-for="col in columns" :key="col.key" class="flex cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-on-surface/4">
236
300
  <MCheckbox
@@ -242,7 +306,7 @@ function colStyle(col: DataTableColumn) {
242
306
  </div>
243
307
  </div>
244
308
 
245
- <MIconButton v-if="exportable" icon="download" label="Exportar CSV" :size="36" @click="exportCSV" />
309
+ <MIconButton v-if="exportable" icon="download" :label="exportLabel ?? locale.exportCsv" :size="36" @click="exportCSV" />
246
310
  </div>
247
311
 
248
312
  <!-- Table -->
@@ -308,7 +372,7 @@ function colStyle(col: DataTableColumn) {
308
372
  <td :colspan="visibleColumns.length + extraCols" class="border-t border-outline-variant px-4 py-14 text-center">
309
373
  <slot name="empty">
310
374
  <MIcon name="search_off" :size="36" class="mb-2 text-on-surface-variant opacity-30" />
311
- <p class="text-body-medium text-on-surface-variant">{{ emptyText }}</p>
375
+ <p class="text-body-medium text-on-surface-variant">{{ emptyText ?? locale.noResults }}</p>
312
376
  </slot>
313
377
  </td>
314
378
  </tr>
@@ -330,7 +394,7 @@ function colStyle(col: DataTableColumn) {
330
394
  <td v-if="hasExpand" class="px-2" :class="dense ? 'py-1' : 'py-2'" @click.stop>
331
395
  <MIconButton
332
396
  icon="expand_more"
333
- label="Expandir"
397
+ :label="expandLabel ?? locale.expand"
334
398
  :size="28"
335
399
  :class="isExpanded(row) ? 'rotate-180' : ''"
336
400
  class="transition-transform duration-200"
@@ -374,9 +438,9 @@ function colStyle(col: DataTableColumn) {
374
438
  <!-- Footer -->
375
439
  <div class="flex items-center justify-between gap-4 border-t border-outline-variant bg-surface-container-lowest px-4 py-2">
376
440
  <span class="text-label-small text-on-surface-variant">
377
- {{ totalCount }} registro{{ totalCount !== 1 ? 's' : '' }}
441
+ {{ totalCount }} {{ recordsText ?? locale.recordCount }}{{ totalCount !== 1 ? 's' : '' }}
378
442
  </span>
379
- <MPagination :page="internalPage" :per-page="perPage" :total="totalCount" @update:page="internalPage = $event" />
443
+ <MPagination :page="currentPage" :per-page="perPage" :total="totalCount" @update:page="currentPage = $event" />
380
444
  </div>
381
445
  </div>
382
446
  </template>
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
3
3
  import MIcon from './MIcon.vue'
4
4
  import MIconButton from './MIconButton.vue'
5
5
  import { useFieldBg } from '../composables/useFieldBg'
6
+ import { useLocale } from '../composables/useLocale'
6
7
 
7
8
  const props = withDefaults(defineProps<{
8
9
  modelValue: string | null
@@ -15,7 +16,13 @@ const props = withDefaults(defineProps<{
15
16
  hint?: string
16
17
  locale?: string
17
18
  fieldBg?: string
18
- }>(), { locale: 'es-ES' })
19
+ prevMonthLabel?: string
20
+ nextMonthLabel?: string
21
+ }>(), {
22
+ locale: 'es-ES',
23
+ })
24
+
25
+ const localeStrings = useLocale()
19
26
 
20
27
  const emit = defineEmits<{ 'update:modelValue': [string | null] }>()
21
28
 
@@ -173,7 +180,7 @@ onUnmounted(() => {
173
180
  >
174
181
  <MIcon name="calendar_today" :size="20" class="shrink-0 text-on-surface-variant" />
175
182
  <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>
183
+ <span v-else class="flex-1 text-on-surface-variant">{{ placeholder || label || localeStrings.selectDate }}</span>
177
184
  <MIcon
178
185
  v-if="modelValue"
179
186
  name="close"
@@ -210,9 +217,9 @@ onUnmounted(() => {
210
217
  >
211
218
  <!-- Header -->
212
219
  <div class="mb-3 flex items-center justify-between">
213
- <MIconButton icon="chevron_left" label="Mes anterior" :size="36" @click="prevMonth" />
220
+ <MIconButton icon="chevron_left" :label="prevMonthLabel ?? localeStrings.previousMonth" :size="36" @click="prevMonth" />
214
221
  <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" />
222
+ <MIconButton icon="chevron_right" :label="nextMonthLabel ?? localeStrings.nextMonth" :size="36" @click="nextMonth" />
216
223
  </div>
217
224
 
218
225
  <!-- Weekday headers -->
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
3
3
  import MIcon from './MIcon.vue'
4
4
  import MIconButton from './MIconButton.vue'
5
5
  import { useFieldBg } from '../composables/useFieldBg'
6
+ import { useLocale } from '../composables/useLocale'
6
7
 
7
8
  export interface DateRange {
8
9
  start: string | null
@@ -12,6 +13,7 @@ export interface DateRange {
12
13
  const props = withDefaults(defineProps<{
13
14
  modelValue: DateRange
14
15
  label?: string
16
+ placeholder?: string
15
17
  min?: string
16
18
  max?: string
17
19
  disabled?: boolean
@@ -19,7 +21,15 @@ const props = withDefaults(defineProps<{
19
21
  hint?: string
20
22
  locale?: string
21
23
  fieldBg?: string
22
- }>(), { locale: 'es-ES' })
24
+ prevMonthLabel?: string
25
+ nextMonthLabel?: string
26
+ pickStartText?: string
27
+ pickEndText?: string
28
+ }>(), {
29
+ locale: 'es-ES',
30
+ })
31
+
32
+ const localeStrings = useLocale()
23
33
 
24
34
  const emit = defineEmits<{ 'update:modelValue': [DateRange] }>()
25
35
 
@@ -184,7 +194,7 @@ onUnmounted(() => {
184
194
  >
185
195
  <MIcon name="date_range" :size="20" class="shrink-0 text-on-surface-variant" />
186
196
  <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>
197
+ <span v-else class="flex-1 text-on-surface-variant">{{ label || placeholder || localeStrings.selectRange }}</span>
188
198
  <MIcon
189
199
  v-if="modelValue.start || modelValue.end"
190
200
  name="close"
@@ -219,13 +229,13 @@ onUnmounted(() => {
219
229
  :style="dropPos"
220
230
  >
221
231
  <p class="mb-2 text-center text-label-medium text-on-surface-variant">
222
- {{ picking === 'start' ? 'Selecciona inicio' : 'Selecciona fin' }}
232
+ {{ picking === 'start' ? (pickStartText ?? localeStrings.pickStart) : (pickEndText ?? localeStrings.pickEnd) }}
223
233
  </p>
224
234
 
225
235
  <div class="mb-3 flex items-center justify-between">
226
- <MIconButton icon="chevron_left" label="Anterior" :size="36" @click="prevMonth" />
236
+ <MIconButton icon="chevron_left" :label="prevMonthLabel ?? localeStrings.previousMonth" :size="36" @click="prevMonth" />
227
237
  <span class="text-title-small font-medium capitalize text-on-surface">{{ monthLabel }}</span>
228
- <MIconButton icon="chevron_right" label="Siguiente" :size="36" @click="nextMonth" />
238
+ <MIconButton icon="chevron_right" :label="nextMonthLabel ?? localeStrings.nextMonth" :size="36" @click="nextMonth" />
229
239
  </div>
230
240
 
231
241
  <div class="mb-1 grid grid-cols-7 gap-0.5 text-center">
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { watch } from 'vue'
3
3
  import MIconButton from './MIconButton.vue'
4
+ import { useLocale } from '../composables/useLocale'
4
5
 
5
6
  const props = withDefaults(
6
7
  defineProps<{
@@ -8,6 +9,7 @@ const props = withDefaults(
8
9
  title?: string
9
10
  maxWidth?: string
10
11
  persistent?: boolean
12
+ closeLabel?: string
11
13
  }>(),
12
14
  {
13
15
  maxWidth: 'max-w-md',
@@ -15,6 +17,8 @@ const props = withDefaults(
15
17
  },
16
18
  )
17
19
 
20
+ const locale = useLocale()
21
+
18
22
  const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
19
23
 
20
24
  function close() {
@@ -56,7 +60,7 @@ watch(
56
60
  <h2 class="text-headline-small text-on-surface">
57
61
  <slot name="title">{{ title }}</slot>
58
62
  </h2>
59
- <MIconButton v-if="!persistent" icon="close" label="Cerrar" @click="close" />
63
+ <MIconButton v-if="!persistent" icon="close" :label="closeLabel ?? locale.close" @click="close" />
60
64
  </div>
61
65
  <div class="overflow-y-auto px-6 py-2 text-body-medium text-on-surface-variant">
62
66
  <slot />
@@ -3,6 +3,9 @@ import { ref, computed } from 'vue'
3
3
  import MIcon from './MIcon.vue'
4
4
  import MIconButton from './MIconButton.vue'
5
5
  import MSpinner from './MSpinner.vue'
6
+ import { useLocale } from '../composables/useLocale'
7
+
8
+ const locale = useLocale()
6
9
 
7
10
  export interface UploadFile {
8
11
  file: File
@@ -18,8 +21,15 @@ const props = withDefaults(
18
21
  multiple?: boolean
19
22
  maxSize?: number
20
23
  disabled?: boolean
24
+ dropText?: string
25
+ selectText?: string
26
+ maxSizePrefix?: string
27
+ removeLabel?: string
21
28
  }>(),
22
- { multiple: false, disabled: false },
29
+ {
30
+ multiple: false,
31
+ disabled: false,
32
+ },
23
33
  )
24
34
 
25
35
  const emit = defineEmits<{
@@ -128,14 +138,16 @@ function openPicker() {
128
138
  class="text-on-surface-variant"
129
139
  />
130
140
  <div class="text-center">
131
- <p class="text-body-large text-on-surface">
132
- Arrastra archivos aquí o <span class="font-medium text-primary">selecciona</span>
133
- </p>
134
- <p v-if="accept || maxSize" class="mt-1 text-body-small text-on-surface-variant">
135
- <span v-if="accept">{{ accept }}</span>
136
- <span v-if="accept && maxSize"> · </span>
137
- <span v-if="maxSize">Máx. {{ formatSize(maxSize) }}</span>
138
- </p>
141
+ <slot name="dropzone">
142
+ <p class="text-body-large text-on-surface">
143
+ {{ dropText ?? locale.dropText }} <span class="font-medium text-primary">{{ selectText ?? locale.selectText }}</span>
144
+ </p>
145
+ <p v-if="accept || maxSize" class="mt-1 text-body-small text-on-surface-variant">
146
+ <span v-if="accept">{{ accept }}</span>
147
+ <span v-if="accept && maxSize"> · </span>
148
+ <span v-if="maxSize">{{ maxSizePrefix ?? locale.maxSizePrefix }} {{ formatSize(maxSize) }}</span>
149
+ </p>
150
+ </slot>
139
151
  </div>
140
152
  </div>
141
153
 
@@ -187,7 +199,7 @@ function openPicker() {
187
199
  <MIcon v-else-if="entry.status === 'done'" name="check_circle" :size="20" class="text-success" />
188
200
  <MIcon v-else-if="entry.status === 'error'" name="error" :size="20" class="text-error" />
189
201
 
190
- <MIconButton icon="close" label="Eliminar" :size="32" @click="removeFile(entry)" />
202
+ <MIconButton icon="close" :label="removeLabel ?? locale.remove" :size="32" @click="removeFile(entry)" />
191
203
  </div>
192
204
  </TransitionGroup>
193
205
  </div>
@@ -1,6 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
3
3
  import MSpinner from './MSpinner.vue'
4
+ import { useLocale } from '../composables/useLocale'
5
+
6
+ const locale = useLocale()
4
7
 
5
8
  const props = withDefaults(
6
9
  defineProps<{
@@ -15,8 +18,6 @@ const props = withDefaults(
15
18
  loading: false,
16
19
  disabled: false,
17
20
  threshold: 100,
18
- loadingText: 'Cargando...',
19
- endText: 'No hay más elementos',
20
21
  ended: false,
21
22
  },
22
23
  )
@@ -55,13 +56,17 @@ onBeforeUnmount(() => observer?.disconnect())
55
56
  <slot />
56
57
 
57
58
  <div ref="sentinelRef" class="flex items-center justify-center py-4">
58
- <div v-if="loading" class="flex items-center gap-3">
59
- <MSpinner :size="20" class="text-primary" />
60
- <span class="text-body-medium text-on-surface-variant">{{ loadingText }}</span>
61
- </div>
62
- <p v-else-if="ended" class="text-body-small text-on-surface-variant">
63
- {{ endText }}
64
- </p>
59
+ <slot v-if="loading" name="loading">
60
+ <div class="flex items-center gap-3">
61
+ <MSpinner :size="20" class="text-primary" />
62
+ <span class="text-body-medium text-on-surface-variant">{{ loadingText ?? locale.loadingMore }}</span>
63
+ </div>
64
+ </slot>
65
+ <slot v-else-if="ended" name="end">
66
+ <p class="text-body-small text-on-surface-variant">
67
+ {{ endText ?? locale.noMoreItems }}
68
+ </p>
69
+ </slot>
65
70
  <slot v-else name="idle" />
66
71
  </div>
67
72
  </div>
@@ -0,0 +1,198 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, useId, watch } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+ import { useFieldBg } from '../composables/useFieldBg'
5
+
6
+ export type MaskPreset = 'credit-card' | 'phone' | 'date' | 'time' | 'cpf' | 'cnpj' | 'zip'
7
+
8
+ const PRESETS: Record<MaskPreset, string> = {
9
+ 'credit-card': '#### #### #### ####',
10
+ phone: '(###) ###-####',
11
+ date: '##/##/####',
12
+ time: '##:##',
13
+ cpf: '###.###.###-##',
14
+ cnpj: '##.###.###/####-##',
15
+ zip: '#####-####',
16
+ }
17
+
18
+ const props = withDefaults(
19
+ defineProps<{
20
+ modelValue: string
21
+ label: string
22
+ mask: string | MaskPreset
23
+ variant?: 'filled' | 'outlined'
24
+ error?: string
25
+ hint?: string
26
+ disabled?: boolean
27
+ required?: boolean
28
+ leadingIcon?: string
29
+ clearable?: boolean
30
+ fieldBg?: string
31
+ }>(),
32
+ {
33
+ variant: 'filled',
34
+ clearable: false,
35
+ },
36
+ )
37
+
38
+ const emit = defineEmits<{ 'update:modelValue': [string] }>()
39
+
40
+ const id = useId()
41
+ const fieldBgEl = ref<HTMLElement | null>(null)
42
+ const { resolvedFieldBg } = useFieldBg(fieldBgEl, () => props.fieldBg)
43
+ const inputEl = ref<HTMLInputElement | null>(null)
44
+
45
+ const resolvedMask = computed(() => PRESETS[props.mask as MaskPreset] ?? props.mask)
46
+
47
+ const showClear = computed(() => props.clearable && props.modelValue.length > 0 && !props.disabled)
48
+
49
+ function rawToMasked(raw: string, mask: string): string {
50
+ let result = ''
51
+ let ri = 0
52
+ for (let mi = 0; mi < mask.length && ri < raw.length; mi++) {
53
+ if (mask[mi] === '#') {
54
+ result += raw[ri++]
55
+ } else {
56
+ result += mask[mi]
57
+ if (raw[ri] === mask[mi]) ri++
58
+ }
59
+ }
60
+ return result
61
+ }
62
+
63
+ function maskedToRaw(masked: string): string {
64
+ const mask = resolvedMask.value
65
+ let raw = ''
66
+ for (let i = 0; i < masked.length && i < mask.length; i++) {
67
+ if (mask[i] === '#') raw += masked[i]
68
+ }
69
+ return raw
70
+ }
71
+
72
+ function onInput(e: Event) {
73
+ const input = e.target as HTMLInputElement
74
+ const raw = input.value.replace(/[^\d]/g, '')
75
+ const masked = rawToMasked(raw, resolvedMask.value)
76
+ emit('update:modelValue', masked)
77
+
78
+ requestAnimationFrame(() => {
79
+ if (inputEl.value) inputEl.value.value = masked
80
+ })
81
+ }
82
+
83
+ function onKeydown(e: KeyboardEvent) {
84
+ if (e.key === 'Backspace' && props.modelValue.length > 0) {
85
+ const raw = maskedToRaw(props.modelValue)
86
+ if (raw.length > 0) {
87
+ e.preventDefault()
88
+ const newRaw = raw.slice(0, -1)
89
+ const masked = rawToMasked(newRaw, resolvedMask.value)
90
+ emit('update:modelValue', masked)
91
+ requestAnimationFrame(() => {
92
+ if (inputEl.value) inputEl.value.value = masked
93
+ })
94
+ }
95
+ }
96
+ }
97
+
98
+ const hasTrailing = computed(() => props.clearable)
99
+
100
+ const inputClasses = computed(() => {
101
+ const pl = props.leadingIcon ? 'pl-12' : 'pl-4'
102
+ const pr = hasTrailing.value ? 'pr-12' : 'pr-4'
103
+ const base = [
104
+ 'peer block w-full text-body-large text-on-surface outline-none placeholder:text-transparent',
105
+ 'transition-[border-color,border-width] duration-150',
106
+ 'disabled:cursor-not-allowed disabled:opacity-[0.38]',
107
+ 'h-14', pl, pr,
108
+ ]
109
+ if (props.variant === 'outlined') {
110
+ return [...base,
111
+ 'rounded-sm border bg-transparent py-4',
112
+ props.error
113
+ ? 'border-error focus:border-2 focus:border-error'
114
+ : 'border-outline hover:border-on-surface focus:border-2 focus:border-primary',
115
+ ].join(' ')
116
+ }
117
+ return [...base,
118
+ 'rounded-t-sm bg-surface-container-highest border-b pt-6 pb-2',
119
+ props.error
120
+ ? 'border-error focus:border-b-2 focus:border-error'
121
+ : 'border-on-surface-variant hover:border-on-surface focus:border-b-2 focus:border-primary',
122
+ ].join(' ')
123
+ })
124
+
125
+ const labelClasses = computed(() => {
126
+ const left = props.leadingIcon
127
+ ? (props.variant === 'outlined' ? 'left-11' : 'left-12')
128
+ : (props.variant === 'outlined' ? 'left-3' : 'left-4')
129
+ const unfloatedTop = props.variant === 'filled' ? 'top-[53%]' : 'top-1/2'
130
+ const base = [
131
+ 'pointer-events-none absolute truncate transition-all duration-200',
132
+ left, 'right-4', `${unfloatedTop} -translate-y-1/2 text-body-large`,
133
+ ]
134
+ if (props.variant === 'outlined') {
135
+ return [...base,
136
+ 'peer-focus:-top-2.5 peer-focus:translate-y-0 peer-focus:text-label-small peer-focus:right-auto peer-focus:max-w-[calc(100%-1.5rem)] peer-focus:bg-[var(--field-bg)] peer-focus:px-1',
137
+ 'peer-[&:not(:placeholder-shown)]:-top-2.5 peer-[&:not(:placeholder-shown)]:translate-y-0 peer-[&:not(:placeholder-shown)]:right-auto peer-[&:not(:placeholder-shown)]:max-w-[calc(100%-1.5rem)]',
138
+ 'peer-[&:not(:placeholder-shown)]:text-label-small peer-[&:not(:placeholder-shown)]:bg-[var(--field-bg)] peer-[&:not(:placeholder-shown)]:px-1',
139
+ props.error ? 'text-error peer-focus:text-error' : 'text-on-surface-variant peer-focus:text-primary',
140
+ ].join(' ')
141
+ }
142
+ return [...base,
143
+ 'peer-focus:top-2 peer-focus:translate-y-0 peer-focus:text-label-small',
144
+ 'peer-[&:not(:placeholder-shown)]:top-2 peer-[&:not(:placeholder-shown)]:translate-y-0 peer-[&:not(:placeholder-shown)]:text-label-small',
145
+ props.error ? 'text-error peer-focus:text-error' : 'text-on-surface-variant peer-focus:text-primary',
146
+ ].join(' ')
147
+ })
148
+ </script>
149
+
150
+ <template>
151
+ <div class="flex flex-col gap-1">
152
+ <div
153
+ ref="fieldBgEl"
154
+ class="relative"
155
+ :class="variant === 'outlined' ? 'mt-2' : ''"
156
+ :style="variant === 'outlined' ? { '--field-bg': resolvedFieldBg } : undefined"
157
+ >
158
+ <div
159
+ v-if="leadingIcon"
160
+ class="pointer-events-none absolute left-3.5 text-on-surface-variant"
161
+ :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
162
+ >
163
+ <MIcon :name="leadingIcon" :size="20" />
164
+ </div>
165
+
166
+ <input
167
+ :id="id"
168
+ ref="inputEl"
169
+ type="text"
170
+ inputmode="numeric"
171
+ :value="modelValue"
172
+ :disabled="disabled"
173
+ :required="required"
174
+ placeholder=" "
175
+ :class="inputClasses"
176
+ @input="onInput"
177
+ @keydown="onKeydown"
178
+ />
179
+
180
+ <label :for="id" :class="labelClasses">
181
+ {{ label }}<span v-if="required" class="text-error">&nbsp;*</span>
182
+ </label>
183
+
184
+ <button
185
+ v-if="showClear"
186
+ type="button"
187
+ class="absolute right-3 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
188
+ :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
189
+ @click="emit('update:modelValue', '')"
190
+ >
191
+ <MIcon name="close" :size="18" />
192
+ </button>
193
+ </div>
194
+
195
+ <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
196
+ <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
197
+ </div>
198
+ </template>