@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,404 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import MCheckbox from './MCheckbox.vue'
4
+ import MIcon from './MIcon.vue'
5
+ import MPagination from './MPagination.vue'
6
+
7
+ export interface TableColumn {
8
+ key: string
9
+ label: string
10
+ sortable?: boolean
11
+ width?: string
12
+ align?: 'left' | 'center' | 'right'
13
+ }
14
+
15
+ export interface TableFetchParams {
16
+ page: number
17
+ perPage: number
18
+ search: string
19
+ sortKey: string
20
+ sortDir: 'asc' | 'desc' | ''
21
+ }
22
+
23
+ // Static widths so skeleton markup is stable across re-renders
24
+ const SKEL = [65, 80, 50, 75, 90, 55, 70, 85, 60, 78, 88, 52, 70, 83, 58]
25
+
26
+ const props = withDefaults(
27
+ defineProps<{
28
+ columns: TableColumn[]
29
+ rows: Record<string, any>[]
30
+ loading?: boolean
31
+ emptyText?: string
32
+ rowKey?: string
33
+ selectable?: boolean
34
+ modelValue?: Record<string, any>[]
35
+ perPage?: number
36
+ searchable?: boolean
37
+ /** Emit `fetch` instead of filtering locally. Requires :total. */
38
+ serverSide?: boolean
39
+ total?: number
40
+ page?: number
41
+ }>(),
42
+ {
43
+ loading: false,
44
+ emptyText: 'Sin resultados',
45
+ rowKey: 'id',
46
+ selectable: false,
47
+ modelValue: () => [],
48
+ perPage: 10,
49
+ searchable: true,
50
+ serverSide: false,
51
+ total: 0,
52
+ page: 1,
53
+ },
54
+ )
55
+
56
+ const emit = defineEmits<{
57
+ 'update:modelValue': [Record<string, any>[]]
58
+ 'update:page': [number]
59
+ fetch: [TableFetchParams]
60
+ }>()
61
+
62
+ // ── Search ─────────────────────────────────────────────────────────────────
63
+ const search = ref('')
64
+
65
+ // ── Sort ───────────────────────────────────────────────────────────────────
66
+ const sortKey = ref('')
67
+ const sortDir = ref<'asc' | 'desc' | ''>('')
68
+
69
+ function toggleSort(key: string) {
70
+ if (sortKey.value !== key) {
71
+ sortKey.value = key
72
+ sortDir.value = 'asc'
73
+ } else if (sortDir.value === 'asc') {
74
+ sortDir.value = 'desc'
75
+ } else {
76
+ sortKey.value = ''
77
+ sortDir.value = ''
78
+ }
79
+ }
80
+
81
+ // ── Pagination ─────────────────────────────────────────────────────────────
82
+ const internalPage = ref(1)
83
+
84
+ const currentPage = computed({
85
+ get: () => (props.serverSide ? (props.page ?? 1) : internalPage.value),
86
+ set: (val: number) => {
87
+ if (props.serverSide) emit('update:page', val)
88
+ else internalPage.value = val
89
+ },
90
+ })
91
+
92
+ // ── Client-side data processing ────────────────────────────────────────────
93
+ const processedRows = computed(() => {
94
+ if (props.serverSide) return props.rows
95
+
96
+ let result = props.rows
97
+
98
+ if (search.value.trim()) {
99
+ const q = search.value.toLowerCase()
100
+ result = result.filter((row) =>
101
+ props.columns.some((col) => {
102
+ const val = row[col.key]
103
+ return val != null && String(val).toLowerCase().includes(q)
104
+ }),
105
+ )
106
+ }
107
+
108
+ if (sortKey.value && sortDir.value) {
109
+ const key = sortKey.value
110
+ const dir = sortDir.value
111
+ result = [...result].sort((a, b) => {
112
+ const cmp = String(a[key] ?? '').localeCompare(String(b[key] ?? ''), undefined, {
113
+ numeric: true,
114
+ sensitivity: 'base',
115
+ })
116
+ return dir === 'asc' ? cmp : -cmp
117
+ })
118
+ }
119
+
120
+ return result
121
+ })
122
+
123
+ const totalCount = computed(() =>
124
+ props.serverSide ? (props.total ?? 0) : processedRows.value.length,
125
+ )
126
+
127
+ const visibleRows = computed(() => {
128
+ if (props.serverSide) return props.rows
129
+ const start = (currentPage.value - 1) * props.perPage
130
+ return processedRows.value.slice(start, start + props.perPage)
131
+ })
132
+
133
+ watch([search, sortKey, sortDir], () => {
134
+ if (!props.serverSide) internalPage.value = 1
135
+ })
136
+
137
+ // ── Server-side fetch ──────────────────────────────────────────────────────
138
+ const mounted = ref(false)
139
+
140
+ function emitFetch() {
141
+ emit('fetch', {
142
+ page: currentPage.value,
143
+ perPage: props.perPage,
144
+ search: search.value,
145
+ sortKey: sortKey.value,
146
+ sortDir: sortDir.value,
147
+ })
148
+ }
149
+
150
+ onMounted(() => {
151
+ mounted.value = true
152
+ if (props.serverSide) emitFetch()
153
+ })
154
+
155
+ watch([search, sortKey, sortDir], () => {
156
+ if (!props.serverSide || !mounted.value) return
157
+ internalPage.value = 1
158
+ emitFetch()
159
+ })
160
+
161
+ watch(currentPage, () => {
162
+ if (!props.serverSide || !mounted.value) return
163
+ emitFetch()
164
+ })
165
+
166
+ // ── Row selection ──────────────────────────────────────────────────────────
167
+ const selected = computed({
168
+ get: () => props.modelValue ?? [],
169
+ set: (val) => emit('update:modelValue', val),
170
+ })
171
+
172
+ function rowId(row: Record<string, any>) {
173
+ return row[props.rowKey]
174
+ }
175
+ function isSelected(row: Record<string, any>) {
176
+ return selected.value.some((r) => rowId(r) === rowId(row))
177
+ }
178
+ function toggleRow(row: Record<string, any>) {
179
+ if (isSelected(row)) selected.value = selected.value.filter((r) => rowId(r) !== rowId(row))
180
+ else selected.value = [...selected.value, row]
181
+ }
182
+
183
+ const allOnPageSelected = computed(
184
+ () => visibleRows.value.length > 0 && visibleRows.value.every((r) => isSelected(r)),
185
+ )
186
+ const someOnPageSelected = computed(
187
+ () => visibleRows.value.some((r) => isSelected(r)) && !allOnPageSelected.value,
188
+ )
189
+
190
+ function toggleAll() {
191
+ if (allOnPageSelected.value) {
192
+ selected.value = selected.value.filter(
193
+ (r) => !visibleRows.value.some((v) => rowId(v) === rowId(r)),
194
+ )
195
+ } else {
196
+ selected.value = [...selected.value, ...visibleRows.value.filter((r) => !isSelected(r))]
197
+ }
198
+ }
199
+
200
+ // ── Helpers ────────────────────────────────────────────────────────────────
201
+ const extraCols = computed(
202
+ () => (props.selectable ? 1 : 0) + (useSlots()['row-actions'] ? 1 : 0),
203
+ )
204
+
205
+ function alignClass(align?: string) {
206
+ return align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left'
207
+ }
208
+ function skelWidth(ri: number, ci: number) {
209
+ return `${SKEL[(ri * 3 + ci) % SKEL.length]}%`
210
+ }
211
+
212
+ import { useSlots } from 'vue'
213
+ const slots = useSlots()
214
+ const hasActions = computed(() => !!slots['row-actions'])
215
+ </script>
216
+
217
+ <template>
218
+ <div class="flex flex-col overflow-hidden rounded-sm border border-outline-variant">
219
+
220
+ <!-- ── Toolbar ───────────────────────────────────────────────────────── -->
221
+ <div
222
+ v-if="searchable || $slots.toolbar"
223
+ class="flex flex-wrap items-center gap-3 border-b border-outline-variant bg-surface-container-lowest px-4 py-2.5"
224
+ >
225
+ <!-- Search -->
226
+ <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 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30 transition-[border-color,box-shadow] duration-150">
227
+ <MIcon name="search" :size="16" class="shrink-0 text-on-surface-variant" />
228
+ <input
229
+ v-model="search"
230
+ type="text"
231
+ placeholder="Buscar..."
232
+ class="w-full bg-transparent text-body-medium text-on-surface outline-none placeholder:text-on-surface-variant"
233
+ />
234
+ <button
235
+ v-if="search"
236
+ class="text-on-surface-variant transition-colors hover:text-on-surface"
237
+ @click="search = ''"
238
+ >
239
+ <MIcon name="close" :size="14" />
240
+ </button>
241
+ </div>
242
+
243
+ <!-- Extra toolbar content (filters, buttons, etc.) -->
244
+ <slot name="toolbar" />
245
+
246
+ <!-- Selection count pill -->
247
+ <Transition
248
+ enter-active-class="transition-[opacity,transform] duration-150"
249
+ enter-from-class="opacity-0 scale-90"
250
+ leave-active-class="transition-[opacity,transform] duration-100"
251
+ leave-to-class="opacity-0 scale-90"
252
+ >
253
+ <span
254
+ v-if="selectable && selected.length > 0"
255
+ class="rounded-full bg-primary/12 px-3 py-1 text-label-small font-medium text-primary"
256
+ >
257
+ {{ selected.length }} seleccionado{{ selected.length !== 1 ? 's' : '' }}
258
+ </span>
259
+ </Transition>
260
+ </div>
261
+
262
+ <!-- ── Table ─────────────────────────────────────────────────────────── -->
263
+ <div class="overflow-x-auto">
264
+ <table class="w-full border-collapse">
265
+
266
+ <!-- Header -->
267
+ <thead>
268
+ <tr class="bg-surface-container-high">
269
+ <th v-if="selectable" class="w-12 px-4 py-3">
270
+ <MCheckbox
271
+ :model-value="allOnPageSelected"
272
+ :indeterminate="someOnPageSelected"
273
+ @update:model-value="toggleAll"
274
+ />
275
+ </th>
276
+ <th
277
+ v-for="col in columns"
278
+ :key="col.key"
279
+ :style="col.width ? { width: col.width } : undefined"
280
+ :class="[
281
+ 'px-4 py-3 text-label-medium font-medium text-on-surface-variant whitespace-nowrap',
282
+ alignClass(col.align),
283
+ col.sortable
284
+ ? 'cursor-pointer select-none hover:text-on-surface transition-colors duration-100'
285
+ : '',
286
+ ]"
287
+ @click="col.sortable ? toggleSort(col.key) : undefined"
288
+ >
289
+ <span class="inline-flex items-center gap-1">
290
+ {{ col.label }}
291
+ <span v-if="col.sortable" class="inline-flex">
292
+ <MIcon
293
+ v-if="sortKey === col.key && sortDir === 'asc'"
294
+ name="arrow_upward"
295
+ :size="14"
296
+ class="text-primary"
297
+ />
298
+ <MIcon
299
+ v-else-if="sortKey === col.key && sortDir === 'desc'"
300
+ name="arrow_downward"
301
+ :size="14"
302
+ class="text-primary"
303
+ />
304
+ <MIcon v-else name="unfold_more" :size="14" class="opacity-30" />
305
+ </span>
306
+ </span>
307
+ </th>
308
+ <th v-if="hasActions" class="w-1 px-4 py-3" />
309
+ </tr>
310
+ </thead>
311
+
312
+ <!-- Body -->
313
+ <tbody>
314
+ <!-- Loading skeleton -->
315
+ <template v-if="loading">
316
+ <tr
317
+ v-for="ri in perPage"
318
+ :key="`sk-${ri}`"
319
+ class="border-t border-outline-variant"
320
+ >
321
+ <td v-if="selectable" class="px-4 py-3.5">
322
+ <div class="h-4 w-4 animate-pulse rounded bg-on-surface/10" />
323
+ </td>
324
+ <td
325
+ v-for="(col, ci) in columns"
326
+ :key="col.key"
327
+ class="px-4 py-3.5"
328
+ >
329
+ <div
330
+ class="h-4 animate-pulse rounded-full bg-on-surface/10"
331
+ :style="{ width: skelWidth(ri, ci) }"
332
+ />
333
+ </td>
334
+ <td v-if="hasActions" class="px-4 py-3.5">
335
+ <div class="ml-auto h-4 w-16 animate-pulse rounded-full bg-on-surface/10" />
336
+ </td>
337
+ </tr>
338
+ </template>
339
+
340
+ <!-- Empty state -->
341
+ <template v-else-if="visibleRows.length === 0">
342
+ <tr>
343
+ <td
344
+ :colspan="columns.length + extraCols"
345
+ class="border-t border-outline-variant px-4 py-14 text-center"
346
+ >
347
+ <slot name="empty">
348
+ <MIcon name="search_off" :size="36" class="mb-2 text-on-surface-variant opacity-30" />
349
+ <p class="text-body-medium text-on-surface-variant">{{ emptyText }}</p>
350
+ </slot>
351
+ </td>
352
+ </tr>
353
+ </template>
354
+
355
+ <!-- Data rows -->
356
+ <template v-else>
357
+ <tr
358
+ v-for="row in visibleRows"
359
+ :key="rowId(row)"
360
+ :class="[
361
+ 'border-t border-outline-variant transition-colors duration-100',
362
+ 'hover:bg-on-surface/[0.04]',
363
+ selectable && isSelected(row) ? 'bg-primary/[0.06]' : '',
364
+ selectable ? 'cursor-pointer' : '',
365
+ ]"
366
+ @click="selectable ? toggleRow(row) : undefined"
367
+ >
368
+ <td v-if="selectable" class="px-4 py-3" @click.stop="toggleRow(row)">
369
+ <MCheckbox
370
+ :model-value="isSelected(row)"
371
+ @update:model-value="toggleRow(row)"
372
+ />
373
+ </td>
374
+ <td
375
+ v-for="col in columns"
376
+ :key="col.key"
377
+ :class="['px-4 py-3 text-body-medium text-on-surface', alignClass(col.align)]"
378
+ >
379
+ <slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]" :col="col">
380
+ {{ row[col.key] ?? '—' }}
381
+ </slot>
382
+ </td>
383
+ <td v-if="hasActions" class="px-4 py-3 text-right" @click.stop>
384
+ <slot name="row-actions" :row="row" />
385
+ </td>
386
+ </tr>
387
+ </template>
388
+ </tbody>
389
+
390
+ </table>
391
+ </div>
392
+
393
+ <!-- ── Footer ────────────────────────────────────────────────────────── -->
394
+ <div class="border-t border-outline-variant bg-surface-container-lowest px-4 py-2">
395
+ <MPagination
396
+ :page="currentPage"
397
+ :per-page="perPage"
398
+ :total="totalCount"
399
+ @update:page="currentPage = $event"
400
+ />
401
+ </div>
402
+
403
+ </div>
404
+ </template>
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, onMounted, ref, watch } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+
5
+ interface Tab {
6
+ value: string | number
7
+ label: string
8
+ icon?: string
9
+ disabled?: boolean
10
+ }
11
+
12
+ const props = withDefaults(defineProps<{
13
+ modelValue: string | number
14
+ tabs: Tab[]
15
+ variant?: 'primary' | 'secondary'
16
+ }>(), { variant: 'primary' })
17
+
18
+ const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
19
+
20
+ // Primary tab: refs for indicator position
21
+ const tabEls = ref<HTMLElement[]>([])
22
+ const indicatorLeft = ref(0)
23
+ const indicatorWidth = ref(0)
24
+
25
+ function updateIndicator() {
26
+ nextTick(() => {
27
+ const idx = props.tabs.findIndex((t) => t.value === props.modelValue)
28
+ const el = tabEls.value[idx]
29
+ if (!el) return
30
+ indicatorLeft.value = el.offsetLeft
31
+ indicatorWidth.value = el.offsetWidth
32
+ })
33
+ }
34
+
35
+ onMounted(updateIndicator)
36
+ watch(() => props.modelValue, updateIndicator)
37
+ watch(() => props.tabs, updateIndicator, { deep: true })
38
+
39
+ function select(tab: Tab) {
40
+ if (!tab.disabled) emit('update:modelValue', tab.value)
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <!-- ── Primary: underline with sliding indicator ──────────────────────── -->
46
+ <div v-if="variant === 'primary'" class="relative border-b border-outline-variant">
47
+ <div class="flex overflow-x-auto" style="scrollbar-width: none">
48
+ <button
49
+ v-for="tab in tabs"
50
+ :key="tab.value"
51
+ :ref="(el) => { if (el) tabEls[tabs.indexOf(tab)] = el as HTMLElement }"
52
+ type="button"
53
+ class="relative flex h-12 shrink-0 flex-col items-center justify-center gap-0.5 px-6 text-label-large transition-colors duration-150 focus-visible:outline-none"
54
+ :class="[
55
+ tab.value === modelValue
56
+ ? 'text-primary'
57
+ : tab.disabled
58
+ ? 'cursor-not-allowed text-on-surface/38'
59
+ : 'cursor-pointer text-on-surface-variant hover:text-on-surface',
60
+ tab.icon ? 'pt-2 pb-1' : '',
61
+ ]"
62
+ :disabled="tab.disabled"
63
+ @click="select(tab)"
64
+ >
65
+ <MIcon v-if="tab.icon" :name="tab.icon" :size="20" />
66
+ <span>{{ tab.label }}</span>
67
+ </button>
68
+ </div>
69
+ <!-- Sliding indicator -->
70
+ <div
71
+ class="absolute bottom-0 h-[3px] rounded-t-sm bg-primary transition-[left,width] duration-200 ease-[cubic-bezier(0.2,0,0,1)]"
72
+ :style="{ left: `${indicatorLeft}px`, width: `${indicatorWidth}px` }"
73
+ />
74
+ </div>
75
+
76
+ <!-- ── Secondary: pill style ──────────────────────────────────────────── -->
77
+ <div v-else class="flex flex-wrap gap-1 rounded-full bg-surface-container p-1">
78
+ <button
79
+ v-for="tab in tabs"
80
+ :key="tab.value"
81
+ type="button"
82
+ class="flex h-8 items-center gap-2 rounded-full px-4 text-label-large transition-all duration-150 focus-visible:outline-none"
83
+ :class="
84
+ tab.value === modelValue
85
+ ? 'bg-secondary-container text-on-secondary-container shadow-elevation-1'
86
+ : tab.disabled
87
+ ? 'cursor-not-allowed text-on-surface/38'
88
+ : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8 hover:text-on-surface'
89
+ "
90
+ :disabled="tab.disabled"
91
+ @click="select(tab)"
92
+ >
93
+ <MIcon v-if="tab.icon" :name="tab.icon" :size="16" />
94
+ {{ tab.label }}
95
+ </button>
96
+ </div>
97
+ </template>
@@ -0,0 +1,146 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
3
+ import { Terminal } from '@xterm/xterm'
4
+ import { FitAddon } from '@xterm/addon-fit'
5
+ import MIcon from './MIcon.vue'
6
+
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ lines?: string[]
10
+ readonly?: boolean
11
+ title?: string
12
+ minHeight?: string
13
+ }>(),
14
+ {
15
+ lines: () => [],
16
+ readonly: false,
17
+ title: 'Terminal',
18
+ minHeight: '300px',
19
+ },
20
+ )
21
+
22
+ const emit = defineEmits<{
23
+ input: [string]
24
+ line: [string]
25
+ }>()
26
+
27
+ const containerRef = ref<HTMLElement | null>(null)
28
+ let terminal: Terminal | null = null
29
+ let fitAddon: FitAddon | null = null
30
+ let resizeObserver: ResizeObserver | null = null
31
+ let lineBuffer = ''
32
+
33
+ function getThemeColors() {
34
+ const style = getComputedStyle(document.documentElement)
35
+ return {
36
+ background: style.getPropertyValue('--color-surface-container-lowest').trim() || '#0f0d13',
37
+ foreground: style.getPropertyValue('--color-on-surface').trim() || '#e6e1e5',
38
+ cursor: style.getPropertyValue('--color-primary').trim() || '#d0bcff',
39
+ cursorAccent: style.getPropertyValue('--color-on-primary').trim() || '#381e72',
40
+ selectionBackground: style.getPropertyValue('--color-primary-container').trim() || '#4f378b',
41
+ }
42
+ }
43
+
44
+ onMounted(async () => {
45
+ if (!containerRef.value) return
46
+
47
+ try { await import('@xterm/xterm/css/xterm.css') } catch { /* consumer will provide styles */ }
48
+
49
+ const colors = getThemeColors()
50
+
51
+ terminal = new Terminal({
52
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
53
+ fontSize: 14,
54
+ lineHeight: 1.4,
55
+ cursorBlink: !props.readonly,
56
+ disableStdin: props.readonly,
57
+ theme: {
58
+ background: colors.background,
59
+ foreground: colors.foreground,
60
+ cursor: colors.cursor,
61
+ cursorAccent: colors.cursorAccent,
62
+ selectionBackground: colors.selectionBackground,
63
+ },
64
+ scrollback: 1000,
65
+ convertEol: true,
66
+ })
67
+
68
+ fitAddon = new FitAddon()
69
+ terminal.loadAddon(fitAddon)
70
+ terminal.open(containerRef.value)
71
+
72
+ try { fitAddon.fit() } catch { /* container might not be visible yet */ }
73
+
74
+ for (const line of props.lines) {
75
+ terminal.writeln(line)
76
+ }
77
+
78
+ if (!props.readonly) {
79
+ terminal.onData((data) => {
80
+ emit('input', data)
81
+
82
+ if (data === '\r') {
83
+ terminal!.write('\r\n')
84
+ emit('line', lineBuffer)
85
+ lineBuffer = ''
86
+ } else if (data === '\x7f') {
87
+ if (lineBuffer.length > 0) {
88
+ lineBuffer = lineBuffer.slice(0, -1)
89
+ terminal!.write('\b \b')
90
+ }
91
+ } else {
92
+ lineBuffer += data
93
+ terminal!.write(data)
94
+ }
95
+ })
96
+ }
97
+
98
+ resizeObserver = new ResizeObserver(() => {
99
+ try { fitAddon?.fit() } catch { /* ignore */ }
100
+ })
101
+ resizeObserver.observe(containerRef.value)
102
+ })
103
+
104
+ watch(() => props.lines, (newLines) => {
105
+ if (!terminal) return
106
+ terminal.clear()
107
+ for (const line of newLines) {
108
+ terminal.writeln(line)
109
+ }
110
+ })
111
+
112
+ onBeforeUnmount(() => {
113
+ resizeObserver?.disconnect()
114
+ terminal?.dispose()
115
+ })
116
+
117
+ defineExpose({
118
+ write: (text: string) => terminal?.write(text),
119
+ writeln: (text: string) => terminal?.writeln(text),
120
+ clear: () => terminal?.clear(),
121
+ focus: () => terminal?.focus(),
122
+ })
123
+ </script>
124
+
125
+ <template>
126
+ <div class="overflow-hidden rounded-lg border border-outline-variant">
127
+ <!-- Title bar -->
128
+ <div class="flex items-center gap-2 border-b border-outline-variant bg-surface-container px-4 py-2">
129
+ <MIcon name="terminal" :size="18" class="text-on-surface-variant" />
130
+ <span class="flex-1 text-label-medium text-on-surface-variant">{{ title }}</span>
131
+ <div class="flex gap-1.5">
132
+ <div class="h-3 w-3 rounded-full bg-error/60" />
133
+ <div class="h-3 w-3 rounded-full bg-tertiary-container" />
134
+ <div class="h-3 w-3 rounded-full bg-success/60" />
135
+ </div>
136
+ </div>
137
+
138
+ <!-- Terminal container -->
139
+ <div
140
+ ref="containerRef"
141
+ class="bg-surface-container-lowest px-2 py-1"
142
+ :style="{ minHeight }"
143
+ />
144
+ </div>
145
+ </template>
146
+