@estiva-app/ui 0.12.8 → 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.8",
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
  />