@proteos/ui 0.54.1 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -107,6 +107,14 @@ export type DataTableProps<TRow> = {
107
107
  enableColumnRemoval?: boolean
108
108
  onReorderColumn?: (params: { id: string; toIndex: number }) => void
109
109
  onRemoveColumn?: (id: string) => void
110
+ /**
111
+ * Enable a resize handle on each column header's trailing separator.
112
+ * Widths are controlled: `onResizeColumn` reports the column id and its
113
+ * new pixel width while dragging; the parent feeds it back through the
114
+ * column defs' `size`. Each `ColumnDef` must carry a stable `id`.
115
+ */
116
+ enableColumnResizing?: boolean
117
+ onResizeColumn?: (params: { id: string; width: number }) => void
110
118
  /**
111
119
  * Visual chrome around the table. `panel` (default) wraps the table in a
112
120
  * bordered, rounded, soft-shadowed card. `bare` strips that chrome so the
@@ -135,8 +143,10 @@ export type DataTableProps<TRow> = {
135
143
  */
136
144
  bodyBefore?: ReactNode
137
145
  /**
138
- * Slot rendered BELOW the body. Used for controls like an Add row button
139
- * at slot='end'. Sits inside the scrollable area below the rows.
146
+ * Trailing row rendered directly below the last row — an Add row control at
147
+ * slot='end', say. Row-height, edge-to-edge, and sticky to the bottom of
148
+ * the scroll viewport, so it stays reachable while the end of a long list
149
+ * is scrolled out of view.
140
150
  */
141
151
  bodyAfter?: ReactNode
142
152
  /**
@@ -181,7 +191,7 @@ type ListItem<TRow> =
181
191
  const DEFAULT_ROW_HEIGHT = 32
182
192
 
183
193
  /** Column id of the leading checkbox column added by `selectionMode`. */
184
- export const SELECTION_COLUMN_ID = '__select__'
194
+ export const SELECTION_COLUMN_ID = '__select__'
185
195
 
186
196
  export function DataTable<TRow>({
187
197
  query,
@@ -202,6 +212,8 @@ export function DataTable<TRow>({
202
212
  enableColumnRemoval = false,
203
213
  onReorderColumn,
204
214
  onRemoveColumn,
215
+ enableColumnResizing = false,
216
+ onResizeColumn,
205
217
  variant = 'panel',
206
218
  rowWrapper,
207
219
  extraRows,
@@ -457,8 +469,6 @@ export function DataTable<TRow>({
457
469
  !query.isLoading && !query.error && tableRowCount === 0 && !query.isFetchingNextPage
458
470
  const showInitialLoading = query.isLoading && tableRowCount === 0
459
471
  const showError = !!query.error && tableRowCount === 0
460
- const showEndMarker =
461
- !query.isLoading && !query.isFetchingNextPage && !query.hasNextPage && tableRowCount > 0
462
472
 
463
473
  const totalSize = virtualizer.getTotalSize()
464
474
  const virtualItems = virtualizer.getVirtualItems()
@@ -475,7 +485,13 @@ export function DataTable<TRow>({
475
485
  {/* Single both-axes scroll container. Horizontal wheel/trackpad scrolls
476
486
  the columns; vertical scrolls the rows. The sticky header pins to the
477
487
  top, and any `sticky left-0` gutter column freezes to the left. */}
478
- <TableScroller ref={scrollerRef}>
488
+ <TableScroller
489
+ ref={scrollerRef}
490
+ // Scroll padding keeps a row scrolled (or focused) into view clear of
491
+ // the sticky header and, when present, the sticky trailing row —
492
+ // otherwise a freshly added last row lands underneath the strip.
493
+ className={cn('scroll-pt-[36px]', bodyAfter ? 'scroll-pb-8' : undefined)}
494
+ >
479
495
  {/* Content track — owns the horizontal min-width so the header + rows
480
496
  scroll horizontally together as one unit. */}
481
497
  <div className="flex flex-col" style={{ minWidth: minPanelWidth }}>
@@ -525,6 +541,12 @@ export function DataTable<TRow>({
525
541
  {...(enableColumnRemoval && onRemoveColumn && header.column.id
526
542
  ? { onRemove: () => onRemoveColumn(header.column.id) }
527
543
  : {})}
544
+ {...(enableColumnResizing && onResizeColumn && header.column.id
545
+ ? {
546
+ onResize: (width: number) =>
547
+ onResizeColumn({ id: header.column.id, width }),
548
+ }
549
+ : {})}
528
550
  >
529
551
  {headerNode}
530
552
  </TableHeaderCell>
@@ -585,18 +607,30 @@ export function DataTable<TRow>({
585
607
  </div>
586
608
  )}
587
609
 
588
- {/* Sentinel — visually empty, positioned just above the loader/end
589
- marker so it intersects ~200 px before the bottom. */}
610
+ {/* Sentinel — visually empty, positioned just above the loader so
611
+ it intersects ~200 px before the bottom. */}
590
612
  {!showEmpty && (
591
613
  <div ref={sentinelRef} aria-hidden="true" style={{ height: 1, width: '100%' }} />
592
614
  )}
593
615
 
594
616
  {query.isFetchingNextPage ? <LoaderRow /> : null}
595
- {showEndMarker ? <EndMarker count={tableRowCount} /> : null}
596
617
  </div>
597
618
  )}
598
619
 
599
- {bodyAfter}
620
+ {bodyAfter ? (
621
+ // Sticky to the scroller's bottom edge; `bg-bg` covers the rows
622
+ // sliding underneath. z-[4] — like the sticky header — stays above an
623
+ // editing row (z-[3]), so an editor scrolled under the strip is
624
+ // covered rather than drawn over it.
625
+ <div
626
+ className={cn(
627
+ 'sticky bottom-0 z-[4] flex items-center h-8 px-0.5',
628
+ 'border-t border-rule-2 bg-bg',
629
+ )}
630
+ >
631
+ {bodyAfter}
632
+ </div>
633
+ ) : null}
600
634
  </div>
601
635
  </TableScroller>
602
636
  </Table>
@@ -705,7 +739,7 @@ function DataRow<TRow>({
705
739
  )
706
740
  }
707
741
 
708
- /* ---------- Loader & end marker ----------------------------------------- */
742
+ /* ---------- Loader ------------------------------------------------------- */
709
743
 
710
744
  function LoaderRow() {
711
745
  return (
@@ -732,17 +766,3 @@ function LoaderRow() {
732
766
  </div>
733
767
  )
734
768
  }
735
-
736
- function EndMarker({ count }: { count: number }) {
737
- return (
738
- <div
739
- className={cn(
740
- 'flex items-center justify-center',
741
- 'h-8 font-mono text-[10.5px] uppercase tracking-[0.08em]',
742
- 'text-ink-4',
743
- )}
744
- >
745
- end · {count.toLocaleString()} rows
746
- </div>
747
- )
748
- }
@@ -0,0 +1,44 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants'
2
+
3
+ /* =========================================================================
4
+ ListboxCheck — the selection box at the head of a multi-select listbox
5
+ row. Empty box at rest, ink fill with a tick once the row is selected.
6
+ Reads the row's RAC `data-selected` through the `group` class, so the
7
+ host ListBoxItem must carry `group`. Shared by MultiSelect and
8
+ BadgeMultiSelect so both pickers mark selection identically.
9
+ ========================================================================= */
10
+
11
+ export const listboxCheckStyles = tv({
12
+ slots: {
13
+ box: [
14
+ 'relative inline-flex items-center justify-center shrink-0',
15
+ 'size-[16px] rounded-[3px] border border-ink-4 bg-surface',
16
+ 'transition-[background-color,border-color] duration-150',
17
+ 'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
18
+ ],
19
+ tick: [
20
+ 'size-3 text-surface transition-opacity duration-150',
21
+ 'opacity-0 group-data-[selected]:opacity-100',
22
+ ],
23
+ },
24
+ })
25
+
26
+ export type ListboxCheckVariants = VariantProps<typeof listboxCheckStyles>
27
+
28
+ export function ListboxCheck() {
29
+ const { box, tick } = listboxCheckStyles()
30
+ return (
31
+ <span aria-hidden className={box()}>
32
+ <svg viewBox="0 0 12 12" className={tick()} aria-hidden>
33
+ <path
34
+ d="M2.5 6.25 5 8.75 9.5 3.75"
35
+ stroke="currentColor"
36
+ strokeWidth="1.6"
37
+ strokeLinecap="round"
38
+ strokeLinejoin="round"
39
+ fill="none"
40
+ />
41
+ </svg>
42
+ </span>
43
+ )
44
+ }
@@ -0,0 +1,45 @@
1
+ import { type RefObject, useLayoutEffect, useState } from 'react'
2
+
3
+ /* =========================================================================
4
+ useChipRowWrap — does a `flex-wrap` row of chips currently need more than
5
+ one line? Drives the inline cell editors: they stay one row tall while
6
+ the chips fit, and grow into the two-row pop-out only once they wrap.
7
+
8
+ Measured from the children's boxes: a child whose top sits at or below
9
+ the first child's bottom is on a later line. Wrapping depends on the
10
+ container's width, not its height, so the answer is the same whether the
11
+ box is currently short (clipping) or tall. Re-measured when `signature`
12
+ (the chip identity) changes and whenever the container resizes. `false`
13
+ without layout (jsdom) and while `isEnabled` is off.
14
+ ========================================================================= */
15
+
16
+ export function useChipRowWrap(
17
+ ref: RefObject<HTMLElement | null>,
18
+ isEnabled: boolean,
19
+ signature: string,
20
+ ): boolean {
21
+ const [hasWrapped, setHasWrapped] = useState(false)
22
+
23
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `signature` (chip identity) is the re-measure trigger; it is not read inside.
24
+ useLayoutEffect(() => {
25
+ if (!isEnabled) return
26
+ const element = ref.current
27
+ if (!element) return
28
+ const measure = () => {
29
+ const children = Array.from(element.children)
30
+ const first = children[0]?.getBoundingClientRect()
31
+ if (!first || first.height === 0) {
32
+ setHasWrapped(false)
33
+ return
34
+ }
35
+ setHasWrapped(children.some((child) => child.getBoundingClientRect().top >= first.bottom - 1))
36
+ }
37
+ measure()
38
+ if (typeof ResizeObserver === 'undefined') return
39
+ const observer = new ResizeObserver(measure)
40
+ observer.observe(element)
41
+ return () => observer.disconnect()
42
+ }, [ref, isEnabled, signature])
43
+
44
+ return hasWrapped
45
+ }
@@ -13,6 +13,7 @@ import {
13
13
  fieldMinHeight,
14
14
  fieldTextStyles,
15
15
  } from '../field/field'
16
+ import { ListboxCheck } from '../field/listbox-check'
16
17
 
17
18
  /* =========================================================================
18
19
  MultiSelect — pick many from a list. Trigger shows selected items as
@@ -180,7 +181,9 @@ export function MultiSelect<T extends Key>({
180
181
  placement="bottom start"
181
182
  className={cn(
182
183
  'w-[var(--trigger-width)]',
183
- 'rounded-md border border-rule bg-surface p-1',
184
+ // The popover element itself takes focus when opened programmatically
185
+ // (defaultOpen) — no browser outline around the whole panel.
186
+ 'outline-none rounded-md border border-rule bg-surface p-1',
184
187
  'shadow-[var(--shadow-raised)]',
185
188
  'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
186
189
  'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
@@ -205,6 +208,9 @@ export function MultiSelect<T extends Key>({
205
208
  <ListBox
206
209
  aria-label={typeof label === 'string' ? label : 'Options'}
207
210
  selectionMode="multiple"
211
+ // RAC clears a multi-selection on Escape by default — here Escape
212
+ // must close the list (useOverlay) and keep the selection.
213
+ escapeKeyBehavior="none"
208
214
  selectedKeys={selectedSet}
209
215
  onSelectionChange={(keys) => {
210
216
  if (keys === 'all') {
@@ -232,11 +238,10 @@ export function MultiSelect<T extends Key>({
232
238
  'cursor-pointer outline-none select-none',
233
239
  'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
234
240
  'data-[focused]:bg-surface-2 data-[focused]:text-ink',
235
- 'data-[selected]:bg-surface-3 data-[selected]:text-ink',
236
241
  'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
237
242
  )}
238
243
  >
239
- <Checkmark />
244
+ <ListboxCheck />
240
245
  <span className="flex-1 truncate">{option.content ?? option.label}</span>
241
246
  </ListBoxItem>
242
247
  ))}
@@ -301,39 +306,3 @@ function Chip({
301
306
  </span>
302
307
  )
303
308
  }
304
-
305
- /* =========================================================================
306
- Internal: empty box → indigo tick when the row is selected.
307
- ========================================================================= */
308
-
309
- function Checkmark() {
310
- return (
311
- <span
312
- aria-hidden
313
- className={cn(
314
- 'relative inline-flex items-center justify-center shrink-0',
315
- 'size-[16px] rounded-[3px] border border-ink-4 bg-surface',
316
- 'transition-[background-color,border-color] duration-150',
317
- 'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
318
- )}
319
- >
320
- <svg
321
- viewBox="0 0 12 12"
322
- className={cn(
323
- 'size-3 text-surface transition-opacity duration-150',
324
- 'opacity-0 group-data-[selected]:opacity-100',
325
- )}
326
- aria-hidden
327
- >
328
- <path
329
- d="M2.5 6.25 5 8.75 9.5 3.75"
330
- stroke="currentColor"
331
- strokeWidth="1.6"
332
- strokeLinecap="round"
333
- strokeLinejoin="round"
334
- fill="none"
335
- />
336
- </svg>
337
- </span>
338
- )
339
- }
@@ -1,15 +1,8 @@
1
- import { render, screen } from '@testing-library/react'
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
2
  import userEvent from '@testing-library/user-event'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
 
5
- import {
6
- Table,
7
- TableBody,
8
- TableCell,
9
- TableHeader,
10
- TableHeaderCell,
11
- TableRow,
12
- } from './table'
5
+ import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from './table'
13
6
 
14
7
  describe('Table chrome', () => {
15
8
  it('renders semantic ARIA grid roles', () => {
@@ -113,3 +106,89 @@ describe('Table chrome', () => {
113
106
  expect(onClick).toHaveBeenCalledTimes(1)
114
107
  })
115
108
  })
109
+
110
+ describe('TableHeaderCell resize handle', () => {
111
+ it('renders no handle without onResize', () => {
112
+ render(
113
+ <Table>
114
+ <TableHeader>
115
+ <TableHeaderCell ariaLabel="Lane">Lane</TableHeaderCell>
116
+ </TableHeader>
117
+ </Table>,
118
+ )
119
+ expect(screen.queryByRole('separator')).not.toBeInTheDocument()
120
+ })
121
+
122
+ it('reports the dragged width and never toggles sort', () => {
123
+ const onResize = vi.fn()
124
+ const onSortToggle = vi.fn()
125
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
126
+ width: 160,
127
+ height: 36,
128
+ top: 0,
129
+ left: 0,
130
+ right: 160,
131
+ bottom: 36,
132
+ x: 0,
133
+ y: 0,
134
+ toJSON: () => ({}),
135
+ } as DOMRect)
136
+ render(
137
+ <Table>
138
+ <TableHeader>
139
+ <TableHeaderCell
140
+ ariaLabel="Lane"
141
+ sortDirection={null}
142
+ onSortToggle={onSortToggle}
143
+ onResize={onResize}
144
+ >
145
+ Lane
146
+ </TableHeaderCell>
147
+ </TableHeader>
148
+ </Table>,
149
+ )
150
+ // Reports are frame-throttled; run each frame synchronously.
151
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
152
+ callback(0)
153
+ return 0
154
+ })
155
+ const handle = screen.getByRole('separator', { name: 'Resize column Lane' })
156
+ fireEvent.pointerDown(handle, { button: 0, clientX: 100, pointerId: 1 })
157
+ fireEvent.pointerMove(handle, { clientX: 140, pointerId: 1 })
158
+ fireEvent.pointerUp(handle, { clientX: 140, pointerId: 1 })
159
+ fireEvent.click(handle)
160
+ expect(onResize).toHaveBeenLastCalledWith(200)
161
+ expect(onSortToggle).not.toHaveBeenCalled()
162
+ vi.restoreAllMocks()
163
+ })
164
+
165
+ it('clamps to minWidth and resizes by keyboard', () => {
166
+ const onResize = vi.fn()
167
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
168
+ width: 70,
169
+ height: 36,
170
+ top: 0,
171
+ left: 0,
172
+ right: 70,
173
+ bottom: 36,
174
+ x: 0,
175
+ y: 0,
176
+ toJSON: () => ({}),
177
+ } as DOMRect)
178
+ render(
179
+ <Table>
180
+ <TableHeader>
181
+ <TableHeaderCell ariaLabel="Lane" onResize={onResize} minWidth={64}>
182
+ Lane
183
+ </TableHeaderCell>
184
+ </TableHeader>
185
+ </Table>,
186
+ )
187
+ const handle = screen.getByRole('separator', { name: 'Resize column Lane' })
188
+ fireEvent.keyDown(handle, { key: 'ArrowLeft' })
189
+ expect(onResize).toHaveBeenLastCalledWith(64)
190
+ fireEvent.keyDown(handle, { key: 'ArrowRight' })
191
+ expect(onResize).toHaveBeenLastCalledWith(78)
192
+ vi.restoreAllMocks()
193
+ })
194
+ })
@@ -59,7 +59,9 @@ const tableHeaderStyles = tv({
59
59
  'border-b border-rule',
60
60
  ],
61
61
  variants: {
62
- sticky: { true: 'sticky top-0 z-[2]', false: '' },
62
+ // z-[4]: above an editing row (z-[3]) — sticky chrome stays on top of
63
+ // rows scrolling underneath, popped-out editors included.
64
+ sticky: { true: 'sticky top-0 z-[4]', false: '' },
63
65
  },
64
66
  defaultVariants: { sticky: false },
65
67
  })
@@ -109,7 +111,8 @@ export const tableHeaderCellStyles = tv({
109
111
  },
110
112
  sticky: {
111
113
  left: 'sticky left-0 z-[3] bg-[color-mix(in_oklab,var(--color-surface-2)_60%,var(--color-surface))]',
112
- right: 'sticky right-0 z-[3] bg-[color-mix(in_oklab,var(--color-surface-2)_60%,var(--color-surface))]',
114
+ right:
115
+ 'sticky right-0 z-[3] bg-[color-mix(in_oklab,var(--color-surface-2)_60%,var(--color-surface))]',
113
116
  false: '',
114
117
  },
115
118
  },
@@ -164,6 +167,14 @@ export type TableHeaderCellProps = Omit<HTMLAttributes<HTMLDivElement>, 'onClick
164
167
  onRemove?: () => void
165
168
  /** Aria label fragment used in the remove button label and any drag a11y. */
166
169
  ariaLabel?: string
170
+ /**
171
+ * When provided, renders a resize handle on the trailing edge. Dragging it
172
+ * (or focusing it and pressing ←/→) reports the new width; the column is
173
+ * controlled, so the parent decides what to do with it.
174
+ */
175
+ onResize?: (width: number) => void
176
+ /** Smallest width the resize handle will report. Defaults to 64. */
177
+ minWidth?: number
167
178
  }
168
179
 
169
180
  export function TableHeaderCell({
@@ -179,11 +190,63 @@ export function TableHeaderCell({
179
190
  onRemove,
180
191
  ariaLabel,
181
192
  sticky = false,
193
+ onResize,
194
+ minWidth = 64,
182
195
  ...rest
183
196
  }: TableHeaderCellProps) {
184
197
  const cellRef = useRef<HTMLDivElement | null>(null)
185
198
  const handleRef = useRef<HTMLSpanElement | null>(null)
186
199
  const [dragState, setDragState] = useState<DragState>({ kind: 'idle' })
200
+ const [isResizing, setIsResizing] = useState(false)
201
+
202
+ /* Column resize — pointer capture on the handle so the drag survives the
203
+ pointer leaving the header, one report per animation frame so a busy
204
+ table re-renders at most 60×/s. The reported width is the cell's live
205
+ box width plus the pointer delta, never below `minWidth`. */
206
+ const startResize = (event: React.PointerEvent<HTMLSpanElement>) => {
207
+ if (!onResize || event.button !== 0) return
208
+ const cell = cellRef.current
209
+ if (!cell) return
210
+ event.preventDefault()
211
+ event.stopPropagation()
212
+ const handle = event.currentTarget
213
+ const startX = event.clientX
214
+ const startWidth = cell.getBoundingClientRect().width
215
+ if (typeof handle.setPointerCapture === 'function') handle.setPointerCapture(event.pointerId)
216
+ setIsResizing(true)
217
+ document.body.style.cursor = 'col-resize'
218
+ let frame = 0
219
+ const report = (clientX: number) => {
220
+ const next = Math.max(minWidth, Math.round(startWidth + clientX - startX))
221
+ if (typeof requestAnimationFrame !== 'function') {
222
+ onResize(next)
223
+ return
224
+ }
225
+ cancelAnimationFrame(frame)
226
+ frame = requestAnimationFrame(() => onResize(next))
227
+ }
228
+ const onMove = (move: PointerEvent) => report(move.clientX)
229
+ const onEnd = () => {
230
+ handle.removeEventListener('pointermove', onMove)
231
+ handle.removeEventListener('pointerup', onEnd)
232
+ handle.removeEventListener('pointercancel', onEnd)
233
+ document.body.style.cursor = ''
234
+ setIsResizing(false)
235
+ }
236
+ handle.addEventListener('pointermove', onMove)
237
+ handle.addEventListener('pointerup', onEnd)
238
+ handle.addEventListener('pointercancel', onEnd)
239
+ }
240
+
241
+ const resizeByKey = (event: React.KeyboardEvent<HTMLSpanElement>) => {
242
+ if (!onResize) return
243
+ const step = event.key === 'ArrowRight' ? 8 : event.key === 'ArrowLeft' ? -8 : 0
244
+ if (step === 0) return
245
+ event.preventDefault()
246
+ event.stopPropagation()
247
+ const current = cellRef.current?.getBoundingClientRect().width ?? minWidth
248
+ onResize(Math.max(minWidth, Math.round(current + step)))
249
+ }
187
250
 
188
251
  useEffect(() => {
189
252
  if (!isDraggable || !columnId) return
@@ -203,13 +266,9 @@ export function TableHeaderCell({
203
266
  }),
204
267
  dropTargetForElements({
205
268
  element: cell,
206
- canDrop: ({ source }) =>
207
- isColumnDragData(source.data) && source.data.columnId !== columnId,
269
+ canDrop: ({ source }) => isColumnDragData(source.data) && source.data.columnId !== columnId,
208
270
  getData: ({ input, element }) =>
209
- attachClosestEdge(
210
- { ...data },
211
- { input, element, allowedEdges: ['left', 'right'] },
212
- ),
271
+ attachClosestEdge({ ...data }, { input, element, allowedEdges: ['left', 'right'] }),
213
272
  getIsSticky: () => true,
214
273
  onDragEnter: ({ self }) => {
215
274
  const edge = extractClosestEdge(self.data)
@@ -238,6 +297,7 @@ export function TableHeaderCell({
238
297
  const target = e.target as HTMLElement
239
298
  if (target.closest('[data-dnd-handle="true"]')) return
240
299
  if (target.closest('[data-column-remove="true"]')) return
300
+ if (target.closest('[data-column-resize="true"]')) return
241
301
  onSortToggle?.()
242
302
  }
243
303
 
@@ -292,7 +352,11 @@ export function TableHeaderCell({
292
352
  <span
293
353
  className={cn(
294
354
  'flex items-center gap-[7px] min-w-0 flex-1',
295
- align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start',
355
+ align === 'right'
356
+ ? 'justify-end'
357
+ : align === 'center'
358
+ ? 'justify-center'
359
+ : 'justify-start',
296
360
  )}
297
361
  >
298
362
  <span className="truncate">{children}</span>
@@ -328,6 +392,40 @@ export function TableHeaderCell({
328
392
  )}
329
393
  />
330
394
  ) : null}
395
+
396
+ {onResize ? (
397
+ // Sits astride the trailing separator: an 8px grab zone with a 2px
398
+ // bar that lights up on hover, focus and while dragging. A focusable
399
+ // separator is the ARIA pattern for a resizer (no semantic element
400
+ // exists for it); its value is the column width when one is known.
401
+ // biome-ignore lint/a11y/useSemanticElements: interactive vertical resizer — <hr> cannot be one
402
+ <span
403
+ role="separator"
404
+ aria-orientation="vertical"
405
+ aria-label={`Resize column ${ariaLabel ?? 'column'}`}
406
+ aria-valuemin={minWidth}
407
+ aria-valuenow={typeof width === 'number' ? width : undefined}
408
+ tabIndex={0}
409
+ data-column-resize="true"
410
+ onPointerDown={startResize}
411
+ onKeyDown={resizeByKey}
412
+ onClick={(e) => e.stopPropagation()}
413
+ className={cn(
414
+ 'group/resize absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize outline-none',
415
+ 'flex justify-center',
416
+ )}
417
+ >
418
+ <span
419
+ aria-hidden="true"
420
+ className={cn(
421
+ 'h-full w-0.5 transition-colors',
422
+ isResizing
423
+ ? 'bg-accent'
424
+ : 'bg-transparent group-hover/resize:bg-accent group-focus-visible/resize:bg-accent',
425
+ )}
426
+ />
427
+ </span>
428
+ ) : null}
331
429
  </div>
332
430
  )
333
431
  }
@@ -407,7 +505,12 @@ const tableScrollerStyles = tv({
407
505
 
408
506
  export function TableScroller({ className, ref, ...props }: TableBodyProps) {
409
507
  return (
410
- <div role="presentation" ref={ref} {...props} className={cn(tableScrollerStyles(), className)} />
508
+ <div
509
+ role="presentation"
510
+ ref={ref}
511
+ {...props}
512
+ className={cn(tableScrollerStyles(), className)}
513
+ />
411
514
  )
412
515
  }
413
516
 
@@ -424,8 +527,28 @@ const rowStripeBg = 'bg-[color-mix(in_oklab,var(--color-ink)_var(--row-stripe),v
424
527
  const rowHoverBg =
425
528
  'hover:bg-[color-mix(in_oklab,var(--color-ink)_var(--row-hover),var(--color-bg))]'
426
529
 
530
+ /* Inline cell editors that outgrow the row (a chip picker, say) escape the
531
+ cell clip by marking their host `data-cell-editing`: the row lifts above
532
+ its neighbours and the cell drops its overflow clip, so an editor sized
533
+ taller than the row pops out over the rows above and below. The app-side
534
+ wrapper sets the attribute and positions the editor absolutely inside the
535
+ cell. Virtualized rows are transformed (own stacking context), hence the
536
+ z-index has to sit on the row, not only on the cell. Rows and cells are
537
+ positioned unconditionally (`relative`, overridden by the absolute /
538
+ sticky variants) so the z-index applies without the `:has()` rule ever
539
+ touching `position` — a `:has()` rule outranks a plain utility, and
540
+ forcing `relative` on a virtualized row would drop it into normal flow
541
+ and shove every row after it down by one. */
542
+ const cellEditingRow = 'has-[[data-cell-editing]]:z-[3]'
543
+ const cellEditingCell = 'has-[[data-cell-editing]]:z-[3] has-[[data-cell-editing]]:overflow-visible'
544
+
427
545
  const tableRowStyles = tv({
428
- base: ['flex items-stretch', 'border-b border-rule-2 last:border-b-0', 'bg-bg'],
546
+ base: [
547
+ 'relative flex items-stretch',
548
+ 'border-b border-rule-2 last:border-b-0',
549
+ 'bg-bg',
550
+ cellEditingRow,
551
+ ],
429
552
  variants: {
430
553
  alt: {
431
554
  true: rowStripeBg,
@@ -436,7 +559,9 @@ const tableRowStyles = tv({
436
559
  false: rowHoverBg,
437
560
  },
438
561
  absolute: {
439
- true: 'absolute left-0 right-0',
562
+ // `top-0` pins the static position: a virtualized row must never
563
+ // inherit a flow offset from a sibling that is in normal flow.
564
+ true: 'absolute top-0 left-0 right-0',
440
565
  false: '',
441
566
  },
442
567
  },
@@ -481,11 +606,12 @@ export function TableRow({
481
606
 
482
607
  export const tableCellStyles = tv({
483
608
  base: [
484
- 'flex items-center shrink-0 min-w-0',
609
+ 'relative flex items-center shrink-0 min-w-0',
485
610
  'px-[14px]',
486
611
  'whitespace-nowrap overflow-hidden text-ellipsis',
487
612
  'font-sans text-[13px] tracking-[-0.005em]',
488
613
  'text-ink-2',
614
+ cellEditingCell,
489
615
  ],
490
616
  variants: {
491
617
  align: {