@veristone/nuxt-v-app 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 (136) hide show
  1. package/README.md +42 -0
  2. package/app/app.vue +7 -0
  3. package/app/assets/css/v-app.css +313 -0
  4. package/app/components/V/A/Badge.vue +75 -0
  5. package/app/components/V/A/Btn/Add.vue +17 -0
  6. package/app/components/V/A/Btn/Back.vue +25 -0
  7. package/app/components/V/A/Btn/ConfirmDelete.vue +45 -0
  8. package/app/components/V/A/Btn/Edit.vue +35 -0
  9. package/app/components/V/A/Btn/Export.vue +28 -0
  10. package/app/components/V/A/Btn/Refresh.vue +21 -0
  11. package/app/components/V/A/Btn/Submit.vue +45 -0
  12. package/app/components/V/A/Btn/View.vue +23 -0
  13. package/app/components/V/A/Card.legacy.vue +291 -0
  14. package/app/components/V/A/Card.vue +108 -0
  15. package/app/components/V/A/CompanyMenu.vue +83 -0
  16. package/app/components/V/A/Data/KeyValue.vue +98 -0
  17. package/app/components/V/A/Data/StatusBadge.vue +44 -0
  18. package/app/components/V/A/DataField.vue +140 -0
  19. package/app/components/V/A/DataGrid.vue +43 -0
  20. package/app/components/V/A/DataTable.vue +144 -0
  21. package/app/components/V/A/EmptyState.vue +154 -0
  22. package/app/components/V/A/Fmt/Currency.vue +36 -0
  23. package/app/components/V/A/Fmt/DateTime.vue +34 -0
  24. package/app/components/V/A/Fmt/Percent.vue +47 -0
  25. package/app/components/V/A/LoadingState.vue +140 -0
  26. package/app/components/V/A/MetricCard.vue +129 -0
  27. package/app/components/V/A/Modal/Base.vue +195 -0
  28. package/app/components/V/A/Modal/Confirm.vue +92 -0
  29. package/app/components/V/A/Modal/Form.vue +105 -0
  30. package/app/components/V/A/Navigation.vue +110 -0
  31. package/app/components/V/A/QuickActions.vue +169 -0
  32. package/app/components/V/A/Slide.vue +109 -0
  33. package/app/components/V/A/Slideover.vue +259 -0
  34. package/app/components/V/A/State/Empty.vue +20 -0
  35. package/app/components/V/A/State/Error.vue +34 -0
  36. package/app/components/V/A/State/Loading.vue +33 -0
  37. package/app/components/V/A/StatsCard.vue +215 -0
  38. package/app/components/V/A/StatusBadge.vue +215 -0
  39. package/app/components/V/A/Table.vue +674 -0
  40. package/app/components/V/A/UserMenu.vue +127 -0
  41. package/app/components/V/A/WelcomeHeader.vue +96 -0
  42. package/app/components/V/Modal.vue +36 -0
  43. package/app/components/Va/Blocks/VaBlockGridCharts.vue +32 -0
  44. package/app/components/Va/Blocks/VaBlockGridKPI.vue +32 -0
  45. package/app/components/Va/Blocks/VaBlockGridTables.vue +23 -0
  46. package/app/components/Va/Blocks/VaBlockKpiGrid.vue +8 -0
  47. package/app/components/Va/Blocks/VaBlockSessionFilterBar.vue +8 -0
  48. package/app/components/Va/Cards/VaCardDonutChart.vue +59 -0
  49. package/app/components/Va/Cards/VaCardHeader.vue +10 -0
  50. package/app/components/Va/Cards/VaCardKpi.vue +17 -0
  51. package/app/components/Va/Cards/VaCardKpi2.vue +55 -0
  52. package/app/components/Va/Cards/VaCardLatestOrders.vue +82 -0
  53. package/app/components/Va/Cards/VaCardPopularProducts.vue +88 -0
  54. package/app/components/Va/Cards/VaCardRevenueBarChart.vue +49 -0
  55. package/app/components/Va/Cards/VaCardSubtitle.vue +5 -0
  56. package/app/components/Va/Cards/VaCardTitle.vue +5 -0
  57. package/app/components/Va/Cards/VaCardWithActiveUsers.vue +41 -0
  58. package/app/components/Va/Cards/VaCardWithChart.vue +135 -0
  59. package/app/components/Va/Cards/VaCardWithChartBlock.vue +26 -0
  60. package/app/components/Va/Cards/VaCardWithIndicator.vue +39 -0
  61. package/app/components/Va/Cards/VaCardWithProgressCircle.vue +34 -0
  62. package/app/components/Va/Cards/types.ts +11 -0
  63. package/app/components/Va/Charts/VaChartAppPerformanceBar.vue +118 -0
  64. package/app/components/Va/Charts/VaChartAppPerformanceBarChart.vue +118 -0
  65. package/app/components/Va/Charts/VaChartAreaMini.vue +127 -0
  66. package/app/components/Va/Charts/VaChartBarMini.vue +68 -0
  67. package/app/components/Va/Charts/VaChartCardinalMulti.vue +108 -0
  68. package/app/components/Va/Charts/VaChartColorBarChart.vue +78 -0
  69. package/app/components/Va/Charts/VaChartDonutHalf.vue +35 -0
  70. package/app/components/Va/Charts/VaChartDonutMini.vue +77 -0
  71. package/app/components/Va/Charts/VaChartExpensesBar.vue +58 -0
  72. package/app/components/Va/Charts/VaChartFinanceSummary.vue +96 -0
  73. package/app/components/Va/Charts/VaChartGoogleSearchConsole.vue +90 -0
  74. package/app/components/Va/Charts/VaChartIncomeBar.vue +82 -0
  75. package/app/components/Va/Charts/VaChartLegend.vue +25 -0
  76. package/app/components/Va/Charts/VaChartLineMini.vue +205 -0
  77. package/app/components/Va/Charts/VaChartRealtimeTraffic.vue +182 -0
  78. package/app/components/Va/Charts/VaChartRevenue.vue +43 -0
  79. package/app/components/Va/Charts/VaChartRevenueLine.vue +42 -0
  80. package/app/components/Va/Charts/VaChartRevenuevsCost.vue +84 -0
  81. package/app/components/Va/Charts/VaChartSearchIntent.vue +179 -0
  82. package/app/components/Va/Charts/VaChartSpendingTrend.vue +127 -0
  83. package/app/components/Va/Charts/VaChartStackedHorizontal.vue +64 -0
  84. package/app/components/Va/Charts/VaChartStepMinimal.vue +109 -0
  85. package/app/components/Va/Charts/VaChartStockComparisonLine.vue +86 -0
  86. package/app/components/Va/Charts/VaChartStocksPortfolioLine.vue +161 -0
  87. package/app/components/Va/Charts/VaChartStocksSectorLine.vue +223 -0
  88. package/app/components/Va/Charts/VaChartTasksCategories.vue +96 -0
  89. package/app/components/Va/Charts/VaChartTasksProgress.vue +130 -0
  90. package/app/components/Va/Charts/VaChartTrafficOverview.vue +112 -0
  91. package/app/components/Va/Charts/VaChartWebPerformanceLineChart.vue +114 -0
  92. package/app/components/Va/Charts/VaChartWinLostBar.vue +110 -0
  93. package/app/components/Va/Charts/VaChartWinLostDonut.vue +107 -0
  94. package/app/components/Va/Charts/VaChartWinLostLine.vue +111 -0
  95. package/app/components/Va/Charts/types.ts +10 -0
  96. package/app/components/Va/Dashboard/Navigation/types.ts +8 -0
  97. package/app/components/Va/Dashboard/VaDashboardKPICard.vue +31 -0
  98. package/app/components/Va/Dashboard/VaDashboardNavigation.vue +50 -0
  99. package/app/components/Va/Dashboard/VaDashboardPricePlan.vue +102 -0
  100. package/app/components/Va/Dashboard/VaDashboardUsageChart.vue +84 -0
  101. package/app/components/Va/Dashboard/VaDashboardUsageRequestChart.vue +46 -0
  102. package/app/components/Va/Layout/NotificationsSlideover.vue +169 -0
  103. package/app/components/Va/Layout/SideNav/types.ts +5 -0
  104. package/app/components/Va/Layout/SideNav.vue +108 -0
  105. package/app/components/Va/Layout/TeamsMenu.vue +57 -0
  106. package/app/components/Va/Layout/UserMenu.vue +57 -0
  107. package/app/composables/useDashboard.ts +25 -0
  108. package/app/composables/useVAAnimation.ts +324 -0
  109. package/app/composables/useVAUtils.ts +118 -0
  110. package/app/composables/useVCrud.ts +647 -0
  111. package/app/composables/useVFetch.ts +46 -0
  112. package/app/composables/useVFileUpload.ts +45 -0
  113. package/app/composables/useVToast.ts +73 -0
  114. package/app/composables/useXATableColumns.ts +456 -0
  115. package/app/data/BillingStats.ts +65 -0
  116. package/app/data/SearchData.ts +58 -0
  117. package/app/data/TasksData.ts +101 -0
  118. package/app/data/dashboardData.ts +113 -0
  119. package/app/layouts/default.vue +171 -0
  120. package/app/layouts/legacy.vue +61 -0
  121. package/app/pages/playground/base.vue +498 -0
  122. package/app/pages/playground/blocks.vue +108 -0
  123. package/app/pages/playground/buttons.vue +237 -0
  124. package/app/pages/playground/cards.vue +326 -0
  125. package/app/pages/playground/charts.vue +338 -0
  126. package/app/pages/playground/dashboard.vue +315 -0
  127. package/app/pages/playground/formatters.vue +329 -0
  128. package/app/pages/playground/index.vue +109 -0
  129. package/app/pages/playground/layout.vue +159 -0
  130. package/app/pages/playground/modals.vue +606 -0
  131. package/app/pages/playground/states.vue +282 -0
  132. package/app/pages/playground/tables.vue +618 -0
  133. package/app/pages/test-layout.vue +10 -0
  134. package/nuxt.config.ts +12 -0
  135. package/package.json +71 -0
  136. package/tsconfig.json +18 -0
