@veristone/nuxt-v-app 0.2.6 → 0.2.8

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.
@@ -3,106 +3,130 @@
3
3
  * VACard - Veristone Card
4
4
  * Matches XACard API exactly.
5
5
  */
6
- const props = withDefaults(defineProps<{
7
- title?: string
8
- description?: string
9
- icon?: string
10
- collapsible?: boolean
11
- collapsed?: boolean
12
- padding?: boolean
13
- divider?: boolean
14
- variant?: 'default' | 'error' | 'warning' | 'success'
15
- }>(), {
16
- title: '',
17
- description: '',
18
- icon: '',
19
- collapsible: false,
20
- collapsed: false,
21
- padding: true,
22
- divider: true,
23
- variant: 'default'
24
- })
6
+ const props = withDefaults(
7
+ defineProps<{
8
+ title?: string;
9
+ description?: string;
10
+ icon?: string;
11
+ collapsible?: boolean;
12
+ collapsed?: boolean;
13
+ padding?: boolean;
14
+ divider?: boolean;
15
+ variant?: "default" | "error" | "warning" | "success";
16
+ }>(),
17
+ {
18
+ title: "",
19
+ description: "",
20
+ icon: "",
21
+ collapsible: false,
22
+ collapsed: false,
23
+ padding: true,
24
+ divider: true,
25
+ variant: "default",
26
+ }
27
+ );
25
28
 
26
- const isCollapsed = ref(props.collapsed)
27
- watch(() => props.collapsed, (val) => isCollapsed.value = val)
29
+ const isCollapsed = ref(props.collapsed);
30
+ watch(
31
+ () => props.collapsed,
32
+ (val) => (isCollapsed.value = val)
33
+ );
28
34
 
29
35
  const toggleCollapse = () => {
30
- if (props.collapsible) isCollapsed.value = !isCollapsed.value
31
- }
36
+ if (props.collapsible) isCollapsed.value = !isCollapsed.value;
37
+ };
32
38
 
33
39
  const variantClass = computed(() => {
34
- switch (props.variant) {
35
- case 'error': return 'border-red-200 dark:border-red-900 bg-red-50/50 dark:bg-red-900/10'
36
- case 'warning': return 'border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-900/10'
37
- case 'success': return 'border-green-200 dark:border-green-900 bg-green-50/50 dark:bg-green-900/10'
38
- default: return 'border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900'
39
- }
40
- })
40
+ switch (props.variant) {
41
+ case "error":
42
+ return "border-red-200 dark:border-red-900 bg-red-50/50 dark:bg-red-900/10";
43
+ case "warning":
44
+ return "border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-900/10";
45
+ case "success":
46
+ return "border-green-200 dark:border-green-900 bg-green-50/50 dark:bg-green-900/10";
47
+ default:
48
+ return "border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900";
49
+ }
50
+ });
41
51
  </script>
42
52
 
43
53
  <template>
