From f5c68029af56eac45e68c682c1cff513ddeffcdf Mon Sep 17 00:00:00 2001 From: Elad Mordechai Mizrahi Date: Thu, 11 Jun 2026 18:10:24 +0300 Subject: [PATCH] fix(Dropdown): expose selected multi values to screen readers Co-authored-by: Cursor --- .../Dropdown/__tests__/Dropdown.test.tsx | 31 +++++++++++++++++++ .../MultiSelectedValues.tsx | 13 +++++++- .../components/Trigger/DropdownInput.tsx | 19 +++++++++--- .../components/Trigger/MultiSelectTrigger.tsx | 17 ++++++++-- .../Trigger/SingleSelectTrigger.tsx | 2 +- .../DropdownBasicDropdown.stories.tsx | 10 +++--- 6 files changed, 80 insertions(+), 12 deletions(-) diff --git a/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx b/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx index 6bf45594e0..206f332c37 100644 --- a/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx +++ b/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx @@ -651,6 +651,37 @@ describe("DropdownNew", () => { expect(getByTestId("dropdown-chip-opt3")).toBeInTheDocument(); }); + it("should label searchable input and describe it with selected item labels", () => { + const { getByRole, getByTestId } = renderDropdown({ + multi: true, + label: "Selected options", + defaultValue: [ + { label: "Option 1", value: "opt1", index: 0 }, + { label: "Option 3", value: "opt3", index: 2 } + ] + }); + + const input = getByRole("combobox", { name: "Selected options" }); + const selectedItemsDescription = getByTestId("dropdown-selected-items-description"); + + expect(selectedItemsDescription).toHaveTextContent("Selected items Option 1, Option 3"); + expect(input.getAttribute("aria-labelledby")).not.toContain(selectedItemsDescription.id); + expect(input).toHaveAttribute("aria-describedby", selectedItemsDescription.id); + expect(input).not.toHaveAttribute("aria-label"); + }); + + it("should not use generated downshift ids as fallback aria labels", () => { + const { container } = renderDropdown({ + multi: true, + defaultValue: [ + { label: "Option 1", value: "opt1", index: 0 }, + { label: "Option 3", value: "opt3", index: 2 } + ] + }); + + expect(container.querySelector('[aria-label^="downshift-"]')).not.toBeInTheDocument(); + }); + it("should remove an item when its chip is deleted", () => { const onChange = vi.fn(); const { getByPlaceholderText, getByText, getAllByRole } = renderDropdown({ diff --git a/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx b/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx index 8ed9b62d04..86b63cba9b 100644 --- a/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx +++ b/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx @@ -3,6 +3,7 @@ import { type BaseItemData } from "../../../BaseItem"; import { Chips } from "../../../Chips"; import { Flex } from "@vibe/layout"; import { DialogContentContainer, Dialog } from "@vibe/dialog"; +import HiddenText from "../../../HiddenText/HiddenText"; import useItemsOverflow from "../../../../hooks/useItemsOverflow/useItemsOverflow"; import styles from "./MultiSelectedValues.module.scss"; import cx from "classnames"; @@ -17,6 +18,7 @@ type MultiSelectedValuesProps = { disabled?: boolean; readOnly?: boolean; minVisibleCount?: number; + selectedItemsDescriptionId?: string; }; function MultiSelectedValues>>({ @@ -25,7 +27,8 @@ function MultiSelectedValues>> renderInput, disabled, readOnly, - minVisibleCount = 0 + minVisibleCount = 0, + selectedItemsDescriptionId }: MultiSelectedValuesProps) { const containerRef = useRef(null); const deductedSpaceRef = useRef(null); @@ -100,6 +103,7 @@ function MultiSelectedValues>> if (!selectedItems?.length) return null; const isSingleChip = selectedItems.length === 1; + const selectedItemsDescription = `Selected items ${selectedItems.map(item => item.label).join(", ")}`; return ( >> [styles.measuring]: !hasMeasured })} > + {selectedItemsDescriptionId && ( + + )} {chipElements} diff --git a/packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx b/packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx index 0ecb0718fa..d5ee9983ec 100644 --- a/packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx +++ b/packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx @@ -1,12 +1,20 @@ import React, { useRef } from "react"; import cx from "classnames"; import { BaseInput } from "@vibe/base"; -import styles from "./Trigger.module.scss"; +import { Text } from "@vibe/typography"; import { useDropdownContext } from "../../context/DropdownContext"; import { type BaseItemData } from "../../../BaseItem"; -import { Text } from "@vibe/typography"; +import styles from "./Trigger.module.scss"; -const DropdownInput = ({ inputSize, fullWidth }: { inputSize?: "small" | "medium" | "large"; fullWidth?: boolean }) => { +const DropdownInput = ({ + inputSize, + fullWidth, + selectedItemsDescriptionId +}: { + inputSize?: "small" | "medium" | "large"; + fullWidth?: boolean; + selectedItemsDescriptionId?: string; +}) => { const { inputValue, autoFocus, @@ -17,6 +25,7 @@ const DropdownInput = ({ inputSize, fullWidth }: { inputSize?: "small" | "medium selectedItem, selectedItems = [], inputAriaLabel, + "aria-label": ariaLabel, searchable, size, label, @@ -29,6 +38,7 @@ const DropdownInput = ({ inputSize, fullWidth }: { inputSize?: "small" | "medium const inputRef = useRef(null); const hasSelection = multi ? selectedItems.length > 0 : !!selectedItem; const multipleSelectionDropdownProps = getDropdownProps ? getDropdownProps({ preventKeyAction: isOpen }) : {}; + const inputLabel = inputAriaLabel || (label ? undefined : ariaLabel); return ( <> @@ -36,7 +46,8 @@ const DropdownInput = ({ inputSize, fullWidth }: { inputSize?: "small" | "medium { "aria-label": ariaLabel, minVisibleCount } = useDropdownContext(); + const selectedItemsDescriptionId = + searchable && selectedItems.length > 0 ? `${getLabelProps().id}-selected-items` : undefined; return ( @@ -35,7 +37,7 @@ const MultiSelectTrigger = () => { ? getToggleButtonProps({ "aria-haspopup": "dialog", "aria-labelledby": label ? getLabelProps().id : undefined, - "aria-label": ariaLabel || (label ? undefined : getLabelProps()?.id), + "aria-label": label ? undefined : ariaLabel, "aria-disabled": disabled ? "true" : undefined, "aria-invalid": error ? "true" : undefined, "aria-readonly": readOnly ? "true" : undefined @@ -52,8 +54,19 @@ const MultiSelectTrigger = () => { onRemove={item => { contextOnOptionRemove?.(item); }} - renderInput={searchable ? () => : undefined} + renderInput={ + searchable + ? () => ( + + ) + : undefined + } minVisibleCount={minVisibleCount} + selectedItemsDescriptionId={selectedItemsDescriptionId} /> ) : ( diff --git a/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx b/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx index 87941c5b22..47bec43d21 100644 --- a/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx +++ b/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx @@ -33,7 +33,7 @@ const SingleSelectTrigger = () => { ? getToggleButtonProps({ "aria-haspopup": "dialog", "aria-labelledby": label ? getLabelProps().id : undefined, - "aria-label": ariaLabel || (label ? undefined : getLabelProps()?.id), + "aria-label": label ? undefined : ariaLabel, "aria-disabled": disabled ? "true" : undefined, "aria-invalid": error ? "true" : undefined, "aria-readonly": readOnly ? "true" : undefined diff --git a/packages/docs/src/pages/components/Dropdown/DropdownBasicDropdown.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownBasicDropdown.stories.tsx index 653da99fb3..881fd1d839 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownBasicDropdown.stories.tsx +++ b/packages/docs/src/pages/components/Dropdown/DropdownBasicDropdown.stories.tsx @@ -176,19 +176,19 @@ export const MultiSelect: Story = { () => [ { value: "1", - label: "Chip one" + label: "Bulbasaur" }, { value: "2", - label: "Chip two" + label: "Charmander" }, { value: "3", - label: "Chip three" + label: "Squirtle" }, { value: "4", - label: "Chip four" + label: "Pikachu" } ], [] @@ -200,10 +200,12 @@ export const MultiSelect: Story = { Single line with hidden options