@veristone/nuxt-v-app 0.2.2 → 0.2.4
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.
- package/app/components/V/A/Crud/Delete.vue +1 -0
- package/app/components/V/A/CrudTable/index.vue +486 -0
- package/app/components/V/A/Table/ActionColumn.vue +133 -0
- package/app/components/V/A/Table/Actions.vue +79 -0
- package/app/components/V/A/Table/CellRenderer.vue +198 -0
- package/app/components/V/A/Table/ColumnToggle.vue +131 -0
- package/app/components/V/A/Table/EditableCell.vue +176 -0
- package/app/components/V/A/Table/Export.vue +154 -0
- package/app/components/V/A/Table/FilterBar.vue +140 -0
- package/app/components/V/A/Table/FilterChips.vue +107 -0
- package/app/components/V/A/Table/README.md +380 -0
- package/app/components/V/A/Table/Toolbar.vue +163 -0
- package/app/components/V/A/Table/index.vue +483 -0
- package/app/composables/useDataTable.js +169 -0
- package/app/composables/useXATableColumns.ts +279 -386
- package/app/pages/playground/tables.vue +182 -553
- package/app/types/table.ts +52 -0
- package/package.json +4 -2
- package/app/components/V/A/Table.vue +0 -674
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<header class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
|
3
|
+
<!-- Left side: Search + Filters -->
|
|
4
|
+
<div class="flex items-center gap-3 flex-1 min-w-0">
|
|
5
|
+
<!-- Search -->
|
|
6
|
+
<UInput
|
|
7
|
+
v-if="searchable"
|
|
8
|
+
v-model="searchModel"
|
|
9
|
+
:placeholder="searchPlaceholder"
|
|
10
|
+
icon="i-lucide-search"
|
|
11
|
+
size="sm"
|
|
12
|
+
class="w-64"
|
|
13
|
+
/>
|
|
14
|
+
|
|
15
|
+
<!-- Filter chips -->
|
|
16
|
+
<div v-if="activeFilters?.length" class="flex flex-wrap gap-2">
|
|
17
|
+
<UBadge
|
|
18
|
+
v-for="filter in activeFilters"
|
|
19
|
+
:key="filter.key"
|
|
20
|
+
variant="subtle"
|
|
21
|
+
class="cursor-pointer"
|
|
22
|
+
@click="$emit('remove-filter', filter.key)"
|
|
23
|
+
>
|
|
24
|
+
{{ filter.label }}: {{ filter.value }}
|
|
25
|
+
<UIcon name="i-lucide-x" class="w-3 h-3 ml-1" />
|
|
26
|
+
</UBadge>
|
|
27
|
+
<button
|
|
28
|
+
class="text-sm text-neutral-500 hover:text-neutral-700"
|
|
29
|
+
@click="$emit('clear-filters')"
|
|
30
|
+
>
|
|
31
|
+
Clear all
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Extra toolbar content -->
|
|
36
|
+
<slot name="left" />
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<!-- Right side: Column toggle, Export, Actions -->
|
|
40
|
+
<div class="flex items-center gap-2">
|
|
41
|
+
<slot name="right" />
|
|
42
|
+
|
|
43
|
+
<!-- Column visibility toggle -->
|
|
44
|
+
<VATableColumnToggle
|
|
45
|
+
v-if="showColumnToggle && columns?.length"
|
|
46
|
+
v-model="visibleColumnsModel"
|
|
47
|
+
:columns="hideableColumns"
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<!-- Export -->
|
|
51
|
+
<VATableExport
|
|
52
|
+
v-if="exportable && data?.length"
|
|
53
|
+
:data="data"
|
|
54
|
+
:columns="exportColumns"
|
|
55
|
+
:filename="exportFilename"
|
|
56
|
+
:show-label="false"
|
|
57
|
+
enable-csv
|
|
58
|
+
enable-json
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<!-- Extra actions slot -->
|
|
62
|
+
<slot name="actions" />
|
|
63
|
+
</div>
|
|
64
|
+
</header>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<script setup>
|
|
68
|
+
defineOptions({
|
|
69
|
+
name: 'VATableToolbar'
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const props = defineProps({
|
|
73
|
+
// Search
|
|
74
|
+
searchable: {
|
|
75
|
+
type: Boolean,
|
|
76
|
+
default: true
|
|
77
|
+
},
|
|
78
|
+
search: {
|
|
79
|
+
type: String,
|
|
80
|
+
default: ''
|
|
81
|
+
},
|
|
82
|
+
searchPlaceholder: {
|
|
83
|
+
type: String,
|
|
84
|
+
default: 'Search...'
|
|
85
|
+
},
|
|
86
|
+
// Filters
|
|
87
|
+
activeFilters: {
|
|
88
|
+
type: Array,
|
|
89
|
+
default: () => []
|
|
90
|
+
},
|
|
91
|
+
// Column toggle
|
|
92
|
+
showColumnToggle: {
|
|
93
|
+
type: Boolean,
|
|
94
|
+
default: true
|
|
95
|
+
},
|
|
96
|
+
columns: {
|
|
97
|
+
type: Array,
|
|
98
|
+
default: () => []
|
|
99
|
+
},
|
|
100
|
+
visibleColumns: {
|
|
101
|
+
type: Array,
|
|
102
|
+
default: () => []
|
|
103
|
+
},
|
|
104
|
+
// Export
|
|
105
|
+
exportable: {
|
|
106
|
+
type: Boolean,
|
|
107
|
+
default: true
|
|
108
|
+
},
|
|
109
|
+
data: {
|
|
110
|
+
type: Array,
|
|
111
|
+
default: () => []
|
|
112
|
+
},
|
|
113
|
+
exportFilename: {
|
|
114
|
+
type: String,
|
|
115
|
+
default: 'export'
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const emit = defineEmits([
|
|
120
|
+
'update:search',
|
|
121
|
+
'update:visibleColumns',
|
|
122
|
+
'remove-filter',
|
|
123
|
+
'clear-filters'
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
// Two-way binding for search
|
|
127
|
+
const searchModel = computed({
|
|
128
|
+
get: () => props.search,
|
|
129
|
+
set: (value) => emit('update:search', value)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Two-way binding for visible columns
|
|
133
|
+
const visibleColumnsModel = computed({
|
|
134
|
+
get: () => props.visibleColumns,
|
|
135
|
+
set: (value) => emit('update:visibleColumns', value)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Filter columns that can be hidden
|
|
139
|
+
const hideableColumns = computed(() =>
|
|
140
|
+
props.columns.filter(col =>
|
|
141
|
+
col.enableHiding !== false &&
|
|
142
|
+
col.id !== 'actions' &&
|
|
143
|
+
col.accessorKey !== 'actions'
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
// Columns for export (use visible columns if available, otherwise all)
|
|
148
|
+
const exportColumns = computed(() => {
|
|
149
|
+
const visible = props.visibleColumns?.length
|
|
150
|
+
? props.columns.filter(col =>
|
|
151
|
+
props.visibleColumns.includes(col.id || col.accessorKey || col.key)
|
|
152
|
+
)
|
|
153
|
+
: props.columns
|
|
154
|
+
|
|
155
|
+
// Transform to export format
|
|
156
|
+
return visible
|
|
157
|
+
.filter(col => col.id !== 'actions' && col.accessorKey !== 'actions')
|
|
158
|
+
.map(col => ({
|
|
159
|
+
key: col.accessorKey || col.key || col.id,
|
|
160
|
+
label: col.header || col.label || col.id
|
|
161
|
+
}))
|
|
162
|
+
})
|
|
163
|
+
</script>
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
|
|
2
|
+
<template>
|
|
3
|
+
<UCard
|
|
4
|
+
class="w-full"
|
|
5
|
+
:ui="{
|
|
6
|
+
body: 'p-0 sm:p-0',
|
|
7
|
+
header: 'px-3 py-2',
|
|
8
|
+
footer: 'px-3 py-2 pt-0',
|
|
9
|
+
}"
|
|
10
|
+
>
|
|
11
|
+
<template #header v-if="name || $slots['header-right']">
|
|
12
|
+
<div class="flex items-center justify-between gap-3">
|
|
13
|
+
<h2 v-if="name" class="font-semibold text-base text-highlighted">
|
|
14
|
+
{{ name }}
|
|
15
|
+
</h2>
|
|
16
|
+
<slot name="header-right" />
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<!-- Initial Loading State -->
|
|
21
|
+
<template v-if="isInitialLoading">
|
|
22
|
+
<VAStateLoading class="py-12" />
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<template v-else>
|
|
26
|
+
<!-- Toolbar -->
|
|
27
|
+
<div
|
|
28
|
+
class="flex items-center justify-between gap-2 px-3 py-2 border-b border-default"
|
|
29
|
+
>
|
|
30
|
+
<!-- Bulk Actions (shown when items selected) -->
|
|
31
|
+
<div v-if="selectedRows.length > 0" class="flex items-center gap-2">
|
|
32
|
+
<slot
|
|
33
|
+
name="bulk-actions"
|
|
34
|
+
:selected="selectedRows"
|
|
35
|
+
:count="selectedRows.length"
|
|
36
|
+
:clear="clearSelection"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Normal Toolbar (shown when no items selected) -->
|
|
41
|
+
<div v-else class="flex items-center gap-2">
|
|
42
|
+
<UInput
|
|
43
|
+
v-model="globalFilter"
|
|
44
|
+
placeholder="Search..."
|
|
45
|
+
icon="i-lucide-search"
|
|
46
|
+
size="sm"
|
|
47
|
+
class="max-w-xs"
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<template v-if="props.filters && props.filters.length > 0">
|
|
51
|
+
<USelect
|
|
52
|
+
v-for="filter in props.filters"
|
|
53
|
+
:key="filter.key"
|
|
54
|
+
v-model="filterValues[filter.key]"
|
|
55
|
+
:items="filter.options"
|
|
56
|
+
:placeholder="filter.label"
|
|
57
|
+
size="sm"
|
|
58
|
+
class="w-40"
|
|
59
|
+
/>
|
|
60
|
+
</template>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="flex items-center gap-2">
|
|
64
|
+
<UButton
|
|
65
|
+
v-if="props.onRefresh"
|
|
66
|
+
icon="i-lucide-refresh-cw"
|
|
67
|
+
color="neutral"
|
|
68
|
+
variant="ghost"
|
|
69
|
+
size="sm"
|
|
70
|
+
:loading="refreshing"
|
|
71
|
+
@click="handleRefresh"
|
|
72
|
+
/>
|
|
73
|
+
|
|
74
|
+
<UDropdownMenu :items="columnVisibilityItems" :content="{ align: 'end' }">
|
|
75
|
+
<UButton
|
|
76
|
+
icon="i-lucide-columns-3"
|
|
77
|
+
color="neutral"
|
|
78
|
+
variant="ghost"
|
|
79
|
+
size="sm"
|
|
80
|
+
trailing-icon="i-lucide-chevron-down"
|
|
81
|
+
/>
|
|
82
|
+
</UDropdownMenu>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<!-- Active Filter Chips -->
|
|
87
|
+
<div v-if="hasActiveFilters" class="px-3 py-2 border-b border-default">
|
|
88
|
+
<FilterChips
|
|
89
|
+
:filters="filterValues"
|
|
90
|
+
:labels="filterLabels"
|
|
91
|
+
@remove="removeFilter"
|
|
92
|
+
@clear="clearAllFilters"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<!-- Table -->
|
|
97
|
+
<UTable
|
|
98
|
+
ref="table"
|
|
99
|
+
v-model:sorting="sorting"
|
|
100
|
+
v-model:global-filter="globalFilter"
|
|
101
|
+
v-model:pagination="pagination"
|
|
102
|
+
v-model:column-visibility="columnVisibility"
|
|
103
|
+
v-model:row-selection="rowSelection"
|
|
104
|
+
:data="data"
|
|
105
|
+
:columns="tableColumns"
|
|
106
|
+
:loading="loading"
|
|
107
|
+
:row-selection-options="{ enableRowSelection: props.selectable }"
|
|
108
|
+
:sorting-options="{ getSortedRowModel: getSortedRowModel() }"
|
|
109
|
+
:pagination-options="{ getPaginationRowModel: getPaginationRowModel() }"
|
|
110
|
+
:ui="{
|
|
111
|
+
th: 'px-3 py-2',
|
|
112
|
+
td: 'px-3 py-2',
|
|
113
|
+
}"
|
|
114
|
+
empty="Nothing to show."
|
|
115
|
+
v-bind="$attrs"
|
|
116
|
+
@row-click="handleRowClick"
|
|
117
|
+
>
|
|
118
|
+
<template v-for="slotEntry in forwardedSlots" :key="slotEntry.target" #[slotEntry.target]="slotProps">
|
|
119
|
+
<slot :name="slotEntry.source" v-bind="slotProps" />
|
|
120
|
+
</template>
|
|
121
|
+
</UTable>
|
|
122
|
+
</template>
|
|
123
|
+
|
|
124
|
+
<!-- Footer -->
|
|
125
|
+
<template #footer v-if="!isInitialLoading">
|
|
126
|
+
<div
|
|
127
|
+
class="flex items-center justify-between gap-3 text-xs border-t border-default pt-2"
|
|
128
|
+
>
|
|
129
|
+
<p class="text-muted">
|
|
130
|
+
<template v-if="totalRows > 0">
|
|
131
|
+
{{ startIndex }}-{{ endIndex }} of {{ totalRows }}
|
|
132
|
+
</template>
|
|
133
|
+
<template v-else>No results</template>
|
|
134
|
+
</p>
|
|
135
|
+
<div class="flex items-center gap-3">
|
|
136
|
+
<div class="flex items-center gap-1.5">
|
|
137
|
+
<span class="text-muted">Per page:</span>
|
|
138
|
+
<USelect
|
|
139
|
+
:model-value="pagination.pageSize"
|
|
140
|
+
:items="[10, 20, 50, 100]"
|
|
141
|
+
size="sm"
|
|
142
|
+
class="w-16"
|
|
143
|
+
@update:model-value="onPageSizeChange"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<UPagination
|
|
147
|
+
:page="pagination.pageIndex + 1"
|
|
148
|
+
:items-per-page="pagination.pageSize"
|
|
149
|
+
:total="totalRows"
|
|
150
|
+
size="sm"
|
|
151
|
+
@update:page="(p) => (pagination.pageIndex = p - 1)"
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</template>
|
|
156
|
+
</UCard>
|
|
157
|
+
</template>
|
|
158
|
+
|
|
159
|
+
<script setup lang="ts">
|
|
160
|
+
import { getCoreRowModel, getPaginationRowModel, getSortedRowModel } from "@tanstack/vue-table";
|
|
161
|
+
import type { TableColumn, DropdownMenuItem } from "@nuxt/ui";
|
|
162
|
+
import type { SortingState } from "@tanstack/vue-table";
|
|
163
|
+
import { h, resolveComponent, useSlots } from 'vue';
|
|
164
|
+
import CellRenderer from './CellRenderer.vue';
|
|
165
|
+
import FilterChips from './FilterChips.vue';
|
|
166
|
+
|
|
167
|
+
type FilterDefinition = {
|
|
168
|
+
label: string;
|
|
169
|
+
key: string;
|
|
170
|
+
options: { label: string; value: string }[];
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const props = withDefaults(defineProps<{
|
|
174
|
+
name?: string;
|
|
175
|
+
data: unknown[];
|
|
176
|
+
columns: TableColumn<unknown>[];
|
|
177
|
+
loading?: boolean;
|
|
178
|
+
initialPageLimit?: number;
|
|
179
|
+
selectable?: boolean;
|
|
180
|
+
filters?: FilterDefinition[];
|
|
181
|
+
onRefresh?: () => void | Promise<void>;
|
|
182
|
+
onRowClick?: (row: unknown) => void;
|
|
183
|
+
/** Default column to sort by */
|
|
184
|
+
defaultSort?: string;
|
|
185
|
+
/** Default sort direction */
|
|
186
|
+
defaultSortDesc?: boolean;
|
|
187
|
+
}>(), {
|
|
188
|
+
selectable: false,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Sorting state - v-model support
|
|
192
|
+
const sorting = defineModel<SortingState>("sorting", {
|
|
193
|
+
default: () => [],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const filterValues = defineModel<Record<string, unknown>>("filterValues", {
|
|
197
|
+
default: () => ({}),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const refreshing = ref(false);
|
|
201
|
+
const slots = useSlots()
|
|
202
|
+
|
|
203
|
+
async function handleRefresh() {
|
|
204
|
+
if (!props.onRefresh) return;
|
|
205
|
+
refreshing.value = true;
|
|
206
|
+
try {
|
|
207
|
+
await props.onRefresh();
|
|
208
|
+
} finally {
|
|
209
|
+
refreshing.value = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const hasActiveFilters = computed(() => {
|
|
214
|
+
return Object.values(filterValues.value).some((v) => v !== undefined && v !== null && v !== '');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const filterLabels = computed<Record<string, string>>(() => {
|
|
218
|
+
const labels: Record<string, string> = {};
|
|
219
|
+
for (const filter of props.filters ?? []) {
|
|
220
|
+
labels[filter.key] = filter.label;
|
|
221
|
+
}
|
|
222
|
+
return labels;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
function removeFilter(key: string) {
|
|
226
|
+
const current = { ...(filterValues.value ?? {}) };
|
|
227
|
+
delete current[key];
|
|
228
|
+
filterValues.value = current;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function clearAllFilters() {
|
|
232
|
+
filterValues.value = {};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Row selection state
|
|
236
|
+
const rowSelection = ref<Record<string, boolean>>({});
|
|
237
|
+
|
|
238
|
+
function clearSelection() {
|
|
239
|
+
rowSelection.value = {};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
watch(
|
|
243
|
+
() => filterValues.value,
|
|
244
|
+
() => {
|
|
245
|
+
clearSelection();
|
|
246
|
+
},
|
|
247
|
+
{ deep: true }
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Table ref for accessing TanStack API
|
|
251
|
+
const table = ref<{ tableApi?: any } | null>(null);
|
|
252
|
+
|
|
253
|
+
// Track if we've ever received data (to distinguish initial load from refresh)
|
|
254
|
+
const hasLoadedOnce = ref(false);
|
|
255
|
+
|
|
256
|
+
// Initial loading: loading is true AND we've never had data
|
|
257
|
+
const isInitialLoading = computed(() => {
|
|
258
|
+
return props.loading && !hasLoadedOnce.value;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Mark as loaded once we receive data
|
|
262
|
+
watch(
|
|
263
|
+
() => props.data,
|
|
264
|
+
(newData) => {
|
|
265
|
+
if (newData && newData.length > 0) {
|
|
266
|
+
hasLoadedOnce.value = true;
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
{ immediate: true }
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Also mark as loaded when loading completes (even with empty data)
|
|
273
|
+
watch(
|
|
274
|
+
() => props.loading,
|
|
275
|
+
(isLoading, wasLoading) => {
|
|
276
|
+
if (wasLoading && !isLoading) {
|
|
277
|
+
hasLoadedOnce.value = true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Global filter (search)
|
|
283
|
+
const globalFilter = ref("");
|
|
284
|
+
|
|
285
|
+
// Initialize default sorting if provided
|
|
286
|
+
onMounted(() => {
|
|
287
|
+
if (props.defaultSort && sorting.value.length === 0) {
|
|
288
|
+
sorting.value = [{ id: props.defaultSort, desc: props.defaultSortDesc ?? false }];
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Pagination state
|
|
293
|
+
const pagination = ref({
|
|
294
|
+
pageIndex: 0,
|
|
295
|
+
pageSize: props.initialPageLimit ?? 10,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Column visibility state
|
|
299
|
+
const columnVisibility = ref<Record<string, boolean>>({});
|
|
300
|
+
|
|
301
|
+
// Transform columns for dropdown menu
|
|
302
|
+
const columnVisibilityItems = computed<DropdownMenuItem[]>(() => {
|
|
303
|
+
const hidableColumns = table.value?.tableApi
|
|
304
|
+
?.getAllColumns()
|
|
305
|
+
.filter((col: any) => col.getCanHide());
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
hidableColumns?.map((column: any) => ({
|
|
309
|
+
label:
|
|
310
|
+
typeof column.columnDef.header === "string"
|
|
311
|
+
? column.columnDef.header
|
|
312
|
+
: column.id,
|
|
313
|
+
type: "checkbox" as const,
|
|
314
|
+
checked: column.getIsVisible(),
|
|
315
|
+
onUpdateChecked: (checked: boolean) => column.toggleVisibility(checked),
|
|
316
|
+
onSelect: (e: Event) => e.preventDefault(),
|
|
317
|
+
})) ?? []
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Process columns to add preset rendering
|
|
322
|
+
const tableColumns = computed(() => {
|
|
323
|
+
const UCheckbox = resolveComponent('UCheckbox');
|
|
324
|
+
|
|
325
|
+
// Date column patterns for auto-detection
|
|
326
|
+
const dateColumnPatterns = [
|
|
327
|
+
'createdAt', 'updatedAt', 'deletedAt', 'publishedAt',
|
|
328
|
+
'startedAt', 'endedAt', 'dueDate', 'due_at',
|
|
329
|
+
'expiresAt', 'completedAt', 'created_at', 'updated_at'
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
const columns = props.columns.map(col => {
|
|
333
|
+
const hasDataAccessor = Boolean(col.accessorKey || (col as any).accessorFn || (col as any).key);
|
|
334
|
+
const colDef = {
|
|
335
|
+
...col,
|
|
336
|
+
enableSorting: col.enableSorting ?? hasDataAccessor,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// Auto-detect date columns based on accessorKey
|
|
340
|
+
const isDateColumn = dateColumnPatterns.includes(col.accessorKey)
|
|
341
|
+
const hasPreset = col.meta?.preset && col.meta.preset !== 'actions'
|
|
342
|
+
|
|
343
|
+
if (hasPreset || isDateColumn) {
|
|
344
|
+
// Use existing preset or auto-detected date preset
|
|
345
|
+
const preset = col.meta?.preset || 'date'
|
|
346
|
+
const format = col.meta?.format || 'relative'
|
|
347
|
+
|
|
348
|
+
colDef.cell = ({ row }) => {
|
|
349
|
+
const value = row?.original?.[col.accessorKey as string];
|
|
350
|
+
return h(CellRenderer, {
|
|
351
|
+
value,
|
|
352
|
+
column: { ...col, meta: { ...col.meta, preset, format } },
|
|
353
|
+
row: row?.original
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return colDef;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (props.selectable) {
|
|
362
|
+
const selectColumn: TableColumn<unknown> = {
|
|
363
|
+
id: 'select',
|
|
364
|
+
enableSorting: false,
|
|
365
|
+
enableHiding: false,
|
|
366
|
+
header: ({ table }) =>
|
|
367
|
+
h(UCheckbox, {
|
|
368
|
+
modelValue: table.getIsSomePageRowsSelected()
|
|
369
|
+
? 'indeterminate'
|
|
370
|
+
: table.getIsAllPageRowsSelected(),
|
|
371
|
+
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
|
|
372
|
+
table.toggleAllPageRowsSelected(!!value),
|
|
373
|
+
'aria-label': 'Select all rows',
|
|
374
|
+
}),
|
|
375
|
+
cell: ({ row }) =>
|
|
376
|
+
h(UCheckbox, {
|
|
377
|
+
modelValue: row.getIsSelected(),
|
|
378
|
+
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
|
|
379
|
+
row.toggleSelected(!!value),
|
|
380
|
+
'aria-label': 'Select row',
|
|
381
|
+
onClick: (e: Event) => e.stopPropagation(),
|
|
382
|
+
}),
|
|
383
|
+
meta: {
|
|
384
|
+
class: {
|
|
385
|
+
td: 'w-10',
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
columns.unshift(selectColumn);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Add actions column if slot provided AND no actions column already exists
|
|
394
|
+
if (slots['actions-cell'] && !columns.some(c => c.id === 'actions' || c.accessorKey === 'actions')) {
|
|
395
|
+
const actionsColumn: TableColumn<unknown> = {
|
|
396
|
+
id: 'actions',
|
|
397
|
+
header: 'Actions',
|
|
398
|
+
enableSorting: false,
|
|
399
|
+
enableHiding: false,
|
|
400
|
+
cell: ({ row }) => {
|
|
401
|
+
// Render slot content
|
|
402
|
+
return h('div', {}, slots['actions-cell']?.({ row }));
|
|
403
|
+
},
|
|
404
|
+
meta: {
|
|
405
|
+
class: {
|
|
406
|
+
th: 'text-right',
|
|
407
|
+
td: 'text-right',
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
columns.push(actionsColumn);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return columns;
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const forwardedSlots = computed(() => {
|
|
419
|
+
const passthrough = Object.keys(slots).filter((name) => {
|
|
420
|
+
return !['default', 'header-right', 'bulk-actions'].includes(name);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return passthrough.map((source) => {
|
|
424
|
+
// Support both `name-cell` and `cell-name` slot naming patterns.
|
|
425
|
+
if (source.startsWith('cell-')) {
|
|
426
|
+
return {
|
|
427
|
+
source,
|
|
428
|
+
target: `${source.slice(5)}-cell`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return { source, target: source };
|
|
433
|
+
});
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
const selectedRows = computed(() => {
|
|
437
|
+
return table.value?.tableApi?.getSelectedRowModel().rows.map((r: any) => r.original) ?? [];
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Handle row click
|
|
441
|
+
function handleRowClick(row: any) {
|
|
442
|
+
if (props.onRowClick) {
|
|
443
|
+
props.onRowClick(row.original);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Computed pagination info
|
|
448
|
+
const totalRows = computed(() => {
|
|
449
|
+
return table.value?.tableApi?.getFilteredRowModel().rows.length ?? 0;
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const startIndex = computed(() => {
|
|
453
|
+
if (totalRows.value === 0) return 0;
|
|
454
|
+
return pagination.value.pageIndex * pagination.value.pageSize + 1;
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const endIndex = computed(() => {
|
|
458
|
+
const end = (pagination.value.pageIndex + 1) * pagination.value.pageSize;
|
|
459
|
+
return Math.min(end, totalRows.value);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Handle page size change
|
|
463
|
+
const onPageSizeChange = (size: number) => {
|
|
464
|
+
pagination.value = {
|
|
465
|
+
pageIndex: 0,
|
|
466
|
+
pageSize: size,
|
|
467
|
+
};
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Reset pagination when data changes
|
|
471
|
+
watch(
|
|
472
|
+
() => props.data,
|
|
473
|
+
() => {
|
|
474
|
+
pagination.value.pageIndex = 0;
|
|
475
|
+
}
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
defineExpose({
|
|
479
|
+
selectedRows,
|
|
480
|
+
rowSelection,
|
|
481
|
+
clearSelection,
|
|
482
|
+
});
|
|
483
|
+
</script>
|