@@ -0,0 +1,674 @@
1
+ <script setup lang="ts">
2
+ import { refDebounced } from '@vueuse/core'
3
+
4
+ /**
5
+ * VATable - Full-Featured Data Table
6
+ * HubSpot-inspired design with search, sort, filter, export, selection, and pagination
7
+ */
8
+
9
+ export interface TableColumn {
10
+ id?: string
11
+ accessorKey?: string
12
+ header: string
13
+ enableSorting?: boolean
14
+ enableHiding?: boolean
15
+ cell?: (info: { row: { original: any }, getValue: () => any }) => any
16
+ meta?: {
17
+ class?: string
18
+ headerClass?: string
19
+ }
20
+ }
21
+
22
+ export interface SortState {
23
+ column: string
24
+ direction: 'asc' | 'desc' | null
25
+ }
26
+
27
+ const props = withDefaults(defineProps<{
28
+ // Data
29
+ data: any[]
30
+ columns: TableColumn[]
31
+ rowKey?: string
32
+
33
+ // Features
34
+ searchable?: boolean
35
+ searchPlaceholder?: string
36
+ sortable?: boolean
37
+ selectable?: boolean | 'single' | 'multi'
38
+ showColumnToggle?: boolean
39
+ exportable?: boolean
40
+ exportFormats?: ('csv' | 'json')[]
41
+
42
+ // Pagination
43
+ paginated?: boolean
44
+ pageSize?: number
45
+ pageSizes?: number[]
46
+ showPageSize?: boolean
47
+ total?: number
48
+
49
+ // UI
50
+ card?: boolean
51
+ name?: string
52
+ stickyHeader?: boolean
53
+ striped?: boolean
54
+ compact?: boolean
55
+
56
+ // Actions
57
+ showView?: boolean
58
+ showEdit?: boolean
59
+ showDelete?: boolean
60
+
61
+ // Empty state
62
+ emptyTitle?: string
63
+ emptyDescription?: string
64
+ emptyIcon?: string
65
+
66
+ // Loading
67
+ loading?: boolean
68
+
69
+ // Sort (controlled)
70
+ sort?: SortState | null
71
+ }>(), {
72
+ rowKey: 'id',
73
+ searchable: false,
74
+ searchPlaceholder: 'Search...',
75
+ sortable: true,
76
+ selectable: false,
77
+ showColumnToggle: false,
78
+ exportable: false,
79
+ exportFormats: () => ['csv', 'json'],
80
+ paginated: true,
81
+ pageSize: 10,
82
+ pageSizes: () => [10, 20, 50, 100],
83
+ showPageSize: true,
84
+ total: 0,
85
+ card: false,
86
+ name: '',
87
+ stickyHeader: false,
88
+ striped: false,
89
+ compact: false,
90
+ showView: false,
91
+ showEdit: false,
92
+ showDelete: false,
93
+ emptyTitle: 'No records found',
94
+ emptyDescription: 'Try adjusting your search or filters.',
95
+ emptyIcon: 'i-lucide-inbox',
96
+ loading: false,
97
+ sort: null
98
+ })
99
+
100
+ const emit = defineEmits<{
101
+ search: [value: string]
102
+ 'update:sort': [sort: SortState | null]
103
+ select: [rows: any[]]
104
+ 'row-click': [row: any]
105
+ page: [page: number]
106
+ 'page-size': [size: number]
107
+ 'update:pagination': [{ pageSize: number; pageIndex: number }]
108
+ view: [row: any]
109
+ edit: [row: any]
110
+ delete: [row: any]
111
+ export: [format: 'csv' | 'json', data: any[]]
112
+ }>()
113
+
114
+ // Internal State
115
+ const searchQuery = ref('')
116
+ const debouncedSearch = refDebounced(searchQuery, 300)
117
+ const currentPage = ref(1)
118
+ const internalPageSize = ref(props.pageSize)
119
+ const selectedRows = ref<any[]>([])
120
+ const hiddenColumns = ref<Set<string>>(new Set())
121
+ const internalSort = ref<SortState | null>(props.sort)
122
+
123
+ // Column toggle dropdown state
124
+ const columnToggleOpen = ref(false)
125
+ const exportMenuOpen = ref(false)
126
+
127
+ // Watch for external search changes
128
+ watch(debouncedSearch, (val) => emit('search', val))
129
+
130
+ // Computed: Get column key
131
+ const getColumnKey = (col: TableColumn): string => col.id || col.accessorKey || col.header
132
+
133
+ // Computed: Visible columns (excluding hidden)
134
+ const visibleColumns = computed(() => {
135
+ const cols = props.columns.filter(col => !hiddenColumns.value.has(getColumnKey(col)))
136
+
137
+ // Add actions column if any action is enabled
138
+ if (props.showView || props.showEdit || props.showDelete) {
139
+ cols.push({
140
+ id: 'actions',
141
+ header: '',
142
+ enableSorting: false,
143
+ enableHiding: false
144
+ })
145
+ }
146
+
147
+ return cols
148
+ })
149
+
150
+ // Computed: Filter data by search
151
+ const filteredData = computed(() => {
152
+ if (!debouncedSearch.value) return props.data
153
+
154
+ const query = debouncedSearch.value.toLowerCase()
155
+ return props.data.filter(row => {
156
+ return props.columns.some(col => {
157
+ const key = col.accessorKey || col.id
158
+ if (!key) return false
159
+ const value = row[key]
160
+ if (value == null) return false
161
+ return String(value).toLowerCase().includes(query)
162
+ })
163
+ })
164
+ })
165
+
166
+ // Computed: Sort data
167
+ const sortedData = computed(() => {
168
+ const sortState = internalSort.value || props.sort
169
+ if (!sortState || !sortState.direction) return filteredData.value
170
+
171
+ const { column, direction } = sortState
172
+ return [...filteredData.value].sort((a, b) => {
173
+ const aVal = a[column]
174
+ const bVal = b[column]
175
+
176
+ if (aVal == null && bVal == null) return 0
177
+ if (aVal == null) return 1
178
+ if (bVal == null) return -1
179
+
180
+ let comparison = 0
181
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
182
+ comparison = aVal - bVal
183
+ } else {
184
+ comparison = String(aVal).localeCompare(String(bVal))
185
+ }
186
+
187
+ return direction === 'desc' ? -comparison : comparison
188
+ })
189
+ })
190
+
191
+ // Computed: Total rows
192
+ const totalRows = computed(() => props.total || sortedData.value.length)
193
+
194
+ // Computed: Paginated data
195
+ const paginatedData = computed(() => {
196
+ if (!props.paginated) return sortedData.value
197
+
198
+ const start = (currentPage.value - 1) * internalPageSize.value
199
+ const end = start + internalPageSize.value
200
+ return sortedData.value.slice(start, end)
201
+ })
202
+
203
+ // Computed: Page count
204
+ const pageCount = computed(() => Math.ceil(totalRows.value / internalPageSize.value))
205
+
206
+ // Computed: Showing range text
207
+ const showingText = computed(() => {
208
+ const start = (currentPage.value - 1) * internalPageSize.value + 1
209
+ const end = Math.min(currentPage.value * internalPageSize.value, totalRows.value)
210
+ return `Showing ${start} to ${end} of ${totalRows.value} results`
211
+ })
212
+
213
+ // Methods
214
+ const handleSort = (column: TableColumn) => {
215
+ if (!props.sortable || column.enableSorting === false) return
216
+
217
+ const key = getColumnKey(column)
218
+ const currentSort = internalSort.value || props.sort
219
+
220
+ let newDirection: 'asc' | 'desc' | null = 'asc'
221
+
222
+ if (currentSort?.column === key) {
223
+ if (currentSort.direction === 'asc') newDirection = 'desc'
224
+ else if (currentSort.direction === 'desc') newDirection = null
225
+ }
226
+
227
+ const newSort = newDirection ? { column: key, direction: newDirection } : null
228
+ internalSort.value = newSort
229
+ emit('update:sort', newSort)
230
+ }
231
+
232
+ const getSortIcon = (column: TableColumn): string => {
233
+ const key = getColumnKey(column)
234
+ const currentSort = internalSort.value || props.sort
235
+
236
+ if (currentSort?.column !== key) return 'i-lucide-chevrons-up-down'
237
+ if (currentSort.direction === 'asc') return 'i-lucide-chevron-up'
238
+ if (currentSort.direction === 'desc') return 'i-lucide-chevron-down'
239
+ return 'i-lucide-chevrons-up-down'
240
+ }
241
+
242
+ const handleRowClick = (row: any) => {
243
+ emit('row-click', row)
244
+ }
245
+
246
+ const toggleRowSelection = (row: any) => {
247
+ const key = row[props.rowKey]
248
+ const index = selectedRows.value.findIndex(r => r[props.rowKey] === key)
249
+
250
+ if (props.selectable === 'single') {
251
+ selectedRows.value = index >= 0 ? [] : [row]
252
+ } else {
253
+ if (index >= 0) {
254
+ selectedRows.value.splice(index, 1)
255
+ } else {
256
+ selectedRows.value.push(row)
257
+ }
258
+ }
259
+
260
+ emit('select', selectedRows.value)
261
+ }
262
+
263
+ const isRowSelected = (row: any): boolean => {
264
+ return selectedRows.value.some(r => r[props.rowKey] === row[props.rowKey])
265
+ }
266
+
267
+ const toggleSelectAll = () => {
268
+ if (selectedRows.value.length === paginatedData.value.length) {
269
+ selectedRows.value = []
270
+ } else {
271
+ selectedRows.value = [...paginatedData.value]
272
+ }
273
+ emit('select', selectedRows.value)
274
+ }
275
+
276
+ const isAllSelected = computed(() => {
277
+ return paginatedData.value.length > 0 && selectedRows.value.length === paginatedData.value.length
278
+ })
279
+
280
+ const isSomeSelected = computed(() => {
281
+ return selectedRows.value.length > 0 && selectedRows.value.length < paginatedData.value.length
282
+ })
283
+
284
+ const toggleColumn = (column: TableColumn) => {
285
+ const key = getColumnKey(column)
286
+ if (hiddenColumns.value.has(key)) {
287
+ hiddenColumns.value.delete(key)
288
+ } else {
289
+ hiddenColumns.value.add(key)
290
+ }
291
+ }
292
+
293
+ const handlePageChange = (page: number) => {
294
+ currentPage.value = page
295
+ emit('page', page)
296
+ }
297
+
298
+ const handlePageSizeChange = (size: number) => {
299
+ internalPageSize.value = size
300
+ currentPage.value = 1
301
+ emit('page-size', size)
302
+ emit('update:pagination', { pageSize: size, pageIndex: 0 })
303
+ }
304
+
305
+ const getCellValue = (row: any, column: TableColumn): any => {
306
+ const key = column.accessorKey || column.id
307
+ if (!key) return ''
308
+ return row[key]
309
+ }
310
+
311
+ // Export functions
312
+ const exportToCSV = () => {
313
+ const headers = props.columns
314
+ .filter(col => col.accessorKey || col.id)
315
+ .map(col => col.header)
316
+
317
+ const rows = sortedData.value.map(row =>
318
+ props.columns
319
+ .filter(col => col.accessorKey || col.id)
320
+ .map(col => {
321
+ const val = getCellValue(row, col)
322
+ // Escape quotes and wrap in quotes if contains comma
323
+ if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
324
+ return `"${val.replace(/"/g, '""')}"`
325
+ }
326
+ return val ?? ''
327
+ })
328
+ )
329
+
330
+ const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
331
+ downloadFile(csv, `${props.name || 'export'}.csv`, 'text/csv')
332
+ emit('export', 'csv', sortedData.value)
333
+ }
334
+
335
+ const exportToJSON = () => {
336
+ const json = JSON.stringify(sortedData.value, null, 2)
337
+ downloadFile(json, `${props.name || 'export'}.json`, 'application/json')
338
+ emit('export', 'json', sortedData.value)
339
+ }
340
+
341
+ const downloadFile = (content: string, filename: string, mimeType: string) => {
342
+ const blob = new Blob([content], { type: mimeType })
343
+ const url = URL.createObjectURL(blob)
344
+ const link = document.createElement('a')
345
+ link.href = url
346
+ link.download = filename
347
+ document.body.appendChild(link)
348
+ link.click()
349
+ document.body.removeChild(link)
350
+ URL.revokeObjectURL(url)
351
+ }
352
+
353
+ // Slots
354
+ const slots = useSlots()
355
+ </script>
356
+
357
+ <template>
358
+ <div
359
+ :class="[
360
+ card ? 'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-[var(--va-card-shadow)] overflow-hidden' : ''
361
+ ]"
362
+ >
363
+ <!-- Header/Toolbar -->
364
+ <div
365
+ v-if="name || searchable || $slots.toolbar || showColumnToggle || exportable"
366
+ class="px-4 py-3 border-b border-gray-200 dark:border-gray-800 flex flex-wrap items-center justify-between gap-4"
367
+ >
368
+ <div class="flex items-center gap-3">
369
+ <h3 v-if="name" class="font-bold text-gray-900 dark:text-white text-base">{{ name }}</h3>
370
+ <span
371
+ v-if="selectable && selectedRows.length > 0"
372
+ class="text-sm text-gray-500 dark:text-gray-400"
373
+ >
374
+ {{ selectedRows.length }} selected
375
+ </span>
376
+ <slot name="toolbar" />
377
+ </div>
378
+
379
+ <div class="flex items-center gap-2">
380
+ <!-- Search -->
381
+ <UInput
382
+ v-if="searchable"
383
+ v-model="searchQuery"
384
+ :placeholder="searchPlaceholder"
385
+ icon="i-lucide-search"
386
+ size="sm"
387
+ class="w-64"
388
+ >
389
+ <template v-if="searchQuery" #trailing>
390
+ <UButton
391
+ color="neutral"
392
+ variant="link"
393
+ icon="i-lucide-x"
394
+ size="xs"
395
+ :padded="false"
396
+ @click="searchQuery = ''"
397
+ />
398
+ </template>
399
+ </UInput>
400
+
401
+ <!-- Column Toggle -->
402
+ <UDropdownMenu v-if="showColumnToggle">
403
+ <UButton
404
+ color="neutral"
405
+ variant="outline"
406
+ icon="i-lucide-columns-3"
407
+ size="sm"
408
+ />
409
+ <template #content>
410
+ <div class="p-2 min-w-[180px]">
411
+ <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 px-2">Columns</p>
412
+ <div
413
+ v-for="col in columns"
414
+ :key="getColumnKey(col)"
415
+ class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
416
+ @click="toggleColumn(col)"
417
+ >
418
+ <UCheckbox
419
+ :model-value="!hiddenColumns.has(getColumnKey(col))"
420
+ size="sm"
421
+ />
422
+ <span class="text-sm text-gray-700 dark:text-gray-300">{{ col.header }}</span>
423
+ </div>
424
+ </div>
425
+ </template>
426
+ </UDropdownMenu>
427
+
428
+ <!-- Export -->
429
+ <UDropdownMenu v-if="exportable">
430
+ <UButton
431
+ color="neutral"
432
+ variant="outline"
433
+ icon="i-lucide-download"
434
+ size="sm"
435
+ />
436
+ <template #content>
437
+ <div class="py-1">
438
+ <button
439
+ v-if="exportFormats.includes('csv')"
440
+ class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"
441
+ @click="exportToCSV"
442
+ >
443
+ <UIcon name="i-lucide-file-spreadsheet" class="w-4 h-4" />
444
+ Export as CSV
445
+ </button>
446
+ <button
447
+ v-if="exportFormats.includes('json')"
448
+ class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"
449
+ @click="exportToJSON"
450
+ >
451
+ <UIcon name="i-lucide-braces" class="w-4 h-4" />
452
+ Export as JSON
453
+ </button>
454
+ </div>
455
+ </template>
456
+ </UDropdownMenu>
457
+
458
+ <slot name="actions" />
459
+ </div>
460
+ </div>
461
+
462
+ <!-- Table -->
463
+ <div class="overflow-x-auto">
464
+ <table class="w-full">
465
+ <!-- Header -->
466
+ <thead
467
+ :class="[
468
+ 'bg-gray-50/80 dark:bg-gray-800/50',
469
+ stickyHeader ? 'sticky top-0 z-10' : ''
470
+ ]"
471
+ >
472
+ <tr>
473
+ <!-- Select All Checkbox -->
474
+ <th
475
+ v-if="selectable"
476
+ class="w-12 px-4 py-3"
477
+ >
478
+ <UCheckbox
479
+ v-if="selectable === 'multi' || selectable === true"
480
+ :model-value="isAllSelected"
481
+ :indeterminate="isSomeSelected"
482
+ @update:model-value="toggleSelectAll"
483
+ />
484
+ </th>
485
+
486
+ <!-- Column Headers -->
487
+ <th
488
+ v-for="col in visibleColumns"
489
+ :key="getColumnKey(col)"
490
+ :class="[
491
+ 'px-4 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider',
492
+ compact ? 'py-2.5' : 'py-3.5',
493
+ col.meta?.headerClass || '',
494
+ sortable && col.enableSorting !== false ? 'cursor-pointer select-none hover:text-gray-900 dark:hover:text-white transition-colors' : '',
495
+ col.id === 'actions' ? 'w-24 text-right' : ''
496
+ ]"
497
+ @click="handleSort(col)"
498
+ >
499
+ <div class="flex items-center gap-1.5" :class="col.id === 'actions' ? 'justify-end' : ''">
500
+ <span>{{ col.header }}</span>
501
+ <UIcon
502
+ v-if="sortable && col.enableSorting !== false && col.id !== 'actions'"
503
+ :name="getSortIcon(col)"
504
+ class="w-4 h-4 text-gray-400"
505
+ />
506
+ </div>
507
+ </th>
508
+ </tr>
509
+ </thead>
510
+
511
+ <!-- Body -->
512
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-800">
513
+ <!-- Loading State -->
514
+ <template v-if="loading">
515
+ <tr v-for="i in internalPageSize" :key="`skeleton-${i}`">
516
+ <td v-if="selectable" class="px-4 py-4">
517
+ <div class="va-skeleton h-4 w-4 rounded" />
518
+ </td>
519
+ <td
520
+ v-for="col in visibleColumns"
521
+ :key="`skeleton-${i}-${getColumnKey(col)}`"
522
+ class="px-4 py-4"
523
+ >
524
+ <div class="va-skeleton h-4 w-3/4 rounded" />
525
+ </td>
526
+ </tr>
527
+ </template>
528
+
529
+ <!-- Empty State -->
530
+ <template v-else-if="paginatedData.length === 0">
531
+ <tr>
532
+ <td
533
+ :colspan="visibleColumns.length + (selectable ? 1 : 0)"
534
+ class="px-4 py-16 text-center"
535
+ >
536
+ <div class="flex flex-col items-center justify-center">
537
+ <div class="p-4 rounded-full bg-gray-50 dark:bg-gray-800 mb-4">
538
+ <UIcon :name="emptyIcon" class="w-10 h-10 text-gray-400" />
539
+ </div>
540
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">{{ emptyTitle }}</h3>
541
+ <p class="text-sm text-gray-500 max-w-sm">{{ emptyDescription }}</p>
542
+ </div>
543
+ </td>
544
+ </tr>
545
+ </template>
546
+
547
+ <!-- Data Rows -->
548
+ <template v-else>
549
+ <tr
550
+ v-for="row in paginatedData"
551
+ :key="row[rowKey]"
552
+ class="group va-table-row-hover"
553
+ :class="[
554
+ striped ? 'odd:bg-gray-50/50 dark:odd:bg-gray-800/30' : '',
555
+ isRowSelected(row) ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''
556
+ ]"
557
+ @click="handleRowClick(row)"
558
+ >
559
+ <!-- Selection Checkbox -->
560
+ <td
561
+ v-if="selectable"
562
+ class="px-4"
563
+ :class="compact ? 'py-2.5' : 'py-4'"
564
+ @click.stop
565
+ >
566
+ <UCheckbox
567
+ :model-value="isRowSelected(row)"
568
+ @update:model-value="toggleRowSelection(row)"
569
+ />
570
+ </td>
571
+
572
+ <!-- Data Cells -->
573
+ <td
574
+ v-for="col in visibleColumns"
575
+ :key="`${row[rowKey]}-${getColumnKey(col)}`"
576
+ :class="[
577
+ 'px-4 text-sm text-gray-600 dark:text-gray-300',
578
+ compact ? 'py-2.5' : 'py-4',
579
+ col.meta?.class || '',
580
+ col.id === 'actions' ? 'text-right' : ''
581
+ ]"
582
+ >
583
+ <!-- Actions Column -->
584
+ <template v-if="col.id === 'actions'">
585
+ <div class="va-row-actions flex items-center justify-end gap-1">
586
+ <UButton
587
+ v-if="showView"
588
+ icon="i-lucide-eye"
589
+ color="neutral"
590
+ variant="ghost"
591
+ size="xs"
592
+ @click.stop="emit('view', row)"
593
+ />
594
+ <UButton
595
+ v-if="showEdit"
596
+ icon="i-lucide-pencil"
597
+ color="neutral"
598
+ variant="ghost"
599
+ size="xs"
600
+ @click.stop="emit('edit', row)"
601
+ />
602
+ <UButton
603
+ v-if="showDelete"
604
+ icon="i-lucide-trash-2"
605
+ color="error"
606
+ variant="ghost"
607
+ size="xs"
608
+ @click.stop="emit('delete', row)"
609
+ />
610
+ </div>
611
+ </template>
612
+
613
+ <!-- Custom Cell Slot -->
614
+ <template v-else-if="slots[`${getColumnKey(col)}-data`]">
615
+ <slot :name="`${getColumnKey(col)}-data`" :row="row" :value="getCellValue(row, col)" />
616
+ </template>
617
+
618
+ <!-- Custom Cell Renderer -->
619
+ <template v-else-if="col.cell">
620
+ <component
621
+ :is="() => col.cell!({ row: { original: row }, getValue: () => getCellValue(row, col) })"
622
+ />
623
+ </template>
624
+
625
+ <!-- Default Cell -->
626
+ <template v-else>
627
+ {{ getCellValue(row, col) }}
628
+ </template>
629
+ </td>
630
+ </tr>
631
+ </template>
632
+ </tbody>
633
+ </table>
634
+ </div>
635
+
636
+ <!-- Footer / Pagination -->
637
+ <div
638
+ v-if="paginated && !loading && paginatedData.length > 0"
639
+ class="px-4 py-3 border-t border-gray-200 dark:border-gray-800 flex items-center justify-between bg-gray-50/50 dark:bg-gray-800/30"
640
+ >
641
+ <div class="text-sm text-gray-500 dark:text-gray-400 font-medium">
642
+ {{ showingText }}
643
+ </div>
644
+
645
+ <div class="flex items-center gap-4">
646
+ <!-- Page Size Selector -->
647
+ <div v-if="showPageSize" class="flex items-center gap-2">
648
+ <span class="text-sm text-gray-500 dark:text-gray-400">Show</span>
649
+ <USelectMenu
650
+ :model-value="internalPageSize"
651
+ :items="pageSizes.map(s => ({ label: String(s), value: s }))"
652
+ size="sm"
653
+ class="w-20"
654
+ @update:model-value="handlePageSizeChange"
655
+ />
656
+ </div>
657
+
658
+ <!-- Pagination -->
659
+ <UPagination
660
+ :model-value="currentPage"
661
+ :total="totalRows"
662
+ :page-count="internalPageSize"
663
+ size="sm"
664
+ :ui="{
665
+ wrapper: 'gap-1',
666
+ base: 'min-w-8 h-8',
667
+ rounded: 'rounded-md'
668
+ }"
669
+ @update:model-value="handlePageChange"
670
+ />
671
+ </div>
672
+ </div>
673
+ </div>
674
+ </template>