@datametria/vue-components 2.3.1 → 2.4.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 (128) hide show
  1. package/README.md +625 -594
  2. package/dist/index.es.js +1962 -1887
  3. package/dist/index.umd.js +6 -6
  4. package/dist/src/components/DatametriaForm.vue.d.ts +1 -1
  5. package/dist/src/components/DatametriaInput.vue.d.ts +9 -1
  6. package/dist/src/components/DatametriaSelect.vue.d.ts +10 -1
  7. package/dist/vue-components.css +1 -1
  8. package/package.json +103 -102
  9. package/src/components/DatametriaAlert.vue +151 -133
  10. package/src/components/DatametriaAutocomplete.vue +250 -229
  11. package/src/components/DatametriaAvatar.vue +256 -238
  12. package/src/components/DatametriaBadge.vue +101 -96
  13. package/src/components/DatametriaBreadcrumb.vue +132 -128
  14. package/src/components/DatametriaButton.vue +191 -173
  15. package/src/components/DatametriaCard.vue +84 -66
  16. package/src/components/DatametriaCheckbox.vue +197 -193
  17. package/src/components/DatametriaCheckboxGroup.vue +56 -38
  18. package/src/components/DatametriaChip.vue +159 -141
  19. package/src/components/DatametriaContainer.vue +70 -52
  20. package/src/components/DatametriaDataTable.vue +318 -300
  21. package/src/components/DatametriaDatePicker.vue +396 -378
  22. package/src/components/DatametriaDialog.vue +297 -293
  23. package/src/components/DatametriaDivider.vue +105 -98
  24. package/src/components/DatametriaDropdown.vue +356 -350
  25. package/src/components/DatametriaEmpty.vue +155 -151
  26. package/src/components/DatametriaFileUpload.vue +413 -395
  27. package/src/components/DatametriaFloatingBar.vue +144 -126
  28. package/src/components/DatametriaForm.vue +174 -156
  29. package/src/components/DatametriaFormItem.vue +183 -179
  30. package/src/components/DatametriaGrid.vue +55 -37
  31. package/src/components/DatametriaInput.vue +314 -263
  32. package/src/components/DatametriaMenu.vue +618 -600
  33. package/src/components/DatametriaModal.vue +147 -129
  34. package/src/components/DatametriaNavbar.vue +277 -223
  35. package/src/components/DatametriaPagination.vue +375 -371
  36. package/src/components/DatametriaPasswordInput.vue +444 -426
  37. package/src/components/DatametriaPopconfirm.vue +240 -234
  38. package/src/components/DatametriaProgress.vue +228 -224
  39. package/src/components/DatametriaRadio.vue +151 -147
  40. package/src/components/DatametriaRadioGroup.vue +55 -37
  41. package/src/components/DatametriaResult.vue +135 -131
  42. package/src/components/DatametriaSelect.vue +311 -211
  43. package/src/components/DatametriaSidebar.vue +294 -222
  44. package/src/components/DatametriaSkeleton.vue +257 -234
  45. package/src/components/DatametriaSlider.vue +409 -391
  46. package/src/components/DatametriaSortableTable.vue +820 -802
  47. package/src/components/DatametriaSpinner.vue +114 -110
  48. package/src/components/DatametriaSteps.vue +318 -312
  49. package/src/components/DatametriaSwitch.vue +146 -142
  50. package/src/components/DatametriaTabPane.vue +94 -76
  51. package/src/components/DatametriaTable.vue +118 -100
  52. package/src/components/DatametriaTabs.vue +315 -297
  53. package/src/components/DatametriaTextarea.vue +213 -195
  54. package/src/components/DatametriaTimePicker.vue +317 -299
  55. package/src/components/DatametriaToast.vue +176 -176
  56. package/src/components/DatametriaTooltip.vue +421 -400
  57. package/src/components/DatametriaTree.vue +126 -122
  58. package/src/components/DatametriaTreeNode.vue +176 -172
  59. package/src/components/DatametriaUpload.vue +379 -361
  60. package/src/components/__tests__/DatametriaAlert.test.js +35 -35
  61. package/src/components/__tests__/DatametriaAlert.test.ts +190 -190
  62. package/src/components/__tests__/DatametriaAvatar.test.ts +151 -151
  63. package/src/components/__tests__/DatametriaBadge.test.js +29 -29
  64. package/src/components/__tests__/DatametriaBadge.test.ts +167 -167
  65. package/src/components/__tests__/DatametriaBreadcrumb.test.ts +187 -0
  66. package/src/components/__tests__/DatametriaButton.test.js +30 -30
  67. package/src/components/__tests__/DatametriaButton.test.ts +283 -283
  68. package/src/components/__tests__/DatametriaCard.test.ts +201 -201
  69. package/src/components/__tests__/DatametriaCheckbox.test.ts +204 -0
  70. package/src/components/__tests__/DatametriaChip.test.js +38 -38
  71. package/src/components/__tests__/DatametriaContainer.test.ts +52 -52
  72. package/src/components/__tests__/DatametriaDialog.test.ts +338 -0
  73. package/src/components/__tests__/DatametriaDivider.test.ts +54 -54
  74. package/src/components/__tests__/DatametriaDropdown.test.ts +357 -0
  75. package/src/components/__tests__/DatametriaEmpty.test.ts +261 -0
  76. package/src/components/__tests__/DatametriaFileUpload.test.ts +290 -290
  77. package/src/components/__tests__/DatametriaFloatingBar.test.ts +137 -137
  78. package/src/components/__tests__/DatametriaForm.test.ts +96 -0
  79. package/src/components/__tests__/DatametriaFormItem.test.ts +58 -0
  80. package/src/components/__tests__/DatametriaGrid.test.ts +31 -31
  81. package/src/components/__tests__/DatametriaInput.test.ts +72 -72
  82. package/src/components/__tests__/DatametriaMenu.test.ts +366 -366
  83. package/src/components/__tests__/DatametriaModal.test.ts +86 -86
  84. package/src/components/__tests__/DatametriaNavbar.test.js +48 -48
  85. package/src/components/__tests__/DatametriaNavbar.test.ts +203 -203
  86. package/src/components/__tests__/DatametriaPasswordInput.test.js +305 -305
  87. package/src/components/__tests__/DatametriaRadio.test.ts +195 -0
  88. package/src/components/__tests__/DatametriaSelect.test.ts +77 -77
  89. package/src/components/__tests__/DatametriaSidebar.test.ts +169 -169
  90. package/src/components/__tests__/DatametriaSlider.test.ts +261 -261
  91. package/src/components/__tests__/DatametriaSortableTable.test.js +168 -168
  92. package/src/components/__tests__/DatametriaSpinner.test.ts +156 -156
  93. package/src/components/__tests__/DatametriaSteps.test.ts +211 -0
  94. package/src/components/__tests__/DatametriaSwitch.test.ts +129 -0
  95. package/src/components/__tests__/DatametriaTabPane.test.ts +205 -0
  96. package/src/components/__tests__/DatametriaTable.test.ts +97 -97
  97. package/src/components/__tests__/DatametriaTabs.test.ts +232 -232
  98. package/src/components/__tests__/DatametriaToast.test.js +48 -48
  99. package/src/components/__tests__/DatametriaToast.test.ts +99 -99
  100. package/src/components/__tests__/DatametriaTree.test.ts +376 -0
  101. package/src/components/__tests__/index.test.ts +48 -0
  102. package/src/composables/useAccessibilityScale.ts +94 -94
  103. package/src/composables/useBreakpoints.ts +82 -82
  104. package/src/composables/useHapticFeedback.ts +439 -439
  105. package/src/composables/useRipple.ts +218 -218
  106. package/src/composables/useTheme.ts +5 -1
  107. package/src/index.ts +84 -84
  108. package/src/stories/Variants.stories.js +95 -95
  109. package/src/styles/design-tokens.css +623 -623
  110. package/src/theme/ThemeProvider.vue +96 -96
  111. package/src/theme/__tests__/ThemeProvider.test.ts +208 -208
  112. package/src/theme/__tests__/constants.test.ts +31 -31
  113. package/src/theme/__tests__/presets.test.ts +166 -166
  114. package/src/theme/__tests__/tokens.test.ts +155 -155
  115. package/src/theme/__tests__/types.test.ts +153 -153
  116. package/src/theme/__tests__/useTheme.test.ts +146 -146
  117. package/src/theme/constants.ts +14 -14
  118. package/src/theme/index.ts +12 -12
  119. package/src/theme/presets/datametria.ts +94 -94
  120. package/src/theme/presets/default.ts +94 -94
  121. package/src/theme/presets/index.ts +8 -8
  122. package/src/theme/tokens/colors.ts +28 -28
  123. package/src/theme/tokens/index.ts +47 -47
  124. package/src/theme/tokens/spacing.ts +21 -21
  125. package/src/theme/tokens/typography.ts +35 -35
  126. package/src/theme/types.ts +111 -111
  127. package/src/theme/useTheme.ts +28 -28
  128. package/src/types/index.ts +55 -55
