@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 +1 -1
- package/src/badge.tsx +28 -8
- package/src/data-grid/data-grid.tsx +56 -0
- package/src/data-grid/sortable-parts.tsx +32 -6
- package/src/data-grid/sticky.ts +153 -0
- package/src/data-grid/table-body.tsx +186 -121
- package/src/data-grid/table-elements.tsx +7 -0
- package/src/data-grid/table-header.tsx +76 -19
- package/src/data-grid/table-summary-footer.tsx +65 -37
- package/src/data-grid/table-view.tsx +28 -1
- package/src/data-grid/types.ts +15 -0
- package/src/data-grid/use-grid-columns.ts +23 -1
package/package.json
CHANGED
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
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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={
|
|
207
|
-
|
|
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={{
|
|
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
|
-
{
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
{
|
|
55
|
-
<col
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
{
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 {
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
...
|
|
113
|
+
...footerStyle({ side: 'left', offset: 0 }),
|
|
71
114
|
}}
|
|
72
115
|
/>
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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={{
|
|
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
|
)
|
package/src/data-grid/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|