@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.
- package/dist/index.cjs +638 -311
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -7
- package/dist/index.d.ts +79 -7
- package/dist/index.js +636 -314
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/badge/badge.stories.tsx +37 -1
- package/src/components/badge/badge.test.tsx +39 -2
- package/src/components/badge/badge.tsx +65 -1
- package/src/components/badge/index.ts +2 -2
- package/src/components/badge-group/badge-group.stories.tsx +75 -0
- package/src/components/badge-group/badge-group.test.tsx +121 -0
- package/src/components/badge-group/badge-group.tsx +208 -0
- package/src/components/badge-group/index.ts +7 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +25 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +110 -1
- package/src/components/badge-multi-select/badge-multi-select.tsx +190 -128
- package/src/components/data-table/data-table.stories.tsx +53 -2
- package/src/components/data-table/data-table.tsx +45 -25
- package/src/components/field/listbox-check.tsx +44 -0
- package/src/components/field/use-chip-row-wrap.ts +45 -0
- package/src/components/multi-select/multi-select.tsx +8 -39
- package/src/components/table/table.test.tsx +88 -9
- package/src/components/table/table.tsx +139 -13
- package/src/components/tag-input/tag-input.test.tsx +67 -1
- package/src/components/tag-input/tag-input.tsx +98 -64
- package/src/index.ts +1 -0
|
@@ -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
|
-
*
|
|
139
|
-
*
|
|
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
|
|
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
|
|
589
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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:
|
|
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'
|
|
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
|
|
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: [
|
|
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
|
-
|
|
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: {
|