@proteos/ui 0.54.1 → 0.56.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 +679 -321
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -9
- package/dist/index.d.ts +84 -9
- package/dist/index.js +677 -324
- 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/record-filter/chip-value.tsx +6 -5
- package/src/components/record-filter/filter-value-input.tsx +11 -9
- package/src/components/record-filter/operators.test.ts +67 -12
- package/src/components/record-filter/operators.ts +43 -2
- package/src/components/record-filter/types.ts +6 -1
- 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
|
@@ -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: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react'
|
|
2
2
|
import userEvent from '@testing-library/user-event'
|
|
3
3
|
import { useState } from 'react'
|
|
4
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
|
|
6
6
|
import { TagInput } from './tag-input'
|
|
7
7
|
|
|
@@ -78,3 +78,69 @@ describe('TagInput', () => {
|
|
|
78
78
|
expect(onChange).not.toHaveBeenCalled()
|
|
79
79
|
})
|
|
80
80
|
})
|
|
81
|
+
|
|
82
|
+
describe('TagInput — inline cell editor', () => {
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
vi.restoreAllMocks()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('stays one row tall while the chips fit on a line', () => {
|
|
88
|
+
render(<TagInput value={['alpha', 'beta']} onChange={() => {}} appearance="cell" />)
|
|
89
|
+
const box = screen.getByRole('textbox').closest('.h-\\[29px\\]')
|
|
90
|
+
expect(box).not.toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('grows to two rows with a scroll area once the chips wrap', () => {
|
|
94
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
|
|
95
|
+
this: Element,
|
|
96
|
+
) {
|
|
97
|
+
const index = this.parentElement ? Array.from(this.parentElement.children).indexOf(this) : 0
|
|
98
|
+
const top = index > 1 ? 30 : 0
|
|
99
|
+
return {
|
|
100
|
+
top,
|
|
101
|
+
bottom: top + 23,
|
|
102
|
+
height: 23,
|
|
103
|
+
left: 0,
|
|
104
|
+
right: 40,
|
|
105
|
+
width: 40,
|
|
106
|
+
x: 0,
|
|
107
|
+
y: top,
|
|
108
|
+
toJSON: () => ({}),
|
|
109
|
+
} as DOMRect
|
|
110
|
+
})
|
|
111
|
+
render(<TagInput value={['alpha', 'beta', 'gamma']} onChange={() => {}} appearance="cell" />)
|
|
112
|
+
const box = screen.getByRole('textbox').closest('.h-\\[64px\\]')
|
|
113
|
+
expect(box).not.toBeNull()
|
|
114
|
+
expect(box?.querySelector('.overflow-y-auto')).not.toBeNull()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('TagInput — Enter inside a keyboard-navigating host', () => {
|
|
119
|
+
it('keeps Enter to itself when it commits a tag', async () => {
|
|
120
|
+
const user = userEvent.setup()
|
|
121
|
+
const onHostKeyDown = vi.fn()
|
|
122
|
+
const onChange = vi.fn()
|
|
123
|
+
render(
|
|
124
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: test host mimicking the table cell wrapper
|
|
125
|
+
<div onKeyDown={onHostKeyDown}>
|
|
126
|
+
<TagInput value={[]} onChange={onChange} />
|
|
127
|
+
</div>,
|
|
128
|
+
)
|
|
129
|
+
await user.type(screen.getByRole('textbox'), 'alpha{Enter}')
|
|
130
|
+
expect(onChange).toHaveBeenCalledWith(['alpha'])
|
|
131
|
+
expect(onHostKeyDown.mock.calls.some(([event]) => event.key === 'Enter')).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('lets Enter reach the host when there is nothing to commit', async () => {
|
|
135
|
+
const user = userEvent.setup()
|
|
136
|
+
const onHostKeyDown = vi.fn()
|
|
137
|
+
render(
|
|
138
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: test host mimicking the table cell wrapper
|
|
139
|
+
<div onKeyDown={onHostKeyDown}>
|
|
140
|
+
<TagInput value={['alpha']} onChange={() => {}} />
|
|
141
|
+
</div>,
|
|
142
|
+
)
|
|
143
|
+
await user.type(screen.getByRole('textbox'), '{Enter}')
|
|
144
|
+
expect(onHostKeyDown.mock.calls.some(([event]) => event.key === 'Enter')).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -1,27 +1,41 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type KeyboardEvent,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
2
10
|
import { cn } from '../../lib/cn'
|
|
11
|
+
import { Badge, BadgeAction } from '../badge/badge'
|
|
3
12
|
import {
|
|
4
13
|
type FieldAppearance,
|
|
14
|
+
type FieldSize,
|
|
5
15
|
fieldChipPadding,
|
|
6
16
|
fieldControlStyles,
|
|
7
17
|
fieldErrorStyles,
|
|
8
18
|
fieldHintStyles,
|
|
9
19
|
fieldLabelStyles,
|
|
10
20
|
fieldMinHeight,
|
|
11
|
-
type FieldSize,
|
|
12
21
|
fieldTextStyles,
|
|
13
22
|
} from '../field/field'
|
|
23
|
+
import { useChipRowWrap } from '../field/use-chip-row-wrap'
|
|
14
24
|
|
|
15
25
|
/* =========================================================================
|
|
16
26
|
TagInput — free-form list of strings rendered as removable chips.
|
|
17
27
|
Used for filter `in`/`not_in` value entry on non-enum types where the
|
|
18
|
-
set of valid values isn't known up front.
|
|
28
|
+
set of valid values isn't known up front, and for `array` attributes.
|
|
19
29
|
|
|
20
30
|
Behaviour:
|
|
21
31
|
- Type into the trailing input → Enter or `,` commits as a tag.
|
|
22
32
|
- Backspace on empty input → removes the last tag.
|
|
23
33
|
- Click × on a chip → removes it.
|
|
24
34
|
|
|
35
|
+
Chips are the enum-value Badge (rounded, no dot, active register) with
|
|
36
|
+
the × in the badge's `actions` slot — the same chip the read-only
|
|
37
|
+
Display shows, so view ↔ edit toggles don't restyle the values.
|
|
38
|
+
|
|
25
39
|
Controlled API only — `value: string[]` + `onChange`.
|
|
26
40
|
========================================================================= */
|
|
27
41
|
|
|
@@ -69,17 +83,31 @@ export function TagInput({
|
|
|
69
83
|
appearance === 'cell'
|
|
70
84
|
? 'focus-within:border-accent focus-within:shadow-none'
|
|
71
85
|
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
72
|
-
const
|
|
86
|
+
const isCell = appearance === 'cell'
|
|
73
87
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
88
|
+
const chipsRef = useRef<HTMLDivElement>(null)
|
|
89
|
+
// Cell editor height follows the content: one row while the chips (and
|
|
90
|
+
// the entry input) fit on a line, the two-row pop-out once they wrap.
|
|
91
|
+
const hasWrapped = useChipRowWrap(chipsRef, isCell, JSON.stringify(value))
|
|
74
92
|
|
|
93
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only — autoFocus is a one-shot signal, not sustained.
|
|
75
94
|
useEffect(() => {
|
|
76
95
|
if (autoFocus) inputRef.current?.focus()
|
|
77
|
-
// mount-only — autoFocus is a one-shot signal, not sustained.
|
|
78
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
96
|
}, [])
|
|
80
97
|
const inputId = useId()
|
|
81
98
|
const [draft, setDraft] = useState('')
|
|
82
99
|
|
|
100
|
+
// Committing a tag pushes the entry input further down the wrapping row —
|
|
101
|
+
// in the scrolling cell box that can be out of view. Keep it visible while
|
|
102
|
+
// it holds focus; an outside value change must never scroll anything.
|
|
103
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `value` is the re-run trigger (a commit re-flows the row); it is not read inside.
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
const input = inputRef.current
|
|
106
|
+
if (!isCell || !input || document.activeElement !== input) return
|
|
107
|
+
if (typeof input.scrollIntoView !== 'function') return
|
|
108
|
+
input.scrollIntoView({ block: 'nearest' })
|
|
109
|
+
}, [isCell, value])
|
|
110
|
+
|
|
83
111
|
const commit = () => {
|
|
84
112
|
const v = draft.trim()
|
|
85
113
|
if (!v) return
|
|
@@ -98,7 +126,13 @@ export function TagInput({
|
|
|
98
126
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
99
127
|
if (e.key === 'Enter' || e.key === ',') {
|
|
100
128
|
e.preventDefault()
|
|
101
|
-
|
|
129
|
+
if (draft.trim()) {
|
|
130
|
+
// Committing a tag is the whole gesture: the host must not also read
|
|
131
|
+
// the Enter — an inline table cell would move to the next row. Enter
|
|
132
|
+
// on an empty entry still bubbles, so it can mean "done with this cell".
|
|
133
|
+
e.stopPropagation()
|
|
134
|
+
commit()
|
|
135
|
+
}
|
|
102
136
|
return
|
|
103
137
|
}
|
|
104
138
|
if (e.key === 'Backspace' && !draft && value.length > 0) {
|
|
@@ -120,68 +154,68 @@ export function TagInput({
|
|
|
120
154
|
data-disabled={isDisabled || undefined}
|
|
121
155
|
className={cn(
|
|
122
156
|
styles.box(),
|
|
123
|
-
'flex
|
|
124
|
-
|
|
125
|
-
|
|
157
|
+
'flex',
|
|
158
|
+
// Cell editor: one table row (29px under a 32px row) while the chips
|
|
159
|
+
// fit on a line; two rows (64px), popping out of the row, once they
|
|
160
|
+
// wrap — the chips then scroll inside. Field mode grows with its
|
|
161
|
+
// content.
|
|
162
|
+
isCell
|
|
163
|
+
? hasWrapped
|
|
164
|
+
? 'h-[64px] p-1'
|
|
165
|
+
: 'h-[29px] px-1 py-0'
|
|
166
|
+
: [fieldChipPadding[size], fieldMinHeight[size]],
|
|
126
167
|
focusWithin,
|
|
127
168
|
)}
|
|
128
169
|
>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<span
|
|
132
|
-
key={tag}
|
|
133
|
-
className={cn(
|
|
134
|
-
'inline-flex items-center gap-1',
|
|
135
|
-
'px-1.5 py-0.5 rounded-sm bg-surface-3',
|
|
136
|
-
'font-mono text-[12px] text-ink-2',
|
|
137
|
-
)}
|
|
138
|
-
>
|
|
139
|
-
<span className="max-w-[10rem] truncate">{tag}</span>
|
|
140
|
-
<button
|
|
141
|
-
type="button"
|
|
142
|
-
aria-label={`Remove ${tag}`}
|
|
143
|
-
disabled={isDisabled}
|
|
144
|
-
onClick={() => remove(idx)}
|
|
145
|
-
className={cn(
|
|
146
|
-
'inline-flex items-center justify-center',
|
|
147
|
-
'size-3.5 rounded-full text-ink-3 hover:text-ink hover:bg-surface-2',
|
|
148
|
-
'cursor-pointer outline-none',
|
|
149
|
-
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
150
|
-
'disabled:opacity-45 disabled:cursor-not-allowed',
|
|
151
|
-
)}
|
|
152
|
-
>
|
|
153
|
-
<svg aria-hidden viewBox="0 0 10 10" className="size-2" fill="none">
|
|
154
|
-
<path
|
|
155
|
-
d="M2 2l6 6M8 2l-6 6"
|
|
156
|
-
stroke="currentColor"
|
|
157
|
-
strokeWidth="1.4"
|
|
158
|
-
strokeLinecap="round"
|
|
159
|
-
/>
|
|
160
|
-
</svg>
|
|
161
|
-
</button>
|
|
162
|
-
</span>
|
|
163
|
-
))}
|
|
164
|
-
|
|
165
|
-
<input
|
|
166
|
-
ref={inputRef}
|
|
167
|
-
id={inputId}
|
|
168
|
-
type={inputType}
|
|
169
|
-
value={draft}
|
|
170
|
-
onChange={(e) => setDraft(e.target.value)}
|
|
171
|
-
onKeyDown={onKeyDown}
|
|
172
|
-
onBlur={commit}
|
|
173
|
-
disabled={isDisabled}
|
|
174
|
-
placeholder={value.length === 0 ? placeholder : ''}
|
|
170
|
+
<div
|
|
171
|
+
ref={chipsRef}
|
|
175
172
|
className={cn(
|
|
176
|
-
'flex-1
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
|
|
180
|
-
// overall height so it matches Input/Select at the same size.
|
|
181
|
-
'px-1.5',
|
|
182
|
-
'disabled:cursor-not-allowed disabled:opacity-45',
|
|
173
|
+
'flex flex-1 flex-wrap items-center gap-1.5 min-w-0',
|
|
174
|
+
isCell &&
|
|
175
|
+
(hasWrapped ? 'content-start overflow-y-auto' : 'content-center overflow-hidden'),
|
|
183
176
|
)}
|
|
184
|
-
|
|
177
|
+
>
|
|
178
|
+
{value.map((tag, idx) => (
|
|
179
|
+
// tags are kept unique on commit, so the value alone is a stable key
|
|
180
|
+
<Badge
|
|
181
|
+
key={tag}
|
|
182
|
+
variant="active"
|
|
183
|
+
shape="rounded"
|
|
184
|
+
hasDot={false}
|
|
185
|
+
className="shrink-0"
|
|
186
|
+
actions={
|
|
187
|
+
<BadgeAction
|
|
188
|
+
label={`Remove ${tag}`}
|
|
189
|
+
onPress={() => remove(idx)}
|
|
190
|
+
isDisabled={isDisabled}
|
|
191
|
+
/>
|
|
192
|
+
}
|
|
193
|
+
>
|
|
194
|
+
<span className="max-w-[10rem] truncate">{tag}</span>
|
|
195
|
+
</Badge>
|
|
196
|
+
))}
|
|
197
|
+
|
|
198
|
+
<input
|
|
199
|
+
ref={inputRef}
|
|
200
|
+
id={inputId}
|
|
201
|
+
type={inputType}
|
|
202
|
+
value={draft}
|
|
203
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
204
|
+
onKeyDown={onKeyDown}
|
|
205
|
+
onBlur={commit}
|
|
206
|
+
disabled={isDisabled}
|
|
207
|
+
placeholder={value.length === 0 ? placeholder : ''}
|
|
208
|
+
className={cn(
|
|
209
|
+
'flex-1 min-w-[6rem] bg-transparent outline-none',
|
|
210
|
+
'font-sans text-ink placeholder:text-ink-4',
|
|
211
|
+
fieldTextStyles[size],
|
|
212
|
+
// No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
|
|
213
|
+
// overall height so it matches Input/Select at the same size.
|
|
214
|
+
'px-1.5',
|
|
215
|
+
'disabled:cursor-not-allowed disabled:opacity-45',
|
|
216
|
+
)}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
185
219
|
</div>
|
|
186
220
|
|
|
187
221
|
{description ? <p className={fieldHintStyles}>{description}</p> : null}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './components/attribute-type-icon'
|
|
|
3
3
|
export * from './components/avatar'
|
|
4
4
|
export * from './components/avatar-group'
|
|
5
5
|
export * from './components/badge'
|
|
6
|
+
export * from './components/badge-group'
|
|
6
7
|
export * from './components/badge-multi-select'
|
|
7
8
|
export * from './components/breadcrumbs'
|
|
8
9
|
export * from './components/button'
|