@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.
@@ -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'