44
- <div
45
- class="va-card rounded-xl border transition-all duration-200 shadow-[0_2px_9px_rgba(0,0,0,0.08)] hover:shadow-[0_4px_12px_rgba(61,113,136,0.12)] hover:-translate-y-0.5"
46
- :class="variantClass"
47
- >
48
- <!-- Header -->
49
- <div
50
- v-if="title || $slots['header-actions'] || icon"
51
- class="flex items-center justify-between"
52
- :class="[
53
- padding ? 'px-5 py-4' : 'px-4 py-3',
54
- divider && !isCollapsed ? 'border-b border-gray-100 dark:border-gray-800' : ''
55
- ]"
56
- >
57
- <div class="flex items-center gap-3 overflow-hidden">
58
- <slot name="header">
59
- <div v-if="icon" class="flex-shrink-0 p-1.5 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-500">
60
- <UIcon :name="icon" class="w-5 h-5" />
61
- </div>
62
- <div class="min-w-0">
63
- <div class="flex items-center gap-2">
64
- <h3 class="font-bold text-gray-900 dark:text-white truncate text-base">{{ title }}</h3>
65
-
66
- <button
67
- v-if="collapsible"
68
- @click="toggleCollapse"
69
- class="ml-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors focus:outline-none"
70
- >
71
- <UIcon
72
- name="i-lucide-chevron-down"
73
- class="w-4 h-4 transition-transform duration-300"
74
- :class="{ '-rotate-180': !isCollapsed }"
75
- />
76
- </button>
77
- </div>
78
- <p v-if="description" class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5 font-medium">{{ description }}</p>
79
- </div>
80
- </slot>
81
- </div>
54
+ <div
55
+ class="va-card rounded-xl border transition-all duration-200 shadow-[0_2px_9px_rgba(0,0,0,0.08)] hover:shadow-[0_4px_12px_rgba(61,113,136,0.12)]"
56
+ :class="variantClass"
57
+ >
58
+ <!-- Header -->
59
+ <div
60
+ v-if="title || $slots['header-actions'] || icon"
61
+ class="flex items-center justify-between"
62
+ :class="[
63
+ padding ? 'px-5 py-4' : 'px-4 py-3',
64
+ divider && !isCollapsed
65
+ ? 'border-b border-gray-100 dark:border-gray-800'
66
+ : '',
67
+ ]"
68
+ >
69
+ <div class="flex items-center gap-3 overflow-hidden">
70
+ <slot name="header">
71
+ <div
72
+ v-if="icon"
73
+ class="flex-shrink-0 p-1.5 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-500"
74
+ >
75
+ <UIcon :name="icon" class="w-5 h-5" />
76
+ </div>
77
+ <div class="min-w-0">
78
+ <div class="flex items-center gap-2">
79
+ <h3
80
+ class="font-bold text-gray-900 dark:text-white truncate text-base"
81
+ >
82
+ {{ title }}
83
+ </h3>
82
84
 
83
- <!-- XACard uses 'header-actions' slot -->
84
- <div v-if="$slots['header-actions']" class="flex-shrink-0 flex items-center gap-2 ml-4">
85
- <slot name="header-actions" />
86
- </div>
87
- </div>
85
+ <button
86
+ v-if="collapsible"
87
+ @click="toggleCollapse"
88
+ class="ml-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors focus:outline-none"
89
+ >
90
+ <UIcon
91
+ name="i-lucide-chevron-down"
92
+ class="w-4 h-4 transition-transform duration-300"
93
+ :class="{ '-rotate-180': !isCollapsed }"
94
+ />
95
+ </button>
96
+ </div>
97
+ <p
98
+ v-if="description"
99
+ class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5 font-medium"
100
+ >
101
+ {{ description }}
102
+ </p>
103
+ </div>
104
+ </slot>
105
+ </div>
88
106
 
89
- <!-- Body -->
90
- <div
91
- v-show="!isCollapsed"
92
- class="transition-all duration-300 ease-in-out"
93
- >
94
- <div :class="{ 'p-5': padding, 'p-0': !padding }">
95
- <slot />
96
- </div>
107
+ <!-- XACard uses 'header-actions' slot -->
108
+ <div
109
+ v-if="$slots['header-actions']"
110
+ class="flex-shrink-0 flex items-center gap-2 ml-4"
111
+ >
112
+ <slot name="header-actions" />
113
+ </div>
114
+ </div>
97
115
 
98
- <!-- Footer -->
99
- <div
100
- v-if="$slots.footer"
101
- class="bg-gray-50 dark:bg-gray-800/50 border-t border-gray-100 dark:border-gray-800"
102
- :class="padding ? 'px-5 py-3' : 'px-4 py-2'"
103
- >
104
- <slot name="footer" />
105
- </div>
106
- </div>
107
- </div>
116
+ <!-- Body -->
117
+ <div v-show="!isCollapsed" class="transition-all duration-300 ease-in-out">
118
+ <div :class="{ 'p-5': padding, 'p-0': !padding }">
119
+ <slot />
120
+ </div>
121
+
122
+ <!-- Footer -->
123
+ <div
124
+ v-if="$slots.footer"
125
+ class="bg-gray-50 dark:bg-gray-800/50 border-t border-gray-100 dark:border-gray-800"
126
+ :class="padding ? 'px-5 py-3' : 'px-4 py-2'"
127
+ >
128
+ <slot name="footer" />
129
+ </div>
130
+ </div>
131
+ </div>
108
132
  </template>
@@ -34,12 +34,13 @@ The primary table component. Wraps Nuxt UI's `<UTable>` with a toolbar, filter c
34
34
  | `loading` | `boolean` | `false` | Shows loading skeleton on initial load |
