@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.
- package/app/components/ui/DataTable/index.stories.ts +249 -154
- package/app/components/ui/DataTable/index.vue +94 -65
- package/app/components/ui/DataTable/types.ts +2 -0
- package/app/components/ui/InfiniteDataTable/en.json +6 -0
- package/app/components/ui/InfiniteDataTable/index.stories.ts +165 -0
- package/app/components/ui/InfiniteDataTable/index.vue +239 -0
- package/app/components/ui/InfiniteDataTable/types.ts +35 -0
- package/i18n/messages/ar.json +6 -0
- package/i18n/messages/de.json +6 -0
- package/i18n/messages/en.json +6 -0
- package/i18n/messages/es.json +6 -0
- package/i18n/messages/fr.json +6 -0
- package/i18n/messages/hi.json +6 -0
- package/i18n/messages/id.json +6 -0
- package/i18n/messages/it.json +6 -0
- package/i18n/messages/ja.json +6 -0
- package/i18n/messages/ko.json +6 -0
- package/i18n/messages/nl.json +6 -0
- package/i18n/messages/pl.json +6 -0
- package/i18n/messages/pt.json +6 -0
- package/i18n/messages/ru.json +6 -0
- package/i18n/messages/th.json +6 -0
- package/i18n/messages/tr.json +6 -0
- package/i18n/messages/vi.json +6 -0
- package/i18n/messages/zh-CN.json +6 -0
- package/i18n/messages/zh-TW.json +6 -0
- package/package.json +2 -2
|
@@ -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(
|
|
271
|
-
|
|
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-
|
|
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
|
|
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
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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="
|
|
473
|
+
class="
|
|
474
|
+
[&_td]:px-4 [&_td]:py-2
|
|
475
|
+
border-t-0 bg-transparent
|
|
476
|
+
"
|
|
434
477
|
>
|
|
435
|
-
<
|
|
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
|
-
|
|
443
|
-
:
|
|
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
|
-
/*
|
|
457
|
-
|
|
458
|
-
:
|
|
459
|
-
|
|
460
|
-
|
|
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(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
503
|
+
.has-sticky-bounds :deep(tfoot td) {
|
|
504
|
+
position: sticky;
|
|
505
|
+
bottom: 0;
|
|
506
|
+
z-index: 20;
|
|
468
507
|
}
|
|
469
508
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
477
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
|
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,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
|
+
}
|