@polymarbot/nuxt-layer-shadcn-ui 0.6.4 → 0.7.1

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.
@@ -27,6 +27,7 @@ const props = withDefaults(defineProps<DataTableProps<TData>>(), {
27
27
  sortOrder: undefined,
28
28
  loading: false,
29
29
  clickable: false,
30
+ height: undefined,
30
31
  })
31
32
 
32
33
  const emit = defineEmits<{
@@ -46,6 +47,8 @@ defineSlots<
46
47
  }) => any> & {
47
48
  empty?: () => any
48
49
  footer?: () => any
50
+ bodyStart?: () => any
51
+ bodyEnd?: () => any
49
52
  } & Record<`header-${string}`, (_: { column: DataTableColumn }) => any>
50
53
  >()
51
54
 
@@ -252,6 +255,9 @@ function buildColumnStyle (column: DataTableColumn): Record<string, string> {
252
255
  return style
253
256
  }
254
257
 
258
+ // CSS var bound on outer wrapper, read by table-container via :deep selector
259
+ const heightStyle = computed(() => props.height ? { '--data-table-height': props.height } : undefined)
260
+
255
261
  // Reusable class fragments
256
262
  const headerCellClass = 'h-auto bg-border px-4 py-3 text-xs font-normal text-foreground'
257
263
  const headerDividerClass = 'relative after:absolute after:top-1/2 after:right-0 after:h-4 after:w-px after:-translate-y-1/2 after:bg-muted-foreground/25'
@@ -263,13 +269,21 @@ const selectionColumnStyle = { width: '1%' }
263
269
  const selectionColumnShadowDir = computed<FrozenShadow | undefined>(() =>
264
270
  showSelectionColumn.value && !lastLeftFrozenField.value && !atStart.value ? 'left' : undefined,
265
271
  )
272
+
273
+ defineExpose({
274
+ /** The shadcn table-container element — useful as IntersectionObserver root. */
275
+ scrollEl,
276
+ })
266
277
  </script>
267
278
 
268
279
  <template>
269
280
  <div
