@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.
@@ -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>