@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.
Files changed (33) hide show
  1. package/dist/index.cjs +679 -321
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +84 -9
  4. package/dist/index.d.ts +84 -9
  5. package/dist/index.js +677 -324
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  8. package/src/components/badge/badge.stories.tsx +37 -1
  9. package/src/components/badge/badge.test.tsx +39 -2
  10. package/src/components/badge/badge.tsx +65 -1
  11. package/src/components/badge/index.ts +2 -2
  12. package/src/components/badge-group/badge-group.stories.tsx +75 -0
  13. package/src/components/badge-group/badge-group.test.tsx +121 -0
  14. package/src/components/badge-group/badge-group.tsx +208 -0
  15. package/src/components/badge-group/index.ts +7 -0
  16. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +25 -0
  17. package/src/components/badge-multi-select/badge-multi-select.test.tsx +110 -1
  18. package/src/components/badge-multi-select/badge-multi-select.tsx +190 -128
  19. package/src/components/data-table/data-table.stories.tsx +53 -2
  20. package/src/components/data-table/data-table.tsx +45 -25
  21. package/src/components/field/listbox-check.tsx +44 -0
  22. package/src/components/field/use-chip-row-wrap.ts +45 -0
  23. package/src/components/multi-select/multi-select.tsx +8 -39
  24. package/src/components/record-filter/chip-value.tsx +6 -5
  25. package/src/components/record-filter/filter-value-input.tsx +11 -9
  26. package/src/components/record-filter/operators.test.ts +67 -12
  27. package/src/components/record-filter/operators.ts +43 -2
  28. package/src/components/record-filter/types.ts +6 -1
  29. package/src/components/table/table.test.tsx +88 -9
  30. package/src/components/table/table.tsx +139 -13
  31. package/src/components/tag-input/tag-input.test.tsx +67 -1
  32. package/src/components/tag-input/tag-input.tsx +98 -64
  33. 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
- 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: {
@@ -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 { type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react'
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 minHeight = appearance === 'cell' ? 'min-h-[29px]' : fieldMinHeight[size]
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
- commit()
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 flex-wrap items-center gap-1',
124
- minHeight,
125
- fieldChipPadding[size],
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
- {value.map((tag, idx) => (
130
- // tags are kept unique on commit, so the value alone is a stable key
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 min-w-[6rem] bg-transparent outline-none',
177
- 'font-sans text-ink placeholder:text-ink-4',
178
- fieldTextStyles[size],
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'