35
35
  | `initialPageLimit` | `number` | `10` | Rows per page (client-side mode) |
36
36
  | `selectable` | `boolean` | `false` | Enable row selection checkboxes |
37
+ | `showSearch` | `boolean` | `false` | Show the built-in search input in the toolbar |
37
38
  | `filters` | `FilterDefinition[]` | — | Filter dropdown definitions |
38
39
  | `onRefresh` | `() => void \| Promise<void>` | — | Shows refresh button; called on click |
39
40
  | `onRowClick` | `(row: unknown) => void` | — | Called when a row is clicked |
40
41
  | `defaultSort` | `string` | — | Column accessorKey to sort by on mount |
41
42
  | `defaultSortDesc` | `boolean` | `false` | Sort descending by default |
42
- | `manualPagination` | `boolean` | `false` | Enable server-side pagination mode |
43
+ | `manualPagination` | `boolean` | `false` | Enable server-side pagination mode (disables client-side sorting and filtering) |
43
44
  | `total` | `number` | — | Total row count from server (required for manualPagination) |
44
45
 
45
46
  #### v-model Bindings
@@ -47,8 +48,8 @@ The primary table component. Wraps Nuxt UI's `<UTable>` with a toolbar, filter c
47
48
  | Model | Type | Description |
48
49
  |-------|------|-------------|
49
50
  | `sorting` | `SortingState` | TanStack sorting state |
50
- | `filterValues` | `Record<string, unknown>` | Active filter values |
51
- | `globalFilter` | `string` | Search/query filter |
51
+ | `filterValues` | `Record<string, unknown>` | Active filter values (for built-in filters) |
52
+ | `globalFilter` | `string` | Search/query filter (for built-in search) |
52
53
  | `page` | `number` | Current page (1-based, server-side mode) |
53
54
  | `itemsPerPage` | `number` | Page size (server-side mode) |
54
55
 
@@ -378,6 +379,8 @@ const columns = [
378
379
 
379
380
  ### Server-side pagination
380
381
 
382
+ When `manualPagination` is enabled, the table disables client-side sorting and filtering. The parent component owns all query logic — it watches the table's `sorting`, `page`, and `itemsPerPage` models, builds API params, and fetches data.
383
+
381
384
  ```vue
382
385
  <script setup>
383
386
  const columns = [
@@ -386,23 +389,34 @@ const columns = [
386
389
  { accessorKey: 'status', header: 'Status' },
387
390
  ]
388
391
 
389
- // Server-side pagination state
392
+ const sorting = ref([])
390
393
  const page = ref(1)
391
394
  const itemsPerPage = ref(20)
392
395
  const total = ref(0)
393
396
  const users = ref([])
394
397
  const loading = ref(false)
395
398
 
396
- // Fetch data from API with pagination params
399
+ // Parent-owned search (not built into the table)
400
+ const search = ref('')
401
+
402
+ // Build API params from table state + parent state
403
+ const apiParams = computed(() => {
404
+ const params = { page: page.value, limit: itemsPerPage.value }
405
+ const sort = sorting.value[0]
406
+ if (sort?.id) {
407
+ params.sort_by = sort.id
408
+ params.sort_order = sort.desc ? 'desc' : 'asc'
409
+ }
410
+ if (search.value.trim()) {
411
+ params.search = search.value.trim()
412
+ }
413
+ return params
414
+ })
415
+
397
416
  async function fetchUsers() {
398
417
  loading.value = true
399
418
  try {
400
- const response = await $fetch('/api/users', {
401
- query: {
402
- page: page.value,
403
- limit: itemsPerPage.value,
404
- }
405
- })
419
+ const response = await $fetch('/api/users', { query: apiParams.value })
406
420
  users.value = response.data
407
421
  total.value = response.total
408
422
  } finally {
@@ -410,11 +424,13 @@ async function fetchUsers() {
410
424
  }
411
425
  }
412
426
 
413
- // React to pagination changes
414
- watch([page, itemsPerPage], fetchUsers, { immediate: true })
427
+ watch(apiParams, fetchUsers, { immediate: true, deep: true })
415
428
  </script>
416
429
 
417
430
  <template>
431
+ <!-- Parent-owned search input -->
432
+ <UInput v-model="search" placeholder="Search..." icon="i-lucide-search" class="mb-4 max-w-xs" />
433
+
418
434
  <VATable
419
435
  name="Users"
420
436
  :data="users"
@@ -422,8 +438,10 @@ watch([page, itemsPerPage], fetchUsers, { immediate: true })
422
438
  :loading="loading"
423
439
  :manual-pagination="true"
424
440
  :total="total"
441
+ v-model:sorting="sorting"
425
442
  v-model:page="page"
426
443
  v-model:items-per-page="itemsPerPage"
444
+ :on-refresh="fetchUsers"
427
445
  />
428
446
  </template>
429
447
  ```
@@ -40,6 +40,7 @@
40
40
  <!-- Normal Toolbar (shown when no items selected) -->
41
41
  <div v-else class="flex items-center gap-2">
42
42
  <UInput
43
+ v-if="showSearch"
43
44
  v-model="globalFilter"
44
45
  placeholder="Search..."
45
46
  icon="i-lucide-search"
@@ -58,6 +59,8 @@
58
59
  class="w-40"
59
60
  />
60
61
  </template>
62
+
63
+ <slot name="toolbar-left" />
61
64
  </div>
62
65
 
63
66
  <div class="flex items-center gap-2">
@@ -97,7 +100,7 @@
97
100
  <UTable
98
101
  ref="table"
99
102
  v-model:sorting="sorting"
100
- v-model:global-filter="globalFilter"
103
+ v-model:global-filter="tableGlobalFilter"
101
104
  v-model:pagination="pagination"
102
105
  v-model:column-visibility="columnVisibility"
103
106
  v-model:row-selection="rowSelection"
@@ -105,7 +108,7 @@
105
108
  :columns="tableColumns"
106
109
  :loading="loading"
107
110
  :row-selection-options="{ enableRowSelection: props.selectable }"
108
- :sorting-options="{ getSortedRowModel: getSortedRowModel() }"
111
+ :sorting-options="sortingOptions"
109
112
  :pagination-options="{
110
113
  getPaginationRowModel: getPaginationRowModel(),
111
114
  manualPagination: props.manualPagination,
@@ -119,7 +122,7 @@
119
122
  }"
