@polymarbot/nuxt-layer-shadcn-ui 0.6.3 → 0.7.0

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.
Files changed (37) hide show
  1. package/app/components/ui/DataTable/index.stories.ts +249 -154
  2. package/app/components/ui/DataTable/index.vue +94 -65
  3. package/app/components/ui/DataTable/types.ts +2 -0
  4. package/app/components/ui/DatePicker/index.stories.ts +1 -1
  5. package/app/components/ui/DatePicker/index.vue +1 -1
  6. package/app/components/ui/DateRangePicker/index.stories.ts +2 -2
  7. package/app/components/ui/DateRangePicker/index.vue +2 -2
  8. package/app/components/ui/InfiniteDataTable/en.json +6 -0
  9. package/app/components/ui/InfiniteDataTable/index.stories.ts +165 -0
  10. package/app/components/ui/InfiniteDataTable/index.vue +239 -0
  11. package/app/components/ui/InfiniteDataTable/types.ts +35 -0
  12. package/app/components/ui/InputRange/index.stories.ts +2 -2
  13. package/app/components/ui/InputRange/index.vue +2 -2
  14. package/app/components/ui/SearchSelect/index.vue +4 -4
  15. package/app/components/ui/Select/index.stories.ts +10 -0
  16. package/app/components/ui/Select/index.vue +10 -3
  17. package/app/components/ui/Select/types.ts +2 -0
  18. package/i18n/messages/ar.json +6 -0
  19. package/i18n/messages/de.json +6 -0
  20. package/i18n/messages/en.json +6 -0
  21. package/i18n/messages/es.json +6 -0
  22. package/i18n/messages/fr.json +6 -0
  23. package/i18n/messages/hi.json +6 -0
  24. package/i18n/messages/id.json +6 -0
  25. package/i18n/messages/it.json +6 -0
  26. package/i18n/messages/ja.json +6 -0
  27. package/i18n/messages/ko.json +6 -0
  28. package/i18n/messages/nl.json +6 -0
  29. package/i18n/messages/pl.json +6 -0
  30. package/i18n/messages/pt.json +6 -0
  31. package/i18n/messages/ru.json +6 -0
  32. package/i18n/messages/th.json +6 -0
  33. package/i18n/messages/tr.json +6 -0
  34. package/i18n/messages/vi.json +6 -0
  35. package/i18n/messages/zh-CN.json +6 -0
  36. package/i18n/messages/zh-TW.json +6 -0
  37. 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('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
  }
@@ -28,7 +28,7 @@ const meta = {
28
28
  showTime: false,
29
29
  disabled: false,
30
30
  readonly: false,
31
- placeholder: undefined,
31
+ placeholder: '',
32
32
  minDate: undefined,
33
33
  maxDate: undefined,
34
34
  valueFormat: undefined,
@@ -86,7 +86,7 @@ const timeConfig = computed(() => {
86
86
  <div @click.stop>
87
87
  <Input
88
88
  :modelValue="value"
89
- :placeholder="placeholder ?? T('placeholder')"
89
+ :placeholder="placeholder || T('placeholder')"
90
90
  :disabled="disabled"
91
91
  :readonly="readonly"
92
92
  @update:modelValue="(v: string | undefined) => onInput(v ?? '')"
@@ -27,8 +27,8 @@ const meta = {
27
27
  showTime: false,
28
28
  disabled: false,
29
29
  readonly: false,
30
- startPlaceholder: undefined,
31
- endPlaceholder: undefined,
30
+ startPlaceholder: '',
31
+ endPlaceholder: '',
32
32
  maxSpanDays: undefined,
33
33
  valueFormat: undefined,
34
34
  autoApply: true,
@@ -97,7 +97,7 @@ const endMaxDate = computed(() => {
97
97
  :showTime="showTime"
98
98
  :disabled="disabled"
99
99
  :readonly="readonly"
100
- :placeholder="startPlaceholder ?? T('startPlaceholder')"
100
+ :placeholder="startPlaceholder || T('startPlaceholder')"
101
101
  :minDate="startMinDate"
102
102
  :maxDate="startMaxDate"
103
103
  :valueFormat="valueFormat"
@@ -112,7 +112,7 @@ const endMaxDate = computed(() => {
112
112
  :showTime="showTime"
113
113
  :disabled="disabled"
114
114
  :readonly="readonly"
115
- :placeholder="endPlaceholder ?? T('endPlaceholder')"
115
+ :placeholder="endPlaceholder || T('endPlaceholder')"
116
116
  :minDate="endMinDate"
117
117
  :maxDate="endMaxDate"
118
118
  :valueFormat="valueFormat"
@@ -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 `next` token */
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.next ? Number(params.next) : 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
+ }