@lovett/ui 0.0.1 → 0.0.3

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.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "restricted"
package/src/badge.tsx CHANGED
@@ -17,6 +17,8 @@
17
17
 
18
18
  import type { ReactNode } from 'react'
19
19
 
20
+ import { cn } from './lib/utils'
21
+
20
22
  export type BadgeTone =
21
23
  | 'neutral'
22
24
  | 'accent'
@@ -57,33 +59,51 @@ export interface BadgeProps {
57
59
  children: ReactNode
58
60
  /** Optional leading icon — sized 12-14px expected. */
59
61
  icon?: ReactNode
60
- /** Size variant. `sm` = compact (10px text), `md` = default (11px). */
61
- size?: 'sm' | 'md'
62
- /** Optional className override on the outer span. */
62
+ /** Leading status dot in the tone color (gray-ui chip style). Ignored
63
+ * when `icon` is provided. */
64
+ dot?: boolean
65
+ /** Size variant. `sm` = compact (10px), `md` = default (11px),
66
+ * `lg` = roomier category-chip (12px, taller). */
67
+ size?: 'sm' | 'md' | 'lg'
68
+ /** Extra classes merged onto the outer span (no longer replaces base). */
63
69
  className?: string
64
70
  }
65
71
 
72
+ const SIZE_CLASS: Record<NonNullable<BadgeProps['size']>, string> = {
73
+ sm: 'text-[10px] px-1.5 py-0.5',
74
+ md: 'text-[11px] px-2 py-0.5',
75
+ lg: 'text-xs px-2.5 py-1',
76
+ }
77
+
66
78
  export function Badge({
67
79
  tone,
68
80
  children,
69
81
  icon,
82
+ dot = false,
70
83
  size = 'md',
71
84
  className,
72
85
  }: BadgeProps) {
73
86
  const { bg, color } = TONE_STYLE[tone]
74
- const sizeClass = size === 'sm' ? 'text-[10px] px-1.5 py-0.5' : 'text-[11px] px-2 py-0.5'
75
87
  return (
76
88
  <span
77
- className={
78
- className ??
79
- `inline-flex items-center gap-1 font-medium ${sizeClass}`
80
- }
89
+ className={cn(
90
+ 'inline-flex w-fit items-center gap-1.5 font-medium whitespace-nowrap',
91
+ SIZE_CLASS[size],
92
+ className,
93
+ )}
81
94
  style={{
82
95
  background: bg,
83
96
  color,
84
97
  borderRadius: 'var(--radius-full)',
85
98
  }}
86
99
  >
100
+ {dot && !icon ? (
101
+ <span
102
+ aria-hidden
103
+ className="size-1.5 shrink-0 rounded-full"
104
+ style={{ background: 'currentColor' }}
105
+ />
106
+ ) : null}
87
107
  {icon && <span className="flex-shrink-0">{icon}</span>}
88
108
  {children}
89
109
  </span>
@@ -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,10 @@ 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
+ fillHeight={fillAvailableHeight}
419
+ scrollEdges={scrollEdges}
364
420
  tableContainerClassName={tableContainerClassName}
365
421
  isEmptyValue={isEmptyValue}
366
422
  onResizeStart={beginResize}
@@ -15,7 +15,9 @@
15
15
  * local table-elements (inlined per DEV-1)
16
16
  */
17
17
 
18
+ import { useRef } from 'react'
18
19
  import type {
20
+ CSSProperties,
19
21
  KeyboardEvent as ReactKeyboardEvent,
20
22
  PointerEvent as ReactPointerEvent,
21
23
  } from 'react'
@@ -43,6 +45,9 @@ type SortableHeaderCellProps<ColumnId extends string> = {
43
45
  /** ADR-027 — header click handler. When omitted the header is non-
44
46
  * interactive for sort (column reorder + resize still work). */
45
47
  onSortChange: ((columnId: ColumnId) => void) | undefined
48
+ /** Extra style merged onto the <th> (sticky/pinned positioning, opaque
49
+ * background + scroll-shadow). Spread last so it wins over the base. */
50
+ cellStyle?: CSSProperties
46
51
  }
47
52
 
48
53
  type SortableColumnOptionItemProps<ColumnId extends string> = {
@@ -130,9 +135,14 @@ export function SortableHeaderCell<ColumnId extends string>({
130
135
  onResize,
131
136
  sortDir,
132
137
  onSortChange,
138
+ cellStyle,
133
139
  }: SortableHeaderCellProps<ColumnId>) {
134
140
  // ADR-027 — column sortability: explicit `sortable: false` opts out;
135
141
  // missing `onSortChange` also disables (no parent handler).
142
+ // Suppress the sort click that the browser fires after a resize
143
+ // interaction (drag OR a stray click on the resize handle), so resizing
144
+ // never accidentally toggles the sort.
145
+ const justResizedRef = useRef(false)
136
146
  const isSortable =
137
147
  column.sortable !== false && typeof onSortChange === 'function'
138
148
  const ariaSort: 'ascending' | 'descending' | 'none' = !isSortable
@@ -145,9 +155,28 @@ export function SortableHeaderCell<ColumnId extends string>({
145
155
 
146
156
  function handleSortClick() {
147
157
  if (!isSortable) return
158
+ if (justResizedRef.current) return
148
159
  onSortChange!(column.id)
149
160
  }
150
161
 
162
+ function handleResizePointerDown(event: ReactPointerEvent<HTMLButtonElement>) {
163
+ // Don't bubble the resize-pointer-down up to the th's onClick sort
164
+ // handler — resize is its own interaction.
165
+ event.stopPropagation()
166
+ onResize(event, column.id)
167
+ const onUp = () => {
168
+ window.removeEventListener('pointerup', onUp)
169
+ justResizedRef.current = true
170
+ // Clear after the click that fires on this same pointerup has been
171
+ // dispatched (a later macrotask), so that click is swallowed but
172
+ // genuine subsequent clicks still sort.
173
+ window.setTimeout(() => {
174
+ justResizedRef.current = false
175
+ }, 0)
176
+ }
177
+ window.addEventListener('pointerup', onUp)
178
+ }
179
+
151
180
  function handleSortKey(event: ReactKeyboardEvent<HTMLDivElement>) {
152
181
  if (!isSortable) return
153
182
  if (event.key === 'Enter' || event.key === ' ') {
@@ -173,6 +202,7 @@ export function SortableHeaderCell<ColumnId extends string>({
173
202
  borderRight: '1px solid rgb(var(--border))',
174
203
  background: 'rgb(var(--surface-overlay-soft))',
175
204
  color: 'rgb(var(--foreground))',
205
+ ...cellStyle,
176
206
  }}
177
207
  >
178
208
  <div className="flex h-full min-w-0 items-center justify-between gap-1 overflow-hidden">
@@ -203,12 +233,8 @@ export function SortableHeaderCell<ColumnId extends string>({
203
233
  aria-label={`Resize ${column.label} column`}
204
234
  className="absolute top-0 right-0 flex h-full w-3 cursor-col-resize items-center justify-center transition-colors"
205
235
  style={{ color: 'rgb(var(--text-muted))' }}
206
- onPointerDown={(event) => {
207
- // Don't bubble the resize-pointer-down up to the th's onClick
208
- // sort handler — resize is its own interaction.
209
- event.stopPropagation()
210
- onResize(event, column.id)
211
- }}
236
+ onPointerDown={handleResizePointerDown}
237
+ onClick={(event) => event.stopPropagation()}
212
238
  />
213
239
  </TableHead>
214
240
  )
@@ -0,0 +1,153 @@
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 + footer composite their translucent tint
102
+ * over an opaque base so they read as a light-gray band but never let
103
+ * content through. Body pinned cells stay plain card-colored. */
104
+ function opaqueBg(role: Role): CSSProperties {
105
+ if (role === 'header' || role === 'footer') {
106
+ return {
107
+ backgroundColor: 'rgb(var(--bg-card))',
108
+ backgroundImage:
109
+ 'linear-gradient(rgb(var(--surface-overlay-soft)), rgb(var(--surface-overlay-soft)))',
110
+ }
111
+ }
112
+ return { backgroundColor: 'rgb(var(--bg-card))' }
113
+ }
114
+
115
+ /**
116
+ * Compute the inline style for a (possibly) sticky cell. Returns `{}` when
117
+ * the cell isn't sticky in any axis, so callers can spread it
118
+ * unconditionally.
119
+ */
120
+ export function stickyCellStyle(opts: {
121
+ role: Role
122
+ pin?: PinInfo | null
123
+ edges: ScrollEdges
124
+ stickyHeader: boolean
125
+ stickyFooter: boolean
126
+ }): CSSProperties {
127
+ const { role, pin, edges, stickyHeader, stickyFooter } = opts
128
+ const verticalSticky =
129
+ (role === 'header' && stickyHeader) || (role === 'footer' && stickyFooter)
130
+ if (!verticalSticky && !pin) return {}
131
+
132
+ const style: CSSProperties = { position: 'sticky' }
133
+
134
+ if (role === 'header' && stickyHeader) style.top = 0
135
+ if (role === 'footer' && stickyFooter) style.bottom = 0
136
+ if (pin?.side === 'left') style.left = pin.offset
137
+ if (pin?.side === 'right') style.right = pin.offset
138
+
139
+ Object.assign(style, opaqueBg(role))
140
+
141
+ // Corners (vertical-sticky ∩ pinned) must sit above both axes.
142
+ const vertZ = role === 'header' ? 30 : role === 'footer' ? 20 : 0
143
+ style.zIndex = vertZ + (pin ? 10 : 0) || (pin ? 10 : undefined)
144
+
145
+ const shadows: string[] = []
146
+ if (role === 'header' && stickyHeader && !edges.atTop) shadows.push(SHADOW_DOWN)
147
+ if (role === 'footer' && stickyFooter && !edges.atBottom) shadows.push(SHADOW_UP)
148
+ if (pin?.side === 'left' && !edges.atLeft) shadows.push(SHADOW_RIGHT)
149
+ if (pin?.side === 'right' && !edges.atRight) shadows.push(SHADOW_LEFT)
150
+ if (shadows.length) style.boxShadow = shadows.join(', ')
151
+
152
+ return style
153
+ }
@@ -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,10 @@ 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
82
+ fillHeight: boolean
71
83
  }
72
84
 
73
85
  export function DataGridTableBody<
@@ -93,14 +105,165 @@ export function DataGridTableBody<
93
105
  onToggleRowSelection,
94
106
  columnWidths,
95
107
  draggingColumnId,
108
+ pinOffsets,
109
+ scrollEdges,
110
+ fillWidth,
111
+ fillHeight,
96
112
  }: DataGridTableBodyProps<Row, ColumnId>) {
113
+ const { leading, rightPinned } = splitAtRightPinned(visibleColumns)
114
+
115
+ const controlStyle = stickyCellStyle({
116
+ role: 'body',
117
+ pin: { side: 'left', offset: 0 },
118
+ edges: scrollEdges,
119
+ stickyHeader: false,
120
+ stickyFooter: false,
121
+ })
122
+
123
+ const renderDataCell = (
124
+ row: Row,
125
+ rowIndex: number,
126
+ column: DataGridColumn<ColumnId>,
127
+ colIndex: number,
128
+ noRightBorder = false,
129
+ ) => {
130
+ const isEditing =
131
+ editingCell?.rowId === row.id && editingCell.columnId === column.id
132
+ const editable = isEditableColumn(column.id)
133
+ const showDrawerAction = canOpenDrawer(column.id)
134
+ const isDraggingThisColumn = draggingColumnId === column.id
135
+ const sticky = stickyCellStyle({
136
+ role: 'body',
137
+ pin: pinOffsets.get(column.id) ?? null,
138
+ edges: scrollEdges,
139
+ stickyHeader: false,
140
+ stickyFooter: false,
141
+ })
142
+
143
+ return (
144
+ <TableCell
145
+ key={`${row.id}-${column.id}`}
146
+ data-grid-cell="true"
147
+ data-row-index={rowIndex}
148
+ data-col-index={colIndex}
149
+ tabIndex={isEditing ? -1 : 0}
150
+ className={cn(
151
+ 'group/cell relative h-10 overflow-hidden px-2 py-1 whitespace-nowrap outline-none',
152
+ editable && 'cursor-text',
153
+ )}
154
+ style={{
155
+ width: columnWidths[column.id],
156
+ minWidth: columnWidths[column.id],
157
+ // The rightmost pinned cell drops its right border — the table
158
+ // container's own border already draws that edge (no double line).
159
+ borderRight: noRightBorder ? undefined : '1px solid rgb(var(--border))',
160
+ ...sticky,
161
+ ...(isDraggingThisColumn
162
+ ? { background: 'rgb(var(--surface-overlay-soft))' }
163
+ : null),
164
+ }}
165
+ onDoubleClick={() => {
166
+ if (editable) startEditing(row, column.id)
167
+ }}
168
+ onKeyDown={(event) =>
169
+ onCellKeyDown(event, row, rowIndex, column, colIndex)
170
+ }
171
+ >
172
+ {isEditing ? (
173
+ <input
174
+ ref={inputRef}
175
+ value={draftValue}
176
+ onChange={(event) => setDraftValue(event.target.value)}
177
+ onBlur={commitEdit}
178
+ onKeyDown={(event) => {
179
+ if (event.key === 'Enter') {
180
+ event.preventDefault()
181
+ commitEdit()
182
+ }
183
+
184
+ if (event.key === 'Escape') {
185
+ event.preventDefault()
186
+ cancelEdit()
187
+ }
188
+ }}
189
+ className="h-7 w-full px-2 text-xs outline-none rounded-[var(--radius-sm)]"
190
+ style={{
191
+ background: 'rgb(var(--bg-input))',
192
+ border: '1px solid rgb(var(--border-strong))',
193
+ color: 'rgb(var(--foreground))',
194
+ }}
195
+ />
196
+ ) : (
197
+ <>
198
+ <span
199
+ aria-hidden
200
+ className="pointer-events-none absolute inset-0 transition-colors duration-150"
201
+ style={{ border: '1px solid transparent' }}
202
+ />
203
+ <div
204
+ className={cn(
205
+ 'min-w-0 truncate transition-[padding] duration-150',
206
+ showDrawerAction &&
207
+ 'group-hover/cell:pr-16 group-focus-within/cell:pr-16',
208
+ )}
209
+ style={{ color: 'rgb(var(--foreground))' }}
210
+ >
211
+ {renderCell(row, column)}
212
+ </div>
213
+ {showDrawerAction ? (
214
+ <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">
215
+ <Button
216
+ type="button"
217
+ variant="outline"
218
+ size="sm"
219
+ aria-label={`Open details for ${getRowLabel(row)} ${column.label}`}
220
+ title="Open drawer"
221
+ className="h-7 px-2.5"
222
+ onPointerDown={(event) => {
223
+ event.stopPropagation()
224
+ }}
225
+ onDoubleClick={(event) => {
226
+ event.stopPropagation()
227
+ }}
228
+ onClick={(event) => {
229
+ event.stopPropagation()
230
+ const originElement =
231
+ event.currentTarget.closest('td') ?? event.currentTarget
232
+ const originRect = originElement.getBoundingClientRect()
233
+
234
+ onOpenDrawer({
235
+ rowId: row.id,
236
+ columnId: column.id,
237
+ originRect: {
238
+ x: originRect.x,
239
+ y: originRect.y,
240
+ width: originRect.width,
241
+ height: originRect.height,
242
+ },
243
+ })
244
+ }}
245
+ leadingIcon={<ExternalLink className="h-3.5 w-3.5" />}
246
+ >
247
+ Open
248
+ </Button>
249
+ </div>
250
+ ) : null}
251
+ </>
252
+ )}
253
+ </TableCell>
254
+ )
255
+ }
256
+
97
257
  return (
98
258
  <TableBody>
99
259
  {visibleRows.map((row, rowIndex) => (
100
260
  <TableRow key={row.id}>
101
261
  <TableCell
102
262
  className="h-10 px-0 text-center"
103
- style={{ borderRight: '1px solid rgb(var(--border))' }}
263
+ style={{
264
+ borderRight: '1px solid rgb(var(--border))',
265
+ ...controlStyle,
266
+ }}
104
267
  >
105
268
  <Checkbox
106
269
  aria-label={`Select ${getRowLabel(row)}`}
@@ -111,128 +274,30 @@ export function DataGridTableBody<
111
274
  className="mx-auto"
112
275
  />
113
276
  </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
- })}
277
+ {leading.map((column, colIndex) =>
278
+ renderDataCell(row, rowIndex, column, colIndex),
279
+ )}
280
+ {fillWidth ? (
281
+ <td aria-hidden style={{ width: 'auto', padding: 0 }} />
282
+ ) : null}
283
+ {rightPinned.map((column, i) =>
284
+ renderDataCell(
285
+ row,
286
+ rowIndex,
287
+ column,
288
+ leading.length + i,
289
+ i === rightPinned.length - 1,
290
+ ),
291
+ )}
234
292
  </TableRow>
235
293
  ))}
294
+ {fillHeight ? (
295
+ // Filler row absorbs the leftover height (table is height:100%) so
296
+ // the summary footer stays pinned to the bottom even with few rows.
297
+ <tr aria-hidden style={{ height: '100%' }}>
298
+ <td style={{ padding: 0, border: 0 }} colSpan={visibleColumns.length + 2} />
299
+ </tr>
300
+ ) : null}
236
301
  </TableBody>
237
302
  )
238
303
  }
@@ -34,6 +34,13 @@ export const Table = forwardRef<
34
34
  style={{
35
35
  border: '1px solid rgb(var(--border))',
36
36
  borderRadius: 'var(--radius-lg)',
37
+ // Thin, light scrollbars (light slate thumb on a transparent track).
38
+ // Deliberately NOT styling ::-webkit-scrollbar — that would force
39
+ // classic always-on bars; leaving it unset preserves the platform's
40
+ // auto-hiding overlay bars so they don't sit over the pinned action
41
+ // column / content.
42
+ scrollbarWidth: 'thin',
43
+ scrollbarColor: 'rgba(100, 116, 139, 0.4) transparent',
37
44
  }}
38
45
  >
39
46
  <table
@@ -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,66 @@ 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 = (
93
+ column: DataGridColumn<ColumnId>,
94
+ noRightBorder = false,
95
+ ) => (
96
+ <SortableHeaderCell
97
+ key={column.id}
98
+ column={column}
99
+ width={columnWidths[column.id]}
100
+ onResize={onResizeStart}
101
+ sortDir={sort?.columnId === column.id ? sort.dir : null}
102
+ onSortChange={onSortChange}
103
+ cellStyle={{
104
+ ...styleFor(column),
105
+ ...(noRightBorder ? { borderRight: 'none' } : null),
106
+ }}
107
+ />
108
+ )
109
+
50
110
  return (
51
111
  <>
52
112
  <colgroup>
53
113
  <col style={{ width: CONTROL_COLUMN_WIDTH }} />
54
- {visibleColumns.map((column) => (
55
- <col
56
- key={`col-${column.id}`}
57
- style={{ width: columnWidths[column.id] }}
58
- />
114
+ {leading.map((column) => (
115
+ <col key={`col-${column.id}`} style={{ width: columnWidths[column.id] }} />
116
+ ))}
117
+ {fillWidth ? <col style={{ width: 'auto' }} /> : null}
118
+ {rightPinned.map((column) => (
119
+ <col key={`col-${column.id}`} style={{ width: columnWidths[column.id] }} />
59
120
  ))}
60
121
  </colgroup>
61
122
 
@@ -66,6 +127,7 @@ export function DataGridTableHeader<ColumnId extends string>({
66
127
  style={{
67
128
  borderRight: '1px solid rgb(var(--border))',
68
129
  background: 'rgb(var(--surface-overlay-soft))',
130
+ ...controlStyle,
69
131
  }}
70
132
  >
71
133
  <Checkbox
@@ -76,18 +138,13 @@ export function DataGridTableHeader<ColumnId extends string>({
76
138
  className="mx-auto"
77
139
  />
78
140
  </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
- ))}
141
+ {leading.map((column) => renderHeadCell(column))}
142
+ {fillWidth ? (
143
+ <th aria-hidden style={{ width: 'auto', padding: 0, ...spacerStyle }} />
144
+ ) : null}
145
+ {rightPinned.map((column, i) =>
146
+ renderHeadCell(column, i === rightPinned.length - 1),
147
+ )}
91
148
  </TableRow>
92
149
  </TableHeader>
93
150
  </>
@@ -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,51 @@ 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 = (
78
+ column: DataGridColumn<ColumnId>,
79
+ noRightBorder = false,
80
+ ) => {
81
+ const summaryContent = renderSummary(column, visibleRows)
82
+ const isDraggingThisColumn = draggingColumnId === column.id
83
+
84
+ return (
85
+ <TableCell
86
+ key={`summary-${column.id}`}
87
+ className={cn('h-10 px-2 py-1 text-left text-xs whitespace-nowrap')}
88
+ style={{
89
+ width: columnWidths[column.id],
90
+ minWidth: columnWidths[column.id],
91
+ color: 'rgb(var(--text-muted))',
92
+ borderRight: noRightBorder ? undefined : '1px solid rgb(var(--border))',
93
+ ...footerStyle(pinOffsets.get(column.id) ?? null),
94
+ ...(isDraggingThisColumn
95
+ ? { background: 'rgb(var(--surface-overlay-soft))' }
96
+ : null),
97
+ }}
98
+ >
99
+ {!isEmptyValue(summaryContent) ? (
100
+ <span className="inline-block">{summaryContent}</span>
101
+ ) : null}
102
+ </TableCell>
103
+ )
104
+ }
62
105
 
63
106
  return (
64
107
  <TableFooter>
@@ -67,34 +110,19 @@ export function DataGridSummaryFooter<
67
110
  className={cn('h-10 px-2 py-1')}
68
111
  style={{
69
112
  borderRight: '1px solid rgb(var(--border))',
70
- ...stickyCellStyle,
113
+ ...footerStyle({ side: 'left', offset: 0 }),
71
114
  }}
72
115
  />
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
- })}
116
+ {leading.map((column) => renderSummaryCell(column))}
117
+ {fillWidth ? (
118
+ <td
119
+ aria-hidden
120
+ style={{ width: 'auto', padding: 0, ...footerStyle(null) }}
121
+ />
122
+ ) : null}
123
+ {rightPinned.map((column, i) =>
124
+ renderSummaryCell(column, i === rightPinned.length - 1),
125
+ )}
98
126
  </TableRow>
99
127
  </TableFooter>
100
128
  )
@@ -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,10 @@ type DataGridTableViewProps<
73
74
  ) => ReactNode)
74
75
  | undefined
75
76
  stickySummaryFooter: boolean
77
+ stickyHeader: boolean
78
+ fillWidth: boolean
79
+ fillHeight: boolean
80
+ scrollEdges: ScrollEdges
76
81
  tableContainerClassName: string | undefined
77
82
  isEmptyValue: (value: ReactNode) => boolean
78
83
  onResizeStart: (
@@ -114,6 +119,10 @@ export function DataGridTableView<
114
119
  showSummaries,
115
120
  renderSummary,
116
121
  stickySummaryFooter,
122
+ stickyHeader,
123
+ fillWidth,
124
+ fillHeight,
125
+ scrollEdges,
117
126
  tableContainerClassName,
118
127
  isEmptyValue,
119
128
  onResizeStart,
@@ -121,12 +130,19 @@ export function DataGridTableView<
121
130
  sort,
122
131
  onSortChange,
123
132
  }: DataGridTableViewProps<Row, ColumnId>) {
133
+ const pinOffsets = computePinOffsets(visibleColumns, columnWidths)
124
134
  return (
125
135
  <Table
126
136
  ref={tableRef}
127
137
  className="table-fixed"
128
138
  containerClassName={tableContainerClassName}
129
- style={{ width: `max(100%, ${gridMinWidth}px)` }}
139
+ style={{
140
+ width: `max(100%, ${gridMinWidth}px)`,
141
+ // When the grid fills its height, stretch the table to the full
142
+ // viewport so the spacer body row can push the summary footer to
143
+ // the bottom even with few rows.
144
+ ...(fillHeight ? { height: '100%' } : null),
145
+ }}
130
146
  >
131
147
  <DataGridTableHeader
132
148
  visibleColumns={visibleColumns}
@@ -137,6 +153,10 @@ export function DataGridTableView<
137
153
  onResizeStart={onResizeStart}
138
154
  sort={sort}
139
155
  onSortChange={onSortChange}
156
+ pinOffsets={pinOffsets}
157
+ scrollEdges={scrollEdges}
158
+ stickyHeader={stickyHeader}
159
+ fillWidth={fillWidth}
140
160
  />
141
161
 
142
162
  <DataGridTableBody
@@ -159,6 +179,10 @@ export function DataGridTableView<
159
179
  onToggleRowSelection={onToggleRowSelection}
160
180
  columnWidths={columnWidths}
161
181
  draggingColumnId={draggingColumnId}
182
+ pinOffsets={pinOffsets}
183
+ scrollEdges={scrollEdges}
184
+ fillWidth={fillWidth}
185
+ fillHeight={fillHeight}
162
186
  />
163
187
 
164
188
  <DataGridSummaryFooter
@@ -170,6 +194,9 @@ export function DataGridTableView<
170
194
  columnWidths={columnWidths}
171
195
  draggingColumnId={draggingColumnId}
172
196
  isEmptyValue={isEmptyValue}
197
+ pinOffsets={pinOffsets}
198
+ scrollEdges={scrollEdges}
199
+ fillWidth={fillWidth}
173
200
  />
174
201
  </Table>
175
202
  )
@@ -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
@@ -101,7 +101,29 @@ export function useGridColumns<ColumnId extends string>({
101
101
  setVisibleColumnIds((current) => {
102
102
  const kept = current.filter((id) => allowedIds.has(id))
103
103
  const missing = columnOrder.filter((id) => !kept.includes(id))
104
- return [...kept, ...missing]
104
+ if (missing.length === 0) {
105
+ return kept.length === current.length ? current : kept
106
+ }
107
+ // Insert newly-visible columns at their natural position (per the
108
+ // `columns` prop order) instead of appending them at the very end.
109
+ // Appending broke pinned trailing columns (e.g. an actions column
110
+ // pinned right would stop being last once another column was toggled
111
+ // on). Existing columns keep their (possibly drag-reordered) order.
112
+ const orderIndex = (id: ColumnId) => columnOrder.indexOf(id)
113
+ const next = [...kept]
114
+ for (const id of missing) {
115
+ const target = orderIndex(id)
116
+ let insertAt = next.length
117
+ for (let i = 0; i < next.length; i += 1) {
118
+ const cur = next[i]
119
+ if (cur !== undefined && orderIndex(cur) > target) {
120
+ insertAt = i
121
+ break
122
+ }
123
+ }
124
+ next.splice(insertAt, 0, id)
125
+ }
126
+ return next
105
127
  })
106
128
 
107
129
  orderBeforeDragRef.current = columnOrder