@duro-app/ui 0.22.0 → 0.24.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 (89) hide show
  1. package/dist/components/Alert/Alert.meta.d.ts.map +1 -1
  2. package/dist/components/ButtonGroup/ButtonGroup.d.ts +14 -0
  3. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
  4. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts +3 -0
  5. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -0
  6. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +11 -0
  7. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -0
  8. package/dist/components/ButtonGroup/styles.css.d.ts +45 -0
  9. package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -0
  10. package/dist/components/Callout/Callout.meta.d.ts.map +1 -1
  11. package/dist/components/Card/Card.meta.d.ts.map +1 -1
  12. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts +22 -0
  13. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -0
  14. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts +3 -0
  15. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -0
  16. package/dist/components/CheckboxGroup/CheckboxGroup.stories.d.ts +9 -0
  17. package/dist/components/CheckboxGroup/CheckboxGroup.stories.d.ts.map +1 -0
  18. package/dist/components/CheckboxGroup/styles.css.d.ts +12 -0
  19. package/dist/components/CheckboxGroup/styles.css.d.ts.map +1 -0
  20. package/dist/components/EmptyState/EmptyState.meta.d.ts.map +1 -1
  21. package/dist/components/InputGroup/InputGroup.meta.d.ts.map +1 -1
  22. package/dist/components/List/List.d.ts.map +1 -1
  23. package/dist/components/List/List.meta.d.ts.map +1 -1
  24. package/dist/components/List/List.stories.d.ts.map +1 -1
  25. package/dist/components/RadioGroup/RadioGroup.d.ts +22 -0
  26. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -0
  27. package/dist/components/RadioGroup/RadioGroup.meta.d.ts +3 -0
  28. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -0
  29. package/dist/components/RadioGroup/RadioGroup.stories.d.ts +9 -0
  30. package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -0
  31. package/dist/components/RadioGroup/styles.css.d.ts +60 -0
  32. package/dist/components/RadioGroup/styles.css.d.ts.map +1 -0
  33. package/dist/components/StatusIcon/StatusIcon.meta.d.ts.map +1 -1
  34. package/dist/components/Table/ColumnFilter.d.ts +8 -0
  35. package/dist/components/Table/ColumnFilter.d.ts.map +1 -0
  36. package/dist/components/Table/Pagination.d.ts +7 -0
  37. package/dist/components/Table/Pagination.d.ts.map +1 -0
  38. package/dist/components/Table/SortIndicator.d.ts +16 -0
  39. package/dist/components/Table/SortIndicator.d.ts.map +1 -0
  40. package/dist/components/Table/Table.d.ts +10 -3
  41. package/dist/components/Table/Table.d.ts.map +1 -1
  42. package/dist/components/Table/Table.stories.d.ts +4 -0
  43. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  44. package/dist/components/Table/styles.css.d.ts +12 -4
  45. package/dist/components/Table/styles.css.d.ts.map +1 -1
  46. package/dist/components/Table/useDataTable.d.ts +32 -0
  47. package/dist/components/Table/useDataTable.d.ts.map +1 -0
  48. package/dist/components/ThemeProvider/ThemeProvider.meta.d.ts.map +1 -1
  49. package/dist/index.css +1 -1
  50. package/dist/index.d.ts +3 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2399 -1927
  53. package/dist/index.js.map +1 -1
  54. package/package.json +3 -2
  55. package/src/components/ActionBar/ActionBar.stories.tsx +3 -3
  56. package/src/components/Alert/Alert.meta.ts +4 -1
  57. package/src/components/ButtonGroup/ButtonGroup.meta.ts +24 -0
  58. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +103 -0
  59. package/src/components/ButtonGroup/ButtonGroup.tsx +59 -0
  60. package/src/components/ButtonGroup/styles.css.ts +28 -0
  61. package/src/components/Callout/Callout.meta.ts +4 -1
  62. package/src/components/Card/Card.meta.ts +4 -1
  63. package/src/components/Checkbox/styles.css.ts +1 -1
  64. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +25 -0
  65. package/src/components/CheckboxGroup/CheckboxGroup.stories.tsx +88 -0
  66. package/src/components/CheckboxGroup/CheckboxGroup.tsx +105 -0
  67. package/src/components/CheckboxGroup/styles.css.ts +13 -0
  68. package/src/components/EmptyState/EmptyState.meta.ts +1 -4
  69. package/src/components/Icon/Icon.meta.ts +5 -5
  70. package/src/components/InputGroup/InputGroup.meta.ts +4 -1
  71. package/src/components/List/List.meta.ts +8 -2
  72. package/src/components/List/List.stories.tsx +35 -15
  73. package/src/components/List/List.tsx +1 -5
  74. package/src/components/RadioGroup/RadioGroup.meta.ts +25 -0
  75. package/src/components/RadioGroup/RadioGroup.stories.tsx +83 -0
  76. package/src/components/RadioGroup/RadioGroup.tsx +113 -0
  77. package/src/components/RadioGroup/styles.css.ts +66 -0
  78. package/src/components/StatusIcon/StatusIcon.meta.ts +1 -4
  79. package/src/components/Table/ColumnFilter.tsx +46 -0
  80. package/src/components/Table/Pagination.tsx +82 -0
  81. package/src/components/Table/SortIndicator.tsx +56 -0
  82. package/src/components/Table/Table.stories.tsx +355 -9
  83. package/src/components/Table/Table.tsx +62 -17
  84. package/src/components/Table/styles.css.ts +18 -8
  85. package/src/components/Table/useDataTable.ts +113 -0
  86. package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -3
  87. package/src/docs/helpers.tsx +2 -2
  88. package/src/index.ts +5 -0
  89. package/src/strict.css +5 -0
