@chassis-ui/react 0.0.0-20260831090525 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +11 -0
  2. package/dist/index.d.ts +682 -237
  3. package/dist/index.js +2 -2
  4. package/dist/style.css +1 -1
  5. package/package.json +53 -46
  6. package/src/components/accordion/Accordion.tsx +2 -2
  7. package/src/components/accordion/AccordionItem.tsx +6 -6
  8. package/src/components/autocomplete/Autocomplete.tsx +4 -4
  9. package/src/components/avatar/Avatar.tsx +5 -2
  10. package/src/components/breadcrumb/Breadcrumb.tsx +11 -2
  11. package/src/components/button/Button.tsx +7 -4
  12. package/src/components/button-group/ButtonGroup.tsx +2 -2
  13. package/src/components/calendar/Calendar.tsx +56 -42
  14. package/src/components/calendar/CalendarMonthGrid.tsx +3 -1
  15. package/src/components/calendar/CalendarMonthYearPicker.tsx +4 -2
  16. package/src/components/calendar/CalendarYearGrid.tsx +5 -3
  17. package/src/components/calendar/RangeCalendar.scss +8 -8
  18. package/src/components/calendar/RangeCalendar.tsx +56 -44
  19. package/src/components/calendar/index.ts +1 -0
  20. package/src/components/calendar/labels.tsx +71 -0
  21. package/src/components/card/Card.tsx +4 -4
  22. package/src/components/card/CardGroup.tsx +1 -1
  23. package/src/components/card/CardImage.tsx +1 -1
  24. package/src/components/carousel/Carousel.tsx +20 -9
  25. package/src/components/carousel/CarouselControlButton.tsx +1 -1
  26. package/src/components/carousel/CarouselPlayPause.tsx +1 -1
  27. package/src/components/carousel/carouselEngine.ts +3 -3
  28. package/src/components/checkbox/Checkbox.tsx +12 -11
  29. package/src/components/checkbox/CheckboxGroup.tsx +1 -1
  30. package/src/components/chip/Chip.tsx +5 -4
  31. package/src/components/chip-input/ChipInput.tsx +12 -4
  32. package/src/components/chip-input/ChipList.tsx +2 -2
  33. package/src/components/close-button/CloseButton.tsx +2 -2
  34. package/src/components/color-input/ColorInput.tsx +2 -2
  35. package/src/components/combobox/Combobox.tsx +2 -2
  36. package/src/components/datagrid/DataGrid.scss +203 -0
  37. package/src/components/datagrid/DataGrid.tsx +259 -0
  38. package/src/components/datagrid/DataGridBody.tsx +162 -0
  39. package/src/components/datagrid/DataGridCell.tsx +47 -0
  40. package/src/components/datagrid/DataGridColumn.tsx +109 -0
  41. package/src/components/datagrid/DataGridHeader.tsx +47 -0
  42. package/src/components/datagrid/DataGridPinBehavior.tsx +225 -0
  43. package/src/components/datagrid/DataGridRow.tsx +48 -0
  44. package/src/components/datagrid/DataGridSelectionCell.tsx +74 -0
  45. package/src/components/datagrid/DataGridTableLayout.ts +30 -0
  46. package/src/components/datagrid/index.ts +14 -0
  47. package/src/components/datagrid/useColumnGeometry.ts +161 -0
  48. package/src/components/datepicker/ClearButton.tsx +4 -1
  49. package/src/components/datepicker/DatePicker.scss +2 -2
  50. package/src/components/datepicker/DatePicker.tsx +179 -147
  51. package/src/components/datepicker/DateRangePicker.tsx +107 -89
  52. package/src/components/datepicker/renderDatePickerShell.tsx +1 -1
  53. package/src/components/drawer/Drawer.tsx +3 -1
  54. package/src/components/file-input/FileInput.tsx +2 -2
  55. package/src/components/floating-input/FloatingInput.tsx +8 -8
  56. package/src/components/form/renderFormCheck.tsx +3 -3
  57. package/src/components/form-field/FormField.tsx +8 -8
  58. package/src/components/form-field/renderFormField.tsx +14 -3
  59. package/src/components/grid/Col.tsx +1 -1
  60. package/src/components/grid/GridItem.tsx +1 -1
  61. package/src/components/grid/Row.tsx +1 -1
  62. package/src/components/input-group/InputGroup.tsx +2 -2
  63. package/src/components/list/List.tsx +5 -5
  64. package/src/components/menu/Menu.tsx +10 -0
  65. package/src/components/menu/MenuSubmenu.tsx +3 -3
  66. package/src/components/menu/MenuSubmenuBack.tsx +1 -1
  67. package/src/components/menu/MenuToggle.tsx +2 -2
  68. package/src/components/modal/Modal.tsx +5 -3
  69. package/src/components/navbar/Navbar.tsx +1 -1
  70. package/src/components/navbar/NavbarBrand.tsx +5 -2
  71. package/src/components/notification/NotificationStack.tsx +1 -1
  72. package/src/components/otp-input/OtpBox.tsx +1 -1
  73. package/src/components/otp-input/OtpInput.tsx +12 -11
  74. package/src/components/pagination/Pagination.tsx +2 -2
  75. package/src/components/pagination/PaginationItem.tsx +23 -4
  76. package/src/components/popover/Popover.tsx +40 -14
  77. package/src/components/progress/Progress.tsx +1 -1
  78. package/src/components/progress/ProgressBar.tsx +1 -1
  79. package/src/components/radio/Radio.tsx +2 -2
  80. package/src/components/radio/RadioGroup.tsx +1 -1
  81. package/src/components/select/Select.tsx +8 -8
  82. package/src/components/skeleton/Skeleton.tsx +1 -1
  83. package/src/components/spinner/Spinner.tsx +2 -2
  84. package/src/components/stepper/Stepper.tsx +5 -5
  85. package/src/components/switch/Switch.tsx +3 -3
  86. package/src/components/table/Table.css +2 -2
  87. package/src/components/table/Table.tsx +12 -6
  88. package/src/components/text-input/TextInput.tsx +2 -2
  89. package/src/components/textarea/Textarea.tsx +2 -2
  90. package/src/components/toast/ToastBody.tsx +1 -1
  91. package/src/components/toast/ToastHeader.tsx +2 -2
  92. package/src/components/toast/Toaster.tsx +1 -1
  93. package/src/components/tooltip/Tooltip.tsx +23 -7
  94. package/src/hooks/useAutoDismiss.ts +4 -1
  95. package/src/hooks/useDialogElement.ts +10 -0
  96. package/src/hooks/useFloatingOverlay.ts +12 -2
  97. package/src/hooks/usePagination.ts +6 -2
  98. package/src/index.ts +226 -0
  99. package/src/types.ts +26 -26
  100. package/src/utils/breakpoints.ts +1 -1
  101. package/src/utils/devWarning.ts +58 -0
  102. package/src/utils/direction.ts +14 -0
  103. package/src/utils/focusRedirect.ts +10 -16
  104. package/src/utils/polymorphic.ts +18 -1
  105. package/src/utils/suppressFocusRingGlobally.ts +22 -7
  106. package/src/utils/triggerElement.ts +25 -0
