Skip to content

Commit f792be2

Browse files
mirkaciampoaferciaMamaduka
authored andcommitted
UI: Announce selected chips in SearchableChipSelect (WordPress#82863)
* UI: Announce selected chips in SearchableChipSelect * Add changelog * UI: Compute SearchableChipSelect a11y from selected count * UI: Query chip remove hints without DOM closest * UI: Describe chips with aria-describedby * Changelog: Classify SearchableChipSelect chip announcements as a bug fix * UI: Name ChipWithRemove from chip content, not the remove button * UI: Hide ChipWithRemove from assistive technology The remove control is pointer-only. Keep its label for the tooltip and let the chip describe Backspace or Delete removal. * UI: Stabilize chip-remove tooltip browser test The custom tooltip assertion raced the default Tooltip delay. Match the other hover tests with Tooltip.Provider delay={0}, and fix prettier indentation in SearchableChipSelect. * UI: Drop untyped hidden option from chip remove queries getByLabelText does not accept hidden, and typecheck failed on it. The aria-label is enough to find the aria-hidden remove button. * UI: Align SearchableChipSelect describedby with ChipWithRemove Use clsx for describedby ids and hide the selection hint with aria-hidden instead of a VisuallyHidden span. * UI: Expose chip remove buttons with a shared Remove name Name each chip from its label and describe the X with that text, matching FormTokenField, instead of hiding the button from assistive technology. * UI: Use Delete as the custom chip remove tooltip in tests The previous Remove %s example looked like unique button names, which this PR no longer uses. * UI: Key SearchableChipSelect input across first chip selection * UI: Expect chip hints to omit unused keyboard instructions * UI: Omit unused keyboard instructions from chip hints * UI: Drop Chip render workaround for chip keyboard hints * UI: Restore merge-base ChipWithRemove comment lines * UI: Omit readOnly from SearchableChipSelect * UI: Shorten SearchableChipSelect changelog and classify readOnly as breaking * UI: Tighten SearchableChipSelect changelog wording * UI: Rely on native disabled for chip remove and input hints * UI: Drop unused chip-select lint disable and readOnly snapshot entries Trunk now allows string literal IDs in tests, and removing readOnly from SearchableChipSelect needs the Storybook manifest to match. * UI: Name ChipWithRemove from content and drop Remove %s on tags aria-labelledby covers mixed chip content, and the tags field no longer repeats the term on the remove button. * E2E: Find tag chips by Remove described with the term Chip remove buttons are named Remove now, so the old Remove %s locators no longer match. * UI: Describe chip remove from the same name as the chip When the consumer supplies aria-label or aria-labelledby, the remove button should use that name, not the visible content. * UI: Keep omitted grid and readOnly props off Combobox.Root A rest spread was still forwarding props the public types already omit. Unlinked contributors: StommePoes, enriquesanchez, karlgroves. Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: ciampo <mciampini@git.wordpress.org> Co-authored-by: afercia <afercia@git.wordpress.org> Co-authored-by: Mamaduka <mamaduka@git.wordpress.org>
1 parent 51ff903 commit f792be2

17 files changed

Lines changed: 531 additions & 118 deletions

File tree

‎packages/editor/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
### Bug Fixes
2525

26+
- Tags and other flat taxonomies: Chip remove buttons are named Remove and described by the term, instead of repeating the term in the button name ([#82863](https://github.com/WordPress/gutenberg/pull/82863)).
2627
- Distraction free: Raise the top bar above the notices overlay while it is revealed by hover or keyboard focus, so its controls are visible when focused instead of operable but hidden behind a notice. Also repoint the rule that overlays notices in distraction-free mode at the class the interface renders today ([#80349](https://github.com/WordPress/gutenberg/pull/80349)).
2728
- Notes: Focus the text field when editing a note ([#81930](https://github.com/WordPress/gutenberg/pull/81930)).
2829
- Template panel: Hide edit and create actions from users who can't create templates. ([#82858](https://github.com/WordPress/gutenberg/pull/82858))

‎packages/editor/src/components/post-taxonomies/flat-term-selector.jsx‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -417,20 +417,6 @@ export function FlatTermSelector( { slug } ) {
417417
}
418418
emptyContent={ isSearching ? null : notFoundLabel }
419419
showClearButton={ false }
420-
chipsContent={ ( selectedTerms ) =>
421-
selectedTerms.map( ( term ) => (
422-
<SearchableChipSelectControl.ChipWithRemove
423-
key={ term.value }
424-
removeLabel={ sprintf(
425-
/* translators: %s: term name. */
426-
_x( 'Remove %s', 'term' ),
427-
term.label
428-
) }
429-
>
430-
{ term.label }
431-
</SearchableChipSelectControl.ChipWithRemove>
432-
) )
433-
}
434420
/>
435421
<MostUsedTerms taxonomy={ taxonomy } onSelect={ appendTerm } />
436422
</Stack>

‎packages/ui/CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
### Breaking Changes
1616

1717
- `Combobox.Root`: `filteredItems` now uses the selected value type instead of `any`. Consumers that pass object items with primitive selected values must use matching item and value types. ([#82835](https://github.com/WordPress/gutenberg/pull/82835))
18+
- `SearchableChipSelect`, `SearchableChipSelectControl`: Remove the `readOnly` prop ([#82863](https://github.com/WordPress/gutenberg/pull/82863)).
1819
- `Select.Item`, `SelectControl.Item`: Children must start with `ItemLabel`, followed by zero or more `ItemDescription` components. Pass `ItemLabel` instead of a string. For `SelectControl` `items`, set `label` and optional `description` on each item. ([#82369](https://github.com/WordPress/gutenberg/pull/82369))
1920

2021
### New Features
@@ -39,6 +40,7 @@
3940

4041
### Bug Fixes
4142

43+
- `SearchableChipSelect`, `SearchableChipSelectControl`, `Combobox.ChipWithRemove`: Assistive technology now hears selected chip names, how to remove them, and how to move to them from the input ([#82863](https://github.com/WordPress/gutenberg/pull/82863)).
4244
- `Autocomplete`: Use valid grid and rowgroup semantics without local accessibility overrides ([#82835](https://github.com/WordPress/gutenberg/pull/82835)).
4345
- `Menu`: Center labels, descriptions, suffixes, shortcuts, and submenu chevrons within items, including items with taller prefix or suffix content. ([#82847](https://github.com/WordPress/gutenberg/pull/82847))
4446
- `Menu`: vertically center single-line item labels while preserving first-line alignment for wrapped content. ([#81921](https://github.com/WordPress/gutenberg/pull/81921))

‎packages/ui/src/form/primitives/combobox/chip-with-remove.tsx‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Combobox as _Combobox } from '@base-ui/react/combobox';
2-
import { forwardRef } from '@wordpress/element';
2+
import { forwardRef, useId } from '@wordpress/element';
33
import clsx from 'clsx';
44
import { __ } from '@wordpress/i18n';
55
import { closeSmall } from '@wordpress/icons';
66
import { IconButton } from '../../../icon-button';
7+
import { VisuallyHidden } from '../../../visually-hidden';
78
import type { ComboboxChipWithRemoveProps } from './types';
89
import styles from './style.module.css';
910

@@ -15,20 +16,54 @@ export const ChipWithRemove = forwardRef<
1516
HTMLDivElement,
1617
ComboboxChipWithRemoveProps
1718
>( function ChipWithRemove(
18-
{ className, children, prefix, removeLabel = __( 'Remove' ), ...restProps },
19+
{
20+
className,
21+
children,
22+
prefix,
23+
removeLabel = __( 'Remove' ),
24+
'aria-label': ariaLabel,
25+
'aria-labelledby': ariaLabelledby,
26+
'aria-describedby': ariaDescribedby,
27+
...restProps
28+
},
1929
ref
2030
) {
31+
const labelId = useId();
32+
const hintId = useId();
33+
const chipNameFrom = ariaLabelledby || labelId;
34+
const nameFromAriaLabel = Boolean( ariaLabel && ! ariaLabelledby );
35+
2136
return (
2237
<_Combobox.Chip
2338
ref={ ref }
2439
className={ clsx( styles.chip, className ) }
2540
{ ...restProps }
41+
aria-labelledby={ chipNameFrom }
42+
aria-describedby={ clsx( ariaDescribedby, hintId ) || undefined }
2643
>
2744
{ prefix && (
28-
<span className={ styles[ 'chip-prefix' ] }>{ prefix }</span>
45+
<span
46+
className={ styles[ 'chip-prefix' ] }
47+
aria-hidden={ nameFromAriaLabel ? true : undefined }
48+
>
49+
{ prefix }
50+
</span>
2951
) }
30-
<span className={ styles[ 'chip-content' ] }>{ children }</span>
31-
52+
{ nameFromAriaLabel && (
53+
<VisuallyHidden id={ labelId } aria-hidden="true">
54+
{ ariaLabel }
55+
</VisuallyHidden>
56+
) }
57+
<span
58+
id={ nameFromAriaLabel ? undefined : labelId }
59+
className={ styles[ 'chip-content' ] }
60+
aria-hidden={ nameFromAriaLabel ? true : undefined }
61+
>
62+
{ children }
63+
</span>
64+
<VisuallyHidden id={ hintId } aria-hidden="true">
65+
{ __( 'Press Backspace or Delete to remove.' ) }
66+
</VisuallyHidden>
3267
<_Combobox.ChipRemove
3368
className={ styles[ 'chip-remove' ] }
3469
render={ ( props, { disabled } ) => (
@@ -40,8 +75,11 @@ export const ChipWithRemove = forwardRef<
4075
tone="neutral"
4176
focusableWhenDisabled={ false }
4277
disabled={ disabled }
43-
aria-hidden={ disabled || undefined }
4478
{ ...props }
79+
aria-describedby={
80+
clsx( props[ 'aria-describedby' ], chipNameFrom ) ||
81+
undefined
82+
}
4583
/>
4684
) }
4785
/>

‎packages/ui/src/form/primitives/combobox/test/index.browser.test.tsx‎

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -189,35 +189,43 @@ describe( 'Combobox', () => {
189189
);
190190
} );
191191

192-
it( 'uses a custom accessible label for chip remove buttons', async () => {
192+
it( 'uses a custom tooltip label for chip remove buttons', async () => {
193+
const user = userEvent;
194+
193195
await render(
194-
<Combobox.Root< Item, true >
195-
items={ ITEMS }
196-
multiple
197-
defaultValue={ [ ITEMS[ 0 ] ] }
198-
>
199-
<Combobox.Chips>
200-
<Combobox.Value>
201-
{ ( value: Item[] ) => (
202-
<>
203-
{ value.map( ( item ) => (
204-
<Combobox.ChipWithRemove
205-
key={ item.id }
206-
removeLabel={ `Remove ${ item.value }` }
207-
>
208-
{ item.value }
209-
</Combobox.ChipWithRemove>
210-
) ) }
211-
</>
212-
) }
213-
</Combobox.Value>
214-
</Combobox.Chips>
215-
</Combobox.Root>
196+
<Tooltip.Provider delay={ 0 }>
197+
<Combobox.Root< Item, true >
198+
items={ ITEMS }
199+
multiple
200+
defaultValue={ [ ITEMS[ 0 ] ] }
201+
>
202+
<Combobox.Chips>
203+
<Combobox.Value>
204+
{ ( value: Item[] ) => (
205+
<>
206+
{ value.map( ( item ) => (
207+
<Combobox.ChipWithRemove
208+
key={ item.id }
209+
removeLabel="Delete"
210+
>
211+
{ item.value }
212+
</Combobox.ChipWithRemove>
213+
) ) }
214+
</>
215+
) }
216+
</Combobox.Value>
217+
</Combobox.Chips>
218+
</Combobox.Root>
219+
</Tooltip.Provider>
220+
);
221+
222+
await user.hover(
223+
screen.getByLabelText( 'Delete', {
224+
selector: 'button',
225+
} )
216226
);
217227

218-
await expect
219-
.element( screen.getByRole( 'button', { name: 'Remove Item 1' } ) )
220-
.toBeVisible();
228+
await expect.element( screen.getByText( 'Delete' ) ).toBeVisible();
221229
} );
222230

223231
it( 'allows selecting items when Empty is rendered after List', async () => {

‎packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx‎

Lines changed: 172 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,182 @@ describe( 'Combobox', () => {
7474
expect( trigger ).toHaveTextContent( 'Choose an item' );
7575
} );
7676

77+
it( 'names chips from content and describes shared Remove buttons with that label', () => {
78+
render(
79+
<Combobox.Root< Item, true >
80+
items={ ITEMS }
81+
multiple
82+
defaultValue={ [ ITEMS[ 0 ], ITEMS[ 1 ] ] }
83+
>
84+
<Combobox.Chips>
85+
<Combobox.Value>
86+
{ ( value: Item[] ) => (
87+
<>
88+
{ value.map( ( item ) => (
89+
<Combobox.ChipWithRemove key={ item.id }>
90+
{ item.value }
91+
</Combobox.ChipWithRemove>
92+
) ) }
93+
</>
94+
) }
95+
</Combobox.Value>
96+
</Combobox.Chips>
97+
</Combobox.Root>
98+
);
99+
100+
expect( screen.getByLabelText( 'Item 1' ) ).toHaveAccessibleName(
101+
'Item 1'
102+
);
103+
expect( screen.getByLabelText( 'Item 2' ) ).toHaveAccessibleName(
104+
'Item 2'
105+
);
106+
107+
const removeButtons = screen.getAllByRole( 'button', {
108+
name: 'Remove',
109+
} );
110+
expect( removeButtons ).toHaveLength( 2 );
111+
expect( removeButtons[ 0 ] ).toHaveAccessibleDescription( 'Item 1' );
112+
expect( removeButtons[ 1 ] ).toHaveAccessibleDescription( 'Item 2' );
113+
} );
114+
115+
it( 'names chips from mixed content without an aria-label', () => {
116+
render(
117+
<Combobox.Root< Item, true >
118+
items={ ITEMS }
119+
multiple
120+
defaultValue={ [ ITEMS[ 0 ] ] }
121+
>
122+
<Combobox.Chips>
123+
<Combobox.Value>
124+
{ ( value: Item[] ) => (
125+
<>
126+
{ value.map( ( item ) => (
127+
<Combobox.ChipWithRemove key={ item.id }>
128+
<span aria-hidden="true">*</span>
129+
{ item.value }
130+
</Combobox.ChipWithRemove>
131+
) ) }
132+
</>
133+
) }
134+
</Combobox.Value>
135+
</Combobox.Chips>
136+
</Combobox.Root>
137+
);
138+
139+
expect( screen.getByLabelText( /Item 1/ ) ).toHaveAccessibleName(
140+
'Item 1'
141+
);
142+
expect(
143+
screen.getByRole( 'button', { name: 'Remove' } )
144+
).toHaveAccessibleDescription( 'Item 1' );
145+
} );
146+
147+
it( 'uses a consumer aria-label instead of chip content', () => {
148+
render(
149+
<Combobox.Root< Item, true >
150+
items={ ITEMS }
151+
multiple
152+
defaultValue={ [ ITEMS[ 0 ] ] }
153+
>
154+
<Combobox.Chips>
155+
<Combobox.Value>
156+
{ ( value: Item[] ) => (
157+
<>
158+
{ value.map( ( item ) => (
159+
<Combobox.ChipWithRemove
160+
key={ item.id }
161+
aria-label="Apple"
162+
>
163+
{ item.value }
164+
</Combobox.ChipWithRemove>
165+
) ) }
166+
</>
167+
) }
168+
</Combobox.Value>
169+
</Combobox.Chips>
170+
</Combobox.Root>
171+
);
172+
173+
expect( screen.getByLabelText( 'Apple' ) ).toHaveAccessibleName(
174+
'Apple'
175+
);
176+
expect(
177+
screen.getByRole( 'button', { name: 'Remove' } )
178+
).toHaveAccessibleDescription( 'Apple' );
179+
} );
180+
181+
it( 'describes the remove button with a consumer aria-labelledby', () => {
182+
render(
183+
<>
184+
<span id="chip-name">Apple</span>
185+
<Combobox.Root< Item, true >
186+
items={ ITEMS }
187+
multiple
188+
defaultValue={ [ ITEMS[ 0 ] ] }
189+
>
190+
<Combobox.Chips>
191+
<Combobox.Value>
192+
{ ( value: Item[] ) => (
193+
<>
194+
{ value.map( ( item ) => (
195+
<Combobox.ChipWithRemove
196+
key={ item.id }
197+
aria-labelledby="chip-name"
198+
>
199+
{ item.value }
200+
</Combobox.ChipWithRemove>
201+
) ) }
202+
</>
203+
) }
204+
</Combobox.Value>
205+
</Combobox.Chips>
206+
</Combobox.Root>
207+
</>
208+
);
209+
210+
expect( screen.getByLabelText( 'Apple' ) ).toHaveAccessibleName(
211+
'Apple'
212+
);
213+
expect(
214+
screen.getByRole( 'button', { name: 'Remove' } )
215+
).toHaveAccessibleDescription( 'Apple' );
216+
} );
217+
218+
it( 'describes ChipWithRemove with the Backspace or Delete hint by default', () => {
219+
render(
220+
<Combobox.Root< Item, true >
221+
items={ ITEMS }
222+
multiple
223+
defaultValue={ [ ITEMS[ 0 ] ] }
224+
>
225+
<Combobox.Chips>
226+
<Combobox.Value>
227+
{ ( value: Item[] ) => (
228+
<>
229+
{ value.map( ( item ) => (
230+
<Combobox.ChipWithRemove key={ item.id }>
231+
{ item.value }
232+
</Combobox.ChipWithRemove>
233+
) ) }
234+
</>
235+
) }
236+
</Combobox.Value>
237+
</Combobox.Chips>
238+
</Combobox.Root>
239+
);
240+
241+
expect( screen.getByLabelText( 'Item 1' ) ).toHaveAccessibleDescription(
242+
'Press Backspace or Delete to remove.'
243+
);
244+
} );
245+
77246
describe( 'when disabled', () => {
78-
it( 'hides the chip remove button from screen readers', () => {
247+
it( 'disables the chip remove button', () => {
79248
renderDisabledMultiSelect();
80249

81250
expect(
82-
screen.queryByRole( 'button', { name: 'Remove' } )
83-
).not.toBeInTheDocument();
251+
screen.getByRole( 'button', { name: 'Remove' } )
252+
).toBeDisabled();
84253
} );
85254

86255
it( 'hides the clear button from screen readers', () => {

‎packages/ui/src/form/primitives/combobox/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export type ComboboxChipWithRemoveProps = Omit<
2222
prefix?: React.ReactNode;
2323
/**
2424
* Accessible label for the remove button.
25+
* Keep this to the action (for example `Remove`). The chip label describes
26+
* the button.
2527
*
2628
* @default __( 'Remove' )
2729
*/

0 commit comments

Comments
 (0)