@duro-app/ui 0.23.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 (49) 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/List/List.d.ts.map +1 -1
  10. package/dist/components/List/List.meta.d.ts.map +1 -1
  11. package/dist/components/List/List.stories.d.ts.map +1 -1
  12. package/dist/components/Table/ColumnFilter.d.ts +8 -0
  13. package/dist/components/Table/ColumnFilter.d.ts.map +1 -0
  14. package/dist/components/Table/Pagination.d.ts +7 -0
  15. package/dist/components/Table/Pagination.d.ts.map +1 -0
  16. package/dist/components/Table/SortIndicator.d.ts +16 -0
  17. package/dist/components/Table/SortIndicator.d.ts.map +1 -0
  18. package/dist/components/Table/Table.d.ts +7 -1
  19. package/dist/components/Table/Table.d.ts.map +1 -1
  20. package/dist/components/Table/Table.stories.d.ts +4 -0
  21. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  22. package/dist/components/Table/styles.css.d.ts +11 -3
  23. package/dist/components/Table/styles.css.d.ts.map +1 -1
  24. package/dist/components/Table/useDataTable.d.ts +32 -0
  25. package/dist/components/Table/useDataTable.d.ts.map +1 -0
  26. package/dist/index.css +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2181 -1927
  30. package/dist/index.js.map +1 -1
  31. package/package.json +3 -2
  32. package/src/components/ActionBar/ActionBar.stories.tsx +3 -3
  33. package/src/components/ButtonGroup/ButtonGroup.meta.ts +24 -0
  34. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +103 -0
  35. package/src/components/ButtonGroup/ButtonGroup.tsx +59 -0
  36. package/src/components/ButtonGroup/styles.css.ts +28 -0
  37. package/src/components/List/List.meta.ts +8 -2
  38. package/src/components/List/List.stories.tsx +35 -15
  39. package/src/components/List/List.tsx +1 -5
  40. package/src/components/Table/ColumnFilter.tsx +46 -0
  41. package/src/components/Table/Pagination.tsx +82 -0
  42. package/src/components/Table/SortIndicator.tsx +56 -0
  43. package/src/components/Table/Table.stories.tsx +346 -0
  44. package/src/components/Table/Table.tsx +52 -23
  45. package/src/components/Table/styles.css.ts +16 -6
  46. package/src/components/Table/useDataTable.ts +113 -0
  47. package/src/docs/helpers.tsx +2 -2
  48. package/src/index.ts +3 -0
  49. 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: {
@@ -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
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,7 +15,7 @@ export type TableSize = 'sm' | 'md'
12
15
  interface TableContextValue {
13
16
  variant: TableVariant
14
17
  size: TableSize
15
- /** Mutable ref: header Row writes inferred template, body Rows read it */
18
+ /** Mutable ref: header Row writes inferred template, Root reads it */
16
19
  inferredTemplateRef: MutableRefObject<string | null>
17
20
  }
18
21
 
@@ -28,6 +31,10 @@ function useTable() {
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 {
@@ -36,12 +43,49 @@ interface RootProps {
36
43
  size?: TableSize
37
44
  }
38
45
 
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
+
39
71
  function Root({children, variant = 'default', size = 'md'}: RootProps) {
40
72
  const inferredTemplateRef = useRef<string | null>(null)
41
73
 
74
+ // Extract template from HeaderCell children synchronously
75
+ const template = extractTemplate(children)
76
+ if (template) {
77
+ inferredTemplateRef.current = template
78
+ }
79
+
42
80
  return (
43
81
  <TableContext.Provider value={{variant, size, inferredTemplateRef}}>
44
- <html.div role="table" style={styles.root}>
82
+ <html.div
83
+ role="table"
84
+ style={[
85
+ styles.root,
86
+ template ? styles.gridColumns(template) : undefined,
87
+ ]}
88
+ >
45
89
  {children}
46
90
  </html.div>
47
91
  </TableContext.Provider>
@@ -68,7 +112,7 @@ function Body({children}: {children: ReactNode}) {
68
112
 
69
113
  return (
70
114
  <HeaderContext.Provider value={false}>
71
- <html.div role="rowgroup">
115
+ <html.div role="rowgroup" style={styles.body}>
72
116
  {childArray.map((child, index) => {
73
117
  if (variant === 'striped') {
74
118
  return (
@@ -89,35 +133,17 @@ const RowIndexContext = createContext<number>(-1)
89
133
  // --- Row ---
90
134
 
91
135
  function Row({children}: {children: ReactNode}) {
92
- const {variant, inferredTemplateRef} = 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
96
140
  const childArray = Children.toArray(children)
97
141
 
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
142
  return (
116
143
  <html.div
117
144
  role="row"
118
145
  style={[
119
146
  styles.row,
120
- styles.gridColumns(template),
121
147
  !isHeader && styles.bodyRow,
122
148
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
123
149
  ]}
@@ -143,7 +169,7 @@ function HeaderCell({
143
169
  'aria-label': ariaLabel,
144
170
  }: {
145
171
  children?: ReactNode
146
- /** Column width: CSS value like '40px', '2fr', 'auto'. Defaults to '1fr'. */
172
+ /** Column width: CSS value like '40px', '2fr', 'max-content'. Defaults to '1fr'. */
147
173
  width?: string
148
174
  'aria-label'?: string
149
175
  }) {
@@ -198,4 +224,7 @@ export const Table = {
198
224
  Row,
199
225
  HeaderCell,
200
226
  Cell,
227
+ Pagination,
228
+ SortIndicator,
229
+ ColumnFilter,
201
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,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
  }),
@@ -0,0 +1,113 @@
1
+ import {useState} from 'react'
2
+ import {
3
+ useReactTable,
4
+ getCoreRowModel,
5
+ getPaginationRowModel,
6
+ getSortedRowModel,
7
+ getFilteredRowModel,
8
+ type ColumnDef,
9
+ type SortingState,
10
+ type ColumnFiltersState,
11
+ type PaginationState,
12
+ type RowSelectionState,
13
+ type TableOptions,
14
+ type Table,
15
+ } from '@tanstack/react-table'
16
+
17
+ export interface UseDataTableOptions<TData> {
18
+ /** Initial pagination state */
19
+ pagination?: {pageSize?: number; pageIndex?: number}
20
+ /** Enable sorting on columns that don't explicitly disable it */
21
+ enableSorting?: boolean
22
+ /** Enable column filtering */
23
+ enableFiltering?: boolean
24
+ /** Enable row selection (checkbox) */
25
+ enableRowSelection?: boolean | ((row: any) => boolean)
26
+ /** Callback when row selection changes */
27
+ onRowSelectionChange?: (selection: RowSelectionState) => void
28
+ }
29
+
30
+ /**
31
+ * Thin wrapper around TanStack's useReactTable.
32
+ * Pre-configures row models for pagination, sorting, and filtering.
33
+ * Returns the full TanStack table instance.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * const table = useDataTable(users, columns, {
38
+ * pagination: { pageSize: 20 },
39
+ * enableSorting: true,
40
+ * enableFiltering: true,
41
+ * })
42
+ * ```
43
+ */
44
+ export function useDataTable<TData>(
45
+ data: TData[],
46
+ columns: ColumnDef<TData, any>[],
47
+ options: UseDataTableOptions<TData> = {},
48
+ ): Table<TData> {
49
+ const {
50
+ pagination: paginationInit,
51
+ enableSorting = false,
52
+ enableFiltering = false,
53
+ enableRowSelection = false,
54
+ onRowSelectionChange: onSelectionChangeProp,
55
+ } = options
56
+
57
+ const [sorting, setSorting] = useState<SortingState>([])
58
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
59
+ const [pagination, setPagination] = useState<PaginationState>({
60
+ pageIndex: paginationInit?.pageIndex ?? 0,
61
+ pageSize: paginationInit?.pageSize ?? 10,
62
+ })
63
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
64
+
65
+ const handleRowSelectionChange = (updater: any) => {
66
+ setRowSelection(updater)
67
+ if (onSelectionChangeProp) {
68
+ const next = typeof updater === 'function' ? updater(rowSelection) : updater
69
+ onSelectionChangeProp(next)
70
+ }
71
+ }
72
+
73
+ const tableOptions: TableOptions<TData> = {
74
+ data,
75
+ columns,
76
+ getCoreRowModel: getCoreRowModel(),
77
+
78
+ // Sorting
79
+ ...(enableSorting && {
80
+ getSortedRowModel: getSortedRowModel(),
81
+ onSortingChange: setSorting,
82
+ state: {sorting},
83
+ }),
84
+
85
+ // Filtering
86
+ ...(enableFiltering && {
87
+ getFilteredRowModel: getFilteredRowModel(),
88
+ onColumnFiltersChange: setColumnFilters,
89
+ }),
90
+
91
+ // Pagination
92
+ ...(paginationInit && {
93
+ getPaginationRowModel: getPaginationRowModel(),
94
+ onPaginationChange: setPagination,
95
+ }),
96
+
97
+ // Row selection
98
+ ...(enableRowSelection && {
99
+ enableRowSelection: typeof enableRowSelection === 'function' ? enableRowSelection : true,
100
+ onRowSelectionChange: handleRowSelectionChange,
101
+ }),
102
+
103
+ // Merge all state
104
+ state: {
105
+ ...(enableSorting && {sorting}),
106
+ ...(enableFiltering && {columnFilters}),
107
+ ...(paginationInit && {pagination}),
108
+ ...(enableRowSelection && {rowSelection}),
109
+ },
110
+ }
111
+
112
+ return useReactTable(tableOptions)
113
+ }
@@ -64,7 +64,7 @@ export function TypeScaleTable({rows, label}: TypeScaleTableProps) {
64
64
  return (
65
65
  <html.div>
66
66
  {label && <html.h3 style={styles.label}>{label}</html.h3>}
67
- <Table.Root columns={4} variant="striped" size="sm">
67
+ <Table.Root variant="striped" size="sm">
68
68
  <Table.Header>
69
69
  <Table.Row>
70
70
  <Table.HeaderCell>Step</Table.HeaderCell>
@@ -106,7 +106,7 @@ export function TokenTable({tokens, label}: TokenTableProps) {
106
106
  return (
107
107
  <html.div>
108
108
  {label && <html.h3 style={styles.label}>{label}</html.h3>}
109
- <Table.Root columns={3} variant="striped" size="sm">
109
+ <Table.Root variant="striped" size="sm">
110
110
  <Table.Header>
111
111
  <Table.Row>
112
112
  <Table.HeaderCell>Token</Table.HeaderCell>
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export {Alert, type AlertVariant} from './components/Alert/Alert'
8
8
  export {Icon, type IconName} from './components/Icon'
9
9
  export {Badge, type BadgeVariant, type BadgeSize} from './components/Badge/Badge'
10
10
  export {Button, type ButtonVariant, type ButtonSize} from './components/Button/Button'
11
+ export {ButtonGroup} from './components/ButtonGroup/ButtonGroup'
11
12
  export {Callout, type CalloutVariant} from './components/Callout/Callout'
12
13
  export {Card, type CardVariant, type CardSize} from './components/Card/Card'
13
14
  export {Checkbox} from './components/Checkbox/Checkbox'
@@ -41,6 +42,8 @@ export {
41
42
  type StatusIconVariant,
42
43
  } from './components/StatusIcon/StatusIcon'
43
44
  export {Table, type TableVariant, type TableSize} from './components/Table/Table'
45
+ // TanStack Table: consumers install @tanstack/react-table directly and use
46
+ // Table.Pagination, Table.SortIndicator, Table.ColumnFilter for styled integration
44
47
  export {Tabs} from './components/Tabs/Tabs'
45
48
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
46
49
  export {ThemeProvider, type ThemeName} from './components/ThemeProvider/ThemeProvider'