@@ -0,0 +1,203 @@
1
+ @use "@chassis-ui/css/scss/config" as *;
2
+ @use "@chassis-ui/css/scss/mixins" as *;
3
+
4
+ // DataGrid renders react-aria-components' Virtualizer output: <div>s carrying grid/row/gridcell
5
+ // roles, not a real <table> — native table layout can't support windowed rows (see this component's
6
+ // entry under THEMING.md's "Component-scoped CSS"). chassis-css has no equivalent for that markup
7
+ // shape, so unlike Table's two sm Table.css overrides, this stylesheet owns the whole visual
8
+ // surface. It reads the SAME `--table-*` public custom properties `_table.scss` defines instead of
9
+ // inventing `--datagrid-*` duplicates, so a consumer who's already re-themed `.table` globally (on
10
+ // `:root`/`body`) gets a visually consistent `DataGrid` for free — a scoped override applied
11
+ // directly on a `<Table>` element's own subtree won't reach a separate `<DataGrid>` instance
12
+ // elsewhere in the DOM, since custom properties only cascade downward through shared ancestors.
13
+ @layer components {
14
+ .datagrid {
15
+ // scss-docs-start datagrid-variables
16
+ --fg-color: var(--table-td-fg-color, #{$table-td-fg-color});
17
+ --bg-color: var(--table-td-bg-color, #{$table-td-bg-color});
18
+ --th-fg-color: var(--table-th-fg-color, #{$table-th-fg-color});
19
+ --th-bg-color: var(--table-th-bg-color, #{$table-th-bg-color});
20
+ --hover-fg-color: var(--table-hover-fg-color, #{$table-hover-fg-color});
21
+ --hover-bg-color: var(--table-hover-bg-color, #{$table-hover-bg-color});
22
+ --active-fg-color: var(--table-active-fg-color, #{$table-active-fg-color});
23
+ --active-bg-color: var(--table-active-bg-color, #{$table-active-bg-color});
24
+ --border-width: var(--table-border-width, #{$table-border-width});
25
+ --border-color: var(--table-border-color, #{$table-border-color});
26
+ --cue-color: var(--table-cue-color, #{$table-cue-color});
27
+ --cell-padding-y: var(--table-cell-padding-y, #{$table-cell-padding-y});
28
+ --cell-padding-x: var(--table-cell-padding-x, #{$table-cell-padding-x});
29
+ // scss-docs-start datagrid-pin-cue-variables
30
+ // Read by DataGridPinBehavior's generated stylesheet — not exposed via `--table-*` since
31
+ // pinned columns have no `Table` equivalent to stay consistent with.
32
+ --datagrid-pin-cue-shadow-start: inset 6px 0 6px -6px rgb(0 0 0 / 30%);
33
+ --datagrid-pin-cue-shadow-end: inset -6px 0 6px -6px rgb(0 0 0 / 30%);
34
+ // scss-docs-end datagrid-pin-cue-variables
35
+
36
+ @if $enable-table-font-tokens {
37
+ // Same public `--table-*` override namespace Table's own `.table` uses (passing `table` as
38
+ // the component name, not `datagrid`) — see this file's top comment.
39
+ @include map-font($table-td-font, table, td);
40
+ @include map-font($table-th-font, table, th);
41
+ }
42
+ // scss-docs-end datagrid-variables
43
+
44
+ position: relative;
45
+ display: block;
46
+ width: 100%;
47
+ // A virtualizer needs a bounded viewport to know when to stop rendering rows — without an
48
+ // explicit height it lays out its full scroll content, defeating windowing entirely. This is
49
+ // a floor so the component isn't broken out of the box; callers size it for their own layout.
50
+ height: 400px;
51
+ overflow: auto;
52
+ vertical-align: var(--table-cell-vertical-align, $table-cell-vertical-align);
53
+ border-color: var(--border-color);
54
+
55
+ // `[role="rowheader"]` is the identifying cell of a *data* row (the `isRowHeader` column,
56
+ // ARIA's analog of `<th scope="row">`) — a plain data cell, not header chrome. Only
57
+ // `.datagrid-header`'s own `[role="columnheader"]` cells get the th treatment; which
58
+ // rowgroup a cell belongs to (not its role) decides styling here, same as `_table.scss`'s
59
+ // `.table > thead` override applying regardless of `<td>`/`<th>`.
60
+ [role="columnheader"],
61
+ [role="gridcell"],
62
+ [role="rowheader"] {
63
+ box-sizing: border-box;
64
+ height: 100%;
65
+ padding: var(--cell-padding-y) var(--cell-padding-x);
66
+ color: var(--fg-color);
67
+ background-color: var(--bg-color);
68
+ // Style/color unconditional, width per-side — `border-width` alone is a no-op (a browser
69
+ // computes an unstyled edge's width as 0 regardless of the declared value), so `.bordered`/
70
+ // `.borderless` below only ever touch `border-width`, never style or color.
71
+ border-color: var(--border-color);
72
+ border-style: solid;
73
+ border-width: 0 0 var(--border-width) 0;
74
+ @include font(td);
75
+ }
76
+
77
+ .datagrid-header [role="columnheader"] {
78
+ --fg-color: var(--th-fg-color);
79
+ --bg-color: var(--th-bg-color);
80
+ @include font(th);
81
+ }
82
+
83
+ [role="row"][data-selected] > * {
84
+ --fg-color: var(--active-fg-color);
85
+ --bg-color: var(--active-bg-color);
86
+ }
87
+
88
+ &.hoverable [role="row"]:not([data-selected]):hover > *{
89
+ --fg-color: var(--hover-fg-color);
90
+ --bg-color: var(--hover-bg-color);
91
+ }
92
+
93
+ &.bordered {
94
+ border: var(--border-width) solid var(--border-color);
95
+
96
+ [role="columnheader"],
97
+ [role="gridcell"],
98
+ [role="rowheader"] {
99
+ border-width: 0 var(--border-width) var(--border-width) 0;
100
+ }
101
+ }
102
+
103
+ &.borderless {
104
+ [role="columnheader"],
105
+ [role="gridcell"],
106
+ [role="rowheader"] {
107
+ border-width: 0;
108
+ }
109
+ }
110
+
111
+ [role="row"]:focus-visible,
112
+ [role="gridcell"]:focus-visible,
113
+ [role="columnheader"]:focus-visible,
114
+ [role="rowheader"]:focus-visible {
115
+ outline: var(--cx-border-width-xl) solid var(--cx-primary);
116
+ outline-offset: -2px;
117
+ }
118
+
119
+ [role="columnheader"][data-allows-sorting] {
120
+ cursor: pointer;
121
+ user-select: none;
122
+ }
123
+
124
+ .datagrid-sort-icon {
125
+ display: inline-block;
126
+ margin-inline-start: var(--cx-space-2xs);
127
+ @include font-size(.75em);
128
+ opacity: .6;
129
+ }
130
+
131
+ [data-sort-direction] .datagrid-sort-icon {
132
+ opacity: 1;
133
+ }
134
+
135
+ // react-aria-components' internal virtualizer wraps BOTH the `.datagrid-header` and
136
+ // `.datagrid-body` rowgroups in their own auto-generated positioning divs (role=
137
+ // "presentation", no class hook of their own) that default to `overflow: hidden` — every
138
+ // OTHER auto-generated wrapper this virtualizer produces (rows, individual cells) already
139
+ // defaults to `overflow: visible`, so these two are outliers. Left alone, this would clip
140
+ // `pin="start"`'s sticky-positioned cells (see `DataGridTableLayout`) and `pin="end"`'s
141
+ // scroll-driven `transform` (see `DataGridPinBehavior`) alike whenever either moves beyond
142
+ // this wrapper's own box. Nothing under here is meant to be clipped in the first place — rows
143
+ // are windowed by the virtualizer's own JS, not by this rule — so force both to match every
144
+ // other wrapper's own default. `!important` is required here — it's the only way to win
145
+ // against that wrapper's own inline style, which has higher specificity than any plain rule.
146
+ :has(> .datagrid-header),
147
+ :has(> .datagrid-body) {
148
+ // stylelint-disable-next-line declaration-no-important
149
+ overflow: visible !important;
150
+ }
151
+
152
+ // `pin="start"` is handled by DataGridTableLayout (the layout engine's own sticky
153
+ // positioning, reused from what already keeps the header row pinned to the top) — no CSS of
154
+ // ours is needed for the positioning itself, in either the header or body row group.
155
+ // `pin="end"` has no engine-level equivalent — its sticky mechanism is hardcoded to the
156
+ // leading edge, and a pure-CSS `position: sticky; right: 0` doesn't work for a trailing pin
157
+ // either (see `DataGridPinBehavior`'s doc comment for why) — so it's positioned imperatively
158
+ // by that component instead, via a scroll-driven `transform` (and the `z-index` that goes
159
+ // with it) on each `pin="end"` cell. The scroll-cue `box-shadow` is generated there too, as a
160
+ // per-instance-scoped stylesheet keyed on `aria-colindex`, applied uniformly to both row groups
161
+ // since a header row and a body row share the same rendered shape. Nothing about pinning is in
162
+ // this file — only the custom properties those generated rules read, above.
163
+
164
+ // No `width: 1%` here, unlike `.table-selection-cell` — that's a native `<table>` auto-layout
165
+ // trick (an unstyled cell shrinks to content, so `1%` beats the browser down to the
166
+ // checkbox's own width) that has no equivalent in this virtualized, JS-sized layout. Give a
167
+ // `DataGridColumn` carrying this class an explicit `defaultWidth`/`minWidth` instead — see
168
+ // `DataGridColumn`'s own doc comment.
169
+ .datagrid-selection-cell {
170
+ padding-inline-end: 0;
171
+ text-align: center;
172
+ }
173
+ }
174
+
175
+ // Rendered as a sibling of `.datagrid`, not a descendant of it (see DataGrid.tsx's own doc
176
+ // comment on `footer`) — it has to live outside the scrollable/virtualized element so it never
177
+ // scrolls away with the body rows, which means it can't read `.datagrid`'s own custom
178
+ // properties via inheritance. Re-reads the same `--table-*` fallback chain independently rather
179
+ // than duplicating `.datagrid`'s variable block onto a shared ancestor for this alone. One
180
+ // consequence, documented in datagrid.mdx's Scope section: a consumer override scoped directly
181
+ // to a specific `DataGrid` instance's own class won't reach the footer, only a global
182
+ // `--table-*` override will.
183
+ .datagrid-footer {
184
+ --fg-color: var(--table-td-fg-color, #{$table-td-fg-color});
185
+ --bg-color: var(--table-td-bg-color, #{$table-td-bg-color});
186
+ --border-width: var(--table-border-width, #{$table-border-width});
187
+ --border-color: var(--table-border-color, #{$table-border-color});
188
+ --cell-padding-y: var(--table-cell-padding-y, #{$table-cell-padding-y});
189
+ --cell-padding-x: var(--table-cell-padding-x, #{$table-cell-padding-x});
190
+
191
+ box-sizing: border-box;
192
+ padding: var(--cell-padding-y) var(--cell-padding-x);
193
+ color: var(--fg-color);
194
+ background-color: var(--bg-color);
195
+ border-color: var(--border-color);
196
+ border-style: solid;
197
+ border-width: var(--border-width) 0 0 0;
198
+
199
+ &.borderless {
200
+ border-width: 0;
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,259 @@
1
+ import React, {
2
+ ReactElement,
3
+ ReactNode,
4
+ Ref,
5
+ forwardRef,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ useState
11
+ } from 'react'
12
+ import classNames from 'classnames'
13
+ import {
14
+ Key,
15
+ Selection,
16
+ SortDescriptor,
17
+ Table as AriaTable,
18
+ Virtualizer
19
+ } from 'react-aria-components'
20
+
21
+ import { useForkedRef } from '../../hooks/useForkedRef'
22
+ import { ContextColor } from '../../types'
23
+ import { DataGridBodyProps } from './DataGridBody'
24
+ import { DataGridHeaderProps } from './DataGridHeader'
25
+ import { DataGridPinBehavior } from './DataGridPinBehavior'
26
+ import { DataGridTableLayout } from './DataGridTableLayout'
27
+ import './DataGrid.scss'
28
+
29
+ // No `striped`/`stacked` props: zebra-striping via CSS `:nth-of-type` (Table's approach) isn't
30
+ // virtualization-safe — a windowed row's DOM siblings are only the currently-mounted rows, not
31
+ // every logical row, so the stripe parity would flip as the mounted window's starting index
32
+ // changes during scroll. A correct implementation needs the row's real index at render time, not
33
+ // a pure-CSS trick; deferred rather than shipped broken.
34
+ interface DataGridOwnProps<T extends object> {
35
+ /**
36
+ * An accessible label for the grid, used when there's no visible heading.
37
+ */
38
+ 'aria-label'?: string
39
+ /**
40
+ * Identifies a visible heading element for the grid.
41
+ */
42
+ 'aria-labelledby'?: string
43
+ /**
44
+ * Set the vertical alignment of cell content. Mirrors `Table`'s prop of the same name for API
45
+ * parity — same as `Table`, this awaits chassis-css adding the matching `.align-*` styles.
46
+ */
47
+ align?: 'bottom' | 'middle' | 'top'
48
+ /**
49
+ * Add borders on all sides of the grid and cells.
50
+ */
51
+ bordered?: boolean
52
+ /**
53
+ * Remove borders on all sides of the grid and cells.
54
+ */
55
+ borderless?: boolean
56
+ /**
57
+ * A `DataGridHeader` and a `DataGridBody`, each built from `DataGridColumn`/`DataGridRow`/
58
+ * `DataGridCell`.
59
+ */
60
+ children: [ReactElement<DataGridHeaderProps<T>>, ReactElement<DataGridBodyProps<T>>]
61
+ /**
62
+ * A string of all className you want applied to the component.
63
+ */
64
+ className?: string
65
+ /**
66
+ * Sets the color of the component.
67
+ */
68
+ color?: ContextColor
69
+ /**
70
+ * A list of row keys to disable. Disabled rows cannot be selected, focused, or interacted with.
71
+ */
72
+ disabledKeys?: Iterable<Key>
73
+ /**
74
+ * Content shown below the grid, e.g. a totals row or a "load more" control. Rendered as plain
75
+ * markup outside the grid's own scrollable/virtualized region — not part of the
76
+ * keyboard-navigable grid, and never scrolls out of view along with the body rows. Mirrors
77
+ * `Table`'s `footer` prop for API parity.
78
+ */
79
+ footer?: ReactNode
80
+ /**
81
+ * Enable a hover state on grid rows.
82
+ */
83
+ hover?: boolean
84
+ /**
85
+ * Handler that is called when the selection changes.
86
+ */
87
+ onSelectionChange?: (keys: Selection) => void
88
+ /**
89
+ * Handler that is called when a column is sorted.
90
+ */
91
+ onSortChange?: (descriptor: SortDescriptor) => void
92
+ /**
93
+ * The currently selected row keys (controlled).
94
+ */
95
+ selectedKeys?: Selection
96
+ /**
97
+ * The type of selection that is allowed.
98
+ */
99
+ selectionMode?: 'none' | 'single' | 'multiple'
100
+ /**
101
+ * Make the grid more compact by cutting all cell padding.
102
+ */
103
+ sm?: boolean
104
+ /**
105
+ * The current sort column and direction.
106
+ */
107
+ sortDescriptor?: SortDescriptor
108
+ }
109
+
110
+ type DataGridRowHeightProps =
111
+ | {
112
+ /**
113
+ * Row height in pixels, or `"auto"` to measure each row from its own rendered content
114
+ * instead of a single fixed height shared by every row (pair it with a required
115
+ * `estimatedRowHeight`, and see the docs site's "Variable row height" section for the
116
+ * tradeoffs). A fixed number renders every row, including the header, at that exact
117
+ * height; content taller than it visually overflows rather than growing the row.
118
+ */
119
+ rowHeight: number
120
+ estimatedRowHeight?: never
121
+ }
122
+ | {
123
+ rowHeight: 'auto'
124
+ /**
125
+ * Seeds the virtualizer's scroll-position math for rows that haven't been measured yet.
126
+ * Required when `rowHeight="auto"` — pick a value close to the typical row's real height
127
+ * to minimize scroll-jump on first measurement. A row's real height can still turn out far
128
+ * from this estimate once it renders, which can cause a visible scroll-jump for rows the
129
+ * viewport hasn't reached yet.
130
+ */
131
+ estimatedRowHeight: number
132
+ }
133
+
134
+ export type DataGridProps<T extends object> = DataGridOwnProps<T> & DataGridRowHeightProps
135
+
136
+ const DataGridInner = <T extends object>(
137
+ {
138
+ align,
139
+ bordered,
140
+ borderless,
141
+ children,
142
+ className,
143
+ color,
144
+ disabledKeys,
145
+ estimatedRowHeight,
146
+ footer,
147
+ hover,
148
+ onSelectionChange,
149
+ onSortChange,
150
+ rowHeight,
151
+ selectedKeys,
152
+ selectionMode = 'none',
153
+ sm,
154
+ sortDescriptor,
155
+ ...rest
156
+ }: DataGridProps<T>,
157
+ forwardedRef: Ref<HTMLDivElement>
158
+ ) => {
159
+ const isVariableRowHeight = rowHeight === 'auto'
160
+ const layout = useMemo(
161
+ () =>
162
+ new DataGridTableLayout<T>(
163
+ // `headingHeight` is pinned to `estimatedRowHeight` (fixed, not itself an estimate) in
164
+ // variable-height mode — leaving it unset falls back to `estimatedRowHeight` as an
165
+ // *estimate* for the header too, which sounds harmless but isn't: the header's own
166
+ // sticky-positioned outer wrapper (built once in `buildTableHeader`, see
167
+ // `DataGridTableLayout`'s doc comment for why the header is sticky at all) is never
168
+ // recomputed when a column-header cell's later real measurement corrects its height, so
169
+ // the outer wrapper's stale height desyncs from the cell's real one, spilling header
170
+ // content into the body's first row instead of the header growing to fit it. Fixing the
171
+ // header's height sidesteps that gap instead of shipping it.
172
+ isVariableRowHeight
173
+ ? { estimatedRowHeight, headingHeight: estimatedRowHeight }
174
+ : { rowHeight }
175
+ ),
176
+ [isVariableRowHeight, rowHeight, estimatedRowHeight]
177
+ )
178
+ const gridRef = useRef<HTMLDivElement>(null)
179
+ const ref = useForkedRef(forwardedRef, gridRef)
180
+
181
+ // A stable per-instance class, scoping DataGridPinBehavior's generated CSS so multiple grids on
182
+ // the same page (each with their own pinned columns) don't collide.
183
+ const rawId = useId()
184
+ const instanceClassName = `datagrid-${rawId.replace(/[^a-zA-Z0-9]/g, '')}`
185
+
186
+ // Drives the pin=start/pin=end scroll-cue box-shadow — see DataGridPinBehavior — which should
187
+ // only appear once there's actually hidden content under the pinned column to cue.
188
+ const [scrolledStart, setScrolledStart] = useState(false)
189
+ const [scrolledEnd, setScrolledEnd] = useState(false)
190
+
191
+ useEffect(() => {
192
+ const grid = gridRef.current
193
+ if (!grid) return
194
+
195
+ const updateScrollState = () => {
196
+ setScrolledStart(grid.scrollLeft > 0)
197
+ setScrolledEnd(Math.ceil(grid.scrollLeft + grid.clientWidth) < grid.scrollWidth)
198
+ }
199
+
200
+ updateScrollState()
201
+ grid.addEventListener('scroll', updateScrollState, { passive: true })
202
+ const observer =
203
+ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateScrollState)
204
+ observer?.observe(grid)
205
+ return () => {
206
+ grid.removeEventListener('scroll', updateScrollState)
207
+ observer?.disconnect()
208
+ }
209
+ }, [])
210
+
211
+ const _className = classNames(
212
+ 'datagrid',
213
+ instanceClassName,
214
+ color,
215
+ {
216
+ [`align-${align}`]: align,
217
+ bordered,
218
+ borderless,
219
+ hoverable: hover,
220
+ sm,
221
+ 'datagrid-scrolled-start': scrolledStart,
222
+ 'datagrid-scrolled-end': scrolledEnd
223
+ },
224
+ className
225
+ )
226
+
227
+ return (
228
+ <Virtualizer layout={layout} shouldObserveItemSize={isVariableRowHeight}>
229
+ <AriaTable
230
+ aria-label={rest['aria-label']}
231
+ aria-labelledby={rest['aria-labelledby']}
232
+ className={_className || undefined}
233
+ disabledKeys={disabledKeys}
234
+ onSelectionChange={onSelectionChange}
235
+ onSortChange={onSortChange}
236
+ ref={ref}
237
+ selectedKeys={selectedKeys}
238
+ selectionMode={selectionMode}
239
+ sortDescriptor={sortDescriptor}
240
+ >
241
+ {children}
242
+ </AriaTable>
243
+ {footer && <div className={classNames('datagrid-footer', { borderless })}>{footer}</div>}
244
+ <DataGridPinBehavior
245
+ containerRef={gridRef}
246
+ header={children[0]}
247
+ instanceClassName={instanceClassName}
248
+ />
249
+ </Virtualizer>
250
+ )
251
+ }
252
+
253
+ // `forwardRef` erases type parameters, so the generic component is cast back to a generic
254
+ // signature for callers — same pattern `Table` uses.
255
+ export const DataGrid = forwardRef(DataGridInner) as <T extends object>(
256
+ props: DataGridProps<T> & { ref?: Ref<HTMLDivElement> }
257
+ ) => ReactElement
258
+
259
+ ;(DataGrid as { displayName?: string }).displayName = 'DataGrid'
@@ -0,0 +1,162 @@
1
+ import React, { ReactElement, ReactNode, useMemo } from 'react'
2
+ import classNames from 'classnames'
3
+ import {
4
+ TableBody as AriaTableBody,
5
+ TableBodyRenderProps,
6
+ TableLoadMoreItem
7
+ } from 'react-aria-components'
8
+
9
+ // A unique value appended to `items` internally to represent the trailing "load more" row in
10
+ // react-aria-components' collection. `TableLoadMoreItem` has to be returned from the per-item
11
+ // render function like any other row — react-aria-components' `useCachedChildren` only supports
12
+ // EITHER `items` + a render function OR static `children`, never a mix of both in the same
13
+ // `children` prop, so a `<TableLoadMoreItem>` placed as a plain JSX sibling inside `<TableBody>`
14
+ // is silently dropped (or crashes, if `children` becomes an array mixing the render function in).
15
+ // Checked by reference (`item === LOADER_ITEM`), so it can never collide with a real `T` the way a
16
+ // `{ __loader: true }` marker object might if compared structurally. Its `id` matters too, not
17
+ // just its identity: react-aria-components derives each rendered item's React key from
18
+ // `rendered.props.id ?? item?.key ?? item?.id ?? <positional index>` (`useCachedChildren`) —
19
+ // `TableLoadMoreItem` doesn't accept an `id` prop itself, so without this object's own `id` the
20
+ // loader's key falls back to its position in the array, which collides with a real row whenever a
21
+ // row's own `id` happens to equal that count (e.g. a 2-item list with a row literally id `"2"`).
22
+ const LOADER_ITEM = { id: '__datagrid-body-loader__' }
23
+
24
+ interface DataGridBodyOwnProps {
25
+ /**
26
+ * A string of all className you want applied to the body row group.
27
+ */
28
+ className?: string
29
+ /**
30
+ * Values that should invalidate the row cache when using the `items`/render-function form of
31
+ * `children` — e.g. a value from outside `items` that the render function reads. Without this,
32
+ * changing that outside value won't re-render already-cached rows, since dynamic rows are only
33
+ * rebuilt when `items` or `dependencies` change by reference, not on every parent re-render.
34
+ */
35
+ dependencies?: ReadonlyArray<unknown>
36
+ /**
37
+ * Content shown in place of rows when `items` is empty.
38
+ */
39
+ renderEmptyState?: (props: TableBodyRenderProps) => React.ReactNode
40
+ }
41
+
42
+ /**
43
+ * The `items` + render-function form. `isLoading`/`loadingContent`/`onLoadMore` live here rather
44
+ * than alongside the static form because the loader row is generated by appending to `items` and
45
+ * rendering through that same function — there's no growing dataset to load more of, and nothing
46
+ * to render the loader with, in a hand-written list of `DataGridRow` elements.
47
+ */
48
+ interface DataGridBodyDynamicProps<T> {
49
+ /**
50
+ * A render function called once per item in `items`, returning that row's `DataGridRow`.
51
+ */
52
+ children: (item: T) => ReactElement
53
+ /**
54
+ * Whether more items are currently being fetched. Shows `loadingContent` in the loader row while
55
+ * true, and — until this goes back to `false` — suppresses further `onLoadMore` calls, so one
56
+ * scroll-into-view doesn't fire the callback again before the in-flight fetch resolves.
57
+ */
58
+ isLoading?: boolean
59
+ /**
60
+ * A list of row data objects, rendered via the function form of `children`.
61
+ */
62
+ items: Iterable<T>
63
+ /**
64
+ * Content rendered inside the loader row while `isLoading` is true, e.g. a spinner. Only takes
65
+ * effect alongside `onLoadMore`.
66
+ */
67
+ loadingContent?: ReactNode
68
+ /**
69
+ * Called when the grid scrolls near its last row, so more data can be fetched and appended to
70
+ * `items`. Pair with `isLoading` so a fetch already in flight isn't triggered again before it
71
+ * resolves.
72
+ */
73
+ onLoadMore?: () => void
74
+ }
75
+
76
+ /** The static form: `DataGridRow` elements written out directly. */
77
+ interface DataGridBodyStaticProps {
78
+ /**
79
+ * `DataGridRow` elements.
80
+ */
81
+ children: ReactElement | ReactElement[]
82
+ isLoading?: never
83
+ items?: never
84
+ loadingContent?: never
85
+ onLoadMore?: never
86
+ }
87
+
88
+ /**
89
+ * Split into a static and a dynamic form so the two can't be mixed: the loader row is appended to
90
+ * `items` and rendered through `children`, so `onLoadMore` alongside a static list of elements used
91
+ * to type-check and then throw at render ("children is not a function") the moment the loader row
92
+ * came up.
93
+ */
94
+ export type DataGridBodyProps<T> = DataGridBodyOwnProps &
95
+ (DataGridBodyDynamicProps<T> | DataGridBodyStaticProps)
96
+
97
+ /**
98
+ * Body row group for `DataGrid`, containing `DataGridRow`s. Unlike `Table`'s sub-parts, this
99
+ * renders directly — it's a thin wrapper over react-aria-components' own `TableBody`.
100
+ *
101
+ * Always carries the `datagrid-body` class — see `DataGridHeader`'s equivalent note.
102
+ */
103
+ export const DataGridBody = <T extends object>({
104
+ children,
105
+ className,
106
+ dependencies,
107
+ isLoading,
108
+ items,
109
+ loadingContent,
110
+ onLoadMore,
111
+ renderEmptyState
112
+ }: DataGridBodyProps<T>) => {
113
+ const hasLoader = onLoadMore != null && items != null
114
+
115
+ const itemsWithLoader = useMemo(() => {
116
+ if (!hasLoader) return items
117
+ return [...(items as Iterable<T>), LOADER_ITEM] as Iterable<T>
118
+ }, [hasLoader, items])
119
+
120
+ // react-aria-components caches each rendered row by item identity (see `dependencies`'s own doc
121
+ // comment above) — the loader branch below reads `isLoading`/`onLoadMore`/`loadingContent`, none
122
+ // of which are part of `items`, so without surfacing them here the cached loader row would keep
123
+ // whatever `isLoading` value (and stale `onLoadMore` closure) was in scope the first time it
124
+ // rendered, never picking up a later change.
125
+ const dependenciesWithLoader = useMemo(
126
+ () =>
127
+ hasLoader ? [...(dependencies ?? []), isLoading, onLoadMore, loadingContent] : dependencies,
128
+ [dependencies, hasLoader, isLoading, onLoadMore, loadingContent]
129
+ )
130
+
131
+ const bodyChildren = hasLoader
132
+ ? (item: T) =>
133
+ (item as unknown) === LOADER_ITEM ? (
134
+ <TableLoadMoreItem
135
+ className="datagrid-loader"
136
+ isLoading={isLoading}
137
+ // Suppresses re-triggering onLoadMore while a fetch is already in flight — the
138
+ // underlying sentinel has no built-in debounce of its own and calls onLoadMore again
139
+ // whenever it (re-)observes the sentinel still intersecting, e.g. after the collection
140
+ // changes but the loader row hasn't yet scrolled out of view.
141
+ onLoadMore={isLoading ? undefined : onLoadMore}
142
+ >
143
+ {loadingContent}
144
+ </TableLoadMoreItem>
145
+ ) : (
146
+ // `hasLoader` implies the dynamic form of the props union, whose `children` is the
147
+ // render function — `onLoadMore` isn't assignable alongside static children.
148
+ (children as (item: T) => ReactElement)(item)
149
+ )
150
+ : children
151
+
152
+ return (
153
+ <AriaTableBody
154
+ className={classNames('datagrid-body', className)}
155
+ dependencies={dependenciesWithLoader}
156
+ items={itemsWithLoader}
157
+ renderEmptyState={renderEmptyState}
158
+ >
159
+ {bodyChildren}
160
+ </AriaTableBody>
161
+ )
162
+ }
@@ -0,0 +1,47 @@
1
+ import React, { ReactNode } from 'react'
2
+ import { Key, Cell as AriaCell } from 'react-aria-components'
3
+ import classNames from 'classnames'
4
+
5
+ export interface DataGridCellProps {
6
+ /**
7
+ * The contents of the cell.
8
+ */
9
+ children: ReactNode
10
+ /**
11
+ * A string of all className you want applied to the cell.
12
+ */
13
+ className?: string
14
+ /**
15
+ * Indicates how many columns the cell spans.
16
+ */
17
+ colSpan?: number
18
+ /**
19
+ * The unique id of the cell.
20
+ */
21
+ id?: Key
22
+ /**
23
+ * A string representation of the cell's contents, used for typeahead.
24
+ */
25
+ textValue?: string
26
+ }
27
+
28
+ /**
29
+ * A cell within a `DataGrid` row. Unlike `Table`'s sub-parts, this renders directly — it's a
30
+ * thin wrapper over react-aria-components' own `Cell`.
31
+ */
32
+ export const DataGridCell = ({
33
+ children,
34
+ className,
35
+ colSpan,
36
+ id,
37
+ textValue
38
+ }: DataGridCellProps) => (
39
+ <AriaCell
40
+ className={classNames('datagrid-cell', className)}
41
+ colSpan={colSpan}
42
+ id={id}
43
+ textValue={textValue}
44
+ >
45
+ {children}
46
+ </AriaCell>
47
+ )