@duro-app/ui 0.23.0 → 0.25.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 (100) hide show
  1. package/dist/components/ButtonGroup/ButtonGroup.d.ts +14 -0
  2. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
  3. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts +3 -0
  4. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -0
  5. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +11 -0
  6. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -0
  7. package/dist/components/ButtonGroup/styles.css.d.ts +45 -0
  8. package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -0
  9. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
  10. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -1
  11. package/dist/components/Combobox/Combobox.d.ts +44 -0
  12. package/dist/components/Combobox/Combobox.d.ts.map +1 -0
  13. package/dist/components/Combobox/Combobox.meta.d.ts +3 -0
  14. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -0
  15. package/dist/components/Combobox/Combobox.stories.d.ts +9 -0
  16. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -0
  17. package/dist/components/Combobox/ComboboxContext.d.ts +19 -0
  18. package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -0
  19. package/dist/components/Combobox/styles.css.d.ts +112 -0
  20. package/dist/components/Combobox/styles.css.d.ts.map +1 -0
  21. package/dist/components/Combobox/useComboboxRoot.d.ts +15 -0
  22. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -0
  23. package/dist/components/Field/Field.d.ts +4 -0
  24. package/dist/components/Field/Field.d.ts.map +1 -1
  25. package/dist/components/Field/Field.stories.d.ts +3 -0
  26. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  27. package/dist/components/Field/FieldContext.d.ts +5 -0
  28. package/dist/components/Field/FieldContext.d.ts.map +1 -1
  29. package/dist/components/Field/styles.css.d.ts +26 -0
  30. package/dist/components/Field/styles.css.d.ts.map +1 -1
  31. package/dist/components/Form/Form.d.ts +7 -2
  32. package/dist/components/Form/Form.d.ts.map +1 -1
  33. package/dist/components/Form/Form.stories.d.ts +6 -0
  34. package/dist/components/Form/Form.stories.d.ts.map +1 -1
  35. package/dist/components/Form/FormContext.d.ts +9 -2
  36. package/dist/components/Form/FormContext.d.ts.map +1 -1
  37. package/dist/components/Input/Input.d.ts.map +1 -1
  38. package/dist/components/List/List.d.ts.map +1 -1
  39. package/dist/components/List/List.meta.d.ts.map +1 -1
  40. package/dist/components/List/List.stories.d.ts.map +1 -1
  41. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  42. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -1
  43. package/dist/components/Table/ColumnFilter.d.ts +8 -0
  44. package/dist/components/Table/ColumnFilter.d.ts.map +1 -0
  45. package/dist/components/Table/Pagination.d.ts +7 -0
  46. package/dist/components/Table/Pagination.d.ts.map +1 -0
  47. package/dist/components/Table/SortIndicator.d.ts +16 -0
  48. package/dist/components/Table/SortIndicator.d.ts.map +1 -0
  49. package/dist/components/Table/Table.d.ts +7 -1
  50. package/dist/components/Table/Table.d.ts.map +1 -1
  51. package/dist/components/Table/Table.stories.d.ts +4 -0
  52. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  53. package/dist/components/Table/styles.css.d.ts +11 -3
  54. package/dist/components/Table/styles.css.d.ts.map +1 -1
  55. package/dist/components/Table/useDataTable.d.ts +32 -0
  56. package/dist/components/Table/useDataTable.d.ts.map +1 -0
  57. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  58. package/dist/index.css +1 -1
  59. package/dist/index.d.ts +3 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +2863 -2026
  62. package/dist/index.js.map +1 -1
  63. package/package.json +3 -2
  64. package/src/components/ActionBar/ActionBar.stories.tsx +3 -3
  65. package/src/components/ButtonGroup/ButtonGroup.meta.ts +32 -0
  66. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +103 -0
  67. package/src/components/ButtonGroup/ButtonGroup.tsx +59 -0
  68. package/src/components/ButtonGroup/styles.css.ts +28 -0
  69. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
  70. package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
  71. package/src/components/Combobox/Combobox.meta.ts +35 -0
  72. package/src/components/Combobox/Combobox.stories.tsx +119 -0
  73. package/src/components/Combobox/Combobox.tsx +247 -0
  74. package/src/components/Combobox/ComboboxContext.ts +26 -0
  75. package/src/components/Combobox/styles.css.ts +119 -0
  76. package/src/components/Combobox/useComboboxRoot.ts +235 -0
  77. package/src/components/Field/Field.stories.tsx +60 -0
  78. package/src/components/Field/Field.tsx +97 -9
  79. package/src/components/Field/FieldContext.ts +5 -0
  80. package/src/components/Field/styles.css.ts +26 -0
  81. package/src/components/Form/Form.stories.tsx +275 -0
  82. package/src/components/Form/Form.tsx +73 -7
  83. package/src/components/Form/FormContext.ts +10 -1
  84. package/src/components/Input/Input.tsx +2 -1
  85. package/src/components/List/List.meta.ts +8 -2
  86. package/src/components/List/List.stories.tsx +35 -15
  87. package/src/components/List/List.tsx +1 -5
  88. package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
  89. package/src/components/RadioGroup/RadioGroup.tsx +1 -4
  90. package/src/components/Table/ColumnFilter.tsx +46 -0
  91. package/src/components/Table/Pagination.tsx +82 -0
  92. package/src/components/Table/SortIndicator.tsx +53 -0
  93. package/src/components/Table/Table.stories.tsx +397 -0
  94. package/src/components/Table/Table.tsx +58 -24
  95. package/src/components/Table/styles.css.ts +16 -6
  96. package/src/components/Table/useDataTable.ts +99 -0
  97. package/src/components/Textarea/Textarea.tsx +2 -1
  98. package/src/docs/helpers.tsx +2 -2
  99. package/src/index.ts +5 -0
  100. package/src/strict.css +5 -0