@@ -9,6 +9,8 @@ import {Button} from '../Button/Button'
9
9
  import {ActionBar} from '../ActionBar/ActionBar'
10
10
  import {colors} from '@duro-app/tokens/tokens/colors.css'
11
11
 
12
+ import {flexRender, createColumnHelper, useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, type SortingState, type ColumnFiltersState} from '@tanstack/react-table'
13
+
12
14
  const meta: Meta = {
13
15
  title: 'Components/Table',
14
16
  argTypes: {
@@ -55,7 +57,7 @@ function statusVariant(status: string) {
55
57
 
56
58
  export const Default: Story = {
57
59
  render: () => (
58
- <Table.Root columns={4}>
60
+ <Table.Root>
59
61
  <Table.Header>
60
62
  <Table.Row>
61
63
  <Table.HeaderCell>Service</Table.HeaderCell>
@@ -108,7 +110,7 @@ export const Default: Story = {
108
110
 
109
111
  export const Striped: Story = {
110
112
  render: () => (
111
- <Table.Root variant="striped" columns={4}>
113
+ <Table.Root variant="striped">
112
114
  <Table.Header>
113
115
  <Table.Row>
114
116
  <Table.HeaderCell>Service</Table.HeaderCell>
@@ -141,7 +143,7 @@ export const Striped: Story = {
141
143
 
142
144
  export const Bordered: Story = {
143
145
  render: () => (
144
- <Table.Root variant="bordered" columns={4}>
146
+ <Table.Root variant="bordered">
145
147
  <Table.Header>
146
148
  <Table.Row>
147
149
  <Table.HeaderCell>Service</Table.HeaderCell>
@@ -175,7 +177,7 @@ export const Bordered: Story = {
175
177
 
176
178
  export const Compact: Story = {
177
179
  render: () => (
178
- <Table.Root size="sm" columns={4}>
180
+ <Table.Root size="sm">
179
181
  <Table.Header>
180
182
  <Table.Row>
181
183
  <Table.HeaderCell>Service</Table.HeaderCell>
@@ -248,7 +250,7 @@ function CheckboxTableDemo() {
248
250
 
249
251
  return (
250
252
  <>
251
- <Table.Root columns={5}>
253
+ <Table.Root>
252
254
  <Table.Header>
253
255
  <Table.Row>
254
256
  <Table.HeaderCell aria-label="Select all">
@@ -316,7 +318,7 @@ export const AllVariants: Story = {
316
318
  <html.div style={storyStyles.stack}>
317
319
  <html.div>
318
320
  <html.span style={storyStyles.label}>Default</html.span>
319
- <Table.Root columns={3}>
321
+ <Table.Root>
320
322
  <Table.Header>
321
323
  <Table.Row>
322
324
  <Table.HeaderCell>Name</Table.HeaderCell>
@@ -341,7 +343,7 @@ export const AllVariants: Story = {
341
343
 
342
344
  <html.div>
343
345
  <html.span style={storyStyles.label}>Striped</html.span>
344
- <Table.Root variant="striped" columns={3}>
346
+ <Table.Root variant="striped">
345
347
  <Table.Header>
346
348
  <Table.Row>
347
349
  <Table.HeaderCell>Name</Table.HeaderCell>
@@ -371,7 +373,7 @@ export const AllVariants: Story = {
371
373
 
372
374
  <html.div>
373
375
  <html.span style={storyStyles.label}>Bordered</html.span>
374
- <Table.Root variant="bordered" columns={3}>
376
+ <Table.Root variant="bordered">
375
377
  <Table.Header>
376
378
  <Table.Row>
377
379
  <Table.HeaderCell>Name</Table.HeaderCell>
@@ -396,7 +398,7 @@ export const AllVariants: Story = {
396
398
 
397
399
  <html.div>
398
400
  <html.span style={storyStyles.label}>Compact (sm)</html.span>
399
- <Table.Root size="sm" columns={3}>
401
+ <Table.Root size="sm">
400
402
  <Table.Header>
401
403
  <Table.Row>
402
404
  <Table.HeaderCell>Name</Table.HeaderCell>
@@ -426,3 +428,347 @@ export const AllVariants: Story = {
426
428
  await expect(tables.length).toBe(4)
427
429
  },
428
430
  }
431
+
432
+
433
+ // --- Sorting: demonstrates click-to-sort with hover indicators ---
434
+
435
+ function SortingDemo() {
436
+ const data = [
437
+ {name: 'Traefik', role: 'Reverse Proxy', port: 443, uptime: '14d 3h'},
438
+ {name: 'Pi-hole', role: 'DNS Filter', port: 80, uptime: '14d 3h'},
439
+ {name: 'Grafana', role: 'Monitoring', port: 3000, uptime: '7d 12h'},
440
+ {name: 'Portainer', role: 'Container Mgmt', port: 9000, uptime: '2d 5h'},
441
+ {name: 'Nginx', role: 'Web Server', port: 8080, uptime: '30d 1h'},
442
+ ]
443
+
444
+ const cols = [
445
+ createColumnHelper<typeof data[0]>().accessor('name', {header: 'Name', enableSorting: true}),
446
+ createColumnHelper<typeof data[0]>().accessor('role', {header: 'Role', enableSorting: true}),
447
+ createColumnHelper<typeof data[0]>().accessor('port', {header: 'Port', enableSorting: true}),
448
+ createColumnHelper<typeof data[0]>().accessor('uptime', {header: 'Uptime', enableSorting: true}),
449
+ ]
450
+
451
+ const [sorting, setSorting] = useState<SortingState>([])
452
+
453
+ const table = useReactTable({
454
+ data,
455
+ columns: cols,
456
+ state: {sorting},
457
+ onSortingChange: setSorting,
458
+ getCoreRowModel: getCoreRowModel(),
459
+ getSortedRowModel: getSortedRowModel(),
460
+ })
461
+
462
+ return (
463
+ <Table.Root>
464
+ <Table.Header>
465
+ {table.getHeaderGroups().map((hg) => (
466
+ <Table.Row key={hg.id}>
467
+ {hg.headers.map((header) => (
468
+ <Table.HeaderCell key={header.id}>
469
+ <html.span
470
+ style={fullFeaturedStyles.headerContent}
471
+ onClick={header.column.getToggleSortingHandler()}
472
+ >
473
+ {flexRender(header.column.columnDef.header, header.getContext())}
474
+ <Table.SortIndicator column={header.column} />
475
+ </html.span>
476
+ </Table.HeaderCell>
477
+ ))}
478
+ </Table.Row>
479
+ ))}
480
+ </Table.Header>
481
+ <Table.Body>
482
+ {table.getRowModel().rows.map((row) => (
483
+ <Table.Row key={row.id}>
484
+ {row.getVisibleCells().map((cell) => (
485
+ <Table.Cell key={cell.id}>
486
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
487
+ </Table.Cell>
488
+ ))}
489
+ </Table.Row>
490
+ ))}
491
+ </Table.Body>
492
+ </Table.Root>
493
+ )
494
+ }
495
+
496
+ export const Sorting: Story = {
497
+ render: () => <SortingDemo />,
498
+ play: async ({canvas}) => {
499
+ await expect(canvas.getByRole('table')).toBeInTheDocument()
500
+ await expect(canvas.getAllByRole('columnheader').length).toBe(4)
501
+ },
502
+ }
503
+
504
+ // --- Pagination: demonstrates page navigation ---
505
+
506
+ function PaginationDemo() {
507
+ const data = generateUsers(35)
508
+
509
+ const cols = [
510
+ createColumnHelper<User>().accessor('name', {header: 'Name'}),
511
+ createColumnHelper<User>().accessor('role', {header: 'Role'}),
512
+ createColumnHelper<User>().accessor('email', {header: 'Email'}),
513
+ ]
514
+
515
+ const [pagination, setPagination] = useState({pageIndex: 0, pageSize: 10})
516
+
517
+ const table = useReactTable({
518
+ data,
519
+ columns: cols,
520
+ state: {pagination},
521
+ onPaginationChange: setPagination,
522
+ getCoreRowModel: getCoreRowModel(),
523
+ getPaginationRowModel: getPaginationRowModel(),
524
+ })
525
+
526
+ return (
527
+ <html.div style={fullFeaturedStyles.wrapper}>
528
+ <Table.Root>
529
+ <Table.Header>
530
+ {table.getHeaderGroups().map((hg) => (
531
+ <Table.Row key={hg.id}>
532
+ {hg.headers.map((header) => (
533
+ <Table.HeaderCell key={header.id}>
534
+ {flexRender(header.column.columnDef.header, header.getContext())}
535
+ </Table.HeaderCell>
536
+ ))}
537
+ </Table.Row>
538
+ ))}
539
+ </Table.Header>
540
+ <Table.Body>
541
+ {table.getRowModel().rows.map((row) => (
542
+ <Table.Row key={row.id}>
543
+ {row.getVisibleCells().map((cell) => (
544
+ <Table.Cell key={cell.id}>
545
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
546
+ </Table.Cell>
547
+ ))}
548
+ </Table.Row>
549
+ ))}
550
+ </Table.Body>
551
+ </Table.Root>
552
+ <Table.Pagination table={table} />
553
+ </html.div>
554
+ )
555
+ }
556
+
557
+ export const WithPagination: Story = {
558
+ render: () => <PaginationDemo />,
559
+ play: async ({canvas}) => {
560
+ await expect(canvas.getByText('1 / 4')).toBeInTheDocument()
561
+ await expect(canvas.getByText('Next')).toBeInTheDocument()
562
+ },
563
+ }
564
+
565
+ // --- Filtering: demonstrates column filter inputs ---
566
+
567
+ function FilteringDemo() {
568
+ const data = generateUsers(20)
569
+
570
+ const cols = [
571
+ createColumnHelper<User>().accessor('name', {header: 'Name', enableColumnFilter: true}),
572
+ createColumnHelper<User>().accessor('role', {header: 'Role', enableColumnFilter: false}),
573
+ createColumnHelper<User>().accessor('status', {header: 'Status', enableColumnFilter: false}),
574
+ createColumnHelper<User>().accessor('email', {header: 'Email', enableColumnFilter: true}),
575
+ ]
576
+
577
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
578
+
579
+ const table = useReactTable({
580
+ data,
581
+ columns: cols,
582
+ state: {columnFilters},
583
+ onColumnFiltersChange: setColumnFilters,
584
+ getCoreRowModel: getCoreRowModel(),
585
+ getFilteredRowModel: getFilteredRowModel(),
586
+ })
587
+
588
+ return (
589
+ <Table.Root>
590
+ <Table.Header>
591
+ {table.getHeaderGroups().map((hg) => (
592
+ <Table.Row key={hg.id}>
593
+ {hg.headers.map((header) => (
594
+ <Table.HeaderCell key={header.id}>
595
+ {flexRender(header.column.columnDef.header, header.getContext())}
596
+ {header.column.getCanFilter() && (
597
+ <Table.ColumnFilter
598
+ column={header.column}
599
+ placeholder={`Filter ${header.column.id}...`}
600
+ />
601
+ )}
602
+ </Table.HeaderCell>
603
+ ))}
604
+ </Table.Row>
605
+ ))}
606
+ </Table.Header>
607
+ <Table.Body>
608
+ {table.getRowModel().rows.map((row) => (
609
+ <Table.Row key={row.id}>
610
+ {row.getVisibleCells().map((cell) => (
611
+ <Table.Cell key={cell.id}>
612
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
613
+ </Table.Cell>
614
+ ))}
615
+ </Table.Row>
616
+ ))}
617
+ </Table.Body>
618
+ </Table.Root>
619
+ )
620
+ }
621
+
622
+ export const WithFiltering: Story = {
623
+ render: () => <FilteringDemo />,
624
+ play: async ({canvas}) => {
625
+ await expect(canvas.getByRole('table')).toBeInTheDocument()
626
+ await expect(canvas.getByPlaceholderText('Filter name...')).toBeInTheDocument()
627
+ await expect(canvas.getByPlaceholderText('Filter email...')).toBeInTheDocument()
628
+ },
629
+ }
630
+
631
+
632
+ // --- FullFeatured: useDataTable with pagination, sorting, and filtering ---
633
+
634
+ interface User {
635
+ name: string
636
+ role: string
637
+ status: string
638
+ email: string
639
+ }
640
+
641
+ const firstNames = [
642
+ 'Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank',
643
+ 'Iris', 'Jack', 'Karen', 'Leo', 'Mona', 'Nick', 'Olivia', 'Paul',
644
+ 'Quinn', 'Rita', 'Sam', 'Tina', 'Uma', 'Vince', 'Wendy', 'Xander',
645
+ 'Yara', 'Zach',
646
+ ]
647
+ const lastNames = [
648
+ 'Adams', 'Brown', 'Clark', 'Davis', 'Evans', 'Foster', 'Garcia',
649
+ 'Hill', 'Ito', 'Jones', 'Kim', 'Lee', 'Moore', 'Nash', 'Owen',
650
+ 'Patel', 'Quinn', 'Reed', 'Smith', 'Tran', 'Ueda', 'Voss', 'Wang',
651
+ 'Xu', 'Yang', 'Zhang',
652
+ ]
653
+ const roles = ['Admin', 'Editor', 'Viewer', 'Moderator', 'Analyst']
654
+ const statuses = ['Active', 'Inactive', 'Pending', 'Suspended']
655
+
656
+ function generateUsers(count: number): User[] {
657
+ return Array.from({length: count}, (_, i) => {
658
+ const first = firstNames[i % firstNames.length]
659
+ const last = lastNames[(i * 7) % lastNames.length]
660
+ return {
661
+ name: `${first} ${last}`,
662
+ role: roles[i % roles.length],
663
+ status: statuses[i % statuses.length],
664
+ email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
665
+ }
666
+ })
667
+ }
668
+
669
+ const fakeUsers = generateUsers(50)
670
+
671
+ const columnHelper = createColumnHelper<User>()
672
+
673
+ const fullFeaturedColumns = [
674
+ columnHelper.accessor('name', {
675
+ header: 'Name',
676
+ enableSorting: true,
677
+ enableColumnFilter: true,
678
+ }),
679
+ columnHelper.accessor('role', {
680
+ header: 'Role',
681
+ enableSorting: true,
682
+ enableColumnFilter: false,
683
+ }),
684
+ columnHelper.accessor('status', {
685
+ header: 'Status',
686
+ enableSorting: true,
687
+ enableColumnFilter: false,
688
+ }),
689
+ columnHelper.accessor('email', {
690
+ header: 'Email',
691
+ enableSorting: true,
692
+ enableColumnFilter: true,
693
+ }),
694
+ ]
695
+
696
+ const fullFeaturedStyles = css.create({
697
+ wrapper: {display: 'flex', flexDirection: 'column', gap: 0},
698
+ headerContent: {
699
+ display: 'flex',
700
+ flexDirection: 'column',
701
+ cursor: 'pointer',
702
+ userSelect: 'none',
703
+ },
704
+ })
705
+
706
+ function FullFeaturedDemo() {
707
+ const table = useDataTable(fakeUsers, fullFeaturedColumns, {
708
+ pagination: {pageSize: 10},
709
+ enableSorting: true,
710
+ enableFiltering: true,
711
+ })
712
+
713
+ return (
714
+ <html.div style={fullFeaturedStyles.wrapper}>
715
+ <Table.Root>
716
+ <Table.Header>
717
+ {table.getHeaderGroups().map((headerGroup) => (
718
+ <Table.Row key={headerGroup.id}>
719
+ {headerGroup.headers.map((header) => (
720
+ <Table.HeaderCell key={header.id}>
721
+ <html.div
722
+ style={fullFeaturedStyles.headerContent}
723
+ onClick={header.column.getToggleSortingHandler()}
724
+ >
725
+ {flexRender(header.column.columnDef.header, header.getContext())}
726
+ <Table.SortIndicator column={header.column} />
727
+ </html.div>
728
+ {(header.column.id === 'name' || header.column.id === 'email') && (
729
+ <Table.ColumnFilter
730
+ column={header.column}
731
+ placeholder={`Filter ${header.column.id}...`}
732
+ />
733
+ )}
734
+ </Table.HeaderCell>
735
+ ))}
736
+ </Table.Row>
737
+ ))}
738
+ </Table.Header>
739
+ <Table.Body>
740
+ {table.getRowModel().rows.map((row) => (
741
+ <Table.Row key={row.id}>
742
+ {row.getVisibleCells().map((cell) => (
743
+ <Table.Cell key={cell.id}>
744
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
745
+ </Table.Cell>
746
+ ))}
747
+ </Table.Row>
748
+ ))}
749
+ </Table.Body>
750
+ </Table.Root>
751
+ <Table.Pagination table={table} />
752
+ </html.div>
753
+ )
754
+ }
755
+
756
+ export const FullFeatured: Story = {
757
+ render: () => <FullFeaturedDemo />,
758
+ play: async ({canvas}) => {
759
+ await expect(canvas.getByRole('table')).toBeInTheDocument()
760
+
761
+ // Should render 10 body rows (pageSize) + 1 header row
762
+ const rows = canvas.getAllByRole('row')
763
+ await expect(rows.length).toBe(11)
764
+
765
+ // All 4 column headers present
766
+ const columnHeaders = canvas.getAllByRole('columnheader')
767
+ await expect(columnHeaders.length).toBe(4)
768
+
769
+ // Pagination is visible (50 rows / 10 per page = 5 pages)
770
+ await expect(canvas.getByText('1 / 5')).toBeInTheDocument()
771
+ await expect(canvas.getByText('Next')).toBeInTheDocument()
772
+ await expect(canvas.getByText('Previous')).toBeInTheDocument()
773
+ },
774
+ }
@@ -1,6 +1,9 @@
1
- import {type ReactNode, createContext, useContext, Children} from 'react'
1
+ import {type ReactNode, type MutableRefObject, createContext, useContext, useRef, Children, isValidElement} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
+ import {Pagination} from './Pagination'
5
+ import {SortIndicator} from './SortIndicator'
6
+ import {ColumnFilter} from './ColumnFilter'
4
7
 
5
8
  // --- Types ---
6
9
 
@@ -12,8 +15,8 @@ export type TableSize = 'sm' | 'md'
12
15
  interface TableContextValue {
13
16
  variant: TableVariant
14
17
  size: TableSize
15
- columns: number
16
- isHeader: boolean
18
+ /** Mutable ref: header Row writes inferred template, Root reads it */
19
+ inferredTemplateRef: MutableRefObject<string | null>
17
20
  }
18
21
 
19
22
  const TableContext = createContext<TableContextValue | null>(null)
@@ -24,23 +27,65 @@ function useTable() {
24
27
  return ctx
25
28
  }
26
29
 
27
- // --- HeaderContext (to distinguish header vs body rowgroup) ---
30
+ // --- HeaderContext ---
28
31
 
29
32
  const HeaderContext = createContext(false)
30
33
 
34
+ // --- Template context: set after first render pass ---
35
+
36
+ const TemplateContext = createContext<string | null>(null)
37
+
31
38
  // --- Root ---
32
39
 
33
40
  interface RootProps {
34
41
  children: ReactNode
35
42
  variant?: TableVariant
36
43
  size?: TableSize
37
- columns: number
38
44
  }
39
45
 
40
- function Root({children, variant = 'default', size = 'md', columns}: RootProps) {
46
+ /**
47
+ * Scans the React tree for HeaderCell elements to extract width props.
48
+ * Returns a grid-template-columns string.
49
+ */
50
+ function extractTemplate(children: ReactNode): string {
51
+ const widths: string[] = []
52
+
53
+ function walk(node: ReactNode) {
54
+ Children.forEach(node, (child) => {
55
+ if (!isValidElement(child)) return
56
+ const props = child.props as Record<string, any>
57
+ // Check if this looks like a HeaderCell (has width prop or is inside Header)
58
+ const displayName = (child.type as any)?.name || (child.type as any)?.displayName || ''
59
+ if (displayName === 'HeaderCell' || child.type === HeaderCell) {
60
+ widths.push(props.width || '1fr')
61
+ } else if (props.children) {
62
+ walk(props.children)
63
+ }
64
+ })
65
+ }
66
+
67
+ walk(children)
68
+ return widths.length > 0 ? widths.join(' ') : ''
69
+ }
70
+
71
+ function Root({children, variant = 'default', size = 'md'}: RootProps) {
72
+ const inferredTemplateRef = useRef<string | null>(null)
73
+
74
+ // Extract template from HeaderCell children synchronously
75
+ const template = extractTemplate(children)
76
+ if (template) {
77
+ inferredTemplateRef.current = template
78
+ }
79
+
41
80
  return (
42
- <TableContext.Provider value={{variant, size, columns, isHeader: false}}>
43
- <html.div role="table" style={styles.root}>
81
+ <TableContext.Provider value={{variant, size, inferredTemplateRef}}>
82
+ <html.div
83
+ role="table"
84
+ style={[
85
+ styles.root,
86
+ template ? styles.gridColumns(template) : undefined,
87
+ ]}
88
+ >
44
89
  {children}
45
90
  </html.div>
46
91
  </TableContext.Provider>
@@ -67,7 +112,7 @@ function Body({children}: {children: ReactNode}) {
67
112
 
68
113
  return (
69
114
  <HeaderContext.Provider value={false}>
70
- <html.div role="rowgroup">
115
+ <html.div role="rowgroup" style={styles.body}>
71
116
  {childArray.map((child, index) => {
72
117
  if (variant === 'striped') {
73
118
  return (
@@ -83,13 +128,12 @@ function Body({children}: {children: ReactNode}) {
83
128
  )
84
129
  }
85
130
 
86
- // Row index context for striped variant
87
131
  const RowIndexContext = createContext<number>(-1)
88
132
 
89
133
  // --- Row ---
90
134
 
91
135
  function Row({children}: {children: ReactNode}) {
92
- const {variant, columns} = useTable()
136
+ const {variant} = useTable()
93
137
  const isHeader = useContext(HeaderContext)
94
138
  const rowIndex = useContext(RowIndexContext)
95
139
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
@@ -100,7 +144,6 @@ function Row({children}: {children: ReactNode}) {
100
144
  role="row"
101
145
  style={[
102
146
  styles.row,
103
- styles.gridColumns(columns),
104
147
  !isHeader && styles.bodyRow,
105
148
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
106
149
  ]}
@@ -116,19 +159,18 @@ function Row({children}: {children: ReactNode}) {
116
159
  )
117
160
  }
118
161
 
119
- // Cell index context for bordered variant
120
- const CellIndexContext = createContext<{index: number; total: number}>({
121
- index: 0,
122
- total: 0,
123
- })
162
+ const CellIndexContext = createContext<{index: number; total: number}>({index: 0, total: 0})
124
163
 
125
164
  // --- HeaderCell ---
126
165
 
127
166
  function HeaderCell({
128
167
  children,
168
+ width: _width,
129
169
  'aria-label': ariaLabel,
130
170
  }: {
131
171
  children?: ReactNode
172
+ /** Column width: CSS value like '40px', '2fr', 'max-content'. Defaults to '1fr'. */
173
+ width?: string
132
174
  'aria-label'?: string
133
175
  }) {
134
176
  const {size, variant} = useTable()
@@ -182,4 +224,7 @@ export const Table = {
182
224
  Row,
183
225
  HeaderCell,
184
226
  Cell,
227
+ Pagination,
228
+ SortIndicator,
229
+ ColumnFilter,
185
230
  }
@@ -4,8 +4,9 @@ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
 
6
6
  export const styles = css.create({
7
- // Root
7
+ // Root — the single grid container for the entire table
8
8
  root: {
9
+ display: 'grid',
9
10
  borderWidth: 1,
10
11
  borderStyle: 'solid',
11
12
  borderColor: colors.border,
@@ -18,19 +19,28 @@ export const styles = css.create({
18
19
 
19
20
  // Header group
20
21
  header: {
22
+ display: 'grid',
23
+ gridColumn: '1 / -1',
24
+ gridTemplateColumns: 'subgrid',
21
25
  backgroundColor: colors.bgCard,
22
26
  },
23
27
 
24
- // Row
28
+ // Body group
29
+ body: {
30
+ display: 'grid',
31
+ gridColumn: '1 / -1',
32
+ gridTemplateColumns: 'subgrid',
33
+ },
34
+
35
+ // Row — spans all columns, uses subgrid to share parent tracks
25
36
  row: {
26
37
  display: 'grid',
38
+ gridColumn: '1 / -1',
39
+ gridTemplateColumns: 'subgrid',
27
40
  borderBottomWidth: 1,
28
41
  borderBottomStyle: 'solid',
29
42
  borderBottomColor: colors.border,
30
43
  },
31
- rowLastChild: {
32
- borderBottomWidth: 0,
33
- },
34
44
  bodyRow: {
35
45
  backgroundColor: {
36
46
  default: 'transparent',
@@ -90,8 +100,8 @@ export const styles = css.create({
90
100
  borderRightWidth: 0,
91
101
  },
92
102
 
93
- // Dynamic styles — simple identifier params only (StyleX constraint)
94
- gridColumns: (columns: number) => ({
95
- gridTemplateColumns: `repeat(${columns}, 1fr)`,
103
+ // Dynamic: grid columns applied on Root
104
+ gridColumns: (template: string) => ({
105
+ gridTemplateColumns: template,
96
106
  }),
97
107
  })