Skip to content

Commit 8b7517d

Browse files
rivka-ungarclaude
andcommitted
fix(Dropdown): announce multi-select chips via aria-describedby, drop summary-in-value
Replace the inaccessible chips approach (selection summary stuffed into the combobox value + each chip as a single "Remove <item>" button in a role=group) with the visually-hidden + aria-describedby announcement from PR #3351, reading out the selected values. Revert the chips to a chip + labelled × remove button. Keeping the selection out of the input value restores type-to-search and Backspace-to-chip removal, which the value summary had disabled. Update tests and the multi-select a11y doc (new dated section above the preserved history). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a47d3e0 commit 8b7517d

9 files changed

Lines changed: 136 additions & 145 deletions

File tree

packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -805,15 +805,6 @@ describe("DropdownNew", () => {
805805
expect(getByTestId("dropdown-chip-item3")).toBeInTheDocument();
806806
});
807807

808-
it('should expose the chips as a group labelled "selected items"', () => {
809-
const { getByRole, getByPlaceholderText, getByText } = renderDropdown({ multi: true });
810-
811-
fireEvent.click(getByPlaceholderText("Select an option"));
812-
fireEvent.click(getByText("Option 1"));
813-
814-
expect(getByRole("group", { name: "selected items" })).toBeInTheDocument();
815-
});
816-
817808
it("should set aria-selected on options reflecting the multi-select state", () => {
818809
const { getByRole, getByPlaceholderText } = renderDropdown({ multi: true });
819810

@@ -827,59 +818,57 @@ describe("DropdownNew", () => {
827818
expect(unselectedOption).toHaveAttribute("aria-selected", "false");
828819
});
829820

830-
it('should render each chip as a single button labelled "Remove <item>"', () => {
821+
it("should render each chip with a labelled remove (×) button", () => {
831822
const { getByRole, getByPlaceholderText, getByText, getByTestId } = renderDropdown({ multi: true });
832823

833824
fireEvent.click(getByPlaceholderText("Select an option"));
834825
fireEvent.click(getByText("Option 1"));
835826

836-
// The chip element itself is the remove button — no nested button inside it.
837-
const chip = getByTestId("dropdown-chip-opt1");
838-
expect(chip.tagName).toBe("BUTTON");
839-
expect(chip).toHaveAttribute("aria-label", "Remove Option 1");
840-
expect(within(chip).queryByRole("button")).not.toBeInTheDocument();
827+
// The chip's × is the remove control, labelled for screen readers.
828+
expect(getByTestId("dropdown-chip-opt1")).toBeInTheDocument();
829+
expect(getByRole("button", { name: "Remove Option 1" })).toBeInTheDocument();
841830
});
842831

843832
describe("interactiveChips", () => {
844-
it("should expose a short selection summary as the combobox value", () => {
833+
it("should keep the input clear rather than stuffing the selection into its value", () => {
845834
const { getByRole } = renderDropdown({ multi: true, interactiveChips: true });
846835

847836
fireEvent.click(getByRole("combobox"));
848837
fireEvent.click(within(getByRole("listbox")).getByText("Option 1"));
849-
expect(getByRole("combobox")).toHaveValue("Option 1");
850-
851838
fireEvent.click(within(getByRole("listbox")).getByText("Option 3"));
852-
expect(getByRole("combobox")).toHaveValue("Option 1 and 1 other");
853-
854-
fireEvent.click(within(getByRole("listbox")).getByText("Option 4"));
855-
expect(getByRole("combobox")).toHaveValue("Option 1 and 2 others");
856-
});
857839

858-
it("should reflect a defaultValue in the combobox summary on mount", () => {
859-
const { getByRole } = renderDropdown({
860-
multi: true,
861-
interactiveChips: true,
862-
defaultValue: [
863-
{ label: "Option 1", value: "opt1" },
864-
{ label: "Option 3", value: "opt3" }
865-
] as any
866-
});
867-
868-
expect(getByRole("combobox")).toHaveValue("Option 1 and 1 other");
840+
// The selection is announced via aria-describedby, not placed in the input value,
841+
// so type-to-search and Backspace-to-chip stay available.
842+
expect(getByRole("combobox")).toHaveValue("");
869843
});
870844

871-
it("should update the summary when a chip is removed", () => {
845+
it("should update the announced selection when a chip is removed", () => {
872846
const { getByRole } = renderDropdown({ multi: true, interactiveChips: true });
873847

874848
fireEvent.click(getByRole("combobox"));
875849
fireEvent.click(within(getByRole("listbox")).getByText("Option 1"));
876850
fireEvent.click(within(getByRole("listbox")).getByText("Option 3"));
877-
expect(getByRole("combobox")).toHaveValue("Option 1 and 1 other");
878851

879-
// Each chip is itself a remove button.
880852
fireEvent.click(getByRole("button", { name: "Remove Option 1" }));
881853

882-
expect(getByRole("combobox")).toHaveValue("Option 3");
854+
const describedById = getByRole("combobox").getAttribute("aria-describedby");
855+
const description = document.getElementById(describedById!.split(" ").pop()!);
856+
expect(description).toHaveTextContent("Option 3");
857+
expect(description).not.toHaveTextContent("Option 1");
858+
});
859+
860+
it("should announce the current selection via aria-describedby on the combobox", () => {
861+
const { getByRole } = renderDropdown({ multi: true, interactiveChips: true });
862+
863+
fireEvent.click(getByRole("combobox"));
864+
fireEvent.click(within(getByRole("listbox")).getByText("Option 1"));
865+
fireEvent.click(within(getByRole("listbox")).getByText("Option 3"));
866+
867+
const combobox = getByRole("combobox");
868+
const describedById = combobox.getAttribute("aria-describedby");
869+
expect(describedById).toBeTruthy();
870+
const description = document.getElementById(describedById!.split(" ").pop()!);
871+
expect(description).toHaveTextContent("Option 1, Option 3");
883872
});
884873
});
885874
});

packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.module.scss

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
flex-grow: 1;
44

55
&.singleChip {
6-
> .chipButton:first-child {
6+
> div:first-child {
77
min-width: 50px;
88
flex-shrink: 1;
99

@@ -14,7 +14,7 @@
1414
}
1515

1616
&.measuring {
17-
> .chipButton:not(.hiddenChip) {
17+
> div:not(.hiddenChip) {
1818
opacity: 0;
1919
}
2020
}
@@ -33,24 +33,6 @@
3333
}
3434
}
3535

36-
.chipButton {
37-
appearance: none;
38-
border: none;
39-
background: transparent;
40-
padding: 0;
41-
margin: 0;
42-
font: inherit;
43-
color: inherit;
44-
cursor: pointer;
45-
display: inline-flex;
46-
align-items: center;
47-
min-width: 0;
48-
49-
&:disabled {
50-
cursor: default;
51-
}
52-
}
53-
5436
.chipWrapperWithOverflow {
5537
max-width: 100%;
5638
flex-shrink: 0;

packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function MultiSelectedValues<Item extends BaseItemData<Record<string, unknown>>>
3636
const containerRef = useRef<HTMLDivElement>(null);
3737
const deductedSpaceRef = useRef<HTMLDivElement>(null);
3838

39-
const itemRefs = useMemo(() => selectedItems.map(() => createRef<HTMLButtonElement>()), [selectedItems]);
39+
const itemRefs = useMemo(() => selectedItems.map(() => createRef<HTMLDivElement>()), [selectedItems]);
4040

4141
const { visibleCount, hasMeasured } = useItemsOverflow({
4242
containerRef,
@@ -80,30 +80,31 @@ function MultiSelectedValues<Item extends BaseItemData<Record<string, unknown>>>
8080
return selectedItems.map((item, index) => {
8181
const isVisible = index < visibleCount;
8282
const extraProps = isVisible && getChipContainerProps ? getChipContainerProps(item, index) : {};
83-
// Removal is handled by this button's onClick; drop downshift's own onClick (selected-item focus).
84-
const { ref: extraRef, onClick: _extraOnClick, ...extraAttrs } = extraProps;
83+
const { ref: extraRef, ...extraAttrs } = extraProps;
8584

8685
return (
87-
<button
86+
<div
8887
key={`dropdown-chip-visible-${item.value}`}
89-
type="button"
9088
ref={el => {
91-
(itemRefs[index] as React.MutableRefObject<HTMLButtonElement | null>).current = el;
89+
(itemRefs[index] as React.MutableRefObject<HTMLDivElement | null>).current = el;
9290
if (typeof extraRef === "function") extraRef(el);
9391
}}
94-
className={cx(styles.chipButton, {
92+
className={cx({
9593
[styles.chipWrapperWithOverflow]: minVisibleCount !== undefined,
9694
[styles.hiddenChip]: !isVisible
9795
})}
9896
aria-hidden={!isVisible}
99-
aria-label={`Remove ${item.label}`}
10097
data-testid={`dropdown-chip-${item.value}`}
101-
disabled={disabled || readOnly}
10298
{...extraAttrs}
103-
onClick={() => onRemove(item)}
10499
>
105-
<DropdownChip item={item} presentational disabled={disabled} className={styles.visibleChip} />
106-
</button>
100+
<DropdownChip
101+
item={item}
102+
onDelete={() => onRemove(item)}
103+
disabled={disabled}
104+
readOnly={readOnly}
105+
className={styles.visibleChip}
106+
/>
107+
</div>
107108
);
108109
});
109110
}, [selectedItems, visibleCount, onRemove, itemRefs, disabled, readOnly, minVisibleCount, getChipContainerProps]);
@@ -118,8 +119,6 @@ function MultiSelectedValues<Item extends BaseItemData<Record<string, unknown>>>
118119
wrap={false}
119120
gap="xs"
120121
ref={containerRef}
121-
role="group"
122-
aria-label="selected items"
123122
className={cx(styles.containerWrapper, {
124123
[styles.singleChip]: isSingleChip,
125124
[styles.measuring]: !hasMeasured
@@ -138,7 +137,7 @@ function MultiSelectedValues<Item extends BaseItemData<Record<string, unknown>>>
138137
e.stopPropagation();
139138
if (e.key === "ArrowLeft") {
140139
e.preventDefault();
141-
(itemRefs[visibleCount - 1] as React.MutableRefObject<HTMLButtonElement | null>)?.current?.focus();
140+
(itemRefs[visibleCount - 1] as React.MutableRefObject<HTMLDivElement | null>)?.current?.focus();
142141
}
143142
}}
144143
onMouseDown={e => {

packages/core/src/components/Dropdown/components/Trigger/DropdownChip.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,18 @@ const getChipPropsFromItemElements = (item: BaseItemData<Record<string, unknown>
3737

3838
interface DropdownChipProps<Item extends BaseItemData<Record<string, unknown>>> {
3939
item: Item;
40-
onDelete?: () => void;
40+
onDelete: () => void;
4141
disabled?: boolean;
4242
readOnly?: boolean;
4343
className?: string;
44-
/** Render the chip as visual-only (no close button) — used when the chip wrapper is itself the remove button. */
45-
presentational?: boolean;
4644
}
4745

4846
const DropdownChip = <Item extends BaseItemData<Record<string, unknown>>>({
4947
item,
5048
onDelete,
5149
disabled,
5250
readOnly,
53-
className,
54-
presentational
51+
className
5552
}: DropdownChipProps<Item>) => {
5653
const chipSpecificProps = getChipPropsFromItemElements(item);
5754

@@ -61,8 +58,7 @@ const DropdownChip = <Item extends BaseItemData<Record<string, unknown>>>({
6158
closeButtonAriaLabel={`Remove ${item.label}`}
6259
onDelete={onDelete}
6360
disabled={disabled}
64-
// presentational chips hide their own × — removal is handled by the wrapping button.
65-
readOnly={presentational || readOnly}
61+
readOnly={readOnly}
6662
noMargin
6763
className={className}
6864
color={item.chipColor || "primary"}

packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1-
import React, { useRef, type RefObject } from "react";
1+
import React, { useMemo, useRef, type RefObject } from "react";
22
import cx from "classnames";
33
import { BaseInput } from "@vibe/base";
44
import styles from "./Trigger.module.scss";
55
import { useDropdownContext } from "../../context/DropdownContext";
66
import { type BaseItemData } from "../../../BaseItem";
77
import { Text } from "@vibe/typography";
88

9+
// Builds the screen-reader announcement of the current multi-select selection (the chip labels),
10+
// surfaced to the combobox via aria-describedby + a visually hidden element. This is how the selected
11+
// chips are made accessible without depending on the chip buttons themselves carrying the semantics.
12+
function getSelectedValueText(selectedItems: BaseItemData[]): string {
13+
return selectedItems
14+
.map(item => item.label || item.value || "")
15+
.filter(Boolean)
16+
.join(", ");
17+
}
18+
919
const DropdownInput = ({
1020
inputSize,
1121
fullWidth,
@@ -47,37 +57,51 @@ const DropdownInput = ({
4757
const preventKeyAction = interactiveChips ? !!(inputValue && inputValue.length > 0) : isOpen;
4858
const multipleSelectionDropdownProps = getDropdownProps ? getDropdownProps({ preventKeyAction }) : {};
4959

60+
// Stable id for the visually hidden element that announces the current selection.
61+
// Only needed for multi-select chips; single-select already keeps the value inside the input.
62+
const selectedValueId = useRef(`dropdown-selected-${Math.random().toString(36).slice(2, 9)}`).current;
63+
const selectedValueText = useMemo(() => (multi ? getSelectedValueText(selectedItems) : ""), [multi, selectedItems]);
64+
65+
// The combobox can be described by the helper text and/or the selection announcement.
66+
const describedBy =
67+
[helperTextId, selectedValueText ? selectedValueId : undefined].filter(Boolean).join(" ") || undefined;
68+
5069
return (
5170
<>
5271
{searchable ? (
53-
<BaseInput
54-
{...getInputProps({
55-
"aria-labelledby": label ? getLabelProps().id : undefined,
56-
// When there is no visible label, the input must still have a name; fall back to the
57-
// field's aria-label so the input — and the chevron that points at it — are named.
58-
"aria-label": inputAriaLabel || (label ? undefined : ariaLabel),
59-
"aria-describedby": helperTextId,
60-
// The menu is presented in a Dialog, so the combobox advertises a dialog popup.
61-
"aria-haspopup": "dialog",
62-
placeholder: hasSelection ? "" : placeholder,
63-
ref: inputRef,
64-
onKeyDown: externalKeyDown,
65-
...multipleSelectionDropdownProps
66-
})}
67-
inputRole="combobox"
68-
value={inputValue || ""}
69-
autoFocus={autoFocus}
70-
size={inputSize || size}
71-
className={cx(styles.inputWrapper, {
72-
[styles.hasSelected]: !multi && selectedItem && !inputValue,
73-
[styles.small]: inputSize === "small",
74-
[styles.multi]: multi && hasSelection,
75-
[styles.multiSelected]: multi && hasSelection && inputSize === "small",
76-
[styles.fullWidth]: fullWidth
77-
})}
78-
disabled={disabled}
79-
readOnly={readOnly}
80-
/>
72+
<>
73+
<BaseInput
74+
{...getInputProps({
75+
"aria-labelledby": label ? getLabelProps().id : undefined,
76+
// When there is no visible label, the input must still have a name; fall back to the
77+
// field's aria-label so the input — and the chevron that points at it — are named.
78+
"aria-label": inputAriaLabel || (label ? undefined : ariaLabel),
79+
"aria-describedby": describedBy,
80+
// The menu is presented in a Dialog, so the combobox advertises a dialog popup.
81+
"aria-haspopup": "dialog",
82+
placeholder: hasSelection ? "" : placeholder,
83+
ref: inputRef,
84+
onKeyDown: externalKeyDown,
85+
...multipleSelectionDropdownProps
86+
})}
87+
inputRole="combobox"
88+
value={inputValue || ""}
89+
autoFocus={autoFocus}
90+
size={inputSize || size}
91+
className={cx(styles.inputWrapper, {
92+
[styles.hasSelected]: !multi && selectedItem && !inputValue,
93+
[styles.small]: inputSize === "small",
94+
[styles.multi]: multi && hasSelection,
95+
[styles.multiSelected]: multi && hasSelection && inputSize === "small",
96+
[styles.fullWidth]: fullWidth
97+
})}
98+
disabled={disabled}
99+
readOnly={readOnly}
100+
/>
101+
<span id={selectedValueId} className={styles.visuallyHidden}>
102+
{selectedValueText}
103+
</span>
104+
</>
81105
) : (
82106
<>
83107
{!hasSelection && placeholder && (

packages/core/src/components/Dropdown/components/Trigger/Trigger.module.scss

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,15 @@
9595
.actionsWrapper {
9696
padding: 0 var(--space-4);
9797
}
98+
99+
.visuallyHidden {
100+
position: absolute;
101+
width: 1px;
102+
height: 1px;
103+
padding: 0;
104+
margin: -1px;
105+
overflow: hidden;
106+
clip: rect(0, 0, 0, 0);
107+
white-space: nowrap;
108+
border: 0;
109+
}

0 commit comments

Comments
 (0)