@@ -1,806 +1,824 @@
1
- <template>
2
- <div class="datametria-sortable-table">
3
- <!-- Search Bar -->
4
- <div v-if="searchable" class="datametria-sortable-table__search">
5
- <input
6
- v-model="searchQuery"
7
- type="text"
8
- placeholder="Buscar..."
9
- class="datametria-sortable-table__search-input"
10
- aria-label="Buscar na tabela"
11
- />
12
- </div>
13
-
14
- <!-- Table -->
15
- <div class="datametria-sortable-table__wrapper">
16
- <table class="datametria-sortable-table__table" role="table">
17
- <thead class="datametria-sortable-table__thead">
18
- <tr role="row">
19
- <!-- Selection Column -->
20
- <th
21
- v-if="selectable"
22
- class="datametria-sortable-table__th datametria-sortable-table__th--checkbox"
23
- role="columnheader"
24
- >
25
- <input
26
- type="checkbox"
27
- :checked="isAllSelected"
28
- :indeterminate="isIndeterminate"
29
- aria-label="Selecionar todas as linhas"
30
- @change="toggleSelectAll"
31
- />
32
- </th>
33
-
34
- <!-- Data Columns -->
35
- <th
36
- v-for="column in columns"
37
- :key="column.key"
38
- class="datametria-sortable-table__th"
39
- :class="{ 'datametria-sortable-table__th--sortable': column.sortable !== false }"
40
- :style="{ width: column.width }"
41
- role="columnheader"
42
- :aria-sort="sortKey === column.key ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'"
43
- @click="column.sortable !== false && toggleSort(column.key)"
44
- @keydown.enter="column.sortable !== false && toggleSort(column.key)"
45
- @keydown.space.prevent="column.sortable !== false && toggleSort(column.key)"
46
- :tabindex="column.sortable !== false ? 0 : undefined"
47
- >
48
- <div class="datametria-sortable-table__th-content">
49
- <span>{{ column.label }}</span>
50
- <span v-if="column.sortable !== false" class="datametria-sortable-table__sort-icon" aria-hidden="true">
51
- <span v-if="sortKey === column.key">
52
- {{ sortOrder === 'asc' ? '↑' : '↓' }}
53
- </span>
54
- <span v-else class="datametria-sortable-table__sort-icon--inactive">↕</span>
55
- </span>
56
- </div>
57
-
58
- <!-- Column Filter -->
59
- <div v-if="filterable && column.filterable !== false" @click.stop>
60
- <!-- Select Filter -->
61
- <select
62
- v-if="column.filterType === 'select'"
63
- v-model="columnFilters[column.key]"
64
- :aria-label="`Filtrar por ${column.label}`"
65
- class="datametria-sortable-table__filter-input"
66
- @keydown.stop
67
- >
68
- <option value="">Todos</option>
69
- <option
70
- v-for="option in getFilterOptions(column)"
71
- :key="option.value"
72
- :value="option.value"
73
- >
74
- {{ option.label }}
75
- </option>
76
- </select>
77
-
78
- <!-- Multi-Select Filter -->
79
- <div
80
- v-else-if="column.filterType === 'multiselect'"
81
- class="datametria-sortable-table__multiselect"
82
- >
83
- <button
84
- type="button"
85
- class="datametria-sortable-table__multiselect-trigger"
86
- @click="toggleMultiselect(column.key)"
87
- >
88
- {{ getMultiselectLabel(column) }}
89
- </button>
90
- <div
91
- v-if="activeMultiselect === column.key"
92
- class="datametria-sortable-table__multiselect-dropdown"
93
- >
94
- <label
95
- v-for="option in getFilterOptions(column)"
96
- :key="option.value"
97
- class="datametria-sortable-table__multiselect-option"
98
- >
99
- <input
100
- type="checkbox"
101
- :value="option.value"
102
- :checked="isMultiselectChecked(column.key, option.value)"
103
- @change="toggleMultiselectOption(column.key, option.value)"
104
- />
105
- {{ option.label }}
106
- </label>
107
- </div>
108
- </div>
109
-
110
- <!-- Text/Date Filter -->
111
- <input
112
- v-else
113
- v-model="columnFilters[column.key]"
114
- :type="column.filterType || getFilterInputType(column)"
115
- :placeholder="`Filtrar ${column.label}...`"
116
- :aria-label="`Filtrar por ${column.label}`"
117
- class="datametria-sortable-table__filter-input"
118
- @keydown.stop
119
- />
120
- </div>
121
- </th>
122
- </tr>
123
- </thead>
124
-
125
- <tbody class="datametria-sortable-table__tbody">
126
- <tr
127
- v-for="(row, index) in paginatedData"
128
- :key="row.id || index"
129
- class="datametria-sortable-table__tr"
130
- :class="{ 'datametria-sortable-table__tr--selected': selectedRows.has(row.id || index) }"
131
- role="row"
132
- >
133
- <!-- Selection Column -->
134
- <td
135
- v-if="selectable"
136
- class="datametria-sortable-table__td datametria-sortable-table__td--checkbox"
137
- role="cell"
138
- >
139
- <input
140
- type="checkbox"
141
- :checked="selectedRows.has(row.id || index)"
142
- :aria-label="`Selecionar linha ${index + 1}`"
143
- @change="toggleSelectRow(row.id || index)"
144
- />
145
- </td>
146
-
147
- <!-- Data Columns -->
148
- <td
149
- v-for="column in columns"
150
- :key="column.key"
151
- class="datametria-sortable-table__td"
152
- role="cell"
153
- >
154
- <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
155
- {{ row[column.key] }}
156
- </slot>
157
- </td>
158
- </tr>
159
- </tbody>
160
- </table>
161
-
162
- <!-- Empty State -->
163
- <div v-if="filteredData.length === 0" class="datametria-sortable-table__empty" role="status">
164
- <slot name="empty">Nenhum dado encontrado</slot>
165
- </div>
166
- </div>
167
-
168
- <!-- Pagination -->
169
- <div
170
- v-if="paginated && filteredData.length > 0"
171
- class="datametria-sortable-table__pagination"
172
- role="navigation"
173
- aria-label="Paginação da tabela"
174
- >
175
- <div class="datametria-sortable-table__pagination-info" aria-live="polite">
176
- Mostrando {{ startIndex + 1 }} - {{ endIndex }} de {{ filteredData.length }} registros
177
- <span v-if="selectedRows.size > 0">({{ selectedRows.size }} selecionados)</span>
178
- </div>
179
-
180
- <div class="datametria-sortable-table__pagination-controls">
181
- <select
182
- v-model.number="currentPageSize"
183
- class="datametria-sortable-table__page-size"
184
- aria-label="Itens por página"
185
- >
186
- <option v-for="size in pageSizeOptions" :key="size" :value="size">
187
- {{ size }} por página
188
- </option>
189
- </select>
190
-
191
- <button
192
- class="datametria-sortable-table__pagination-btn"
193
- :disabled="currentPage === 1"
194
- aria-label="Primeira página"
195
- @click="currentPage = 1"
196
- >
197
- ««
198
- </button>
199
- <button
200
- class="datametria-sortable-table__pagination-btn"
201
- :disabled="currentPage === 1"
202
- aria-label="Página anterior"
203
- @click="currentPage--"
204
- >
205
-
206
- </button>
207
-
208
- <span class="datametria-sortable-table__pagination-pages" aria-current="page">
209
- Página {{ currentPage }} de {{ totalPages }}
210
- </span>
211
-
212
- <button
213
- class="datametria-sortable-table__pagination-btn"
214
- :disabled="currentPage === totalPages"
215
- aria-label="Próxima página"
216
- @click="currentPage++"
217
- >
218
-
219
- </button>
220
- <button
221
- class="datametria-sortable-table__pagination-btn"
222
- :disabled="currentPage === totalPages"
223
- aria-label="Última página"
224
- @click="currentPage = totalPages"
225
- >
226
- »»
227
- </button>
228
- </div>
229
- </div>
230
- </div>
231
- </template>
232
-
233
- <script setup lang="ts">
234
- import { ref, computed, watch } from 'vue'
235
- import type { SortableTableProps } from '../types'
236
-
237
- const props = withDefaults(defineProps<SortableTableProps>(), {
238
- selectable: false,
239
- searchable: true,
240
- filterable: true,
241
- paginated: true,
242
- pageSize: 10,
243
- pageSizeOptions: () => [5, 10, 25, 50, 100]
244
- })
245
-
246
- // Validação em desenvolvimento
247
- if (process.env.NODE_ENV === 'development') {
248
- if (!props.columns || props.columns.length === 0) {
249
- console.warn('[DatametriaSortableTable] No columns provided')
250
- }
251
- if (!props.data) {
252
- console.warn('[DatametriaSortableTable] No data provided')
253
- }
254
- }
255
-
256
- const emit = defineEmits<{
257
- 'selection-change': [selectedIds: (string | number)[]]
258
- }>()
259
-
260
- // Detect filter input type based on column data
261
- const getFilterInputType = (column: any) => {
262
- if (!props.data || props.data.length === 0) return 'text'
263
-
264
- const sampleValue = props.data[0][column.key]
265
-
266
- // Check if it's a date
267
- if (sampleValue instanceof Date) return 'date'
268
- if (typeof sampleValue === 'string' && !isNaN(Date.parse(sampleValue))) {
269
- // Check if it looks like a date string (YYYY-MM-DD, DD/MM/YYYY, etc)
270
- const datePattern = /^\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4}/
271
- if (datePattern.test(sampleValue)) return 'date'
272
- }
273
-
274
- return 'text'
275
- }
276
-
277
- // Get filter options (auto-generate if 'auto')
278
- const getFilterOptions = (column: any) => {
279
- if (column.filterOptions === 'auto') {
280
- const uniqueValues = new Set<string>()
281
- props.data.forEach(row => {
282
- const value = row[column.key]
283
- if (value !== null && value !== undefined && value !== '') {
284
- uniqueValues.add(String(value))
285
- }
286
- })
287
- return Array.from(uniqueValues)
288
- .sort()
289
- .map(value => ({ value, label: value }))
290
- }
291
- return column.filterOptions || []
292
- }
293
-
294
- // Multi-select methods
295
- const toggleMultiselect = (key: string) => {
296
- activeMultiselect.value = activeMultiselect.value === key ? null : key
297
- }
298
-
299
- const getMultiselectLabel = (column: any) => {
300
- const selected = columnFilters.value[column.key] as string[] || []
301
- if (selected.length === 0) return 'Todos'
302
- if (selected.length === 1) {
303
- const options = getFilterOptions(column)
304
- const option = options.find((o: any) => o.value === selected[0])
305
- return option?.label || selected[0]
306
- }
307
- return `${selected.length} selecionados`
308
- }
309
-
310
- const isMultiselectChecked = (key: string, value: string) => {
311
- const selected = columnFilters.value[key] as string[] || []
312
- return selected.includes(value)
313
- }
314
-
315
- const toggleMultiselectOption = (key: string, value: string) => {
316
- const selected = (columnFilters.value[key] as string[]) || []
317
- const index = selected.indexOf(value)
318
-
319
- if (index > -1) {
320
- columnFilters.value[key] = selected.filter(v => v !== value)
321
- } else {
322
- columnFilters.value[key] = [...selected, value]
323
- }
324
- }
325
-
326
- // Search
327
- const searchQuery = ref('')
328
-
329
- // Sorting
330
- const sortKey = ref<string>('')
331
- const sortOrder = ref<'asc' | 'desc'>('asc')
332
-
333
- // Filtering
334
- const columnFilters = ref<Record<string, string | string[]>>({})
335
- const activeMultiselect = ref<string | null>(null)
336
-
337
- // Selection
338
- const selectedRows = ref<Set<string | number>>(new Set())
339
-
340
- // Pagination
341
- const currentPage = ref(1)
342
- const currentPageSize = ref(props.pageSize)
343
-
344
- // Computed: Filtered Data
345
- const filteredData = computed(() => {
346
- let result = [...props.data]
347
-
348
- // Global search
349
- if (searchQuery.value) {
350
- const query = searchQuery.value.toLowerCase()
351
- result = result.filter(row =>
352
- Object.values(row).some(value =>
353
- String(value).toLowerCase().includes(query)
354
- )
355
- )
356
- }
357
-
358
- // Column filters
359
- Object.entries(columnFilters.value).forEach(([key, filterValue]) => {
360
- if (filterValue) {
361
- // Multi-select filter
362
- if (Array.isArray(filterValue) && filterValue.length > 0) {
363
- result = result.filter(row =>
364
- filterValue.includes(String(row[key]))
365
- )
366
- }
367
- // Text/Date/Select filter
368
- else if (typeof filterValue === 'string') {
369
- const query = filterValue.toLowerCase()
370
- result = result.filter(row =>
371
- String(row[key]).toLowerCase().includes(query)
372
- )
373
- }
374
- }
375
- })
376
-
377
- // Sorting
378
- if (sortKey.value) {
379
- result.sort((a, b) => {
380
- let aVal = a[sortKey.value]
381
- let bVal = b[sortKey.value]
382
-
383
- // Handle null/undefined
384
- if (aVal == null && bVal == null) return 0
385
- if (aVal == null) return 1
386
- if (bVal == null) return -1
387
-
388
- // Convert dates to timestamps for proper comparison
389
- if (aVal instanceof Date) aVal = aVal.getTime()
390
- if (bVal instanceof Date) bVal = bVal.getTime()
391
-
392
- // Try to parse as date if string looks like a date
393
- if (typeof aVal === 'string' && !isNaN(Date.parse(aVal))) {
394
- const datePattern = /^\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4}/
395
- if (datePattern.test(aVal)) {
396
- aVal = new Date(aVal).getTime()
397
- }
398
- }
399
- if (typeof bVal === 'string' && !isNaN(Date.parse(bVal))) {
400
- const datePattern = /^\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4}/
401
- if (datePattern.test(bVal)) {
402
- bVal = new Date(bVal).getTime()
403
- }
404
- }
405
-
406
- // Numeric comparison
407
- if (typeof aVal === 'number' && typeof bVal === 'number') {
408
- return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal
409
- }
410
-
411
- // String comparison (case-insensitive)
412
- const aStr = String(aVal).toLowerCase()
413
- const bStr = String(bVal).toLowerCase()
414
-
415
- if (aStr === bStr) return 0
416
- const comparison = aStr > bStr ? 1 : -1
417
- return sortOrder.value === 'asc' ? comparison : -comparison
418
- })
419
- }
420
-
421
- return result
422
- })
423
-
424
- // Computed: Pagination
425
- const totalPages = computed(() =>
426
- Math.ceil(filteredData.value.length / currentPageSize.value)
427
- )
428
-
429
- const startIndex = computed(() =>
430
- (currentPage.value - 1) * currentPageSize.value
431
- )
432
-
433
- const endIndex = computed(() =>
434
- Math.min(startIndex.value + currentPageSize.value, filteredData.value.length)
435
- )
436
-
437
- const paginatedData = computed(() => {
438
- if (!props.paginated) return filteredData.value
439
- return filteredData.value.slice(startIndex.value, endIndex.value)
440
- })
441
-
442
- // Computed: Selection
443
- const isAllSelected = computed(() =>
444
- paginatedData.value.length > 0 &&
445
- paginatedData.value.every(row => selectedRows.value.has(row.id || props.data.indexOf(row)))
446
- )
447
-
448
- const isIndeterminate = computed(() =>
449
- selectedRows.value.size > 0 && !isAllSelected.value
450
- )
451
-
452
- // Methods: Sorting
453
- const toggleSort = (key: string) => {
454
- if (sortKey.value === key) {
455
- sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
456
- } else {
457
- sortKey.value = key
458
- sortOrder.value = 'asc'
459
- }
460
- }
461
-
462
- // Methods: Selection
463
- const toggleSelectRow = (id: string | number) => {
464
- if (selectedRows.value.has(id)) {
465
- selectedRows.value.delete(id)
466
- } else {
467
- selectedRows.value.add(id)
468
- }
469
- emit('selection-change', Array.from(selectedRows.value))
470
- }
471
-
472
- const toggleSelectAll = () => {
473
- if (isAllSelected.value) {
474
- paginatedData.value.forEach(row => {
475
- selectedRows.value.delete(row.id || props.data.indexOf(row))
476
- })
477
- } else {
478
- paginatedData.value.forEach(row => {
479
- selectedRows.value.add(row.id || props.data.indexOf(row))
480
- })
481
- }
482
- emit('selection-change', Array.from(selectedRows.value))
483
- }
484
-
485
- // Watch: Reset page on filter change
486
- watch([searchQuery, columnFilters], () => {
487
- currentPage.value = 1
488
- }, { deep: true })
489
-
490
- // Watch: Reset page if current page exceeds total pages
491
- watch(totalPages, (newTotal) => {
492
- if (currentPage.value > newTotal && newTotal > 0) {
493
- currentPage.value = newTotal
494
- }
495
- })
496
- </script>
497
-
498
- <style scoped>
499
- .datametria-sortable-table {
500
- width: 100%;
501
- background: var(--dm-neutral-50, #ffffff);
502
- border-radius: var(--dm-radius-md, 0.375rem);
503
- box-shadow: var(--dm-shadow-sm, 0 1px 3px rgba(0, 0, 0, 0.1));
504
- }
505
-
506
- .datametria-sortable-table__search {
507
- padding: var(--dm-spacing-4, 1rem);
508
- border-bottom: 1px solid var(--dm-neutral-100, #f3f4f6);
509
- }
510
-
511
- .datametria-sortable-table__search-input {
512
- width: 100%;
513
- padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-4, 1rem);
514
- border: 1px solid var(--dm-neutral-200, #e5e7eb);
515
- border-radius: var(--dm-radius-md, 0.375rem);
516
- font-size: var(--dm-font-size-sm, 0.875rem);
517
- transition: all 0.2s ease;
518
- }
519
-
520
- .datametria-sortable-table__search-input:focus {
521
- outline: none;
522
- border-color: var(--dm-primary, #0072CE);
523
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
524
- }
525
-
526
- .datametria-sortable-table__wrapper {
527
- overflow-x: auto;
528
- }
529
-
530
- .datametria-sortable-table__table {
531
- width: 100%;
532
- border-collapse: collapse;
533
- }
534
-
535
- .datametria-sortable-table__thead {
536
- background: var(--dm-neutral-50, #f9fafb);
537
- border-bottom: 2px solid var(--dm-neutral-200, #e5e7eb);
538
- }
539
-
540
- .datametria-sortable-table__th {
541
- padding: var(--dm-spacing-3, 0.75rem) var(--dm-spacing-4, 1rem);
542
- text-align: left;
543
- font-size: var(--dm-font-size-sm, 0.875rem);
544
- font-weight: var(--dm-font-weight-semibold, 600);
545
- color: var(--dm-neutral-700, #374151);
546
- position: relative;
547
- }
548
-
549
- .datametria-sortable-table__th--sortable {
550
- cursor: pointer;
551
- user-select: none;
552
- }
553
-
554
- .datametria-sortable-table__th--sortable:hover {
555
- background: var(--dm-neutral-100, #f3f4f6);
556
- }
557
-
558
- .datametria-sortable-table__th--sortable:focus-visible {
559
- outline: none;
560
- box-shadow: inset 0 0 0 2px var(--dm-primary, #0072CE);
561
- }
562
-
563
- .datametria-sortable-table__th--checkbox {
564
- width: 40px;
565
- padding: var(--dm-spacing-3, 0.75rem) var(--dm-spacing-2, 0.5rem);
566
- }
567
-
568
- .datametria-sortable-table__th-content {
569
- display: flex;
570
- align-items: center;
571
- gap: var(--dm-spacing-2, 0.5rem);
572
- }
573
-
574
- .datametria-sortable-table__sort-icon {
575
- font-size: var(--dm-font-size-sm, 0.875rem);
576
- color: var(--dm-primary, #0072CE);
577
- }
578
-
579
- .datametria-sortable-table__sort-icon--inactive {
580
- color: var(--dm-neutral-400, #9ca3af);
581
- opacity: 0.5;
582
- }
583
-
584
- .datametria-sortable-table__filter-input {
585
- width: 100%;
586
- margin-top: var(--dm-spacing-2, 0.5rem);
587
- padding: var(--dm-spacing-2, 0.5rem);
588
- border: 1px solid var(--dm-neutral-200, #e5e7eb);
589
- border-radius: var(--dm-radius-md, 0.375rem);
590
- font-size: var(--dm-font-size-sm, 0.875rem);
591
- font-weight: 400;
592
- transition: all 0.2s ease;
593
- }
594
-
595
- .datametria-sortable-table__filter-input:focus {
596
- outline: none;
597
- border-color: var(--dm-primary, #0072CE);
598
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
599
- }
600
-
601
- .datametria-sortable-table__multiselect {
602
- position: relative;
603
- margin-top: var(--dm-spacing-2, 0.5rem);
604
- }
605
-
606
- .datametria-sortable-table__multiselect-trigger {
607
- width: 100%;
608
- padding: var(--dm-spacing-2, 0.5rem);
609
- border: 1px solid var(--dm-neutral-200, #e5e7eb);
610
- border-radius: var(--dm-radius-md, 0.375rem);
611
- font-size: var(--dm-font-size-sm, 0.875rem);
612
- font-weight: 400;
613
- text-align: left;
614
- background: var(--dm-neutral-50, #ffffff);
615
- cursor: pointer;
616
- transition: all 0.2s ease;
617
- }
618
-
619
- .datametria-sortable-table__multiselect-trigger:hover {
620
- border-color: var(--dm-primary, #0072CE);
621
- }
622
-
623
- .datametria-sortable-table__multiselect-trigger:focus {
624
- outline: none;
625
- border-color: var(--dm-primary, #0072CE);
626
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
627
- }
628
-
629
- .datametria-sortable-table__multiselect-dropdown {
630
- position: absolute;
631
- top: 100%;
632
- left: 0;
633
- right: 0;
634
- margin-top: var(--dm-spacing-1, 0.25rem);
635
- max-height: 200px;
636
- overflow-y: auto;
637
- background: var(--dm-neutral-50, #ffffff);
638
- border: 1px solid var(--dm-neutral-200, #e5e7eb);
639
- border-radius: var(--dm-radius-md, 0.375rem);
640
- box-shadow: var(--dm-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));
641
- z-index: 10;
642
- }
643
-
644
- .datametria-sortable-table__multiselect-option {
645
- display: flex;
646
- align-items: center;
647
- gap: var(--dm-spacing-2, 0.5rem);
648
- padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-3, 0.75rem);
649
- font-size: var(--dm-font-size-sm, 0.875rem);
650
- cursor: pointer;
651
- transition: background-color 0.2s ease;
652
- }
653
-
654
- .datametria-sortable-table__multiselect-option:hover {
655
- background: var(--dm-neutral-100, #f3f4f6);
656
- }
657
-
658
- .datametria-sortable-table__multiselect-option input[type="checkbox"] {
659
- margin: 0;
660
- }
661
-
662
- .datametria-sortable-table__tbody {
663
- background: var(--dm-neutral-50, #ffffff);
664
- }
665
-
666
- .datametria-sortable-table__tr {
667
- border-bottom: 1px solid var(--dm-neutral-100, #f3f4f6);
668
- transition: background-color 0.2s ease;
669
- }
670
-
671
- .datametria-sortable-table__tr:hover {
672
- background: var(--dm-neutral-50, #f9fafb);
673
- }
674
-
675
- .datametria-sortable-table__tr--selected {
676
- background: color-mix(in srgb, var(--dm-primary, #0072CE) 10%, transparent);
677
- }
678
-
679
- .datametria-sortable-table__td {
680
- padding: var(--dm-spacing-3, 0.75rem) var(--dm-spacing-4, 1rem);
681
- font-size: var(--dm-font-size-sm, 0.875rem);
682
- color: var(--dm-neutral-900, #111827);
683
- }
684
-
685
- .datametria-sortable-table__td--checkbox {
686
- width: 40px;
687
- padding: var(--dm-spacing-3, 0.75rem) var(--dm-spacing-2, 0.5rem);
688
- }
689
-
690
- .datametria-sortable-table__empty {
691
- padding: var(--dm-spacing-4, 1rem);
692
- text-align: center;
693
- color: var(--dm-neutral-500, #6b7280);
694
- font-size: var(--dm-font-size-sm, 0.875rem);
695
- }
696
-
697
- .datametria-sortable-table__pagination {
698
- display: flex;
699
- justify-content: space-between;
700
- align-items: center;
701
- padding: var(--dm-spacing-4, 1rem);
702
- border-top: 1px solid var(--dm-neutral-200, #e5e7eb);
703
- flex-wrap: wrap;
704
- gap: var(--dm-spacing-4, 1rem);
705
- }
706
-
707
- .datametria-sortable-table__pagination-info {
708
- font-size: var(--dm-font-size-sm, 0.875rem);
709
- color: var(--dm-neutral-600, #4b5563);
710
- }
711
-
712
- .datametria-sortable-table__pagination-controls {
713
- display: flex;
714
- align-items: center;
715
- gap: var(--dm-spacing-2, 0.5rem);
716
- }
717
-
718
- .datametria-sortable-table__page-size {
719
- padding: var(--dm-spacing-2, 0.5rem);
720
- border: 1px solid var(--dm-neutral-200, #e5e7eb);
721
- border-radius: var(--dm-radius-md, 0.375rem);
722
- font-size: var(--dm-font-size-sm, 0.875rem);
723
- cursor: pointer;
724
- transition: all 0.2s ease;
725
- background: var(--dm-neutral-50, #ffffff);
726
- color: var(--dm-neutral-900, #111827);
727
- }
728
-
729
- .datametria-sortable-table__page-size:focus {
730
- outline: none;
731
- border-color: var(--dm-primary, #0072CE);
732
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
733
- }
734
-
735
- .datametria-sortable-table__pagination-btn {
736
- padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-3, 0.75rem);
737
- border: 1px solid var(--dm-neutral-200, #e5e7eb);
738
- border-radius: var(--dm-radius-md, 0.375rem);
739
- background: var(--dm-neutral-50, #ffffff);
740
- color: var(--dm-neutral-900, #111827);
741
- font-size: var(--dm-font-size-sm, 0.875rem);
742
- cursor: pointer;
743
- transition: all 0.2s ease;
744
- }
745
-
746
- .datametria-sortable-table__pagination-btn:hover:not(:disabled) {
747
- background: var(--dm-neutral-100, #f3f4f6);
748
- border-color: var(--dm-primary, #0072CE);
749
- }
750
-
751
- .datametria-sortable-table__pagination-btn:focus-visible {
752
- outline: none;
753
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
754
- }
755
-
756
- .datametria-sortable-table__pagination-btn:disabled {
757
- opacity: 0.5;
758
- cursor: not-allowed;
759
- }
760
-
761
- .datametria-sortable-table__pagination-pages {
762
- padding: 0 var(--dm-spacing-2, 0.5rem);
763
- font-size: var(--dm-font-size-sm, 0.875rem);
764
- color: var(--dm-neutral-600, #4b5563);
765
- }
766
-
767
- input[type="checkbox"] {
768
- width: 16px;
769
- height: 16px;
770
- cursor: pointer;
771
- accent-color: var(--dm-primary, #0072CE);
772
- }
773
-
774
- input[type="checkbox"]:focus-visible {
775
- outline: 2px solid var(--dm-primary, #0072CE);
776
- outline-offset: 2px;
777
- }
778
-
779
- /* Mobile Responsiveness */
780
- @media (max-width: 640px) {
781
- .datametria-sortable-table__pagination {
782
- flex-direction: column;
783
- align-items: stretch;
784
- }
785
-
786
- .datametria-sortable-table__pagination-controls {
787
- justify-content: center;
788
- }
789
-
790
- .datametria-sortable-table__th,
791
- .datametria-sortable-table__td {
792
- padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-3, 0.75rem);
793
- font-size: calc(var(--dm-font-size-sm, 0.875rem) * 0.9);
1
+ <template>
2
+ <div class="datametria-sortable-table">
3
+ <!-- Search Bar -->
4
+ <div v-if="searchable" class="datametria-sortable-table__search">
5
+ <input
6
+ v-model="searchQuery"
7
+ type="text"
8
+ placeholder="Buscar..."
9
+ class="datametria-sortable-table__search-input"
10
+ aria-label="Buscar na tabela"
11
+ />
12
+ </div>
13
+
14
+ <!-- Table -->
15
+ <div class="datametria-sortable-table__wrapper">
16
+ <table class="datametria-sortable-table__table" role="table">
17
+ <thead class="datametria-sortable-table__thead">
18
+ <tr role="row">
19
+ <!-- Selection Column -->
20
+ <th
21
+ v-if="selectable"
22
+ class="datametria-sortable-table__th datametria-sortable-table__th--checkbox"
23
+ role="columnheader"
24
+ >
25
+ <input
26
+ type="checkbox"
27
+ :checked="isAllSelected"
28
+ :indeterminate="isIndeterminate"
29
+ aria-label="Selecionar todas as linhas"
30
+ @change="toggleSelectAll"
31
+ />
32
+ </th>
33
+
34
+ <!-- Data Columns -->
35
+ <th
36
+ v-for="column in columns"
37
+ :key="column.key"
38
+ class="datametria-sortable-table__th"
39
+ :class="{ 'datametria-sortable-table__th--sortable': column.sortable !== false }"
40
+ :style="{ width: column.width }"
41
+ role="columnheader"
42
+ :aria-sort="sortKey === column.key ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'"
43
+ @click="column.sortable !== false && toggleSort(column.key)"
44
+ @keydown.enter="column.sortable !== false && toggleSort(column.key)"
45
+ @keydown.space.prevent="column.sortable !== false && toggleSort(column.key)"
46
+ :tabindex="column.sortable !== false ? 0 : undefined"
47
+ >
48
+ <div class="datametria-sortable-table__th-content">
49
+ <span>{{ column.label }}</span>
50
+ <span v-if="column.sortable !== false" class="datametria-sortable-table__sort-icon" aria-hidden="true">
51
+ <span v-if="sortKey === column.key">
52
+ {{ sortOrder === 'asc' ? '↑' : '↓' }}
53
+ </span>
54
+ <span v-else class="datametria-sortable-table__sort-icon--inactive">↕</span>
55
+ </span>
56
+ </div>
57
+
58
+ <!-- Column Filter -->
59
+ <div v-if="filterable && column.filterable !== false" @click.stop>
60
+ <!-- Select Filter -->
61
+ <select
62
+ v-if="column.filterType === 'select'"
63
+ v-model="columnFilters[column.key]"
64
+ :aria-label="`Filtrar por ${column.label}`"
65
+ class="datametria-sortable-table__filter-input"
66
+ @keydown.stop
67
+ >
68
+ <option value="">Todos</option>
69
+ <option
70
+ v-for="option in getFilterOptions(column)"
71
+ :key="option.value"
72
+ :value="option.value"
73
+ >
74
+ {{ option.label }}
75
+ </option>
76
+ </select>
77
+
78
+ <!-- Multi-Select Filter -->
79
+ <div
80
+ v-else-if="column.filterType === 'multiselect'"
81
+ class="datametria-sortable-table__multiselect"
82
+ >
83
+ <button
84
+ type="button"
85
+ class="datametria-sortable-table__multiselect-trigger"
86
+ @click="toggleMultiselect(column.key)"
87
+ >
88
+ {{ getMultiselectLabel(column) }}
89
+ </button>
90
+ <div
91
+ v-if="activeMultiselect === column.key"
92
+ class="datametria-sortable-table__multiselect-dropdown"
93
+ >
94
+ <label
95
+ v-for="option in getFilterOptions(column)"
96
+ :key="option.value"
97
+ class="datametria-sortable-table__multiselect-option"
98
+ >
99
+ <input
100
+ type="checkbox"
101
+ :value="option.value"
102
+ :checked="isMultiselectChecked(column.key, option.value)"
103
+ @change="toggleMultiselectOption(column.key, option.value)"
104
+ />
105
+ {{ option.label }}
106
+ </label>
107
+ </div>
108
+ </div>
109
+
110
+ <!-- Text/Date Filter -->
111
+ <input
112
+ v-else
113
+ v-model="columnFilters[column.key]"
114
+ :type="column.filterType || getFilterInputType(column)"
115
+ :placeholder="`Filtrar ${column.label}...`"
116
+ :aria-label="`Filtrar por ${column.label}`"
117
+ class="datametria-sortable-table__filter-input"
118
+ @keydown.stop
119
+ />
120
+ </div>
121
+ </th>
122
+ </tr>
123
+ </thead>
124
+
125
+ <tbody class="datametria-sortable-table__tbody">
126
+ <tr
127
+ v-for="(row, index) in paginatedData"
128
+ :key="row.id || index"
129
+ class="datametria-sortable-table__tr"
130
+ :class="{ 'datametria-sortable-table__tr--selected': selectedRows.has(row.id || index) }"
131
+ role="row"
132
+ >
133
+ <!-- Selection Column -->
134
+ <td
135
+ v-if="selectable"
136
+ class="datametria-sortable-table__td datametria-sortable-table__td--checkbox"
137
+ role="cell"
138
+ >
139
+ <input
140
+ type="checkbox"
141
+ :checked="selectedRows.has(row.id || index)"
142
+ :aria-label="`Selecionar linha ${index + 1}`"
143
+ @change="toggleSelectRow(row.id || index)"
144
+ />
145
+ </td>
146
+
147
+ <!-- Data Columns -->
148
+ <td
149
+ v-for="column in columns"
150
+ :key="column.key"
151
+ class="datametria-sortable-table__td"
152
+ role="cell"
153
+ >
154
+ <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
155
+ {{ row[column.key] }}
156
+ </slot>
157
+ </td>
158
+ </tr>
159
+ </tbody>
160
+ </table>
161
+
162
+ <!-- Empty State -->
163
+ <div v-if="filteredData.length === 0" class="datametria-sortable-table__empty" role="status">
164
+ <slot name="empty">Nenhum dado encontrado</slot>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- Pagination -->
169
+ <div
170
+ v-if="paginated && filteredData.length > 0"
171
+ class="datametria-sortable-table__pagination"
172
+ role="navigation"
173
+ aria-label="Paginação da tabela"
174
+ >
175
+ <div class="datametria-sortable-table__pagination-info" aria-live="polite">
176
+ Mostrando {{ startIndex + 1 }} - {{ endIndex }} de {{ filteredData.length }} registros
177
+ <span v-if="selectedRows.size > 0">({{ selectedRows.size }} selecionados)</span>
178
+ </div>
179
+
180
+ <div class="datametria-sortable-table__pagination-controls">
181
+ <select
182
+ v-model.number="currentPageSize"
183
+ class="datametria-sortable-table__page-size"
184
+ aria-label="Itens por página"
185
+ >
186
+ <option v-for="size in pageSizeOptions" :key="size" :value="size">
187
+ {{ size }} por página
188
+ </option>
189
+ </select>
190
+
191
+ <button
192
+ class="datametria-sortable-table__pagination-btn"
193
+ :disabled="currentPage === 1"
194
+ aria-label="Primeira página"
195
+ @click="currentPage = 1"
196
+ >
197
+ ««
198
+ </button>
199
+ <button
200
+ class="datametria-sortable-table__pagination-btn"
201
+ :disabled="currentPage === 1"
202
+ aria-label="Página anterior"
203
+ @click="currentPage--"
204
+ >
205
+
206
+ </button>
207
+
208
+ <span class="datametria-sortable-table__pagination-pages" aria-current="page">
209
+ Página {{ currentPage }} de {{ totalPages }}
210
+ </span>
211
+
212
+ <button
213
+ class="datametria-sortable-table__pagination-btn"
214
+ :disabled="currentPage === totalPages"
215
+ aria-label="Próxima página"
216
+ @click="currentPage++"
217
+ >
218
+
219
+ </button>
220
+ <button
221
+ class="datametria-sortable-table__pagination-btn"
222
+ :disabled="currentPage === totalPages"
223
+ aria-label="Última página"
224
+ @click="currentPage = totalPages"
225
+ >
226
+ »»
227
+ </button>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </template>
232
+
233
+ <script setup lang="ts">
234
+ import { ref, computed, watch } from 'vue'
235
+ import type { SortableTableProps } from '../types'
236
+
237
+ const props = withDefaults(defineProps<SortableTableProps>(), {
238
+ selectable: false,
239
+ searchable: true,
240
+ filterable: true,
241
+ paginated: true,
242
+ pageSize: 10,
243
+ pageSizeOptions: () => [5, 10, 25, 50, 100]
244
+ })
245
+
246
+ // Validação em desenvolvimento
247
+ if (process.env.NODE_ENV === 'development') {
248
+ if (!props.columns || props.columns.length === 0) {
249
+ console.warn('[DatametriaSortableTable] No columns provided')
250
+ }
251
+ if (!props.data) {
252
+ console.warn('[DatametriaSortableTable] No data provided')
253
+ }
254
+ }
255
+
256
+ const emit = defineEmits<{
257
+ 'selection-change': [selectedIds: (string | number)[]]
258
+ }>()
259
+
260
+ // Detect filter input type based on column data
261
+ const getFilterInputType = (column: any) => {
262
+ if (!props.data || props.data.length === 0) return 'text'
263
+
264
+ const sampleValue = props.data[0][column.key]
265
+
266
+ // Check if it's a date
267
+ if (sampleValue instanceof Date) return 'date'
268
+ if (typeof sampleValue === 'string' && !isNaN(Date.parse(sampleValue))) {
269
+ // Check if it looks like a date string (YYYY-MM-DD, DD/MM/YYYY, etc)
270
+ const datePattern = /^\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4}/
271
+ if (datePattern.test(sampleValue)) return 'date'
272
+ }
273
+
274
+ return 'text'
275
+ }
276
+
277
+ // Get filter options (auto-generate if 'auto')
278
+ const getFilterOptions = (column: any) => {
279
+ if (column.filterOptions === 'auto') {
280
+ const uniqueValues = new Set<string>()
281
+ props.data.forEach(row => {
282
+ const value = row[column.key]
283
+ if (value !== null && value !== undefined && value !== '') {
284
+ uniqueValues.add(String(value))
285
+ }
286
+ })
287
+ return Array.from(uniqueValues)
288
+ .sort()
289
+ .map(value => ({ value, label: value }))
290
+ }
291
+ return column.filterOptions || []
292
+ }
293
+
294
+ // Multi-select methods
295
+ const toggleMultiselect = (key: string) => {
296
+ activeMultiselect.value = activeMultiselect.value === key ? null : key
297
+ }
298
+
299
+ const getMultiselectLabel = (column: any) => {
300
+ const selected = columnFilters.value[column.key] as string[] || []
301
+ if (selected.length === 0) return 'Todos'
302
+ if (selected.length === 1) {
303
+ const options = getFilterOptions(column)
304
+ const option = options.find((o: any) => o.value === selected[0])
305
+ return option?.label || selected[0]
306
+ }
307
+ return `${selected.length} selecionados`
308
+ }
309
+
310
+ const isMultiselectChecked = (key: string, value: string) => {
311
+ const selected = columnFilters.value[key] as string[] || []
312
+ return selected.includes(value)
313
+ }
314
+
315
+ const toggleMultiselectOption = (key: string, value: string) => {
316
+ const selected = (columnFilters.value[key] as string[]) || []
317
+ const index = selected.indexOf(value)
318
+
319
+ if (index > -1) {
320
+ columnFilters.value[key] = selected.filter(v => v !== value)
321
+ } else {
322
+ columnFilters.value[key] = [...selected, value]
323
+ }
324
+ }
325
+
326
+ // Search
327
+ const searchQuery = ref('')
328
+
329
+ // Sorting
330
+ const sortKey = ref<string>('')
331
+ const sortOrder = ref<'asc' | 'desc'>('asc')
332
+
333
+ // Filtering
334
+ const columnFilters = ref<Record<string, string | string[]>>({})
335
+ const activeMultiselect = ref<string | null>(null)
336
+
337
+ // Selection
338
+ const selectedRows = ref<Set<string | number>>(new Set())
339
+
340
+ // Pagination
341
+ const currentPage = ref(1)
342
+ const currentPageSize = ref(props.pageSize)
343
+
344
+ // Computed: Filtered Data
345
+ const filteredData = computed(() => {
346
+ let result = [...props.data]
347
+
348
+ // Global search
349
+ if (searchQuery.value) {
350
+ const query = searchQuery.value.toLowerCase()
351
+ result = result.filter(row =>
352
+ Object.values(row).some(value =>
353
+ String(value).toLowerCase().includes(query)
354
+ )
355
+ )
356
+ }
357
+
358
+ // Column filters
359
+ Object.entries(columnFilters.value).forEach(([key, filterValue]) => {
360
+ if (filterValue) {
361
+ // Multi-select filter
362
+ if (Array.isArray(filterValue) && filterValue.length > 0) {
363
+ result = result.filter(row =>
364
+ filterValue.includes(String(row[key]))
365
+ )
366
+ }
367
+ // Text/Date/Select filter
368
+ else if (typeof filterValue === 'string') {
369
+ const query = filterValue.toLowerCase()
370
+ result = result.filter(row =>
371
+ String(row[key]).toLowerCase().includes(query)
372
+ )
373
+ }
374
+ }
375
+ })
376
+
377
+ // Sorting
378
+ if (sortKey.value) {
379
+ result.sort((a, b) => {
380
+ let aVal = a[sortKey.value]
381
+ let bVal = b[sortKey.value]
382
+
383
+ // Handle null/undefined
384
+ if (aVal == null && bVal == null) return 0
385
+ if (aVal == null) return 1
386
+ if (bVal == null) return -1
387
+
388
+ // Convert dates to timestamps for proper comparison
389
+ if (aVal instanceof Date) aVal = aVal.getTime()
390
+ if (bVal instanceof Date) bVal = bVal.getTime()
391
+
392
+ // Try to parse as date if string looks like a date
393
+ if (typeof aVal === 'string' && !isNaN(Date.parse(aVal))) {
394
+ const datePattern = /^\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4}/
395
+ if (datePattern.test(aVal)) {
396
+ aVal = new Date(aVal).getTime()
397
+ }
398
+ }
399
+ if (typeof bVal === 'string' && !isNaN(Date.parse(bVal))) {
400
+ const datePattern = /^\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4}/
401
+ if (datePattern.test(bVal)) {
402
+ bVal = new Date(bVal).getTime()
403
+ }
404
+ }
405
+
406
+ // Numeric comparison
407
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
408
+ return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal
409
+ }
410
+
411
+ // String comparison (case-insensitive)
412
+ const aStr = String(aVal).toLowerCase()
413
+ const bStr = String(bVal).toLowerCase()
414
+
415
+ if (aStr === bStr) return 0
416
+ const comparison = aStr > bStr ? 1 : -1
417
+ return sortOrder.value === 'asc' ? comparison : -comparison
418
+ })
419
+ }
420
+
421
+ return result
422
+ })
423
+
424
+ // Computed: Pagination
425
+ const totalPages = computed(() =>
426
+ Math.ceil(filteredData.value.length / currentPageSize.value)
427
+ )
428
+
429
+ const startIndex = computed(() =>
430
+ (currentPage.value - 1) * currentPageSize.value
431
+ )
432
+
433
+ const endIndex = computed(() =>
434
+ Math.min(startIndex.value + currentPageSize.value, filteredData.value.length)
435
+ )
436
+
437
+ const paginatedData = computed(() => {
438
+ if (!props.paginated) return filteredData.value
439
+ return filteredData.value.slice(startIndex.value, endIndex.value)
440
+ })
441
+
442
+ // Computed: Selection
443
+ const isAllSelected = computed(() =>
444
+ paginatedData.value.length > 0 &&
445
+ paginatedData.value.every(row => selectedRows.value.has(row.id || props.data.indexOf(row)))
446
+ )
447
+
448
+ const isIndeterminate = computed(() =>
449
+ selectedRows.value.size > 0 && !isAllSelected.value
450
+ )
451
+
452
+ // Methods: Sorting
453
+ const toggleSort = (key: string) => {
454
+ if (sortKey.value === key) {
455
+ sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
456
+ } else {
457
+ sortKey.value = key
458
+ sortOrder.value = 'asc'
459
+ }
460
+ }
461
+
462
+ // Methods: Selection
463
+ const toggleSelectRow = (id: string | number) => {
464
+ if (selectedRows.value.has(id)) {
465
+ selectedRows.value.delete(id)
466
+ } else {
467
+ selectedRows.value.add(id)
468
+ }
469
+ emit('selection-change', Array.from(selectedRows.value))
470
+ }
471
+
472
+ const toggleSelectAll = () => {
473
+ if (isAllSelected.value) {
474
+ paginatedData.value.forEach(row => {
475
+ selectedRows.value.delete(row.id || props.data.indexOf(row))
476
+ })
477
+ } else {
478
+ paginatedData.value.forEach(row => {
479
+ selectedRows.value.add(row.id || props.data.indexOf(row))
480
+ })
481
+ }
482
+ emit('selection-change', Array.from(selectedRows.value))
483
+ }
484
+
485
+ // Watch: Reset page on filter change
486
+ watch([searchQuery, columnFilters], () => {
487
+ currentPage.value = 1
488
+ }, { deep: true })
489
+
490
+ // Watch: Reset page if current page exceeds total pages
491
+ watch(totalPages, (newTotal) => {
492
+ if (currentPage.value > newTotal && newTotal > 0) {
493
+ currentPage.value = newTotal
494
+ }
495
+ })
496
+ </script>
497
+
498
+ <style scoped>
499
+ .datametria-sortable-table {
500
+ width: 100%;
501
+ background: var(--dm-neutral-50, #ffffff);
502
+ border-radius: var(--dm-radius-md, 0.375rem);
503
+ box-shadow: var(--dm-shadow-sm, 0 1px 3px rgba(0, 0, 0, 0.1));
504
+ }
505
+
506
+ .datametria-sortable-table__search {
507
+ padding: var(--dm-spacing-4, 1rem);
508
+ border-bottom: 1px solid var(--dm-neutral-100, #f3f4f6);
509
+ }
510
+
511
+ .datametria-sortable-table__search-input {
512
+ width: 100%;
513
+ padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-4, 1rem);
514
+ border: 1px solid var(--dm-neutral-200, #e5e7eb);
515
+ border-radius: var(--dm-radius-md, 0.375rem);
516
+ font-size: var(--dm-font-size-sm, 0.875rem);
517
+ transition: all 0.2s ease;
518
+ }
519
+
520
+ .datametria-sortable-table__search-input:focus {
521
+ outline: none;
522
+ border-color: var(--dm-primary, #0072CE);
523
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
524
+ }
525
+
526
+ .datametria-sortable-table__wrapper {
527
+ overflow-x: auto;
528
+ }
529
+
530
+ .datametria-sortable-table__table {
531
+ width: 100%;
532
+ border-collapse: collapse;
533
+ }
534
+
535
+ .datametria-sortable-table__thead {
536
+ background: var(--dm-neutral-50, #f9fafb);
537
+ border-bottom: 2px solid var(--dm-neutral-200, #e5e7eb);
538
+ }
539
+
540
+ .datametria-sortable-table__th {
541
+ padding: var(--dm-spacing-3, 0.75rem) var(--dm-spacing-4, 1rem);
542
+ text-align: left;
543
+ font-size: var(--dm-font-size-sm, 0.875rem);
544
+ font-weight: var(--dm-font-weight-semibold, 600);
545
+ color: var(--dm-neutral-700, #374151);
546
+ position: relative;
547
+ }
548
+
549
+ .datametria-sortable-table__th--sortable {
550
+ cursor: pointer;
551
+ user-select: none;
552
+ }
553
+
554
+ .datametria-sortable-table__th--sortable:hover {
555
+ background: var(--dm-neutral-100, #f3f4f6);
556
+ }
557
+
558
+ .datametria-sortable-table__th--sortable:focus-visible {
559
+ outline: none;
560
+ box-shadow: inset 0 0 0 2px var(--dm-primary, #0072CE);
561
+ }
562
+
563
+ .datametria-sortable-table__th--checkbox {
564
+ width: 40px;
565
+ padding: var(--dm-spacing-3, 0.75rem) var(--dm-spacing-2, 0.5rem);
566
+ }
567
+
568
+ .datametria-sortable-table__th-content {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: var(--dm-spacing-2, 0.5rem);
572
+ }
573
+
574
+ .datametria-sortable-table__sort-icon {
575
+ font-size: var(--dm-font-size-sm, 0.875rem);
576
+ color: var(--dm-primary, #0072CE);
577
+ }
578
+
579
+ .datametria-sortable-table__sort-icon--inactive {
580
+ color: var(--dm-neutral-400, #9ca3af);
581
+ opacity: 0.5;
582
+ }
583
+
584
+ .datametria-sortable-table__filter-input {
585
+ width: 100%;
586
+ margin-top: var(--dm-spacing-2, 0.5rem);
587
+ padding: var(--dm-spacing-2, 0.5rem);
588
+ border: 1px solid var(--dm-neutral-200, #e5e7eb);
589
+ border-radius: var(--dm-radius-md, 0.375rem);
590
+ font-size: var(--dm-font-size-sm, 0.875rem);
591
+ font-weight: 400;
592
+ transition: all 0.2s ease;
593
+ }
594
+
595
+ .datametria-sortable-table__filter-input:focus {
596
+ outline: none;
597
+ border-color: var(--dm-primary, #0072CE);
598
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
599
+ }
600
+
601
+ .datametria-sortable-table__multiselect {
602
+ position: relative;
603
+ margin-top: var(--dm-spacing-2, 0.5rem);
604
+ }
605
+
606
+ .datametria-sortable-table__multiselect-trigger {
607
+ width: 100%;
608
+ padding: var(--dm-spacing-2, 0.5rem);
609
+ border: 1px solid var(--dm-neutral-200, #e5e7eb);
610
+ border-radius: var(--dm-radius-md, 0.375rem);
611
+ font-size: var(--dm-font-size-sm, 0.875rem);
612
+ font-weight: 400;
613
+ text-align: left;
614
+ background: var(--dm-neutral-50, #ffffff);
615
+ cursor: pointer;
616
+ transition: all 0.2s ease;
617
+ }
618
+
619
+ .datametria-sortable-table__multiselect-trigger:hover {
620
+ border-color: var(--dm-primary, #0072CE);
621
+ }
622
+
623
+ .datametria-sortable-table__multiselect-trigger:focus {
624
+ outline: none;
625
+ border-color: var(--dm-primary, #0072CE);
626
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
627
+ }
628
+
629
+ .datametria-sortable-table__multiselect-dropdown {
630
+ position: absolute;
631
+ top: 100%;
632
+ left: 0;
633
+ right: 0;
634
+ margin-top: var(--dm-spacing-1, 0.25rem);
635
+ max-height: 200px;
636
+ overflow-y: auto;
637
+ background: var(--dm-neutral-50, #ffffff);
638
+ border: 1px solid var(--dm-neutral-200, #e5e7eb);
639
+ border-radius: var(--dm-radius-md, 0.375rem);
640
+ box-shadow: var(--dm-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));
641
+ z-index: 10;
642
+ }
643
+
644
+ .datametria-sortable-table__multiselect-option {
645
+ display: flex;
646
+ align-items: center;
647
+ gap: var(--dm-spacing-2, 0.5rem);
648
+ padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-3, 0.75rem);
649
+ font-size: var(--dm-font-size-sm, 0.875rem);
650
+ cursor: pointer;
651
+ transition: background-color 0.2s ease;
652
+ }
653
+
654
+ .datametria-sortable-table__multiselect-option:hover {
655
+ background: var(--dm-neutral-100, #f3f4f6);
656
+ }
657
+
658
+ .datametria-sortable-table__multiselect-option input[type="checkbox"] {
659
+ margin: 0;
660
+ }
661
+
662
+ .datametria-sortable-table__tbody {
663
+ background: var(--dm-neutral-50, #ffffff);
664
+ }
665
+
666
+ .datametria-sortable-table__tr {
667
+ border-bottom: 1px solid var(--dm-neutral-100, #f3f4f6);
668
+ transition: background-color 0.2s ease;
669
+ }
670
+
671
+ .datametria-sortable-table__tr:hover {
672
+ background: var(--dm-neutral-50, #f9fafb);
673
+ }
674
+
675
+ .datametria-sortable-table__tr--selected {
676
+ background: color-mix(in srgb, var(--dm-primary, #0072CE) 10%, transparent);
677
+ }
678
+
679
+ .datametria-sortable-table__td {
680
+ padding: var(--dm-spacing-3, 0.75rem) var(--dm-spacing-4, 1rem);
681
+ font-size: var(--dm-font-size-sm, 0.875rem);
682
+ color: var(--dm-neutral-900, #111827);
683
+ }
684
+
685
+ .datametria-sortable-table__td--checkbox {
686
+ width: 40px;
687
+ padding: var(--dm-spacing-3, 0.75rem) var(--dm-spacing-2, 0.5rem);
688
+ }
689
+
690
+ .datametria-sortable-table__empty {
691
+ padding: var(--dm-spacing-4, 1rem);
692
+ text-align: center;
693
+ color: var(--dm-neutral-500, #6b7280);
694
+ font-size: var(--dm-font-size-sm, 0.875rem);
695
+ }
696
+
697
+ .datametria-sortable-table__pagination {
698
+ display: flex;
699
+ justify-content: space-between;
700
+ align-items: center;
701
+ padding: var(--dm-spacing-4, 1rem);
702
+ border-top: 1px solid var(--dm-neutral-200, #e5e7eb);
703
+ flex-wrap: wrap;
704
+ gap: var(--dm-spacing-4, 1rem);
705
+ }
706
+
707
+ .datametria-sortable-table__pagination-info {
708
+ font-size: var(--dm-font-size-sm, 0.875rem);
709
+ color: var(--dm-neutral-600, #4b5563);
710
+ }
711
+
712
+ .datametria-sortable-table__pagination-controls {
713
+ display: flex;
714
+ align-items: center;
715
+ gap: var(--dm-spacing-2, 0.5rem);
716
+ }
717
+
718
+ .datametria-sortable-table__page-size {
719
+ padding: var(--dm-spacing-2, 0.5rem);
720
+ border: 1px solid var(--dm-neutral-200, #e5e7eb);
721
+ border-radius: var(--dm-radius-md, 0.375rem);
722
+ font-size: var(--dm-font-size-sm, 0.875rem);
723
+ cursor: pointer;
724
+ transition: all 0.2s ease;
725
+ background: var(--dm-neutral-50, #ffffff);
726
+ color: var(--dm-neutral-900, #111827);
727
+ }
728
+
729
+ .datametria-sortable-table__page-size:focus {
730
+ outline: none;
731
+ border-color: var(--dm-primary, #0072CE);
732
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
733
+ }
734
+
735
+ .datametria-sortable-table__pagination-btn {
736
+ padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-3, 0.75rem);
737
+ border: 1px solid var(--dm-neutral-200, #e5e7eb);
738
+ border-radius: var(--dm-radius-md, 0.375rem);
739
+ background: var(--dm-neutral-50, #ffffff);
740
+ color: var(--dm-neutral-900, #111827);
741
+ font-size: var(--dm-font-size-sm, 0.875rem);
742
+ cursor: pointer;
743
+ transition: all 0.2s ease;
744
+ }
745
+
746
+ .datametria-sortable-table__pagination-btn:hover:not(:disabled) {
747
+ background: var(--dm-neutral-100, #f3f4f6);
748
+ border-color: var(--dm-primary, #0072CE);
749
+ }
750
+
751
+ .datametria-sortable-table__pagination-btn:focus-visible {
752
+ outline: none;
753
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
754
+ }
755
+
756
+ .datametria-sortable-table__pagination-btn:disabled {
757
+ opacity: 0.5;
758
+ cursor: not-allowed;
759
+ }
760
+
761
+ .datametria-sortable-table__pagination-pages {
762
+ padding: 0 var(--dm-spacing-2, 0.5rem);
763
+ font-size: var(--dm-font-size-sm, 0.875rem);
764
+ color: var(--dm-neutral-600, #4b5563);
765
+ }
766
+
767
+ input[type="checkbox"] {
768
+ width: 16px;
769
+ height: 16px;
770
+ cursor: pointer;
771
+ accent-color: var(--dm-primary, #0072CE);
772
+ }
773
+
774
+ input[type="checkbox"]:focus-visible {
775
+ outline: 2px solid var(--dm-primary, #0072CE);
776
+ outline-offset: 2px;
777
+ }
778
+
779
+ /* Mobile Responsiveness */
780
+ @media (max-width: 640px) {
781
+ .datametria-sortable-table__pagination {
782
+ flex-direction: column;
783
+ align-items: stretch;
784
+ }
785
+
786
+ .datametria-sortable-table__pagination-controls {
787
+ justify-content: center;
788
+ }
789
+
790
+ .datametria-sortable-table__th,
791
+ .datametria-sortable-table__td {
792
+ padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-3, 0.75rem);
793
+ font-size: calc(var(--dm-font-size-sm, 0.875rem) * 0.9);
794
+ }
795
+ }
796
+
797
+ /* Reduced Motion */
798
+ @media (prefers-reduced-motion: reduce) {
799
+ .datametria-sortable-table__tr,
800
+ .datametria-sortable-table__pagination-btn,
801
+ .datametria-sortable-table__search-input,
802
+ .datametria-sortable-table__filter-input {
803
+ transition: none;
804
+ }
805
+ }
806
+
807
+ /* Dark Mode Support - Hybrid Approach */
808
+
809
+ /* Fallback automático (sem JS) */
810
+ @media (prefers-color-scheme: dark) {
811
+ .datametria-sortable-table {
812
+ background: var(--dm-bg-color-dark, #1e1e1e);
813
+ color: var(--dm-text-primary-dark, #e0e0e0);
814
+ border-color: var(--dm-border-color-dark, #404040);
794
815
  }
795
816
  }
796
817
 
797
- /* Reduced Motion */
798
- @media (prefers-reduced-motion: reduce) {
799
- .datametria-sortable-table__tr,
800
- .datametria-sortable-table__pagination-btn,
801
- .datametria-sortable-table__search-input,
802
- .datametria-sortable-table__filter-input {
803
- transition: none;
804
- }
818
+ /* Controle manual via useTheme() */
819
+ [data-theme="dark"] .datametria-sortable-table {
820
+ background: var(--dm-bg-color-dark, #1e1e1e);
821
+ color: var(--dm-text-primary-dark, #e0e0e0);
822
+ border-color: var(--dm-border-color-dark, #404040);
805
823
  }
806
- </style>
824
+ </style>