@estiva-app/ui 0.12.6 → 0.12.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ChipInput.mdx CHANGED
@@ -51,6 +51,10 @@ import { ChipInput } from '@estiva-app/ui'
51
51
  edge, a bare label gets 8px.
52
52
  - Suggestions appear **only once the user types** — focusing must not drop
53
53
  the whole directory over the surface below.
54
+ - It is a **combobox over a listbox**: focus stays in the text while the
55
+ arrow keys move through the suggestions, and a screen reader is told which
56
+ one is highlighted. The list is as wide as the field and hangs below it,
57
+ or above when there is no room.
54
58
 
55
59
  ## Keys
56
60
 
@@ -59,6 +63,8 @@ import { ChipInput } from '@estiva-app/ui'
59
63
  | typing | filters; suggestions appear |
60
64
  | ↑ / ↓ | move the highlight |
61
65
  | Enter | adds the highlighted entry |
66
+ | click a suggestion | adds it; focus stays in the text |
67
+ | ✕ on a chip | removes that chip |
62
68
  | Backspace, empty query | removes the last chip — consumed, never the surface's "back" |
63
69
  | Escape, with a query | clears the query — consumed |
64
70
  | Escape, idle | bubbles, so the dialog or launcher around it can act |
@@ -0,0 +1,142 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the ChipInput page claims, pinned. It had no test file before stage 5
4
+ * (2026-09-13), so every promise below was held only by reading the code —
5
+ * and moving it onto Base UI's `Combobox` is exactly the change that could
6
+ * have broken one quietly.
7
+ *
8
+ * jsdom computes no layout, so where the list hangs and how wide it is were
9
+ * measured in Chrome instead (the list 384px against a 384px field, below it,
10
+ * its bar 1px from the edge). What is here is behaviour and roles.
11
+ */
12
+ import { afterEach, describe, expect, it, vi } from 'vitest'
13
+ import { cleanup, render, screen } from '@testing-library/react'
14
+ import userEvent from '@testing-library/user-event'
15
+ import { useState } from 'react'
16
+ import { ChipInput, type ChipInputOption } from './ChipInput'
17
+
18
+ afterEach(cleanup)
19
+
20
+ const PEOPLE: ChipInputOption[] = [
21
+ { id: 'ada', label: 'Ada Lovelace', description: 'Engineer' },
22
+ { id: 'grace', label: 'Grace Hopper', description: 'Admiral' },
23
+ { id: 'alan', label: 'Alan Turing', description: 'Mathematician' },
24
+ ]
25
+
26
+ function Harness({ initial = [], excludeIds, onChange }: { initial?: ChipInputOption[]; excludeIds?: string[]; onChange?: (v: ChipInputOption[]) => void }) {
27
+ const [value, setValue] = useState(initial)
28
+ return (
29
+ <ChipInput
30
+ value={value}
31
+ onChange={(next) => {
32
+ setValue(next)
33
+ onChange?.(next)
34
+ }}
35
+ options={PEOPLE}
36
+ excludeIds={excludeIds}
37
+ placeholder="Search people"
38
+ />
39
+ )
40
+ }
41
+
42
+ describe('ChipInput', () => {
43
+ it('shows nothing until you type — focus alone must not drop the directory', async () => {
44
+ const user = userEvent.setup()
45
+ render(<Harness />)
46
+ await user.click(screen.getByRole('combobox'))
47
+ expect(screen.queryByRole('listbox')).toBeNull()
48
+ })
49
+
50
+ it('is a combobox over a listbox, and focus stays in the text', async () => {
51
+ const user = userEvent.setup()
52
+ render(<Harness />)
53
+ const input = screen.getByRole('combobox')
54
+ await user.type(input, 'a')
55
+ expect(await screen.findByRole('listbox')).toBeTruthy()
56
+ expect(screen.getAllByRole('option').length).toBeGreaterThan(0)
57
+ expect(document.activeElement).toBe(input)
58
+ expect(input.getAttribute('aria-expanded')).toBe('true')
59
+ })
60
+
61
+ it('names the highlighted row through aria-activedescendant', async () => {
62
+ const user = userEvent.setup()
63
+ render(<Harness />)
64
+ const input = screen.getByRole('combobox')
65
+ await user.type(input, 'a')
66
+ await screen.findByRole('listbox')
67
+ await user.keyboard('{ArrowDown}')
68
+ const id = input.getAttribute('aria-activedescendant')
69
+ expect(id).toBeTruthy()
70
+ expect(document.getElementById(id!)?.getAttribute('role')).toBe('option')
71
+ })
72
+
73
+ it('Enter picks the highlighted row, clears the query and closes the list', async () => {
74
+ const user = userEvent.setup()
75
+ const onChange = vi.fn()
76
+ render(<Harness onChange={onChange} />)
77
+ const input = screen.getByRole('combobox') as HTMLInputElement
78
+ await user.type(input, 'grace')
79
+ await screen.findByRole('listbox')
80
+ await user.keyboard('{ArrowDown}{Enter}')
81
+ expect(onChange).toHaveBeenLastCalledWith([PEOPLE[1]])
82
+ expect(input.value).toBe('')
83
+ expect(screen.queryByRole('listbox')).toBeNull()
84
+ expect(screen.getByRole('button', { name: 'Remove Grace Hopper' })).toBeTruthy()
85
+ })
86
+
87
+ it('matches on the description as well as the label', async () => {
88
+ const user = userEvent.setup()
89
+ render(<Harness />)
90
+ await user.type(screen.getByRole('combobox'), 'admiral')
91
+ const options = await screen.findAllByRole('option')
92
+ expect(options.map((o) => o.textContent)).toEqual([expect.stringContaining('Grace Hopper')])
93
+ })
94
+
95
+ it('never offers what is already chosen, or what the caller excluded', async () => {
96
+ const user = userEvent.setup()
97
+ render(<Harness initial={[PEOPLE[0]]} excludeIds={['alan']} />)
98
+ await user.type(screen.getByRole('combobox'), 'a')
99
+ const options = await screen.findAllByRole('option')
100
+ const labels = options.map((o) => o.textContent ?? '')
101
+ expect(labels.some((l) => l.includes('Ada'))).toBe(false)
102
+ expect(labels.some((l) => l.includes('Alan'))).toBe(false)
103
+ expect(labels.some((l) => l.includes('Grace'))).toBe(true)
104
+ })
105
+
106
+ it('Backspace on an empty query takes the last chip', async () => {
107
+ const user = userEvent.setup()
108
+ const onChange = vi.fn()
109
+ render(<Harness initial={[PEOPLE[0], PEOPLE[1]]} onChange={onChange} />)
110
+ await user.click(screen.getByRole('combobox'))
111
+ await user.keyboard('{Backspace}')
112
+ expect(onChange).toHaveBeenLastCalledWith([PEOPLE[0]])
113
+ })
114
+
115
+ it('a chip’s ✕ removes that chip', async () => {
116
+ const user = userEvent.setup()
117
+ const onChange = vi.fn()
118
+ render(<Harness initial={[PEOPLE[0], PEOPLE[1]]} onChange={onChange} />)
119
+ await user.click(screen.getByRole('button', { name: 'Remove Ada Lovelace' }))
120
+ expect(onChange).toHaveBeenLastCalledWith([PEOPLE[1]])
121
+ })
122
+
123
+ it('Escape clears a query and keeps the key; with nothing typed it lets the key through', async () => {
124
+ const user = userEvent.setup()
125
+ const reachedOutside = vi.fn()
126
+ render(
127
+ <div onKeyDown={(e) => e.key === 'Escape' && reachedOutside()}>
128
+ <Harness />
129
+ </div>,
130
+ )
131
+ const input = screen.getByRole('combobox') as HTMLInputElement
132
+ await user.type(input, 'a')
133
+ await screen.findByRole('listbox')
134
+ await user.keyboard('{Escape}')
135
+ expect(input.value).toBe('')
136
+ expect(reachedOutside).not.toHaveBeenCalled()
137
+ // Now there is nothing to clear, so the surface around the field — a
138
+ // dialog, the launcher — must hear it.
139
+ await user.keyboard('{Escape}')
140
+ expect(reachedOutside).toHaveBeenCalledTimes(1)
141
+ })
142
+ })
package/src/ChipInput.tsx CHANGED
@@ -1,9 +1,24 @@
1
- import { useState, useRef, useMemo, useEffect, useLayoutEffect, type KeyboardEvent, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { useMemo, useState, type KeyboardEvent, type ReactNode } from 'react'
2
+ import { Combobox } from '@base-ui/react/combobox'
3
3
  import { IconX } from '@tabler/icons-react'
4
4
  import { cn } from './cn'
5
- import { fitMenu } from './fit'
6
- import { MenuItem } from './Menu'
5
+ import { MenuItem, MenuPanel } from './Menu'
6
+ import { ScrollArea } from './ScrollArea'
7
+
8
+ /* The chip's own look, written once: `InputChip` draws it for a caller who
9
+ wants a chip on its own, and `ChipInput` gives the same classes to Base UI's
10
+ `Combobox.Chip`, which is the one that joins the input's keyboard. */
11
+ const CHIP_BOX =
12
+ // Curved, not a pill (Katerina, 2026-09-01): the Avatar keeps its own
13
+ // rounded-sm corners — never a forced circle — and the chip's corner
14
+ // follows concentrically: 4px face + 2px inset = rounded-md.
15
+ 'inline-flex items-center gap-1.5 bg-bg-elevated border border-border-subtle rounded-md py-0.5 max-h-[24px]'
16
+ const CHIP_LABEL = 'text-caption font-medium text-text-primary'
17
+ const CHIP_REMOVE = 'size-4 flex items-center justify-center rounded-full hover:bg-bg-hover text-text-secondary'
18
+ // The padding follows the contents (Katerina, 2026-09-01): a face sits 2px
19
+ // from the edge, a bare label needs 8px of air; the ✕ brings its own box, so
20
+ // 4px behind it — 8px when there isn't one.
21
+ const chipPadding = (leading: boolean, removable: boolean) => cn(leading ? 'pl-[2px]' : 'pl-2', removable ? 'pr-1' : 'pr-2')
7
22
 
8
23
  /**
9
24
  * The chip a `ChipInput` is made of: a 24px pill with an optional 16px
@@ -11,6 +26,10 @@ import { MenuItem } from './Menu'
11
26
  * Exported on its own (Katerina, 2026-09-01) under a name that promises
12
27
  * nothing about people — a chip like this may one day hold a label, a file,
13
28
  * a filter.
29
+ *
30
+ * Inside a `ChipInput` the chip is Base UI's `Combobox.Chip` wearing these
31
+ * same classes, because there it has to answer the arrow keys and Backspace
32
+ * along with the input. This component is for a chip standing alone.
14
33
  */
15
34
  export interface InputChipProps {
16
35
  label: string
@@ -23,22 +42,9 @@ export interface InputChipProps {
23
42
 
24
43
  export function InputChip({ label, leading, onRemove, className }: InputChipProps) {
25
44
  return (
26
- <div
27
- className={cn(
28
- // Curved, not a pill (Katerina, 2026-09-01): the Avatar keeps its own
29
- // rounded-sm corners — never a forced circle — and the chip's corner
30
- // follows concentrically: 4px face + 2px inset = rounded-md.
31
- 'inline-flex items-center gap-1.5 bg-bg-elevated border border-border-subtle rounded-md py-0.5 max-h-[24px]',
32
- // The padding follows the contents (Katerina, 2026-09-01): a face
33
- // sits 2px from the edge, a bare label needs 8px of air; the ✕
34
- // brings its own box, so 4px behind it — 8px when there isn't one.
35
- leading ? 'pl-[2px]' : 'pl-2',
36
- onRemove ? 'pr-1' : 'pr-2',
37
- className,
38
- )}
39
- >
45
+ <div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
40
46
  {leading && <span className="flex shrink-0 items-center">{leading}</span>}
41
- <span className="text-caption font-medium text-text-primary">{label}</span>
47
+ <span className={CHIP_LABEL}>{label}</span>
42
48
  {onRemove && (
43
49
  <button
44
50
  type="button"
@@ -46,7 +52,7 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
46
52
  e.stopPropagation()
47
53
  onRemove()
48
54
  }}
49
- className="size-4 flex items-center justify-center rounded-full hover:bg-bg-hover text-text-secondary"
55
+ className={CHIP_REMOVE}
50
56
  aria-label={`Remove ${label}`}
51
57
  >
52
58
  <IconX size={10} stroke={1.5} />
@@ -58,18 +64,28 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
58
64
 
59
65
  /**
60
66
  * A multi-select input: chips for the chosen, a typeahead for the rest —
61
- * Peek's PersonChipInput (2026-09-01), generalised on the way in. Peek's
62
- * version knew it was picking people: it read the directory from Peek's own
63
- * data layer and drew every face itself. Here the caller hands in `options`,
64
- * and — when the entries have faces or icons — the two leading slots: 16px
65
- * in a chip, 32px in a suggestion row. Nothing in this file knows what is
66
- * being picked.
67
+ * Peek's PersonChipInput (2026-09-01), generalised on the way in, and on Base
68
+ * UI's `Combobox` since stage 5 of the migration (2026-09-13).
67
69
  *
68
- * Suggestions appear only once the user types focusing (or auto-focus on
69
- * dialog open) must not drop the full directory over the surface below.
70
- * Backspace on an empty query removes the last chip; Escape clears the query
71
- * when there is one and bubbles when there is not, so the surface around it
72
- * (dialog, launcher) can act.
70
+ * **What the part brought, and what it took away from this file.** The list is
71
+ * a real listbox now: the input keeps focus and says which row is highlighted
72
+ * through `aria-activedescendant`, where before the rows were plain buttons in
73
+ * a `<div>` and nothing was announced. With it went the highlight index, the
74
+ * arrow keys, Enter, the filter loop, the blur timeout that kept a click on a
75
+ * row from closing the list under the pointer, the `createPortal`, the
76
+ * measured anchor rect, the resize and scroll listeners that re-measured it,
77
+ * and `fit.ts` — the flip-up-when-low arithmetic this package carried for one
78
+ * caller (PLAN Finding 22). Base UI's positioner does the flipping, and it
79
+ * does it against the element rather than against a rect read a frame ago.
80
+ *
81
+ * **What this file still decides**, because none of it is the part's business:
82
+ * which options are on offer (the chosen and the excluded are not), that a
83
+ * match is on the label *or* the description, that suggestions appear only
84
+ * once you type — focusing must not drop the whole directory over the surface
85
+ * below — and that Backspace on an empty query takes the last chip.
86
+ *
87
+ * Escape clears the query when there is one and bubbles when there is not, so
88
+ * the surface around it (dialog, launcher) can act.
73
89
  *
74
90
  * Generic over the option type: the objects handed back through `onChange`
75
91
  * are the caller's own, extra fields and all — no re-mapping on the way out.
@@ -110,154 +126,136 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
110
126
  ...aria
111
127
  }: ChipInputProps<T>) {
112
128
  const [query, setQuery] = useState('')
113
- const [highlight, setHighlight] = useState(0)
114
- const [isFocused, setIsFocused] = useState(false)
115
- const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
116
- const wrapperRef = useRef<HTMLDivElement>(null)
117
- const inputRef = useRef<HTMLInputElement>(null)
118
-
119
- const matches = useMemo(() => {
120
- const selectedIds = new Set(value.map((o) => o.id))
121
- const excludedIds = new Set(excludeIds)
122
- const q = query.trim().toLowerCase()
123
- return options.filter((o) => {
124
- if (selectedIds.has(o.id)) return false
125
- if (excludedIds.has(o.id)) return false
126
- if (!q) return true
127
- return o.label.toLowerCase().includes(q) || (o.description ?? '').toLowerCase().includes(q)
128
- })
129
- }, [query, value, excludeIds, options])
129
+ /* The box the list hangs from. Base UI hangs a combobox's list from its
130
+ input by default, and the input sits inside this box's 12px of padding
131
+ and its border, so the list came out 26px narrower than the field it
132
+ belongs to (measured: 358 against 384) and started inside it. Held as an
133
+ element, not a measured rect, so the positioner re-measures it. */
134
+ const [box, setBox] = useState<HTMLDivElement | null>(null)
130
135
 
131
- useEffect(() => {
132
- setHighlight(0)
133
- }, [query, matches.length])
136
+ /* What is on offer: never what is already chosen, never what the caller
137
+ excluded. The part filters by the query; which options exist at all is
138
+ this component's question. */
139
+ const available = useMemo(() => {
140
+ const chosen = new Set(value.map((o) => o.id))
141
+ const excluded = new Set(excludeIds)
142
+ return options.filter((o) => !chosen.has(o.id) && !excluded.has(o.id))
143
+ }, [options, value, excludeIds])
134
144
 
135
- const showDropdown = isFocused && query.trim().length > 0 && matches.length > 0
145
+ /* A match is on the label or the description "who is the engineer" finds
146
+ the person by their role. Base UI's own filter reads one string per item. */
147
+ const filter = useMemo(
148
+ () => (item: T, q: string) => {
149
+ const needle = q.trim().toLowerCase()
150
+ if (!needle) return true
151
+ return item.label.toLowerCase().includes(needle) || (item.description ?? '').toLowerCase().includes(needle)
152
+ },
153
+ [],
154
+ )
136
155
 
137
- useLayoutEffect(() => {
138
- if (!showDropdown) return
139
- const update = () => {
140
- if (wrapperRef.current) setAnchorRect(wrapperRef.current.getBoundingClientRect())
141
- }
142
- update()
143
- window.addEventListener('resize', update)
144
- window.addEventListener('scroll', update, true)
145
- return () => {
146
- window.removeEventListener('resize', update)
147
- window.removeEventListener('scroll', update, true)
148
- }
149
- }, [showDropdown, value.length])
156
+ /* Suggestions appear only once you type. Focus — or a dialog's autoFocus —
157
+ must not drop the whole directory over the surface below, which is why
158
+ the open state is this component's and not the part's. */
159
+ const open = query.trim().length > 0
150
160
 
151
- function addOption(option: T) {
152
- onChange([...value, option])
153
- setQuery('')
154
- inputRef.current?.focus()
161
+ function removeLast() {
162
+ if (value.length > 0) onChange(value.slice(0, -1))
155
163
  }
156
164
 
157
- function removeOption(id: string) {
158
- onChange(value.filter((o) => o.id !== id))
159
- }
160
-
161
- function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
162
- if (e.key === 'Backspace' && query === '' && value.length > 0) {
163
- // Consumed: removing a chip must not double as the surface's "back".
164
- e.preventDefault()
165
- removeOption(value[value.length - 1].id)
166
- return
167
- }
168
- if (e.key === 'ArrowDown') {
169
- e.preventDefault()
170
- setHighlight((h) => Math.min(h + 1, Math.max(0, matches.length - 1)))
165
+ function onInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
166
+ if (event.key === 'Backspace' && query === '' && value.length > 0) {
167
+ // Consumed: removing a chip must not double as the surface's "back",
168
+ // and Base UI would otherwise walk focus into the chips first.
169
+ event.preventDefault()
170
+ event.stopPropagation()
171
+ removeLast()
171
172
  return
172
173
  }
173
- if (e.key === 'ArrowUp') {
174
- e.preventDefault()
175
- setHighlight((h) => Math.max(h - 1, 0))
176
- return
177
- }
178
- if (e.key === 'Enter') {
179
- e.preventDefault()
180
- const target = matches[highlight]
181
- if (target) addOption(target)
182
- return
183
- }
184
- if (e.key === 'Escape') {
174
+ if (event.key === 'Escape' && query !== '') {
185
175
  // Consume it only when there is something to clear; an idle input lets
186
176
  // Escape bubble so the surface around it (dialog, launcher) can act.
187
- if (query !== '') {
188
- e.preventDefault()
189
- setQuery('')
190
- }
177
+ event.preventDefault()
178
+ event.stopPropagation()
179
+ setQuery('')
191
180
  }
192
181
  }
193
182
 
194
183
  return (
195
- <div className="relative">
196
- <div
197
- ref={wrapperRef}
198
- className="bg-bg-inset border border-border-default hover:border-border-strong focus-within:border-border-focus focus-within:hover:border-border-focus rounded-lg px-3 py-1.5 flex flex-wrap items-center gap-1.5 transition-colors min-h-[38px] cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring"
199
- onClick={() => inputRef.current?.focus()}
200
- >
201
- {value.map((o) => (
202
- <InputChip key={o.id} label={o.label} leading={chipLeading?.(o)} onRemove={() => removeOption(o.id)} />
184
+ <Combobox.Root
185
+ multiple
186
+ items={available}
187
+ value={value}
188
+ onValueChange={(next) => {
189
+ onChange(next as T[])
190
+ setQuery('')
191
+ }}
192
+ inputValue={query}
193
+ onInputValueChange={setQuery}
194
+ open={open}
195
+ /* The list is the query's, not the click's — see `open` above. */
196
+ openOnInputClick={false}
197
+ filter={filter}
198
+ itemToStringLabel={(option) => (option as T).label}
199
+ >
200
+ {/* The box the chips and the input share. `Combobox.Chips` is what makes
201
+ the two one control for the keyboard; the look is what it always was. */}
202
+ <Combobox.Chips ref={setBox} className="bg-bg-inset border border-border-default hover:border-border-strong focus-within:border-border-focus focus-within:hover:border-border-focus rounded-lg px-3 py-1.5 flex flex-wrap items-center gap-1.5 transition-colors min-h-[38px] cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring">
203
+ {value.map((option) => (
204
+ <Combobox.Chip key={option.id} className={cn(CHIP_BOX, chipPadding(!!chipLeading, true))}>
205
+ {chipLeading && <span className="flex shrink-0 items-center">{chipLeading(option)}</span>}
206
+ <span className={CHIP_LABEL}>{option.label}</span>
207
+ <Combobox.ChipRemove className={CHIP_REMOVE} aria-label={`Remove ${option.label}`}>
208
+ <IconX size={10} stroke={1.5} />
209
+ </Combobox.ChipRemove>
210
+ </Combobox.Chip>
203
211
  ))}
204
-
205
- <input
206
- ref={inputRef}
212
+ <Combobox.Input
207
213
  autoFocus={autoFocus}
208
- type="text"
209
- value={query}
210
- onChange={(e) => setQuery(e.target.value)}
211
- onKeyDown={handleKeyDown}
212
- onFocus={() => setIsFocused(true)}
213
- onBlur={() => {
214
- setTimeout(() => setIsFocused(false), 150)
215
- }}
216
214
  placeholder={value.length === 0 ? placeholder : ''}
217
215
  aria-required={aria['aria-required']}
216
+ onKeyDown={onInputKeyDown}
218
217
  className="flex-1 min-w-[120px] bg-transparent text-body-2 text-text-primary placeholder:text-text-muted outline-none border-none"
219
218
  />
220
- </div>
219
+ </Combobox.Chips>
221
220
 
222
- {showDropdown && anchorRect && createPortal(
223
- <div
224
- className="fixed z-[60] overflow-y-auto bg-bg-elevated border border-border-default rounded-lg shadow-lg"
225
- /*
226
- Placed by fitMenu (2026-09-03), not hung blindly below: an input
227
- low on the screen flips its list upward instead of running the
228
- tail past the bottom edge. The rows are a fixed 48px, so the
229
- content height is arithmetic and needs no second render pass;
230
- the 240px cap is the old max-h-[240px].
231
- */
232
- style={{
233
- ...fitMenu({
234
- anchor: { left: anchorRect.left, top: anchorRect.top, bottom: anchorRect.bottom },
235
- menu: { width: anchorRect.width, contentHeight: matches.length * 48 },
236
- viewport: { width: window.innerWidth, height: window.innerHeight },
237
- cap: 240,
238
- }),
239
- width: anchorRect.width,
240
- }}
221
+ <Combobox.Portal>
222
+ <Combobox.Positioner
223
+ anchor={box}
224
+ sideOffset={GAP}
225
+ collisionPadding={VIEWPORT_PAD}
226
+ className="z-50 data-[anchor-hidden]:hidden"
241
227
  >
242
- {matches.map((o, i) => (
243
- <MenuItem
244
- key={o.id}
245
- size="tall"
246
- className="h-12 rounded-none"
247
- leading={rowLeading?.(o)}
248
- label={o.label}
249
- description={o.description}
250
- selected={i === highlight}
251
- onMouseEnter={() => setHighlight(i)}
252
- onMouseDown={(e) => {
253
- e.preventDefault()
254
- addOption(o)
255
- }}
256
- />
257
- ))}
258
- </div>,
259
- document.body
260
- )}
261
- </div>
228
+ {/* As wide as the box, which is what the hand-measured rect was
229
+ for; `--anchor-width` is the positioner's own answer, and the
230
+ anchor is the box (see `box` above), not the input. The padding
231
+ is on the scrolling content so the bar hugs the panel (D63), and
232
+ 240px is the cap this list has always had. */}
233
+ <Combobox.Popup className="w-[var(--anchor-width)] p-0" render={<MenuPanel />}>
234
+ <ScrollArea viewportClassName="max-h-[240px]" contentClassName="flex flex-col p-2">
235
+ <Combobox.List>
236
+ {(option: T) => (
237
+ <Combobox.Item
238
+ key={option.id}
239
+ value={option}
240
+ render={
241
+ <MenuItem
242
+ size="tall"
243
+ leading={rowLeading?.(option)}
244
+ label={option.label}
245
+ description={option.description}
246
+ />
247
+ }
248
+ />
249
+ )}
250
+ </Combobox.List>
251
+ </ScrollArea>
252
+ </Combobox.Popup>
253
+ </Combobox.Positioner>
254
+ </Combobox.Portal>
255
+ </Combobox.Root>
262
256
  )
263
257
  }
258
+
259
+ /** `Menu`'s numbers, because the list hangs the same way a menu does. */
260
+ const GAP = 4
261
+ const VIEWPORT_PAD = 8
package/src/Menu.tsx CHANGED
@@ -363,7 +363,11 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
363
363
  * consequence of the port: the fill that followed the pointer now also
364
364
  * follows the arrow keys, because Base UI sets one attribute for both.
365
365
  */
366
- function menuItemClassName({ size, selected, className }: { size: 'default' | 'tall'; selected?: boolean; className?: string }) {
366
+ /** The row's look, shared inside the package: `MenuItem` draws it, and
367
+ * `Select` puts it on Base UI's `Select.Item`, whose parts (`ItemText`,
368
+ * `ItemIndicator`) have to stay the element's own. One row, two parts. Not
369
+ * exported from the package's index. */
370
+ export function menuItemClassName({ size, selected, className }: { size: 'default' | 'tall'; selected?: boolean; className?: string }) {
367
371
  return cn(
368
372
  // shrink-0: a menu is a flex column that scrolls at its max height,
369
373
  // and a flex child shrinks before its container does — so every row
package/src/Select.tsx CHANGED
@@ -3,6 +3,7 @@ import { Select as BaseSelect } from '@base-ui/react/select'
3
3
  import type { ReactNode } from 'react'
4
4
  import { cn } from './cn'
5
5
  import { ScrollArea } from './ScrollArea'
6
+ import { MenuPanel, menuItemClassName } from './Menu'
6
7
 
7
8
  /**
8
9
  * Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may
@@ -126,13 +127,28 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
126
127
  clamped it — the two numbers `fitMenu` used to compute here. The
127
128
  288px is the old `max-h-72`, now a ceiling on that room rather
128
129
  than a height applied blind. */
129
- className="min-w-[var(--anchor-width)] rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
130
+ /* The list is the package's one list (Katerina, 2026-09-13: "i
131
+ thought the type to search menu would be from estiva-ui and be
132
+ the one used in select component"). The box is `MenuPanel` —
133
+ the same border, fill, radius and shadow this used to spell out
134
+ — with its padding moved onto the scrolling content (D63). */
135
+ render={<MenuPanel />}
136
+ className="min-w-[var(--anchor-width)] p-0"
130
137
  >
131
138
  {/* The list scrolls in a ScrollArea: the bar takes no width, so a
132
139
  long list is exactly as wide as a short one (Katerina,
133
- 2026-09-08). The cap sits on the box that scrolls, less the
134
- panel's padding, so 288px stays 288px. */}
135
- <ScrollArea viewportClassName="max-h-[calc(min(288px,var(--available-height))_-_0.5rem)]" contentClassName="flex flex-col">
140
+ 2026-09-08).
141
+ *
142
+ * **The padding is on the scrolling content, not on the panel**
143
+ * (D63, applied here 2026-09-13). On the panel it inset the
144
+ * scrolling box, so the thumb sat 7px from the panel's edge where
145
+ * DialogShell, Popover, Menu and ChipInput all draw it at 3px —
146
+ * Katerina: "the position of the scrollbar in select … not closer
147
+ * to the right side". The rows keep their 4px inset, because the
148
+ * padding that was the panel's is the content's; and the cap loses
149
+ * its `- 0.5rem`, because that padding is inside the box that
150
+ * scrolls now, so 288px stays 288px. */}
151
+ <ScrollArea viewportClassName="max-h-[min(288px,var(--available-height))]" contentClassName="flex flex-col p-2">
136
152
  {options.map((option) => (
137
153
  <BaseSelect.Item
138
154
  key={option.value}
@@ -141,11 +157,17 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
141
157
  keyboard set the same attribute, so what the DOM says and
142
158
  what the row looks like cannot disagree. It used to be an
143
159
  index this component counted. */
144
- className="flex h-9 cursor-pointer items-center justify-between gap-2 rounded-lg px-3 text-[14px] font-normal leading-[1.4] text-text-primary transition-colors data-[highlighted]:bg-bg-hover data-[selected]:font-medium"
160
+ /* A menu row, exactly (`menuItemClassName`): 36px floor, 8px
161
+ in from a panel padded 8px, so the label lands 17px from the
162
+ panel's edge — the same pixel as the old 4px + 12px. What
163
+ Select adds is its own: the ✓ at the end, and the chosen
164
+ row in medium weight. No fade on the highlight, as in every
165
+ menu (Katerina, 2026-09-05). */
166
+ className={cn(menuItemClassName({ size: 'default' }), 'justify-between data-[selected]:font-medium')}
145
167
  >
146
168
  <span className="flex min-w-0 items-center gap-2">
147
169
  {option.leading && <span className="flex shrink-0 items-center">{option.leading}</span>}
148
- <BaseSelect.ItemText className="truncate">{option.label}</BaseSelect.ItemText>
170
+ <BaseSelect.ItemText className="truncate text-[14px] leading-[140%] text-text-primary">{option.label}</BaseSelect.ItemText>
149
171
  </span>
150
172
  <BaseSelect.ItemIndicator
151
173
  render={<IconCheck size={16} stroke={1.5} className="shrink-0 text-text-secondary" />}