270
- :class="cn('rounded-lg bg-border px-1 text-foreground relative', !$slots.footer && `
271
- pb-1
272
- `)"
281
+ :class="cn(
282
+ 'rounded-lg bg-border px-1 text-foreground relative',
283
+ !$slots.footer && 'pb-1',
284
+ height && 'has-sticky-bounds',
285
+ )"
286
+ :style="heightStyle"
273
287
  >
274
288
  <!-- Loading overlay -->
275
289
  <Transition
@@ -281,7 +295,7 @@ const selectionColumnShadowDir = computed<FrozenShadow | undefined>(() =>
281
295
  <div
282
296
  v-if="loading"
283
297
  class="
284
- inset-0 rounded-lg bg-background/60 absolute z-20 flex items-center
298
+ inset-0 rounded-lg bg-background/60 absolute z-30 flex items-center
285
299
  justify-center
286
300
  "
287
301
  >
@@ -292,7 +306,10 @@ const selectionColumnShadowDir = computed<FrozenShadow | undefined>(() =>
292
306
  </div>
293
307
  </Transition>
294
308
 
295
- <Table ref="tableRef">
309
+ <Table
310
+ ref="tableRef"
311
+ class="h-full"
312
+ >
296
313
  <TableHeader>
297
314
  <TableRow
298
315
  class="hover:bg-transparent"
@@ -354,6 +371,17 @@ const selectionColumnShadowDir = computed<FrozenShadow | undefined>(() =>
354
371
  [&_tr]:h-15
355
372
  "
356
373
  >
374
+ <!-- Top body slot (e.g. infinite-scroll trigger) -->
375
+ <TableRow
376
+ v-if="$slots.bodyStart"
377
+ data-virtual-row
378
+ class="hover:bg-transparent"
379
+ >
380
+ <TableCell :colspan="totalColumns">
381
+ <slot name="bodyStart" />
382
+ </TableCell>
383
+ </TableRow>
384
+
357
385
  <template v-if="data?.length">
358
386
  <TableRow
359
387
  v-for="(row, index) in data"
@@ -409,89 +437,90 @@ const selectionColumnShadowDir = computed<FrozenShadow | undefined>(() =>
409
437
  </TableRow>
410
438
  </template>
411
439
 
412
- <template v-else>
413
- <TableEmpty :colspan="totalColumns">
414
- <slot name="empty">
415
- <div
416
- class="gap-2 text-muted-foreground flex flex-col items-center"
417
- >
418
- <Icon
419
- name="inbox"
420
- class="size-8"
421
- />
422
- <span class="text-sm">
423
- {{ T('empty') }}
424
- </span>
425
- </div>
426
- </slot>
427
- </TableEmpty>
428
- </template>
440
+ <TableEmpty
441
+ v-else-if="!loading"
442
+ :colspan="totalColumns"
443
+ >
444
+ <slot name="empty">
445
+ <div
446
+ class="gap-2 text-muted-foreground flex flex-col items-center"
447
+ >
448
+ <Icon
449
+ name="inbox"
450
+ class="size-8"
451
+ />
452
+ <span class="text-sm">
453
+ {{ T('empty') }}
454
+ </span>
455
+ </div>
456
+ </slot>
457
+ </TableEmpty>
458
+
459
+ <!-- Bottom body slot (e.g. infinite-scroll trigger / "all loaded") -->
460
+ <TableRow
461
+ v-if="$slots.bodyEnd"
462
+ data-virtual-row
463
+ class="hover:bg-transparent"
464
+ >
465
+ <TableCell :colspan="totalColumns">
466
+ <slot name="bodyEnd" />
467
+ </TableCell>
468
+ </TableRow>
429
469
  </TableBody>
430
470
 
431
471
  <TableFooter
432
472
  v-if="$slots.footer"
433
- class="border-t-0 bg-transparent"
473
+ class="
474
+ [&_td]:px-4 [&_td]:py-2
475
+ border-t-0 bg-transparent
476
+ "
434
477
  >
435
- <slot name="footer" />
478
+ <TableRow>
479
+ <TableCell
480
+ :colspan="totalColumns"
481
+ class="bg-border"
482
+ >
483
+ <slot name="footer" />
484
+ </TableCell>
485
+ </TableRow>
436
486
  </TableFooter>
437
487
  </Table>
438
488
  </div>
439
489
  </template>
440
490
 
441
491
  <style scoped>
442
- /* CSS variable on tr, background on td — sticky cells need opaque bg */
443
- :deep(tbody tr) {
444
- --cell-bg: var(--color-card);
445
- --corner-r: 8px;
446
- }
447
-
448
- :deep(tbody tr:hover) {
449
- --cell-bg: var(--color-muted);
450
- }
451
-
452
- :deep(tbody td) {
453
- background: var(--cell-bg);
492
+ :deep([data-slot="table-container"]) {
493
+ height: var(--data-table-height, auto);
454
494
  }
455
495
 
456
- /* Rounded corners: radial-gradient positioned at each corner,
457
- transparent circle inside reveals cell-bg, accent fills the corner gap */
458
- :deep(tbody tr:first-child td:first-child) {
459
- background:
460
- radial-gradient(circle at var(--corner-r) var(--corner-r), transparent var(--corner-r), var(--color-accent) var(--corner-r)) 0 0 / var(--corner-r) var(--corner-r) no-repeat,
461
- var(--cell-bg);
496
+ /* sticky on <th>/<td>, not <tr> — row-level sticky lacks browser support */
497
+ .has-sticky-bounds :deep(thead th) {
498
+ position: sticky;
499
+ top: 0;
500
+ z-index: 20;
462
501
  }
463
502
 
464
- :deep(tbody tr:first-child td:last-child) {
465
- background:
466
- radial-gradient(circle at 0 var(--corner-r), transparent var(--corner-r), var(--color-accent) var(--corner-r)) 100% 0 / var(--corner-r) var(--corner-r) no-repeat,
467
- var(--cell-bg);
503
+ .has-sticky-bounds :deep(tfoot td) {
504
+ position: sticky;
505
+ bottom: 0;
506
+ z-index: 20;
468
507
  }
469
508
 
470
- :deep(tbody tr:last-child td:first-child) {
471
- background:
472
- radial-gradient(circle at var(--corner-r) 0, transparent var(--corner-r), var(--color-accent) var(--corner-r)) 0 100% / var(--corner-r) var(--corner-r) no-repeat,
473
- var(--cell-bg);
509
+ /* clip-path is reliable on table-row-group, unlike overflow:hidden */
510
+ :deep(tbody) {
511
+ clip-path: inset(0 round 8px);
474
512
  }
475
513
 
476
- :deep(tbody tr:last-child td:last-child) {
477
- background:
478
- radial-gradient(circle at 0 0, transparent var(--corner-r), var(--color-accent) var(--corner-r)) 100% 100% / var(--corner-r) var(--corner-r) no-repeat,
479
- var(--cell-bg);
514
+ :deep(tbody tr) {
515
+ --cell-bg: var(--color-card);
480
516
  }
481
517
 
482
- /* Single row: combine top + bottom gradients */
483
- :deep(tbody tr:first-child:last-child td:first-child) {
484
- background:
485
- radial-gradient(circle at var(--corner-r) var(--corner-r), transparent var(--corner-r), var(--color-accent) var(--corner-r)) 0 0 / var(--corner-r) var(--corner-r) no-repeat,
486
- radial-gradient(circle at var(--corner-r) 0, transparent var(--corner-r), var(--color-accent) var(--corner-r)) 0 100% / var(--corner-r) var(--corner-r) no-repeat,
487
- var(--cell-bg);
518
+ :deep(tbody tr:hover) {
519
+ --cell-bg: var(--color-muted);
488
520
  }
489
521
 
490
- :deep(tbody tr:first-child:last-child td:last-child) {
491
- background:
492
- radial-gradient(circle at 0 var(--corner-r), transparent var(--corner-r), var(--color-accent) var(--corner-r)) 100% 0 / var(--corner-r) var(--corner-r) no-repeat,
493
- radial-gradient(circle at 0 0, transparent var(--corner-r), var(--color-accent) var(--corner-r)) 100% 100% / var(--corner-r) var(--corner-r) no-repeat,
494
- var(--cell-bg);
522
+ :deep(tbody td) {
523
+ background: var(--cell-bg);
495
524
  }
496
525
 
497
526
  /* Frozen column shadow via ::before — ::after is reserved for header divider */
@@ -41,4 +41,6 @@ export interface DataTableProps<T = Record<string, any>> {
41
41
  loading?: boolean
42
42
  /** Whether rows are clickable (shows pointer cursor and pairs with `@rowClick`) */
43
43
  clickable?: boolean
44
+ /** Fixed height for the inner scroll container (e.g. '400px'). Enables internal vertical scroll, with sticky header and footer. */
45
+ height?: string
44
46
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "allLoaded": "— all loaded —",
3
+ "count": "{loaded} of {total} loaded",
4
+ "refresh": "Refresh",
5
+ "scrollToTop": "Scroll to top"
6
+ }
@@ -0,0 +1,165 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3'
2
+ import type { DataTableColumn } from '../DataTable/types'
3
+ import type { InfiniteDataTableFetchParams, InfiniteDataTableFetchResult } from './types'
4
+ import InfiniteDataTable from './index.vue'
5
+
6
+ interface User {
7
+ id: number
8
+ name: string
9
+ email: string
10
+ role: string
11
+ status: string
12
+ amount: number
13
+ createdAt: string
14
+ }
15
+
16
+ const allUsers: User[] = Array.from({ length: 120 }, (_, i) => ({
17
+ id: i + 1,
18
+ name: `User ${i + 1}`,
19
+ email: `user${i + 1}@example.com`,
20
+ role: [ 'Admin', 'Editor', 'User' ][i % 3]!,
21
+ status: i % 4 === 0 ? 'inactive' : 'active',
22
+ amount: Math.round(Math.random() * 10000) / 100,
23
+ createdAt: new Date(2024, 0, 1 + i).toISOString(),
24
+ }))
25
+
26
+ const columns: DataTableColumn[] = [
27
+ { field: 'name', title: 'Name', width: '120px', sortable: true },
28
+ { field: 'email', title: 'Email', minWidth: '200px' },
29
+ { field: 'role', title: 'Role', width: '100px', sortable: true },
30
+ { field: 'status', title: 'Status', width: '100px' },
31
+ { field: 'amount', title: 'Amount', width: '120px', type: 'currency', sortable: true },
32
+ { field: 'createdAt', title: 'Created', width: '140px', type: 'date' },
33
+ ]
34
+
35
+ /** Mock fetch using offset as the opaque cursor */
36
+ function mockFetch (params: InfiniteDataTableFetchParams): Promise<InfiniteDataTableFetchResult<User>> {
37
+ return new Promise(resolve => {
38
+ setTimeout(() => {
39
+ const data = [ ...allUsers ]
40
+
41
+ if (params.role) {
42
+ data.splice(0, data.length, ...data.filter(u => u.role === params.role))
43
+ }
44
+
45
+ if (params.sortBy) {
46
+ const order = params.sortOrder ?? 1
47
+ data.sort((a, b) => {
48
+ const av = a[params.sortBy as keyof User]
49
+ const bv = b[params.sortBy as keyof User]
50
+ if (av! < bv!) return -1 * order
51
+ if (av! > bv!) return 1 * order
52
+ return 0
53
+ })
54
+ }
55
+
56
+ const offset = params.cursor ? Number(params.cursor) : 0
57
+ const items = data.slice(offset, offset + params.limit)
58
+ const nextOffset = offset + items.length
59
+ resolve({
60
+ items,
61
+ next: nextOffset < data.length ? String(nextOffset) : undefined,
62
+ total: data.length,
63
+ })
64
+ }, 400)
65
+ })
66
+ }
67
+
68
+ const meta = {
69
+ title: 'UI/InfiniteDataTable',
70
+ component: InfiniteDataTable as any,
71
+ argTypes: {
72
+ columns: { control: 'object' },
73
+ fetchMethod: { control: false },
74
+ autoFetch: { control: 'boolean' },
75
+ filters: { control: 'object' },
76
+ pageSize: { control: 'number' },
77
+ height: { control: 'text' },
78
+ clickable: { control: 'boolean' },
79
+ },
80
+ args: {
81
+ columns,
82
+ fetchMethod: mockFetch,
83
+ autoFetch: true,
84
+ filters: undefined,
85
+ pageSize: 30,
86
+ height: '360px',
87
+ clickable: false,
88
+ },
89
+ render: args => ({
90
+ components: { InfiniteDataTable: InfiniteDataTable as any },
91
+ setup: () => ({ args }),
92
+ template: '<InfiniteDataTable v-bind="args" />',
93
+ }),
94
+ } satisfies Meta<typeof InfiniteDataTable>
95
+
96
+ export default meta
97
+ type Story = StoryObj<typeof meta>
98
+
99
+ const noControls = { controls: { disable: true }} satisfies Story['parameters']
100
+
101
+ /** Internal scroll container — scrolling inside the table fetches the next page */
102
+ export const Default: Story = {}
103
+
104
+ /** External `filters` changes also reset pagination — try toggling the role filter */
105
+ export const WithFilters: Story = {
106
+ parameters: {
107
+ ...noControls,
108
+ docs: {
109
+ source: {
110
+ code: `
111
+ <template>
112
+ <select v-model="role">
113
+ <option value="">All</option>
114
+ <option value="Admin">Admin</option>
115
+ <option value="Editor">Editor</option>
116
+ <option value="User">User</option>
117
+ </select>
118
+ <InfiniteDataTable
119
+ :columns="columns"
120
+ :fetchMethod="mockFetch"
121
+ :filters="{ role: role || undefined }"
122
+ height="360px"
123
+ />
124
+ </template>
125
+ `.trim(),
126
+ },
127
+ },
128
+ },
129
+ render: () => ({
130
+ components: { InfiniteDataTable: InfiniteDataTable as any },
131
+ setup () {
132
+ const role = ref('')
133
+ const filters = computed(() => ({ role: role.value || undefined }))
134
+ return { columns, mockFetch, role, filters }
135
+ },
136
+ template: `
137
+ <div class="space-y-3">
138
+ <label class="gap-2 text-sm flex items-center">
139
+ Role:
140
+ <select
141
+ v-model="role"
142
+ class="px-2 py-1 border rounded"
143
+ >
144
+ <option value="">All</option>
145
+ <option value="Admin">Admin</option>
146
+ <option value="Editor">Editor</option>
147
+ <option value="User">User</option>
148
+ </select>
149
+ </label>
150
+ <InfiniteDataTable
151
+ :columns="columns"
152
+ :fetchMethod="mockFetch"
153
+ :filters="filters"
154
+ height="360px"
155
+ />
156
+ </div>
157
+ `,
158
+ }),
159
+ }
160
+
161
+ /** No fixed height — the page scrolls and triggers loading at the bottom */
162
+ export const PageScroll: Story = {
163
+ parameters: noControls,
164
+ args: { height: undefined },
165
+ }
@@ -0,0 +1,239 @@
1
+ <script setup lang="ts" generic="TData extends Record<string, any>">
2
+ import type { InfiniteDataTableFetchParams, InfiniteDataTableProps } from './types'
3
+
4
+ const props = withDefaults(defineProps<InfiniteDataTableProps<TData>>(), {
5
+ columns: () => [],
6
+ fetchMethod: undefined,
7
+ autoFetch: true,
8
+ filters: undefined,
9
+ pageSize: 30,
10
+ height: undefined,
11
+ clickable: false,
12
+ })
13
+
14
+ const emit = defineEmits<{
15
+ 'update:filters': [filters: Record<string, any>]
16
+ 'rowClick': [row: TData, index: number, event: MouseEvent]
17
+ }>()
18
+
19
+ const T = useTranslations('components.ui.InfiniteDataTable')
20
+
21
+ // -- Internal state --
22
+
23
+ const loading = ref(false)
24
+ const internalData = ref<TData[]>([]) as Ref<TData[]>
25
+ const cursor = ref<string | undefined>(undefined)
26
+ const hasMore = ref(true)
27
+ const total = ref<number | undefined>(undefined)
28
+ const requestVersion = ref(0)
29
+
30
+ const sortState = ref<{ sortBy: string | null, sortOrder: number | null }>({
31
+ sortBy: props.filters?.sortBy ? String(props.filters.sortBy) : null,
32
+ sortOrder: props.filters?.sortOrder ? Number(props.filters.sortOrder) : null,
33
+ })
34
+
35
+ const isInitialLoad = computed(() => loading.value && internalData.value.length === 0)
36
+
37
+ // -- IntersectionObserver root: only when internal scroll is active --
38
+
39
+ const dataTableRef = ref<{ scrollEl?: HTMLElement } | null>(null)
40
+ const intersectionOptions = computed<IntersectionObserverInit | undefined>(() => { // eslint-disable-line no-undef
41
+ if (!props.height) return undefined
42
+ const root = dataTableRef.value?.scrollEl
43
+ return root ? { root } : undefined
44
+ })
45
+
46
+ // -- Helpers --
47
+
48
+ function getFilters (): Record<string, any> {
49
+ return {
50
+ ...(props.filters ?? {}),
51
+ sortBy: sortState.value.sortBy,
52
+ sortOrder: sortState.value.sortOrder,
53
+ }
54
+ }
55
+
56
+ function buildFetchParams (): InfiniteDataTableFetchParams {
57
+ return {
58
+ ...getFilters(),
59
+ cursor: cursor.value,
60
+ limit: props.pageSize,
61
+ }
62
+ }
63
+
64
+ function resetState () {
65
+ internalData.value = []
66
+ cursor.value = undefined
67
+ hasMore.value = true
68
+ total.value = undefined
69
+ }
70
+
71
+ // -- Loading --
72
+
73
+ async function loadMore () {
74
+ if (!props.fetchMethod) return
75
+ if (loading.value || !hasMore.value) return
76
+
77
+ const currentVersion = ++requestVersion.value
78
+ loading.value = true
79
+ try {
80
+ const result = await props.fetchMethod(buildFetchParams())
81
+ if (currentVersion !== requestVersion.value) return
82
+
83
+ internalData.value = [ ...internalData.value, ...result.items ]
84
+ if (result.total != null) total.value = result.total
85
+ cursor.value = result.next
86
+ hasMore.value = !!result.next
87
+ } catch (error) {
88
+ if (currentVersion !== requestVersion.value) return
89
+ console.error('InfiniteDataTable loadMore failed:', error)
90
+ } finally {
91
+ if (currentVersion === requestVersion.value) loading.value = false
92
+ }
93
+ }
94
+
95
+ async function refresh () {
96
+ resetState()
97
+ emit('update:filters', getFilters())
98
+ await loadMore()
99
+ }
100
+
101
+ function scrollToTop () {
102
+ const el = dataTableRef.value?.scrollEl
103
+ if (!el) return
104
+ if (el.scrollHeight > el.clientHeight) {
105
+ el.scrollTo({ top: 0, behavior: 'smooth' })
106
+ } else {
107
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' })
108
+ }
109
+ }
110
+
111
+ // -- Sort: incremental loading requires a full reset on sort change --
112
+
113
+ let sortUpdatePending = false
114
+
115
+ function onSortByUpdate (value: string | null) {
116
+ sortState.value.sortBy = value
117
+ scheduleAfterSort()
118
+ }
119
+
120
+ function onSortOrderUpdate (value: number | null) {
121
+ sortState.value.sortOrder = value
122
+ scheduleAfterSort()
123
+ }
124
+
125
+ function scheduleAfterSort () {
126
+ if (sortUpdatePending) return
127
+ sortUpdatePending = true
128
+ nextTick(() => {
129
+ sortUpdatePending = false
130
+ refresh()
131
+ })
132
+ }
133
+
134
+ // -- External filters: any change resets and reloads --
135
+
136
+ watch(() => props.filters, (newVal, oldVal) => {
137
+ if (JSON.stringify(newVal) === JSON.stringify(oldVal)) return
138
+ refresh()
139
+ }, { deep: true })
140
+
141
+ // -- Expose --
142
+
143
+ defineExpose({
144
+ refresh,
145
+ loadMore,
146
+ scrollToTop,
147
+ })
148
+
149
+ // -- Lifecycle --
150
+
151
+ onMounted(() => {
152
+ emit('update:filters', getFilters())
153
+ if (props.autoFetch) loadMore()
154
+ })
155
+ </script>
156
+
157
+ <template>
158
+ <DataTable
159
+ ref="dataTableRef"
160
+ :data="internalData"
161
+ :columns
162
+ :height
163
+ :loading="isInitialLoad"
164
+ :clickable
165
+ :sortBy="sortState.sortBy"
166
+ :sortOrder="sortState.sortOrder"
167
+ @update:sortBy="onSortByUpdate"
168
+ @update:sortOrder="onSortOrderUpdate"
169
+ @rowClick="(row, i, e) => emit('rowClick', row as TData, i, e)"
170
+ >
171
+ <template
172
+ v-for="name in Object.keys($slots).filter(n => n !== 'bodyEnd' && n !== 'footer')"
173
+ :key="name"
174
+ #[name]="slotData"
175
+ >
176
+ <slot
177
+ :name="name"
178
+ v-bind="slotData ?? {}"
179
+ />
180
+ </template>
181
+
182
+ <template
183
+ v-if="hasMore || internalData.length > 0"
184
+ #bodyEnd
185
+ >
186
+ <div
187
+ v-if="!hasMore"
188
+ class="py-2 text-xs text-muted-foreground text-center"
189
+ >
190
+ {{ T('allLoaded') }}
191
+ </div>
192
+ <EffectIntersectionChecker
193
+ v-else-if="!isInitialLoad"
194
+ :disabled="loading"
195
+ :options="intersectionOptions"
196
+ class="py-2 flex items-center justify-center"
197
+ @show="loadMore"
198
+ >
199
+ <Icon
200
+ name="loader-circle"
201
+ class="size-4 animate-spin text-muted-foreground"
202
+ />
203
+ </EffectIntersectionChecker>
204
+ </template>
205
+
206
+ <template #footer>
207
+ <slot name="footer">
208
+ <div class="gap-2 text-xs flex items-center justify-between">
209
+ <div class="gap-2 flex items-center">
210
+ <Tooltip :text="T('scrollToTop')">
211
+ <Button
212
+ variant="ghost"
213
+ size="icon-sm"
214
+ icon="arrow-up-to-line"
215
+ :disabled="loading || internalData.length === 0"
216
+ @click="scrollToTop"
217
+ />
218
+ </Tooltip>
219
+ <Tooltip :text="T('refresh')">
220
+ <Button
221
+ variant="ghost"
222
+ size="icon-sm"
223
+ icon="rotate-cw"
224
+ :disabled="loading"
225
+ @click="refresh"
226
+ />
227
+ </Tooltip>
228
+ </div>
229
+ <span
230
+ v-if="total != null"
231
+ class="text-muted-foreground"
232
+ >
233
+ {{ T('count', { loaded: internalData.length, total }) }}
234
+ </span>
235
+ </div>
236
+ </slot>
237
+ </template>
238
+ </DataTable>
239
+ </template>
@@ -0,0 +1,35 @@
1
+ import type { DataTableColumn } from '../DataTable/types'
2
+
3
+ export interface InfiniteDataTableFetchParams {
4
+ /** Opaque cursor pointing at the next page; absent on the first page */
5
+ cursor?: string
6
+ /** Page size requested */
7
+ limit: number
8
+ /** Filter / sort fields are spread onto the params object */
9
+ [key: string]: any
10
+ }
11
+
12
+ export interface InfiniteDataTableFetchResult<T = Record<string, any>> {
13
+ items: T[]
14
+ /** Cursor for the next page; pass back as `cursor` on the next request. Absent signals "no more". */
15
+ next?: string
16
+ /** Optional total count */
17
+ total?: number
18
+ }
19
+
20
+ export interface InfiniteDataTableProps<T = Record<string, any>> {
21
+ /** Column definitions */
22
+ columns?: DataTableColumn[]
23
+ /** Async function to fetch a page of rows */
24
+ fetchMethod?: (params: InfiniteDataTableFetchParams) => Promise<InfiniteDataTableFetchResult<T>>
25
+ /** Whether to fetch the first page on mount (default: true) */
26
+ autoFetch?: boolean
27
+ /** External filter state — changing this resets and reloads */
28
+ filters?: Record<string, any>
29
+ /** Number of rows per page (default: 30) */
30
+ pageSize?: number
31
+ /** Fixed height enabling internal vertical scroll (e.g. '400px') */
32
+ height?: string
33
+ /** Whether rows are clickable (shows pointer cursor and pairs with `@rowClick`) */
34
+ clickable?: boolean
35
+ }