@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.12.7",
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' } },
@@ -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({ initial = [], excludeIds, onChange }: { initial?: ChipInputOption[]; excludeIds?: string[]; onChange?: (v: ChipInputOption[]) => void }) {
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
- <button
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
- </button>
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
- 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" />}