@estiva-app/ui 0.12.7 → 0.12.9
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 +8 -0
- 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 +16 -8
- package/dist/index.js.map +3 -3
- package/package.json +2 -1
- package/src/ChipInput.mdx +9 -0
- package/src/ChipInput.stories.tsx +0 -4
- package/src/ChipInput.test.tsx +78 -2
- package/src/ChipInput.tsx +31 -2
- package/src/Menu.tsx +5 -1
- package/src/Select.tsx +28 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estiva-app/ui",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.9",
|
|
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",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"test:a11y": "vitest run --project storybook-signal --project storybook-ship",
|
|
34
34
|
"typecheck": "tsc --noEmit",
|
|
35
35
|
"lint": "eslint .",
|
|
36
|
+
"gates:status": "node scripts/gates-status.mjs",
|
|
36
37
|
"prepublishOnly": "npm run build"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
package/src/ChipInput.mdx
CHANGED
|
@@ -55,6 +55,15 @@ import { ChipInput } from '@estiva-app/ui'
|
|
|
55
55
|
arrow keys move through the suggestions, and a screen reader is told which
|
|
56
56
|
one is highlighted. The list is as wide as the field and hangs below it,
|
|
57
57
|
or above when there is no room.
|
|
58
|
+
- **The field's name.** Inside a `Field`, the label names it — pass nothing.
|
|
59
|
+
Anywhere else, pass `aria-label`, or `aria-labelledby` pointing at a
|
|
60
|
+
visible label such as a "To:". With neither, the placeholder is the name,
|
|
61
|
+
and it stays the name after the first chip stops it being drawn.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
<span id="to-label">To:</span>
|
|
65
|
+
<ChipInput aria-labelledby="to-label" value={chosen} onChange={setChosen} options={directory} />
|
|
66
|
+
```
|
|
58
67
|
|
|
59
68
|
## Keys
|
|
60
69
|
|
|
@@ -29,10 +29,6 @@ const meta = {
|
|
|
29
29
|
title: 'Inputs/ChipInput',
|
|
30
30
|
component: ChipInput,
|
|
31
31
|
parameters: {
|
|
32
|
-
// axe label is off here: once a chip is in the field the input drops its placeholder
|
|
33
|
-
// and has no name of its own, and no prop lets the caller give it one. Settled at the
|
|
34
|
-
// Combobox port (PLAN.md stage 5).
|
|
35
|
-
a11y: { config: { rules: [{ id: 'label', enabled: false }] } },
|
|
36
32
|
// The suggestion list portals to document.body at fixed coordinates —
|
|
37
33
|
// render docs usage in an iframe so it lands where the field is.
|
|
38
34
|
docs: { story: { inline: false, height: '320px' } },
|
package/src/ChipInput.test.tsx
CHANGED
|
@@ -13,7 +13,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
13
13
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
14
14
|
import userEvent from '@testing-library/user-event'
|
|
15
15
|
import { useState } from 'react'
|
|
16
|
-
import { ChipInput, type ChipInputOption } from './ChipInput'
|
|
16
|
+
import { ChipInput, InputChip, type ChipInputOption } from './ChipInput'
|
|
17
|
+
import { Field } from './Field'
|
|
17
18
|
|
|
18
19
|
afterEach(cleanup)
|
|
19
20
|
|
|
@@ -23,7 +24,17 @@ const PEOPLE: ChipInputOption[] = [
|
|
|
23
24
|
{ id: 'alan', label: 'Alan Turing', description: 'Mathematician' },
|
|
24
25
|
]
|
|
25
26
|
|
|
26
|
-
function Harness({
|
|
27
|
+
function Harness({
|
|
28
|
+
initial = [],
|
|
29
|
+
excludeIds,
|
|
30
|
+
onChange,
|
|
31
|
+
naming,
|
|
32
|
+
}: {
|
|
33
|
+
initial?: ChipInputOption[]
|
|
34
|
+
excludeIds?: string[]
|
|
35
|
+
onChange?: (v: ChipInputOption[]) => void
|
|
36
|
+
naming?: { 'aria-label'?: string; 'aria-labelledby'?: string }
|
|
37
|
+
}) {
|
|
27
38
|
const [value, setValue] = useState(initial)
|
|
28
39
|
return (
|
|
29
40
|
<ChipInput
|
|
@@ -35,6 +46,7 @@ function Harness({ initial = [], excludeIds, onChange }: { initial?: ChipInputOp
|
|
|
35
46
|
options={PEOPLE}
|
|
36
47
|
excludeIds={excludeIds}
|
|
37
48
|
placeholder="Search people"
|
|
49
|
+
{...naming}
|
|
38
50
|
/>
|
|
39
51
|
)
|
|
40
52
|
}
|
|
@@ -139,4 +151,68 @@ describe('ChipInput', () => {
|
|
|
139
151
|
await user.keyboard('{Escape}')
|
|
140
152
|
expect(reachedOutside).toHaveBeenCalledTimes(1)
|
|
141
153
|
})
|
|
154
|
+
|
|
155
|
+
describe('the field’s name (PLAN Finding 6)', () => {
|
|
156
|
+
/* Chrome names an empty field by its placeholder, and the first chip takes
|
|
157
|
+
the placeholder away. Measured in Chrome's accessibility tree before this
|
|
158
|
+
was fixed: with a chip in, the name was "". */
|
|
159
|
+
it('keeps the placeholder as its name once a chip takes the placeholder away', () => {
|
|
160
|
+
render(<Harness initial={[PEOPLE[0]]} />)
|
|
161
|
+
const input = screen.getByRole('combobox', { name: 'Search people' })
|
|
162
|
+
expect(input.getAttribute('placeholder')).toBe('')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('adds no name of its own while the placeholder is still drawn', () => {
|
|
166
|
+
render(<Harness />)
|
|
167
|
+
expect(screen.getByRole('combobox').getAttribute('aria-label')).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('takes a caller’s aria-label over the placeholder', () => {
|
|
171
|
+
render(<Harness initial={[PEOPLE[0]]} naming={{ 'aria-label': 'To' }} />)
|
|
172
|
+
expect(screen.getByRole('combobox', { name: 'To' })).toBeTruthy()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('takes a caller’s aria-labelledby — a visible “To:”', () => {
|
|
176
|
+
render(
|
|
177
|
+
<div>
|
|
178
|
+
<span id="to">To:</span>
|
|
179
|
+
<Harness initial={[PEOPLE[0]]} naming={{ 'aria-labelledby': 'to' }} />
|
|
180
|
+
</div>,
|
|
181
|
+
)
|
|
182
|
+
expect(screen.getByRole('combobox', { name: 'To:' })).toBeTruthy()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
/* The trap this pins: Base UI copies a caller's prop over its own even when
|
|
186
|
+
it is `undefined`, so an `aria-labelledby` passed through empty would wipe
|
|
187
|
+
the `Field`'s and the placeholder would name the field instead. */
|
|
188
|
+
it('inside a Field, is named by the Field’s label, chip or no chip', () => {
|
|
189
|
+
render(
|
|
190
|
+
<>
|
|
191
|
+
<Field label="Invite people">
|
|
192
|
+
<Harness />
|
|
193
|
+
</Field>
|
|
194
|
+
<Field label="Reviewers">
|
|
195
|
+
<Harness initial={[PEOPLE[0]]} />
|
|
196
|
+
</Field>
|
|
197
|
+
</>,
|
|
198
|
+
)
|
|
199
|
+
expect(screen.getByRole('combobox', { name: 'Invite people' })).toBeTruthy()
|
|
200
|
+
expect(screen.getByRole('combobox', { name: 'Reviewers' })).toBeTruthy()
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('InputChip', () => {
|
|
206
|
+
it('its ✕ is a button named for the chip, and removes it', async () => {
|
|
207
|
+
const user = userEvent.setup()
|
|
208
|
+
const onRemove = vi.fn()
|
|
209
|
+
render(<InputChip label="Label" onRemove={onRemove} />)
|
|
210
|
+
await user.click(screen.getByRole('button', { name: 'Remove Label' }))
|
|
211
|
+
expect(onRemove).toHaveBeenCalledTimes(1)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('draws no ✕ without onRemove', () => {
|
|
215
|
+
render(<InputChip label="Label" />)
|
|
216
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
217
|
+
})
|
|
142
218
|
})
|
package/src/ChipInput.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMemo, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
2
|
+
import { Button as BaseButton } from '@base-ui/react/button'
|
|
2
3
|
import { Combobox } from '@base-ui/react/combobox'
|
|
3
4
|
import { IconX } from '@tabler/icons-react'
|
|
4
5
|
import { cn } from './cn'
|
|
@@ -45,8 +46,12 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
|
|
|
45
46
|
<div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
|
|
46
47
|
{leading && <span className="flex shrink-0 items-center">{leading}</span>}
|
|
47
48
|
<span className={CHIP_LABEL}>{label}</span>
|
|
49
|
+
{/* Base UI's `Button`, as every button in the package is (D6). Base UI
|
|
50
|
+
has no chip of its own — its only chips are `Combobox.Chip` and
|
|
51
|
+
`ChipRemove`, which throw outside a combobox — so the ✕ is the one
|
|
52
|
+
part of a chip standing alone that it has a counterpart for. */}
|
|
48
53
|
{onRemove && (
|
|
49
|
-
<
|
|
54
|
+
<BaseButton
|
|
50
55
|
type="button"
|
|
51
56
|
onClick={(e) => {
|
|
52
57
|
e.stopPropagation()
|
|
@@ -56,7 +61,7 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
|
|
|
56
61
|
aria-label={`Remove ${label}`}
|
|
57
62
|
>
|
|
58
63
|
<IconX size={10} stroke={1.5} />
|
|
59
|
-
</
|
|
64
|
+
</BaseButton>
|
|
60
65
|
)}
|
|
61
66
|
</div>
|
|
62
67
|
)
|
|
@@ -112,6 +117,14 @@ export interface ChipInputProps<T extends ChipInputOption = ChipInputOption> {
|
|
|
112
117
|
rowLeading?: (option: T) => ReactNode
|
|
113
118
|
/** Set by a `Field` with `required`; a caller inside one owes nothing. */
|
|
114
119
|
'aria-required'?: boolean | 'true' | 'false'
|
|
120
|
+
/**
|
|
121
|
+
* The field's name, for a caller that is not inside a `Field` — a `Field`'s
|
|
122
|
+
* label names it already. With neither this nor `aria-labelledby`, the
|
|
123
|
+
* placeholder is the name, and it stays the name once a chip is in.
|
|
124
|
+
*/
|
|
125
|
+
'aria-label'?: string
|
|
126
|
+
/** The id of what names the field on the page — a visible "To:", say. */
|
|
127
|
+
'aria-labelledby'?: string
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
export function ChipInput<T extends ChipInputOption = ChipInputOption>({
|
|
@@ -158,6 +171,21 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
|
|
|
158
171
|
the open state is this component's and not the part's. */
|
|
159
172
|
const open = query.trim().length > 0
|
|
160
173
|
|
|
174
|
+
/* The field's name (PLAN Finding 6). With no chip, Chrome names the field
|
|
175
|
+
by its placeholder; the first chip takes the placeholder away, and the
|
|
176
|
+
field was left with no name at all (measured: ""). So the placeholder
|
|
177
|
+
stays on as the name once it is no longer drawn. A caller's own name
|
|
178
|
+
wins, and so does a `Field`'s label: Base UI points `aria-labelledby` at
|
|
179
|
+
it, and a label by reference outranks `aria-label`.
|
|
180
|
+
Only a name that exists is passed. Base UI copies a caller's prop over its
|
|
181
|
+
own even when the value is `undefined`, so an `aria-labelledby` written
|
|
182
|
+
out empty would wipe the `Field`'s. */
|
|
183
|
+
const ariaLabel = aria['aria-label'] ?? (value.length > 0 ? placeholder : undefined)
|
|
184
|
+
const naming = {
|
|
185
|
+
...(ariaLabel !== undefined && { 'aria-label': ariaLabel }),
|
|
186
|
+
...(aria['aria-labelledby'] !== undefined && { 'aria-labelledby': aria['aria-labelledby'] }),
|
|
187
|
+
}
|
|
188
|
+
|
|
161
189
|
function removeLast() {
|
|
162
190
|
if (value.length > 0) onChange(value.slice(0, -1))
|
|
163
191
|
}
|
|
@@ -213,6 +241,7 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
|
|
|
213
241
|
autoFocus={autoFocus}
|
|
214
242
|
placeholder={value.length === 0 ? placeholder : ''}
|
|
215
243
|
aria-required={aria['aria-required']}
|
|
244
|
+
{...naming}
|
|
216
245
|
onKeyDown={onInputKeyDown}
|
|
217
246
|
className="flex-1 min-w-[120px] bg-transparent text-body-2 text-text-primary placeholder:text-text-muted outline-none border-none"
|
|
218
247
|
/>
|
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" />}
|