120
123
  empty="Nothing to show."
121
124
  v-bind="$attrs"
122
- @row-click="handleRowClick"
125
+ :on-select="props.onRowClick ? (_e: any, row: any) => handleRowClick(row) : undefined"
123
126
  >
124
127
  <template v-for="slotEntry in forwardedSlots" :key="slotEntry.target" #[slotEntry.target]="slotProps">
125
128
  <slot :name="slotEntry.source" v-bind="slotProps" />
@@ -162,42 +165,50 @@
162
165
  </UCard>
163
166
  </template>
164
167
 
165
- <script setup lang="ts">
166
- import { getCoreRowModel, getPaginationRowModel, getSortedRowModel } from "@tanstack/vue-table";
167
- import type { TableColumn, DropdownMenuItem } from "@nuxt/ui";
168
- import type { SortingState } from "@tanstack/vue-table";
169
- import { h, resolveComponent, useSlots } from 'vue';
170
- import CellRenderer from './CellRenderer.vue';
171
- import FilterChips from './FilterChips.vue';
172
-
173
- type FilterDefinition = {
174
- label: string;
175
- key: string;
176
- options: { label: string; value: string }[];
177
- };
178
-
179
- const props = withDefaults(defineProps<{
180
- name?: string;
181
- data: unknown[];
182
- columns: TableColumn<unknown>[];
183
- loading?: boolean;
184
- initialPageLimit?: number;
185
- selectable?: boolean;
186
- filters?: FilterDefinition[];
187
- onRefresh?: () => void | Promise<void>;
188
- onRowClick?: (row: unknown) => void;
189
- /** Default column to sort by */
190
- defaultSort?: string;
191
- /** Default sort direction */
192
- defaultSortDesc?: boolean;
193
- /** Enable server-side pagination mode */
194
- manualPagination?: boolean;
195
- /** Total row count from server (required for manualPagination) */
196
- total?: number;
197
- }>(), {
198
- selectable: false,
199
- manualPagination: false,
200
- });
168
+ /**
169
+ * VATable - Veristone data table component built on Nuxt UI's UTable + TanStack Table.
170
+ *
171
+ * @props
172
+ * name - Optional card header title
173
+ * data - Row data array
174
+ * columns - TanStack TableColumn definitions; supports `meta.preset` for cell rendering
175
+ * loading - Shows skeleton on initial load; shows inline spinner on subsequent refreshes
176
+ * initialPageLimit - Default page size (default: 10)
177
+ * selectable - Enables row checkboxes; exposes `selectedRows` via template ref
178
+ * showSearch - Renders a global search input in the toolbar
179
+ * filters - Array of { key, label, options } for toolbar USelect dropdowns
180
+ * onRefresh - Async callback; shows a refresh spinner button in the toolbar
181
+ * onRowClick - Called with the row's original data when a row is clicked
182
+ * defaultSort - Column key to sort by on mount
183
+ * defaultSortDesc - Sort descending when defaultSort is set (default: false)
184
+ * manualPagination - Enables server-side pagination mode
185
+ * total - Total row count for server-side pagination
186
+ *
187
+ * @models (v-model)
188
+ * sorting - SortingState — sync sort state with parent
189
+ * filterValues - Record<string, unknown> — sync active filter values with parent
190
+ * globalFilter - string sync search input value with parent
191
+ * page - number (1-based) current page in manual pagination mode
192
+ * itemsPerPage - number page size in manual pagination mode
193
+ *
194
+ * @slots
195
+ * header-right - Content rendered on the right side of the card header
196
+ * toolbar-left - Custom controls (e.g. filter selects) injected into the toolbar
197
+ * next to the search input and built-in filter dropdowns
198
+ * Example:
199
+ * <template #toolbar-left>
200
+ * <USelect v-model="status" :items="opts" placeholder="Status" size="sm" />
201
+ * </template>
202
+ * bulk-actions - Shown in the toolbar when rows are selected; receives
203
+ * { selected, count, clear }
204
+ * actions-cell - Renders an "Actions" column; receives { row }
205
+ * [column]-cell - Override cell rendering for a specific column by accessor key
206
+ *
207
+ * @exposes
208
+ * selectedRows - Array of selected row originals
209
+ * rowSelection - Raw TanStack row selection state
210
+ * clearSelection - Clears the current row selection
211
+ */
201
212
 