@@ -9,6 +9,11 @@ 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, type PaginationState} from '@tanstack/react-table'
13
+ import {useDataTable} from './useDataTable'
14
+ import {Combobox} from '../Combobox/Combobox'
15
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
16
+
12
17
  const meta: Meta = {
13
18
  title: 'Components/Table',
14
19
  argTypes: {
@@ -426,3 +431,395 @@ export const AllVariants: Story = {
426
431
  await expect(tables.length).toBe(4)
427
432
  },
428
433
  }
434
+
435
+ // --- Sorting: demonstrates click-to-sort with hover indicators ---
436
+
437
+ type Service = {name: string; role: string; port: number; uptime: string}
438
+
439
+ const sortingData: Service[] = [
440
+ {name: 'Traefik', role: 'Reverse Proxy', port: 443, uptime: '14d 3h'},
441
+ {name: 'Pi-hole', role: 'DNS Filter', port: 80, uptime: '14d 3h'},
442
+ {name: 'Grafana', role: 'Monitoring', port: 3000, uptime: '7d 12h'},
443
+ {name: 'Portainer', role: 'Container Mgmt', port: 9000, uptime: '2d 5h'},
444
+ {name: 'Nginx', role: 'Web Server', port: 8080, uptime: '30d 1h'},
445
+ ]
446
+
447
+ const sortingCols = [
448
+ createColumnHelper<Service>().accessor('name', {header: 'Name', enableSorting: true}),
449
+ createColumnHelper<Service>().accessor('role', {header: 'Role', enableSorting: true}),
450
+ createColumnHelper<Service>().accessor('port', {header: 'Port', enableSorting: true}),
451
+ createColumnHelper<Service>().accessor('uptime', {header: 'Uptime', enableSorting: true}),
452
+ ]
453
+
454
+ function SortingDemo() {
455
+ const [sorting, setSorting] = useState<SortingState>([])
456
+
457
+ const table = useReactTable({
458
+ data: sortingData,
459
+ columns: sortingCols,
460
+ state: {sorting},
461
+ onSortingChange: setSorting,
462
+ getCoreRowModel: getCoreRowModel(),
463
+ getSortedRowModel: getSortedRowModel(),
464
+ })
465
+
466
+ return (
467
+ <Table.Root>
468
+ <Table.Header>
469
+ {table.getHeaderGroups().map((hg) => (
470
+ <Table.Row key={hg.id}>
471
+ {hg.headers.map((header) => (
472
+ <Table.HeaderCell key={header.id}>
473
+ <html.span
474
+ style={fullFeaturedStyles.headerContent}
475
+ onClick={header.column.getToggleSortingHandler()}
476
+ >
477
+ {flexRender(header.column.columnDef.header, header.getContext())}
478
+ <Table.SortIndicator column={header.column} />
479
+ </html.span>
480
+ </Table.HeaderCell>
481
+ ))}
482
+ </Table.Row>
483
+ ))}
484
+ </Table.Header>
485
+ <Table.Body>
486
+ {table.getRowModel().rows.map((row) => (
487
+ <Table.Row key={row.id}>
488
+ {row.getVisibleCells().map((cell) => (
489
+ <Table.Cell key={cell.id}>
490
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
491
+ </Table.Cell>
492
+ ))}
493
+ </Table.Row>
494
+ ))}
495
+ </Table.Body>
496
+ </Table.Root>
497
+ )
498
+ }
499
+
500
+ export const Sorting: Story = {
501
+ render: () => <SortingDemo />,
502
+ play: async ({canvas}) => {
503
+ await expect(canvas.getByRole('table')).toBeInTheDocument()
504
+ await expect(canvas.getAllByRole('columnheader').length).toBe(4)
505
+ },
506
+ }
507
+
508
+ // --- Shared data helpers for interactive demos ---
509
+
510
+ interface User {
511
+ name: string
512
+ role: string
513
+ status: string
514
+ email: string
515
+ }
516
+
517
+ const firstNames = [
518
+ 'Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank',
519
+ 'Iris', 'Jack', 'Karen', 'Leo', 'Mona', 'Nick', 'Olivia', 'Paul',
520
+ 'Quinn', 'Rita', 'Sam', 'Tina', 'Uma', 'Vince', 'Wendy', 'Xander',
521
+ 'Yara', 'Zach',
522
+ ]
523
+ const lastNames = [
524
+ 'Adams', 'Brown', 'Clark', 'Davis', 'Evans', 'Foster', 'Garcia',
525
+ 'Hill', 'Ito', 'Jones', 'Kim', 'Lee', 'Moore', 'Nash', 'Owen',
526
+ 'Patel', 'Quinn', 'Reed', 'Smith', 'Tran', 'Ueda', 'Voss', 'Wang',
527
+ 'Xu', 'Yang', 'Zhang',
528
+ ]
529
+ const roles = ['Admin', 'Editor', 'Viewer', 'Moderator', 'Analyst']
530
+ const statuses = ['Active', 'Inactive', 'Pending', 'Suspended']
531
+
532
+ function generateUsers(count: number): User[] {
533
+ return Array.from({length: count}, (_, i) => {
534
+ const first = firstNames[i % firstNames.length]
535
+ const last = lastNames[(i * 7) % lastNames.length]
536
+ return {
537
+ name: `${first} ${last}`,
538
+ role: roles[i % roles.length],
539
+ status: statuses[i % statuses.length],
540
+ email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
541
+ }
542
+ })
543
+ }
544
+
545
+ // --- Pagination: demonstrates page navigation ---
546
+
547
+ const paginationData = generateUsers(35)
548
+
549
+ const paginationCols = [
550
+ createColumnHelper<User>().accessor('name', {header: 'Name'}),
551
+ createColumnHelper<User>().accessor('role', {header: 'Role'}),
552
+ createColumnHelper<User>().accessor('email', {header: 'Email'}),
553
+ ]
554
+
555
+ function PaginationDemo() {
556
+ const [pagination, setPagination] = useState({pageIndex: 0, pageSize: 10})
557
+
558
+ const table = useReactTable({
559
+ data: paginationData,
560
+ columns: paginationCols,
561
+ state: {pagination},
562
+ onPaginationChange: setPagination,
563
+ getCoreRowModel: getCoreRowModel(),
564
+ getPaginationRowModel: getPaginationRowModel(),
565
+ })
566
+
567
+ return (
568
+ <html.div style={fullFeaturedStyles.wrapper}>
569
+ <Table.Root>
570
+ <Table.Header>
571
+ {table.getHeaderGroups().map((hg) => (
572
+ <Table.Row key={hg.id}>
573
+ {hg.headers.map((header) => (
574
+ <Table.HeaderCell key={header.id}>
575
+ {flexRender(header.column.columnDef.header, header.getContext())}
576
+ </Table.HeaderCell>
577
+ ))}
578
+ </Table.Row>
579
+ ))}
580
+ </Table.Header>
581
+ <Table.Body>
582
+ {table.getRowModel().rows.map((row) => (
583
+ <Table.Row key={row.id}>
584
+ {row.getVisibleCells().map((cell) => (
585
+ <Table.Cell key={cell.id}>
586
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
587
+ </Table.Cell>
588
+ ))}
589
+ </Table.Row>
590
+ ))}
591
+ </Table.Body>
592
+ </Table.Root>
593
+ <Table.Pagination table={table} />
594
+ </html.div>
595
+ )
596
+ }
597
+
598
+ export const WithPagination: Story = {
599
+ render: () => <PaginationDemo />,
600
+ play: async ({canvas}) => {
601
+ await expect(canvas.getByText('1 / 4')).toBeInTheDocument()
602
+ await expect(canvas.getByText('Next')).toBeInTheDocument()
603
+ },
604
+ }
605
+
606
+ // --- Filtering: demonstrates column filter inputs ---
607
+
608
+ const filteringData = generateUsers(20)
609
+
610
+ const filteringCols = [
611
+ createColumnHelper<User>().accessor('name', {header: 'Name', enableColumnFilter: true}),
612
+ createColumnHelper<User>().accessor('role', {header: 'Role', enableColumnFilter: false}),
613
+ createColumnHelper<User>().accessor('status', {header: 'Status', enableColumnFilter: false}),
614
+ createColumnHelper<User>().accessor('email', {header: 'Email', enableColumnFilter: true}),
615
+ ]
616
+
617
+ function FilteringDemo() {
618
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
619
+
620
+ const table = useReactTable({
621
+ data: filteringData,
622
+ columns: filteringCols,
623
+ state: {columnFilters},
624
+ onColumnFiltersChange: setColumnFilters,
625
+ getCoreRowModel: getCoreRowModel(),
626
+ getFilteredRowModel: getFilteredRowModel(),
627
+ })
628
+
629
+ return (
630
+ <Table.Root>
631
+ <Table.Header>
632
+ {table.getHeaderGroups().map((hg) => (
633
+ <Table.Row key={hg.id}>
634
+ {hg.headers.map((header) => (
635
+ <Table.HeaderCell key={header.id}>
636
+ {flexRender(header.column.columnDef.header, header.getContext())}
637
+ {header.column.getCanFilter() && (
638
+ <Table.ColumnFilter
639
+ column={header.column}
640
+ placeholder={`Filter ${header.column.id}...`}
641
+ />
642
+ )}
643
+ </Table.HeaderCell>
644
+ ))}
645
+ </Table.Row>
646
+ ))}
647
+ </Table.Header>
648
+ <Table.Body>
649
+ {table.getRowModel().rows.map((row) => (
650
+ <Table.Row key={row.id}>
651
+ {row.getVisibleCells().map((cell) => (
652
+ <Table.Cell key={cell.id}>
653
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
654
+ </Table.Cell>
655
+ ))}
656
+ </Table.Row>
657
+ ))}
658
+ </Table.Body>
659
+ </Table.Root>
660
+ )
661
+ }
662
+
663
+ export const WithFiltering: Story = {
664
+ render: () => <FilteringDemo />,
665
+ play: async ({canvas}) => {
666
+ await expect(canvas.getByRole('table')).toBeInTheDocument()
667
+ await expect(canvas.getByPlaceholderText('Filter name...')).toBeInTheDocument()
668
+ await expect(canvas.getByPlaceholderText('Filter email...')).toBeInTheDocument()
669
+ },
670
+ }
671
+
672
+ // --- FullFeatured: useDataTable with pagination, sorting, and filtering ---
673
+
674
+ const fakeUsers = generateUsers(50)
675
+
676
+ const columnHelper = createColumnHelper<User>()
677
+
678
+ const fullFeaturedColumns = [
679
+ columnHelper.accessor('name', {
680
+ header: 'Name',
681
+ enableSorting: true,
682
+ enableColumnFilter: false,
683
+ }),
684
+ columnHelper.accessor('role', {
685
+ header: 'Role',
686
+ enableSorting: true,
687
+ enableColumnFilter: true,
688
+ }),
689
+ columnHelper.accessor('status', {
690
+ header: 'Status',
691
+ enableSorting: true,
692
+ enableColumnFilter: true,
693
+ }),
694
+ columnHelper.accessor('email', {
695
+ header: 'Email',
696
+ enableSorting: true,
697
+ enableColumnFilter: false,
698
+ }),
699
+ ]
700
+
701
+ const fullFeaturedStyles = css.create({
702
+ wrapper: {display: 'flex', flexDirection: 'column', gap: 0},
703
+ filterBar: {
704
+ display: 'flex',
705
+ gap: spacing.md,
706
+ paddingBottom: spacing.md,
707
+ },
708
+ headerContent: {
709
+ display: 'flex',
710
+ flexDirection: 'row',
711
+ alignItems: 'center',
712
+ justifyContent: 'space-between',
713
+ cursor: 'pointer',
714
+ userSelect: 'none',
715
+ },
716
+ })
717
+
718
+ const uniqueRoles = [...new Set(fakeUsers.map((u) => u.role))].sort()
719
+ const uniqueStatuses = [...new Set(fakeUsers.map((u) => u.status))].sort()
720
+
721
+ function FullFeaturedDemo() {
722
+ const table = useDataTable(fakeUsers, fullFeaturedColumns, {
723
+ pagination: {pageSize: 10},
724
+ enableSorting: true,
725
+ enableFiltering: true,
726
+ })
727
+
728
+ return (
729
+ <html.div style={fullFeaturedStyles.wrapper}>
730
+ <html.div style={fullFeaturedStyles.filterBar}>
731
+ <Combobox.Root
732
+ value={table.getColumn('role')?.getFilterValue() as string ?? ''}
733
+ onValueChange={(v) => table.getColumn('role')?.setFilterValue(v || undefined)}
734
+ onInputChange={(v) => table.getColumn('role')?.setFilterValue(v || undefined)}
735
+ >
736
+ <Combobox.Input placeholder="Filter role...">
737
+ <Combobox.Trigger />
738
+ </Combobox.Input>
739
+ <Combobox.Popup>
740
+ <Combobox.Item value="">
741
+ <Combobox.ItemText>All Roles</Combobox.ItemText>
742
+ </Combobox.Item>
743
+ {uniqueRoles.map((role) => (
744
+ <Combobox.Item key={role} value={role}>
745
+ <Combobox.ItemText>{role}</Combobox.ItemText>
746
+ </Combobox.Item>
747
+ ))}
748
+ <Combobox.Empty>No roles found</Combobox.Empty>
749
+ </Combobox.Popup>
750
+ </Combobox.Root>
751
+ <Combobox.Root
752
+ value={table.getColumn('status')?.getFilterValue() as string ?? ''}
753
+ onValueChange={(v) => table.getColumn('status')?.setFilterValue(v || undefined)}
754
+ onInputChange={(v) => table.getColumn('status')?.setFilterValue(v || undefined)}
755
+ >
756
+ <Combobox.Input placeholder="Filter status...">
757
+ <Combobox.Trigger />
758
+ </Combobox.Input>
759
+ <Combobox.Popup>
760
+ <Combobox.Item value="">
761
+ <Combobox.ItemText>All Statuses</Combobox.ItemText>
762
+ </Combobox.Item>
763
+ {uniqueStatuses.map((status) => (
764
+ <Combobox.Item key={status} value={status}>
765
+ <Combobox.ItemText>{status}</Combobox.ItemText>
766
+ </Combobox.Item>
767
+ ))}
768
+ <Combobox.Empty>No statuses found</Combobox.Empty>
769
+ </Combobox.Popup>
770
+ </Combobox.Root>
771
+ </html.div>
772
+ <Table.Root>
773
+ <Table.Header>
774
+ {table.getHeaderGroups().map((headerGroup) => (
775
+ <Table.Row key={headerGroup.id}>
776
+ {headerGroup.headers.map((header) => (
777
+ <Table.HeaderCell key={header.id}>
778
+ <html.div
779
+ style={fullFeaturedStyles.headerContent}
780
+ onClick={header.column.getToggleSortingHandler()}
781
+ >
782
+ {flexRender(header.column.columnDef.header, header.getContext())}
783
+ <Table.SortIndicator column={header.column} />
784
+ </html.div>
785
+ </Table.HeaderCell>
786
+ ))}
787
+ </Table.Row>
788
+ ))}
789
+ </Table.Header>
790
+ <Table.Body>
791
+ {table.getRowModel().rows.map((row) => (
792
+ <Table.Row key={row.id}>
793
+ {row.getVisibleCells().map((cell) => (
794
+ <Table.Cell key={cell.id}>
795
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
796
+ </Table.Cell>
797
+ ))}
798
+ </Table.Row>
799
+ ))}
800
+ </Table.Body>
801
+ </Table.Root>
802
+ <Table.Pagination table={table} />
803
+ </html.div>
804
+ )
805
+ }
806
+
807
+ export const FullFeatured: Story = {
808
+ render: () => <FullFeaturedDemo />,
809
+ play: async ({canvas}) => {
810
+ await expect(canvas.getByRole('table')).toBeInTheDocument()
811
+
812
+ // Should render 10 body rows (pageSize) + 1 header row
813
+ const rows = canvas.getAllByRole('row')
814
+ await expect(rows.length).toBe(11)
815
+
816
+ // All 4 column headers present
817
+ const columnHeaders = canvas.getAllByRole('columnheader')
818
+ await expect(columnHeaders.length).toBe(4)
819
+
820
+ // Pagination is visible (50 rows / 10 per page = 5 pages)
821
+ await expect(canvas.getByText('1 / 5')).toBeInTheDocument()
822
+ await expect(canvas.getByText('Next')).toBeInTheDocument()
823
+ await expect(canvas.getByText('Previous')).toBeInTheDocument()
824
+ },
825
+ }
@@ -1,6 +1,17 @@
1
- import {type ReactNode, type MutableRefObject, createContext, useContext, useRef, Children, isValidElement} from 'react'
1
+ import {
2
+ type ReactNode,
3
+ type MutableRefObject,
4
+ createContext,
5
+ useContext,
6
+ useRef,
7
+ Children,
8
+ isValidElement,
9
+ } from 'react'
2
10
  import {html} from 'react-strict-dom'
3
11
  import {styles} from './styles.css'
12
+ import {Pagination} from './Pagination'
13
+ import {SortIndicator} from './SortIndicator'
14
+ import {ColumnFilter} from './ColumnFilter'
4
15
 
5
16
  // --- Types ---
6
17
 
@@ -12,7 +23,7 @@ export type TableSize = 'sm' | 'md'
12
23
  interface TableContextValue {
13
24
  variant: TableVariant
14
25
  size: TableSize
15
- /** Mutable ref: header Row writes inferred template, body Rows read it */
26
+ /** Mutable ref: header Row writes inferred template, Root reads it */
16
27
  inferredTemplateRef: MutableRefObject<string | null>
17
28
  }
18
29
 
@@ -28,6 +39,10 @@ function useTable() {
28
39
 
29
40
  const HeaderContext = createContext(false)
30
41
 
42
+ // --- Template context: set after first render pass ---
43
+
44
+ const TemplateContext = createContext<string | null>(null)
45
+
31
46
  // --- Root ---
32
47
 
33
48
  interface RootProps {
@@ -36,12 +51,46 @@ interface RootProps {
36
51
  size?: TableSize
37
52
  }
38
53
 
54
+ /**
55
+ * Scans the React tree for HeaderCell elements to extract width props.
56
+ * Returns a grid-template-columns string.
57
+ */
58
+ function extractTemplate(children: ReactNode): string {
59
+ const widths: string[] = []
60
+
61
+ function walk(node: ReactNode) {
62
+ Children.forEach(node, (child) => {
63
+ if (!isValidElement(child)) return
64
+ const props = child.props as Record<string, any>
65
+ // Check if this looks like a HeaderCell (has width prop or is inside Header)
66
+ const displayName = (child.type as any)?.name || (child.type as any)?.displayName || ''
67
+ if (displayName === 'HeaderCell' || child.type === HeaderCell) {
68
+ widths.push(props.width || '1fr')
69
+ } else if (props.children) {
70
+ walk(props.children)
71
+ }
72
+ })
73
+ }
74
+
75
+ walk(children)
76
+ return widths.length > 0 ? widths.join(' ') : ''
77
+ }
78
+
39
79
  function Root({children, variant = 'default', size = 'md'}: RootProps) {
40
80
  const inferredTemplateRef = useRef<string | null>(null)
41
81
 
82
+ // Extract template from HeaderCell children synchronously
83
+ const template = extractTemplate(children)
84
+ if (template) {
85
+ inferredTemplateRef.current = template
86
+ }
87
+
42
88
  return (
43
89
  <TableContext.Provider value={{variant, size, inferredTemplateRef}}>
44
- <html.div role="table" style={styles.root}>
90
+ <html.div
91
+ role="table"
92
+ style={[styles.root, template ? styles.gridColumns(template) : undefined]}
93
+ >
45
94
  {children}
46
95
  </html.div>
47
96
  </TableContext.Provider>
@@ -68,7 +117,7 @@ function Body({children}: {children: ReactNode}) {
68
117
 
69
118
  return (
70
119
  <HeaderContext.Provider value={false}>
71
- <html.div role="rowgroup">
120
+ <html.div role="rowgroup" style={styles.body}>
72
121
  {childArray.map((child, index) => {
73
122
  if (variant === 'striped') {
74
123
  return (
@@ -89,35 +138,17 @@ const RowIndexContext = createContext<number>(-1)
89
138
  // --- Row ---
90
139
 
91
140
  function Row({children}: {children: ReactNode}) {
92
- const {variant, inferredTemplateRef} = useTable()
141
+ const {variant} = useTable()
93
142
  const isHeader = useContext(HeaderContext)
94
143
  const rowIndex = useContext(RowIndexContext)
95
144
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
96
145
  const childArray = Children.toArray(children)
97
146
 
98
- let template: string
99
-
100
- if (isHeader) {
101
- // Build template from HeaderCell width props
102
- const widths: string[] = []
103
- Children.forEach(children, (child) => {
104
- if (isValidElement(child)) {
105
- const props = child.props as {width?: string}
106
- widths.push(props.width || '1fr')
107
- }
108
- })
109
- template = widths.join(' ')
110
- inferredTemplateRef.current = template
111
- } else {
112
- template = inferredTemplateRef.current ?? `repeat(${childArray.length}, 1fr)`
113
- }
114
-
115
147
  return (
116
148
  <html.div
117
149
  role="row"
118
150
  style={[
119
151
  styles.row,
120
- styles.gridColumns(template),
121
152
  !isHeader && styles.bodyRow,
122
153
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
123
154
  ]}
@@ -143,7 +174,7 @@ function HeaderCell({
143
174
  'aria-label': ariaLabel,
144
175
  }: {
145
176
  children?: ReactNode
146
- /** Column width: CSS value like '40px', '2fr', 'auto'. Defaults to '1fr'. */
177
+ /** Column width: CSS value like '40px', '2fr', 'max-content'. Defaults to '1fr'. */
147
178
  width?: string
148
179
  'aria-label'?: string
149
180
  }) {
@@ -198,4 +229,7 @@ export const Table = {
198
229
  Row,
199
230
  HeaderCell,
200
231
  Cell,
232
+ Pagination,
233
+ SortIndicator,
234
+ ColumnFilter,
201
235
  }
@@ -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,7 +100,7 @@ export const styles = css.create({
90
100
  borderRightWidth: 0,
91
101
  },
92
102
 
93
- // Dynamic styles — simple identifier params only (StyleX constraint)
103
+ // Dynamic: grid columns applied on Root
94
104
  gridColumns: (template: string) => ({
95
105
  gridTemplateColumns: template,
96
106
  }),