@lovett/ui 0.0.1 → 0.0.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovett/ui",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "restricted"
@@ -44,6 +44,7 @@ import { DrawerPanel } from './drawer-panel'
44
44
  import { DataGridTableView } from './table-view'
45
45
  import { useGridColumns } from './use-grid-columns'
46
46
  import { useGridEditing } from './use-grid-editing'
47
+ import { SCROLL_EDGES_AT_REST, type ScrollEdges } from './sticky'
47
48
  import type {
48
49
  DataGridDrawerPanelProps,
49
50
  DataGridProps,
@@ -86,6 +87,8 @@ export function DataGrid<Row extends DataGridRowBase, ColumnId extends string>({
86
87
  drawerModal = false,
87
88
  drawerSize,
88
89
  stickySummaryFooter = false,
90
+ stickyHeader = false,
91
+ fillWidth = false,
89
92
  fillAvailableHeight = false,
90
93
  tableContainerClassName,
91
94
  onRowsChange,
@@ -96,6 +99,8 @@ export function DataGrid<Row extends DataGridRowBase, ColumnId extends string>({
96
99
  const [drawerCell, setDrawerCell] =
97
100
  useState<EditingCell<ColumnId> | null>(null)
98
101
  const [selectedRowIds, setSelectedRowIds] = useState<string[]>([])
102
+ const [scrollEdges, setScrollEdges] =
103
+ useState<ScrollEdges>(SCROLL_EDGES_AT_REST)
99
104
  const reactInstanceId = useId()
100
105
  const instanceId = useMemo(
101
106
  () => reactInstanceId.replace(/:/g, ''),
@@ -317,6 +322,53 @@ export function DataGrid<Row extends DataGridRowBase, ColumnId extends string>({
317
322
  onToolbarPropsChange?.(toolbarProps)
318
323
  }, [onToolbarPropsChange, toolbarProps])
319
324
 
325
+ // Track which edges of the scroll viewport are at rest, so sticky/pinned
326
+ // cells can show a scroll-shadow only when content is scrolled past them.
327
+ const hasPinnedColumn = useMemo(
328
+ () => columns.some((column) => column.pin),
329
+ [columns],
330
+ )
331
+ useEffect(() => {
332
+ if (!stickyHeader && !stickySummaryFooter && !hasPinnedColumn) return
333
+ const scroller = tableRef.current?.parentElement
334
+ if (!scroller) return
335
+
336
+ let frame = 0
337
+ const measure = () => {
338
+ frame = 0
339
+ const next: ScrollEdges = {
340
+ atTop: scroller.scrollTop <= 0,
341
+ atBottom:
342
+ scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1,
343
+ atLeft: scroller.scrollLeft <= 0,
344
+ atRight:
345
+ scroller.scrollLeft + scroller.clientWidth >= scroller.scrollWidth - 1,
346
+ }
347
+ setScrollEdges((prev) =>
348
+ prev.atTop === next.atTop &&
349
+ prev.atBottom === next.atBottom &&
350
+ prev.atLeft === next.atLeft &&
351
+ prev.atRight === next.atRight
352
+ ? prev
353
+ : next,
354
+ )
355
+ }
356
+ const onScroll = () => {
357
+ if (!frame) frame = requestAnimationFrame(measure)
358
+ }
359
+
360
+ measure()
361
+ scroller.addEventListener('scroll', onScroll, { passive: true })
362
+ const observer = new ResizeObserver(measure)
363
+ observer.observe(scroller)
364
+
365
+ return () => {
366
+ scroller.removeEventListener('scroll', onScroll)
367
+ observer.disconnect()
368
+ if (frame) cancelAnimationFrame(frame)
369
+ }
370
+ }, [stickyHeader, stickySummaryFooter, hasPinnedColumn, rows.length, columnWidths])
371
+
320
372
  return (
321
373
  <>
322
374
  <div
@@ -361,6 +413,9 @@ export function DataGrid<Row extends DataGridRowBase, ColumnId extends string>({
361
413
  showSummaries={showSummaries}
362
414
  renderSummary={renderSummary}
363
415
  stickySummaryFooter={stickySummaryFooter}
416
+ stickyHeader={stickyHeader}
417
+ fillWidth={fillWidth}
418
+ scrollEdges={scrollEdges}
364
419
  tableContainerClassName={tableContainerClassName}
365
420
  isEmptyValue={isEmptyValue}
366
421
  onResizeStart={beginResize}
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type {
19
+ CSSProperties,
19
20
  KeyboardEvent as ReactKeyboardEvent,
20
21
  PointerEvent as ReactPointerEvent,
21
22
  } from 'react'
@@ -43,6 +44,9 @@ type SortableHeaderCellProps<ColumnId extends string> = {
43
44
  /** ADR-027 — header click handler. When omitted the header is non-
44
45
  * interactive for sort (column reorder + resize still work). */
45
46
  onSortChange: ((columnId: ColumnId) => void) | undefined
47
+ /** Extra style merged onto the <th> (sticky/pinned positioning, opaque
48
+ * background + scroll-shadow). Spread last so it wins over the base. */
49
+ cellStyle?: CSSProperties
46
50
  }
47
51
 
48
52
  type SortableColumnOptionItemProps<ColumnId extends string> = {
@@ -130,6 +134,7 @@ export function SortableHeaderCell<ColumnId extends string>({
130
134
  onResize,
131
135
  sortDir,
132
136
  onSortChange,
137
+ cellStyle,
133
138
  }: SortableHeaderCellProps<ColumnId>) {
134
139
  // ADR-027 — column sortability: explicit `sortable: false` opts out;
135
140
  // missing `onSortChange` also disables (no parent handler).
@@ -173,6 +178,7 @@ export function SortableHeaderCell<ColumnId extends string>({
173
178
  borderRight: '1px solid rgb(var(--border))',
174
179
  background: 'rgb(var(--surface-overlay-soft))',
175
180
  color: 'rgb(var(--foreground))',
181
+ ...cellStyle,
176
182
  }}
177
183
  >
178
184
  <div className="flex h-full min-w-0 items-center justify-between gap-1 overflow-hidden">
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Sticky / pinned-column helpers for DataGrid.
3
+ *
4
+ * Centralizes the styling for the grid's three sticky surfaces so they
5
+ * behave consistently:
6
+ * • sticky header (top) and sticky summary footer (bottom)
7
+ * • pinned columns (`column.pin: 'left' | 'right'`), with the built-in
8
+ * select/control column implicitly pinned left
9
+ * • a trailing flex "spacer" column (`fillWidth`) so the table fills
10
+ * 100% width while real columns keep their exact widths (resize stays
11
+ * independent — the spacer gives/takes the slack)
12
+ *
13
+ * Two correctness rules every sticky cell must follow:
14
+ * 1. it MUST be opaque, or scrolled content bleeds through it; and
15
+ * 2. it shows a soft "scroll shadow" on its inner edge ONLY while there
16
+ * is content scrolled past that edge — so there's visual separation
17
+ * while pinned, and it disappears flush when snapped back.
18
+ */
19
+
20
+ import type { CSSProperties } from 'react'
21
+
22
+ import type { DataGridColumn } from './types'
23
+
24
+ export const CONTROL_COLUMN_WIDTH = 40
25
+
26
+ /** Which edges of the scroll viewport are currently AT rest (not scrolled
27
+ * away from). A shadow on a given side shows when its edge is NOT `at*`. */
28
+ export type ScrollEdges = {
29
+ atTop: boolean
30
+ atBottom: boolean
31
+ atLeft: boolean
32
+ atRight: boolean
33
+ }
34
+
35
+ /** Default = everything at rest → no shadows (used before first scroll). */
36
+ export const SCROLL_EDGES_AT_REST: ScrollEdges = {
37
+ atTop: true,
38
+ atBottom: true,
39
+ atLeft: true,
40
+ atRight: true,
41
+ }
42
+
43
+ const SHADOW_RIGHT = '6px 0 8px -6px rgba(2, 6, 23, 0.22)' // pinned-left edge
44
+ const SHADOW_LEFT = '-6px 0 8px -6px rgba(2, 6, 23, 0.22)' // pinned-right edge
45
+ const SHADOW_DOWN = '0 6px 8px -6px rgba(2, 6, 23, 0.22)' // sticky header
46
+ const SHADOW_UP = '0 -6px 8px -6px rgba(2, 6, 23, 0.22)' // sticky footer
47
+
48
+ export type PinInfo = { side: 'left' | 'right'; offset: number }
49
+
50
+ /** Map each pinned column → its side + cumulative sticky offset (px), so
51
+ * multiple pinned columns on the same side stack correctly. The control
52
+ * column occupies the first `CONTROL_COLUMN_WIDTH` on the left. */
53
+ export function computePinOffsets<ColumnId extends string>(
54
+ visibleColumns: DataGridColumn<ColumnId>[],
55
+ columnWidths: Record<ColumnId, number>,
56
+ ): Map<ColumnId, PinInfo> {
57
+ const map = new Map<ColumnId, PinInfo>()
58
+ const widthOf = (c: DataGridColumn<ColumnId>) =>
59
+ columnWidths[c.id] ?? c.defaultWidth
60
+
61
+ let leftOffset = CONTROL_COLUMN_WIDTH
62
+ for (const c of visibleColumns) {
63
+ if (c.pin !== 'left') continue
64
+ map.set(c.id, { side: 'left', offset: leftOffset })
65
+ leftOffset += widthOf(c)
66
+ }
67
+
68
+ let rightOffset = 0
69
+ const rightPinned = visibleColumns.filter((c) => c.pin === 'right')
70
+ for (let i = rightPinned.length - 1; i >= 0; i -= 1) {
71
+ const c = rightPinned[i]
72
+ if (!c) continue
73
+ map.set(c.id, { side: 'right', offset: rightOffset })
74
+ rightOffset += widthOf(c)
75
+ }
76
+
77
+ return map
78
+ }
79
+
80
+ /** Split a column list at the first right-pinned column, so renderers can
81
+ * drop the flex spacer immediately before the right-pinned group (or at
82
+ * the end when nothing is pinned right). */
83
+ export function splitAtRightPinned<ColumnId extends string>(
84
+ visibleColumns: DataGridColumn<ColumnId>[],
85
+ ): {
86
+ leading: DataGridColumn<ColumnId>[]
87
+ rightPinned: DataGridColumn<ColumnId>[]
88
+ } {
89
+ const firstRight = visibleColumns.findIndex((c) => c.pin === 'right')
90
+ if (firstRight === -1) {
91
+ return { leading: visibleColumns, rightPinned: [] }
92
+ }
93
+ return {
94
+ leading: visibleColumns.slice(0, firstRight),
95
+ rightPinned: visibleColumns.slice(firstRight),
96
+ }
97
+ }
98
+
99
+ type Role = 'header' | 'body' | 'footer'
100
+
101
+ /** Opaque background. Header composites its translucent tint over an
102
+ * opaque base so it still reads tinted but never lets content through. */
103
+ function opaqueBg(role: Role): CSSProperties {
104
+ if (role === 'header') {
105
+ return {
106
+ backgroundColor: 'rgb(var(--bg-card))',
107
+ backgroundImage:
108
+ 'linear-gradient(rgb(var(--surface-overlay-soft)), rgb(var(--surface-overlay-soft)))',
109
+ }
110
+ }
111
+ return { backgroundColor: 'rgb(var(--bg-card))' }
112
+ }
113
+
114
+ /**
115
+ * Compute the inline style for a (possibly) sticky cell. Returns `{}` when
116
+ * the cell isn't sticky in any axis, so callers can spread it
117
+ * unconditionally.
118
+ */
119
+ export function stickyCellStyle(opts: {
120
+ role: Role
121
+ pin?: PinInfo | null
122
+ edges: ScrollEdges
123
+ stickyHeader: boolean
124
+ stickyFooter: boolean
125
+ }): CSSProperties {
126
+ const { role, pin, edges, stickyHeader, stickyFooter } = opts
127
+ const verticalSticky =
128
+ (role === 'header' && stickyHeader) || (role === 'footer' && stickyFooter)
129
+ if (!verticalSticky && !pin) return {}
130
+
131
+ const style: CSSProperties = { position: 'sticky' }
132
+
133
+ if (role === 'header' && stickyHeader) style.top = 0
134
+ if (role === 'footer' && stickyFooter) style.bottom = 0
135
+ if (pin?.side === 'left') style.left = pin.offset
136
+ if (pin?.side === 'right') style.right = pin.offset
137
+
138
+ Object.assign(style, opaqueBg(role))
139
+
140
+ // Corners (vertical-sticky ∩ pinned) must sit above both axes.
141
+ const vertZ = role === 'header' ? 30 : role === 'footer' ? 20 : 0
142
+ style.zIndex = vertZ + (pin ? 10 : 0) || (pin ? 10 : undefined)
143
+
144
+ const shadows: string[] = []
145
+ if (role === 'header' && stickyHeader && !edges.atTop) shadows.push(SHADOW_DOWN)
146
+ if (role === 'footer' && stickyFooter && !edges.atBottom) shadows.push(SHADOW_UP)
147
+ if (pin?.side === 'left' && !edges.atLeft) shadows.push(SHADOW_RIGHT)
148
+ if (pin?.side === 'right' && !edges.atRight) shadows.push(SHADOW_LEFT)
149
+ if (shadows.length) style.boxShadow = shadows.join(', ')
150
+
151
+ return style
152
+ }
@@ -18,6 +18,8 @@
18
18
  * rather than the Input primitive — the input is intentionally
19
19
  * compact (h-7) and edge-to-edge inside the cell, not a typical
20
20
  * form input.
21
+ * • Pinned columns + fill-width spacer (workspace addition) via the
22
+ * shared sticky helper.
21
23
  */
22
24
 
23
25
  import type {
@@ -33,6 +35,12 @@ import { Button } from '../button'
33
35
  import { Checkbox } from '../checkbox'
34
36
  import { TableBody, TableCell, TableRow } from './table-elements'
35
37
 
38
+ import {
39
+ splitAtRightPinned,
40
+ stickyCellStyle,
41
+ type PinInfo,
42
+ type ScrollEdges,
43
+ } from './sticky'
36
44
  import type {
37
45
  DataGridColumn,
38
46
  DataGridRowBase,
@@ -68,6 +76,9 @@ type DataGridTableBodyProps<
68
76
  onToggleRowSelection: (rowId: string, checked: boolean) => void
69
77
  columnWidths: Record<ColumnId, number>
70
78
  draggingColumnId: ColumnId | null
79
+ pinOffsets: Map<ColumnId, PinInfo>
80
+ scrollEdges: ScrollEdges
81
+ fillWidth: boolean
71
82
  }
72
83
 
73
84
  export function DataGridTableBody<
@@ -93,14 +104,161 @@ export function DataGridTableBody<
93
104
  onToggleRowSelection,
94
105
  columnWidths,
95
106
  draggingColumnId,
107
+ pinOffsets,
108
+ scrollEdges,
109
+ fillWidth,
96
110
  }: DataGridTableBodyProps<Row, ColumnId>) {
111
+ const { leading, rightPinned } = splitAtRightPinned(visibleColumns)
112
+
113
+ const controlStyle = stickyCellStyle({
114
+ role: 'body',
115
+ pin: { side: 'left', offset: 0 },
116
+ edges: scrollEdges,
117
+ stickyHeader: false,
118
+ stickyFooter: false,
119
+ })
120
+
121
+ const renderDataCell = (
122
+ row: Row,
123
+ rowIndex: number,
124
+ column: DataGridColumn<ColumnId>,
125
+ colIndex: number,
126
+ ) => {
127
+ const isEditing =
128
+ editingCell?.rowId === row.id && editingCell.columnId === column.id
129
+ const editable = isEditableColumn(column.id)
130
+ const showDrawerAction = canOpenDrawer(column.id)
131
+ const isDraggingThisColumn = draggingColumnId === column.id
132
+ const sticky = stickyCellStyle({
133
+ role: 'body',
134
+ pin: pinOffsets.get(column.id) ?? null,
135
+ edges: scrollEdges,
136
+ stickyHeader: false,
137
+ stickyFooter: false,
138
+ })
139
+
140
+ return (
141
+ <TableCell
142
+ key={`${row.id}-${column.id}`}
143
+ data-grid-cell="true"
144
+ data-row-index={rowIndex}
145
+ data-col-index={colIndex}
146
+ tabIndex={isEditing ? -1 : 0}
147
+ className={cn(
148
+ 'group/cell relative h-10 overflow-hidden px-2 py-1 whitespace-nowrap outline-none',
149
+ editable && 'cursor-text',
150
+ )}
151
+ style={{
152
+ width: columnWidths[column.id],
153
+ minWidth: columnWidths[column.id],
154
+ borderRight: '1px solid rgb(var(--border))',
155
+ ...sticky,
156
+ ...(isDraggingThisColumn
157
+ ? { background: 'rgb(var(--surface-overlay-soft))' }
158
+ : null),
159
+ }}
160
+ onDoubleClick={() => {
161
+ if (editable) startEditing(row, column.id)
162
+ }}
163
+ onKeyDown={(event) =>
164
+ onCellKeyDown(event, row, rowIndex, column, colIndex)
165
+ }
166
+ >
167
+ {isEditing ? (
168
+ <input
169
+ ref={inputRef}
170
+ value={draftValue}
171
+ onChange={(event) => setDraftValue(event.target.value)}
172
+ onBlur={commitEdit}
173
+ onKeyDown={(event) => {
174
+ if (event.key === 'Enter') {
175
+ event.preventDefault()
176
+ commitEdit()
177
+ }
178
+
179
+ if (event.key === 'Escape') {
180
+ event.preventDefault()
181
+ cancelEdit()
182
+ }
183
+ }}
184
+ className="h-7 w-full px-2 text-xs outline-none rounded-[var(--radius-sm)]"
185
+ style={{
186
+ background: 'rgb(var(--bg-input))',
187
+ border: '1px solid rgb(var(--border-strong))',
188
+ color: 'rgb(var(--foreground))',
189
+ }}
190
+ />
191
+ ) : (
192
+ <>
193
+ <span
194
+ aria-hidden
195
+ className="pointer-events-none absolute inset-0 transition-colors duration-150"
196
+ style={{ border: '1px solid transparent' }}
197
+ />
198
+ <div
199
+ className={cn(
200
+ 'min-w-0 truncate transition-[padding] duration-150',
201
+ showDrawerAction &&
202
+ 'group-hover/cell:pr-16 group-focus-within/cell:pr-16',
203
+ )}
204
+ style={{ color: 'rgb(var(--foreground))' }}
205
+ >
206
+ {renderCell(row, column)}
207
+ </div>
208
+ {showDrawerAction ? (
209
+ <div className="pointer-events-none absolute top-1/2 right-1.5 z-10 -translate-y-1/2 opacity-0 transition-all group-focus-within/cell:pointer-events-auto group-focus-within/cell:opacity-100 group-hover/cell:pointer-events-auto group-hover/cell:opacity-100">
210
+ <Button
211
+ type="button"
212
+ variant="outline"
213
+ size="sm"
214
+ aria-label={`Open details for ${getRowLabel(row)} ${column.label}`}
215
+ title="Open drawer"
216
+ className="h-7 px-2.5"
217
+ onPointerDown={(event) => {
218
+ event.stopPropagation()
219
+ }}
220
+ onDoubleClick={(event) => {
221
+ event.stopPropagation()
222
+ }}
223
+ onClick={(event) => {
224
+ event.stopPropagation()
225
+ const originElement =
226
+ event.currentTarget.closest('td') ?? event.currentTarget
227
+ const originRect = originElement.getBoundingClientRect()
228
+
229
+ onOpenDrawer({
230
+ rowId: row.id,
231
+ columnId: column.id,
232
+ originRect: {
233
+ x: originRect.x,
234
+ y: originRect.y,
235
+ width: originRect.width,
236
+ height: originRect.height,
237
+ },
238
+ })
239
+ }}
240
+ leadingIcon={<ExternalLink className="h-3.5 w-3.5" />}
241
+ >
242
+ Open
243
+ </Button>
244
+ </div>
245
+ ) : null}
246
+ </>
247
+ )}
248
+ </TableCell>
249
+ )
250
+ }
251
+
97
252
  return (
98
253
  <TableBody>
99
254
  {visibleRows.map((row, rowIndex) => (
100
255
  <TableRow key={row.id}>
101
256
  <TableCell
102
257
  className="h-10 px-0 text-center"
103
- style={{ borderRight: '1px solid rgb(var(--border))' }}
258
+ style={{
259
+ borderRight: '1px solid rgb(var(--border))',
260
+ ...controlStyle,
261
+ }}
104
262
  >
105
263
  <Checkbox
106
264
  aria-label={`Select ${getRowLabel(row)}`}
@@ -111,126 +269,15 @@ export function DataGridTableBody<
111
269
  className="mx-auto"
112
270
  />
113
271
  </TableCell>
114
- {visibleColumns.map((column, colIndex) => {
115
- const isEditing =
116
- editingCell?.rowId === row.id &&
117
- editingCell.columnId === column.id
118
- const editable = isEditableColumn(column.id)
119
- const showDrawerAction = canOpenDrawer(column.id)
120
- const isDraggingThisColumn = draggingColumnId === column.id
121
-
122
- return (
123
- <TableCell
124
- key={`${row.id}-${column.id}`}
125
- data-grid-cell="true"
126
- data-row-index={rowIndex}
127
- data-col-index={colIndex}
128
- tabIndex={isEditing ? -1 : 0}
129
- className={cn(
130
- 'group/cell relative h-10 overflow-hidden px-2 py-1 whitespace-nowrap outline-none',
131
- editable && 'cursor-text',
132
- )}
133
- style={{
134
- width: columnWidths[column.id],
135
- minWidth: columnWidths[column.id],
136
- borderRight: '1px solid rgb(var(--border))',
137
- background: isDraggingThisColumn
138
- ? 'rgb(var(--surface-overlay-soft))'
139
- : undefined,
140
- }}
141
- onDoubleClick={() => {
142
- if (editable) startEditing(row, column.id)
143
- }}
144
- onKeyDown={(event) =>
145
- onCellKeyDown(event, row, rowIndex, column, colIndex)
146
- }
147
- >
148
- {isEditing ? (
149
- <input
150
- ref={inputRef}
151
- value={draftValue}
152
- onChange={(event) => setDraftValue(event.target.value)}
153
- onBlur={commitEdit}
154
- onKeyDown={(event) => {
155
- if (event.key === 'Enter') {
156
- event.preventDefault()
157
- commitEdit()
158
- }
159
-
160
- if (event.key === 'Escape') {
161
- event.preventDefault()
162
- cancelEdit()
163
- }
164
- }}
165
- className="h-7 w-full px-2 text-xs outline-none rounded-[var(--radius-sm)]"
166
- style={{
167
- background: 'rgb(var(--bg-input))',
168
- border: '1px solid rgb(var(--border-strong))',
169
- color: 'rgb(var(--foreground))',
170
- }}
171
- />
172
- ) : (
173
- <>
174
- <span
175
- aria-hidden
176
- className="pointer-events-none absolute inset-0 transition-colors duration-150"
177
- style={{ border: '1px solid transparent' }}
178
- />
179
- <div
180
- className={cn(
181
- 'min-w-0 truncate transition-[padding] duration-150',
182
- showDrawerAction &&
183
- 'group-hover/cell:pr-16 group-focus-within/cell:pr-16',
184
- )}
185
- style={{ color: 'rgb(var(--foreground))' }}
186
- >
187
- {renderCell(row, column)}
188
- </div>
189
- {showDrawerAction ? (
190
- <div className="pointer-events-none absolute top-1/2 right-1.5 z-10 -translate-y-1/2 opacity-0 transition-all group-focus-within/cell:pointer-events-auto group-focus-within/cell:opacity-100 group-hover/cell:pointer-events-auto group-hover/cell:opacity-100">
191
- <Button
192
- type="button"
193
- variant="outline"
194
- size="sm"
195
- aria-label={`Open details for ${getRowLabel(row)} ${column.label}`}
196
- title="Open drawer"
197
- className="h-7 px-2.5"
198
- onPointerDown={(event) => {
199
- event.stopPropagation()
200
- }}
201
- onDoubleClick={(event) => {
202
- event.stopPropagation()
203
- }}
204
- onClick={(event) => {
205
- event.stopPropagation()
206
- const originElement =
207
- event.currentTarget.closest('td') ??
208
- event.currentTarget
209
- const originRect =
210
- originElement.getBoundingClientRect()
211
-
212
- onOpenDrawer({
213
- rowId: row.id,
214
- columnId: column.id,
215
- originRect: {
216
- x: originRect.x,
217
- y: originRect.y,
218
- width: originRect.width,
219
- height: originRect.height,
220
- },
221
- })
222
- }}
223
- leadingIcon={<ExternalLink className="h-3.5 w-3.5" />}
224
- >
225
- Open
226
- </Button>
227
- </div>
228
- ) : null}
229
- </>
230
- )}
231
- </TableCell>
232
- )
233
- })}
272
+ {leading.map((column, colIndex) =>
273
+ renderDataCell(row, rowIndex, column, colIndex),
274
+ )}
275
+ {fillWidth ? (
276
+ <td aria-hidden style={{ width: 'auto', padding: 0 }} />
277
+ ) : null}
278
+ {rightPinned.map((column, i) =>
279
+ renderDataCell(row, rowIndex, column, leading.length + i),
280
+ )}
234
281
  </TableRow>
235
282
  ))}
236
283
  </TableBody>
@@ -11,6 +11,8 @@
11
11
  * • Token reskin per ADR-025 D7
12
12
  * • Checkbox imported from workspace BUILD_FRESH primitive (DEV-2)
13
13
  * • Table sub-components from local table-elements (DEV-1)
14
+ * • Sticky header + pinned columns + fill-width spacer (workspace
15
+ * addition) via the shared sticky helper.
14
16
  */
15
17
 
16
18
  import type { PointerEvent as ReactPointerEvent } from 'react'
@@ -19,10 +21,15 @@ import { Checkbox } from '../checkbox'
19
21
  import { TableHead, TableHeader, TableRow } from './table-elements'
20
22
 
21
23
  import { SortableHeaderCell } from './sortable-parts'
24
+ import {
25
+ CONTROL_COLUMN_WIDTH,
26
+ splitAtRightPinned,
27
+ stickyCellStyle,
28
+ type PinInfo,
29
+ type ScrollEdges,
30
+ } from './sticky'
22
31
  import type { DataGridColumn, DataGridSortState } from './types'
23
32
 
24
- const CONTROL_COLUMN_WIDTH = 40
25
-
26
33
  type DataGridTableHeaderProps<ColumnId extends string> = {
27
34
  visibleColumns: DataGridColumn<ColumnId>[]
28
35
  columnWidths: Record<ColumnId, number>
@@ -35,6 +42,10 @@ type DataGridTableHeaderProps<ColumnId extends string> = {
35
42
  ) => void
36
43
  sort: DataGridSortState<ColumnId> | null
37
44
  onSortChange: ((columnId: ColumnId) => void) | undefined
45
+ pinOffsets: Map<ColumnId, PinInfo>
46
+ scrollEdges: ScrollEdges
47
+ stickyHeader: boolean
48
+ fillWidth: boolean
38
49
  }
39
50
 
40
51
  export function DataGridTableHeader<ColumnId extends string>({
@@ -46,16 +57,60 @@ export function DataGridTableHeader<ColumnId extends string>({
46
57
  onResizeStart,
47
58
  sort,
48
59
  onSortChange,
60
+ pinOffsets,
61
+ scrollEdges,
62
+ stickyHeader,
63
+ fillWidth,
49
64
  }: DataGridTableHeaderProps<ColumnId>) {
65
+ const { leading, rightPinned } = splitAtRightPinned(visibleColumns)
66
+
67
+ const styleFor = (column: DataGridColumn<ColumnId>) =>
68
+ stickyCellStyle({
69
+ role: 'header',
70
+ pin: pinOffsets.get(column.id) ?? null,
71
+ edges: scrollEdges,
72
+ stickyHeader,
73
+ stickyFooter: false,
74
+ })
75
+
76
+ const controlStyle = stickyCellStyle({
77
+ role: 'header',
78
+ pin: { side: 'left', offset: 0 },
79
+ edges: scrollEdges,
80
+ stickyHeader,
81
+ stickyFooter: false,
82
+ })
83
+
84
+ const spacerStyle = stickyCellStyle({
85
+ role: 'header',
86
+ pin: null,
87
+ edges: scrollEdges,
88
+ stickyHeader,
89
+ stickyFooter: false,
90
+ })
91
+
92
+ const renderHeadCell = (column: DataGridColumn<ColumnId>) => (
93
+ <SortableHeaderCell
94
+ key={column.id}
95
+ column={column}
96
+ width={columnWidths[column.id]}
97
+ onResize={onResizeStart}
98
+ sortDir={sort?.columnId === column.id ? sort.dir : null}
99
+ onSortChange={onSortChange}
100
+ cellStyle={styleFor(column)}
101
+ />
102
+ )
103
+
50
104
  return (
51
105
  <>
52
106
  <colgroup>
53
107
  <col style={{ width: CONTROL_COLUMN_WIDTH }} />
54
- {visibleColumns.map((column) => (
55
- <col
56
- key={`col-${column.id}`}
57
- style={{ width: columnWidths[column.id] }}
58
- />
108
+ {leading.map((column) => (
109
+ <col key={`col-${column.id}`} style={{ width: columnWidths[column.id] }} />
110
+ ))}
111
+ {fillWidth ? <col style={{ width: 'auto' }} /> : null}
112
+ {rightPinned.map((column) => (
113
+ <col key={`col-${column.id}`} style={{ width: columnWidths[column.id] }} />
59
114
  ))}
60
115
  </colgroup>
61
116
 
@@ -66,6 +121,7 @@ export function DataGridTableHeader<ColumnId extends string>({
66
121
  style={{
67
122
  borderRight: '1px solid rgb(var(--border))',
68
123
  background: 'rgb(var(--surface-overlay-soft))',
124
+ ...controlStyle,
69
125
  }}
70
126
  >
71
127
  <Checkbox
@@ -76,18 +132,11 @@ export function DataGridTableHeader<ColumnId extends string>({
76
132
  className="mx-auto"
77
133
  />
78
134
  </TableHead>
79
- {visibleColumns.map((column) => (
80
- <SortableHeaderCell
81
- key={column.id}
82
- column={column}
83
- width={columnWidths[column.id]}
84
- onResize={onResizeStart}
85
- sortDir={
86
- sort?.columnId === column.id ? sort.dir : null
87
- }
88
- onSortChange={onSortChange}
89
- />
90
- ))}
135
+ {leading.map(renderHeadCell)}
136
+ {fillWidth ? (
137
+ <th aria-hidden style={{ width: 'auto', padding: 0, ...spacerStyle }} />
138
+ ) : null}
139
+ {rightPinned.map(renderHeadCell)}
91
140
  </TableRow>
92
141
  </TableHeader>
93
142
  </>
@@ -6,14 +6,21 @@
6
6
  * License: MIT (see packages/ui/licenses/gray-ui-csm-LICENSE.txt)
7
7
  * Workspace ADR: ADR-025
8
8
  *
9
- * BRING_WITH_REFACTOR: token reskin per ADR-025 D7.
9
+ * BRING_WITH_REFACTOR: token reskin per ADR-025 D7; sticky footer +
10
+ * pinned columns + fill-width spacer via the shared sticky helper.
10
11
  */
11
12
 
12
- import type { CSSProperties, ReactNode } from 'react'
13
+ import type { ReactNode } from 'react'
13
14
 
14
15
  import { cn } from '../lib/utils'
15
16
  import { TableCell, TableFooter, TableRow } from './table-elements'
16
17
 
18
+ import {
19
+ splitAtRightPinned,
20
+ stickyCellStyle,
21
+ type PinInfo,
22
+ type ScrollEdges,
23
+ } from './sticky'
17
24
  import type { DataGridColumn, DataGridRowBase } from './types'
18
25
 
19
26
  type DataGridSummaryFooterProps<
@@ -33,6 +40,9 @@ type DataGridSummaryFooterProps<
33
40
  columnWidths: Record<ColumnId, number>
34
41
  draggingColumnId: ColumnId | null
35
42
  isEmptyValue: (value: ReactNode) => boolean
43
+ pinOffsets: Map<ColumnId, PinInfo>
44
+ scrollEdges: ScrollEdges
45
+ fillWidth: boolean
36
46
  }
37
47
 
38
48
  export function DataGridSummaryFooter<
@@ -47,18 +57,48 @@ export function DataGridSummaryFooter<
47
57
  columnWidths,
48
58
  draggingColumnId,
49
59
  isEmptyValue,
60
+ pinOffsets,
61
+ scrollEdges,
62
+ fillWidth,
50
63
  }: DataGridSummaryFooterProps<Row, ColumnId>) {
51
64
  if (!showSummaries || !renderSummary) return null
52
65
 
53
- const stickyCellStyle: CSSProperties | undefined = stickySummaryFooter
54
- ? {
55
- position: 'sticky',
56
- bottom: 0,
57
- zIndex: 10,
58
- background: 'rgb(var(--bg-card))',
59
- borderTop: '1px solid rgb(var(--border))',
60
- }
61
- : undefined
66
+ const { leading, rightPinned } = splitAtRightPinned(visibleColumns)
67
+
68
+ const footerStyle = (pin: PinInfo | null) =>
69
+ stickyCellStyle({
70
+ role: 'footer',
71
+ pin,
72
+ edges: scrollEdges,
73
+ stickyHeader: false,
74
+ stickyFooter: stickySummaryFooter,
75
+ })
76
+
77
+ const renderSummaryCell = (column: DataGridColumn<ColumnId>) => {
78
+ const summaryContent = renderSummary(column, visibleRows)
79
+ const isDraggingThisColumn = draggingColumnId === column.id
80
+
81
+ return (
82
+ <TableCell
83
+ key={`summary-${column.id}`}
84
+ className={cn('h-10 px-2 py-1 text-left text-xs whitespace-nowrap')}
85
+ style={{
86
+ width: columnWidths[column.id],
87
+ minWidth: columnWidths[column.id],
88
+ color: 'rgb(var(--text-muted))',
89
+ borderRight: '1px solid rgb(var(--border))',
90
+ ...footerStyle(pinOffsets.get(column.id) ?? null),
91
+ ...(isDraggingThisColumn
92
+ ? { background: 'rgb(var(--surface-overlay-soft))' }
93
+ : null),
94
+ }}
95
+ >
96
+ {!isEmptyValue(summaryContent) ? (
97
+ <span className="inline-block">{summaryContent}</span>
98
+ ) : null}
99
+ </TableCell>
100
+ )
101
+ }
62
102
 
63
103
  return (
64
104
  <TableFooter>
@@ -67,34 +107,17 @@ export function DataGridSummaryFooter<
67
107
  className={cn('h-10 px-2 py-1')}
68
108
  style={{
69
109
  borderRight: '1px solid rgb(var(--border))',
70
- ...stickyCellStyle,
110
+ ...footerStyle({ side: 'left', offset: 0 }),
71
111
  }}
72
112
  />
73
- {visibleColumns.map((column) => {
74
- const summaryContent = renderSummary(column, visibleRows)
75
- const isDraggingThisColumn = draggingColumnId === column.id
76
-
77
- return (
78
- <TableCell
79
- key={`summary-${column.id}`}
80
- className={cn('h-10 px-2 py-1 text-left text-xs whitespace-nowrap')}
81
- style={{
82
- width: columnWidths[column.id],
83
- minWidth: columnWidths[column.id],
84
- color: 'rgb(var(--text-muted))',
85
- borderRight: '1px solid rgb(var(--border))',
86
- background: isDraggingThisColumn
87
- ? 'rgb(var(--surface-overlay-soft))'
88
- : stickyCellStyle?.background,
89
- ...stickyCellStyle,
90
- }}
91
- >
92
- {!isEmptyValue(summaryContent) ? (
93
- <span className="inline-block">{summaryContent}</span>
94
- ) : null}
95
- </TableCell>
96
- )
97
- })}
113
+ {leading.map(renderSummaryCell)}
114
+ {fillWidth ? (
115
+ <td
116
+ aria-hidden
117
+ style={{ width: 'auto', padding: 0, ...footerStyle(null) }}
118
+ />
119
+ ) : null}
120
+ {rightPinned.map(renderSummaryCell)}
98
121
  </TableRow>
99
122
  </TableFooter>
100
123
  )
@@ -25,6 +25,7 @@ import {
25
25
  DataGridTableBody,
26
26
  DataGridTableHeader,
27
27
  } from './table-parts'
28
+ import { computePinOffsets, type ScrollEdges } from './sticky'
28
29
  import type {
29
30
  DataGridColumn,
30
31
  DataGridRowBase,
@@ -73,6 +74,9 @@ type DataGridTableViewProps<
73
74
  ) => ReactNode)
74
75
  | undefined
75
76
  stickySummaryFooter: boolean
77
+ stickyHeader: boolean
78
+ fillWidth: boolean
79
+ scrollEdges: ScrollEdges
76
80
  tableContainerClassName: string | undefined
77
81
  isEmptyValue: (value: ReactNode) => boolean
78
82
  onResizeStart: (
@@ -114,6 +118,9 @@ export function DataGridTableView<
114
118
  showSummaries,
115
119
  renderSummary,
116
120
  stickySummaryFooter,
121
+ stickyHeader,
122
+ fillWidth,
123
+ scrollEdges,
117
124
  tableContainerClassName,
118
125
  isEmptyValue,
119
126
  onResizeStart,
@@ -121,6 +128,7 @@ export function DataGridTableView<
121
128
  sort,
122
129
  onSortChange,
123
130
  }: DataGridTableViewProps<Row, ColumnId>) {
131
+ const pinOffsets = computePinOffsets(visibleColumns, columnWidths)
124
132
  return (
125
133
  <Table
126
134
  ref={tableRef}
@@ -137,6 +145,10 @@ export function DataGridTableView<
137
145
  onResizeStart={onResizeStart}
138
146
  sort={sort}
139
147
  onSortChange={onSortChange}
148
+ pinOffsets={pinOffsets}
149
+ scrollEdges={scrollEdges}
150
+ stickyHeader={stickyHeader}
151
+ fillWidth={fillWidth}
140
152
  />
141
153
 
142
154
  <DataGridTableBody
@@ -159,6 +171,9 @@ export function DataGridTableView<
159
171
  onToggleRowSelection={onToggleRowSelection}
160
172
  columnWidths={columnWidths}
161
173
  draggingColumnId={draggingColumnId}
174
+ pinOffsets={pinOffsets}
175
+ scrollEdges={scrollEdges}
176
+ fillWidth={fillWidth}
162
177
  />
163
178
 
164
179
  <DataGridSummaryFooter
@@ -170,6 +185,9 @@ export function DataGridTableView<
170
185
  columnWidths={columnWidths}
171
186
  draggingColumnId={draggingColumnId}
172
187
  isEmptyValue={isEmptyValue}
188
+ pinOffsets={pinOffsets}
189
+ scrollEdges={scrollEdges}
190
+ fillWidth={fillWidth}
173
191
  />
174
192
  </Table>
175
193
  )
@@ -31,6 +31,12 @@ export type DataGridColumn<ColumnId extends string> = {
31
31
  * `onSortChange` is provided; false when not. Set explicitly to disable
32
32
  * sorting for columns that don't have a meaningful comparator. */
33
33
  sortable?: boolean
34
+ /** Pin this column to the left or right edge so it stays visible while
35
+ * the grid scrolls horizontally. Pinned columns render opaque with a
36
+ * soft scroll-shadow on their inner edge. Place left-pinned columns
37
+ * first and right-pinned columns last. The built-in select column is
38
+ * always pinned left. */
39
+ pin?: 'left' | 'right'
34
40
  }
35
41
 
36
42
  /** Row-sort state (ADR-027). DataGrid renders the chevron + ARIA per
@@ -115,6 +121,15 @@ export type DataGridProps<
115
121
  * Defaults to 520 (the FloatingDrawer side="right" default). */
116
122
  drawerSize?: number
117
123
  stickySummaryFooter?: boolean
124
+ /** Keep the header row pinned to the top while the body scrolls. Renders
125
+ * opaque with a scroll-shadow once scrolled. Default false. */
126
+ stickyHeader?: boolean
127
+ /** Stretch the table to fill its container width via a trailing flex
128
+ * "spacer" column that absorbs leftover space — real columns keep their
129
+ * exact widths, so column resize stays independent. When the columns
130
+ * outgrow the viewport the spacer collapses to 0 and the grid scrolls.
131
+ * Default false. */
132
+ fillWidth?: boolean
118
133
  fillAvailableHeight?: boolean
119
134
  tableContainerClassName?: string
120
135
  onRowsChange?: (rows: Row[]) => void