@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/dist/ChipInput.d.ts +25 -11
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/Menu.d.ts +17 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Select.d.ts.map +1 -1
- package/dist/index.js +139 -204
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/ChipInput.mdx +6 -0
- package/src/ChipInput.test.tsx +142 -0
- package/src/ChipInput.tsx +156 -158
- package/src/Menu.tsx +5 -1
- package/src/Select.tsx +28 -6
- package/dist/fit.d.ts +0 -33
- package/dist/fit.d.ts.map +0 -1
- package/src/fit.test.ts +0 -101
- package/src/fit.ts +0 -50
package/package.json
CHANGED
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
-
import {
|
|
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=
|
|
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=
|
|
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
|
|
62
|
-
*
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
152
|
-
onChange(
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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 (
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
177
|
+
event.preventDefault()
|
|
178
|
+
event.stopPropagation()
|
|
179
|
+
setQuery('')
|
|
191
180
|
}
|
|
192
181
|
}
|
|
193
182
|
|
|
194
183
|
return (
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
</
|
|
219
|
+
</Combobox.Chips>
|
|
221
220
|
|
|
222
|
-
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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" />}
|