@estiva-app/ui 0.12.5 → 0.12.7
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.map +1 -1
- package/dist/Popover.d.ts.map +1 -1
- package/dist/index.js +139 -205
- 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 +18 -6
- package/src/Popover.tsx +7 -5
- 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
|
@@ -208,16 +208,28 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
|
|
|
208
208
|
room. Select's 288 was never this component's — the identity
|
|
209
209
|
panel got it by accident once and grew a scrollbar at full
|
|
210
210
|
height. */
|
|
211
|
-
className={cn('min-w-[180px] outline-none', className)}
|
|
211
|
+
className={cn('min-w-[180px] outline-none p-0', className)}
|
|
212
212
|
render={<MenuPanel />}
|
|
213
213
|
>
|
|
214
214
|
{/* The height cap sits on the box that scrolls — on the panel it
|
|
215
215
|
let the box grow to its content and nothing scrolled (measured,
|
|
216
|
-
2026-09-08)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
2026-09-08).
|
|
217
|
+
*
|
|
218
|
+
* And **the padding is on the content, not on the panel** (D63,
|
|
219
|
+
* 2026-09-13). With it on the panel the scrolling box was inset by
|
|
220
|
+
* it, so the bar floated 9px in from the panel's edge where every
|
|
221
|
+
* other scrolling surface in the suite draws it at 1px —
|
|
222
|
+
* `DialogShell` had it right and said so in its own comment, and
|
|
223
|
+
* this is the same arrangement. The rows do not move: the padding
|
|
224
|
+
* that used to be the panel's is now the content's, at the same
|
|
225
|
+
* 8px. The cap loses its `- 1rem` for the same reason — the
|
|
226
|
+
* padding is inside the scrolling box now, so the panel is exactly
|
|
227
|
+
* as tall as Floating UI allowed.
|
|
228
|
+
*
|
|
229
|
+
* A caller's `className` still lands on the panel, so a caller
|
|
230
|
+
* asking for different padding needs `contentClassName` — which is
|
|
231
|
+
* what `MenuPanel` is for when one is used on its own. */}
|
|
232
|
+
<ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName="flex flex-col p-2 [&>[role=separator]]:mx-0">
|
|
221
233
|
<MenuContext.Provider value={{ openOnHover }}>{children}</MenuContext.Provider>
|
|
222
234
|
</ScrollArea>
|
|
223
235
|
</BaseMenu.Popup>
|
package/src/Popover.tsx
CHANGED
|
@@ -175,14 +175,16 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
|
|
|
175
175
|
*/
|
|
176
176
|
initialFocus={trigger ? undefined : false}
|
|
177
177
|
finalFocus={finalFocus}
|
|
178
|
-
className={cn('min-w-[180px] outline-none', className)}
|
|
178
|
+
className={cn('min-w-[180px] outline-none p-0', className)}
|
|
179
179
|
render={<MenuPanel />}
|
|
180
180
|
>
|
|
181
|
-
{/* As in Menu: the cap on the scrolling box,
|
|
182
|
-
|
|
181
|
+
{/* As in Menu: the cap on the scrolling box, and the padding on the
|
|
182
|
+
content rather than the panel, so the bar is drawn over the
|
|
183
|
+
padding instead of 9px inside it (D63). A caller's `maxHeight`
|
|
184
|
+
replaces the cap — see the prop. */}
|
|
183
185
|
<ScrollArea
|
|
184
|
-
viewportClassName={maxHeight ?? 'max-h-[
|
|
185
|
-
contentClassName="flex flex-col [&>[role=separator]]:mx-0"
|
|
186
|
+
viewportClassName={maxHeight ?? 'max-h-[var(--available-height)]'}
|
|
187
|
+
contentClassName="flex flex-col p-2 [&>[role=separator]]:mx-0"
|
|
186
188
|
>
|
|
187
189
|
{children}
|
|
188
190
|
</ScrollArea>
|
package/dist/fit.d.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where a list of this size goes, given its anchor and the viewport.
|
|
3
|
-
*
|
|
4
|
-
* Left is clamped inside the viewport with an 8px margin. Height is capped at
|
|
5
|
-
* `cap` — `ChipInput` passes 240, its old `max-h-[240px]` — but never taller
|
|
6
|
-
* than the room there is; when the room below the anchor is smaller than both
|
|
7
|
-
* the content and the room above, the list opens UPWARD (anchored to the
|
|
8
|
-
* trigger's top via `bottom`). The 120px floor keeps it usable even in a
|
|
9
|
-
* cramped corner — scrollable beats invisible.
|
|
10
|
-
*/
|
|
11
|
-
export declare function fitMenu({ anchor, menu, viewport, cap, }: {
|
|
12
|
-
anchor: {
|
|
13
|
-
left: number;
|
|
14
|
-
top: number;
|
|
15
|
-
bottom: number;
|
|
16
|
-
};
|
|
17
|
-
menu: {
|
|
18
|
-
width: number;
|
|
19
|
-
contentHeight: number;
|
|
20
|
-
};
|
|
21
|
-
viewport: {
|
|
22
|
-
width: number;
|
|
23
|
-
height: number;
|
|
24
|
-
};
|
|
25
|
-
/** Tallest the menu may stand even with room to spare. Absent: the room is the only limit. */
|
|
26
|
-
cap?: number;
|
|
27
|
-
}): {
|
|
28
|
-
left: number;
|
|
29
|
-
top?: number;
|
|
30
|
-
bottom?: number;
|
|
31
|
-
maxHeight: number;
|
|
32
|
-
};
|
|
33
|
-
//# sourceMappingURL=fit.d.ts.map
|
package/dist/fit.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"fit.d.ts","sourceRoot":"","sources":["../src/fit.ts"],"names":[],"mappings":"AAmBA;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,EACtB,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,GAA8B,GAC/B,EAAE;IACD,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IACrD,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAA;IAC9C,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IAC3C,8FAA8F;IAC9F,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CASrE"}
|