202
213
  // Sorting state - v-model support
203
214
  const sorting = defineModel<SortingState>("sorting", {
@@ -285,14 +296,6 @@ function clearSelection() {
285
296
  rowSelection.value = {};
286
297
  }
287
298
 
288
- watch(
289
- () => filterValues.value,
290
- () => {
291
- clearSelection();
292
- },
293
- { deep: true }
294
- );
295
-
296
299
  // Table ref for accessing TanStack API
297
300
  const table = ref<{ tableApi?: any } | null>(null);
298
301
 
@@ -328,6 +331,30 @@ watch(
328
331
  // Global filter - v-model support
329
332
  const globalFilter = defineModel<string>("globalFilter", { default: "" });
330
333
 
334
+ // In manual mode, prevent TanStack from filtering client-side
335
+ const tableGlobalFilter = computed({
336
+ get: () => props.manualPagination ? undefined : globalFilter.value,
337
+ set: (v: string) => { globalFilter.value = v ?? '' },
338
+ });
339
+
340
+ // Reset page when sorting changes
341
+ watch(
342
+ sorting,
343
+ () => {
344
+ clearSelection();
345
+ if (props.manualPagination) {
346
+ page.value = 1;
347
+ } else {
348
+ pagination.value = { ...pagination.value, pageIndex: 0 };
349
+ }
350
+ },
351
+ { deep: true, flush: 'sync' }
352
+ );
353
+
354
+ const sortingOptions = computed(() =>
355
+ props.manualPagination ? { manualSorting: true } : undefined
356
+ );
357
+
331
358
  // Initialize default sorting if provided
332
359
  onMounted(() => {
333
360
  if (props.defaultSort && sorting.value.length === 0) {
@@ -457,7 +484,7 @@ const tableColumns = computed(() => {
457
484
 
458
485
  const forwardedSlots = computed(() => {
459
486
  const passthrough = Object.keys(slots).filter((name) => {
460
- return !['default', 'header-right', 'bulk-actions'].includes(name);
487
+ return !['default', 'header-right', 'bulk-actions', 'toolbar-left'].includes(name);
461
488
  });
462
489
 
463
490
  return passthrough.map((source) => {