diff --git a/packages/components/layout/src/Flex/Flex.tsx b/packages/components/layout/src/Flex/Flex.tsx index c0867ebe86..931eee0fbe 100644 --- a/packages/components/layout/src/Flex/Flex.tsx +++ b/packages/components/layout/src/Flex/Flex.tsx @@ -63,6 +63,10 @@ export interface FlexProps extends VibeComponentProps { * ID of the element describing the flex container. */ "aria-labelledby"?: string; + /** + * The ARIA role of the flex container. + */ + role?: React.AriaRole; } const Flex = forwardRef( @@ -83,6 +87,7 @@ const Flex = forwardRef( style, "aria-labelledby": ariaLabelledby, "aria-label": ariaLabel, + role, tabIndex, "data-testid": dataTestId }: FlexProps, @@ -153,6 +158,7 @@ const Flex = forwardRef( onMouseDown={onMouseDown} style={overrideStyle} aria-label={ariaLabel} + role={role} > {children} diff --git a/packages/core/src/components/Dropdown/Dropdown.types.ts b/packages/core/src/components/Dropdown/Dropdown.types.ts index c877d82093..4625cb8627 100644 --- a/packages/core/src/components/Dropdown/Dropdown.types.ts +++ b/packages/core/src/components/Dropdown/Dropdown.types.ts @@ -20,6 +20,12 @@ interface MultiSelectSpecifics * Callback fired when an option is removed in multi-select mode. Only available when multi is true. */ onOptionRemove?: (option: Item) => void; + /** + * If true, chips are always visible and support keyboard navigation: pressing Backspace or Left arrow + * from the input moves focus to the last chip; Left/Right navigates between chips; Delete/Backspace + * removes the focused chip. Only applies when searchable=true. + */ + interactiveChips?: boolean; /** * The function to call to render the selected value on single select mode. */ diff --git a/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx b/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx index 6bf45594e0..17c66db369 100644 --- a/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx +++ b/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx @@ -63,6 +63,124 @@ describe("DropdownNew", () => { expect(getByText("Option 1")).toBeVisible(); }); + it("should keep focus on the input after selecting an option", () => { + const { getByPlaceholderText, getByText } = renderDropdown(); + + const input = getByPlaceholderText("Select an option"); + fireEvent.click(input); + + fireEvent.click(getByText("Option 1")); + + expect(input).toHaveFocus(); + }); + + it("should select the highlighted option with Space after arrowing to it", () => { + const onChange = vi.fn(); + const { getByPlaceholderText } = renderDropdown({ onChange }); + + const input = getByPlaceholderText("Select an option"); + fireEvent.click(input); + // Arrow to an option so it is highlighted (aria-activedescendant is set), then press Space. + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: " " }); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ label: "Option 1", value: "opt1" })); + }); + + it("should toggle the highlighted option with Space in multi-select", () => { + const onChange = vi.fn(); + const { getByPlaceholderText } = renderDropdown({ multi: true, onChange }); + + const input = getByPlaceholderText("Select an option"); + fireEvent.click(input); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: " " }); + + expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ label: "Option 1", value: "opt1" })]); + }); + + it("should not select on Space while typing (no option highlighted)", () => { + const onChange = vi.fn(); + const { getByPlaceholderText } = renderDropdown({ onChange }); + + const input = getByPlaceholderText("Select an option"); + fireEvent.click(input); + // No ArrowDown: nothing is highlighted, so Space must not select (it would type a space). + fireEvent.keyDown(input, { key: " " }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should expose aria-haspopup=dialog on the searchable combobox", () => { + const { getByRole } = renderDropdown(); + + expect(getByRole("combobox")).toHaveAttribute("aria-haspopup", "dialog"); + }); + + it("should expose aria-haspopup=dialog on the multi-select searchable combobox", () => { + const { getByRole } = renderDropdown({ multi: true }); + + expect(getByRole("combobox")).toHaveAttribute("aria-haspopup", "dialog"); + }); + + it("should link the helper text to the combobox via aria-describedby (searchable)", () => { + const { getByRole, getByText } = renderDropdown({ id: "team-dropdown", helperText: "Search and pick a team" }); + + const describedById = getByRole("combobox").getAttribute("aria-describedby"); + expect(describedById).toBeTruthy(); + expect(getByText("Search and pick a team")).toHaveAttribute("id", describedById); + }); + + it("should link the helper text to the trigger via aria-describedby (non-searchable)", () => { + const { getByRole, getByText } = renderDropdown({ + id: "team-dropdown", + searchable: false, + helperText: "Search and pick a team" + }); + + const describedById = getByRole("combobox").getAttribute("aria-describedby"); + expect(describedById).toBeTruthy(); + expect(getByText("Search and pick a team")).toHaveAttribute("id", describedById); + }); + + it("should not set aria-describedby when there is no helper text", () => { + const { getByRole } = renderDropdown(); + + expect(getByRole("combobox")).not.toHaveAttribute("aria-describedby"); + }); + + it("should name the chevron after the visible label", () => { + const { getByText, container } = renderDropdown({ label: "Team" }); + + const chevron = container.querySelector("button[aria-expanded]"); + const labelledById = chevron?.getAttribute("aria-labelledby"); + expect(labelledById).toBeTruthy(); + expect(getByText("Team")).toHaveAttribute("id", labelledById); + }); + + it("should give the chevron an aria-label when there is no visible label", () => { + const { container } = renderDropdown({ label: undefined, "aria-label": "Team" }); + + const chevron = container.querySelector("button[aria-expanded]"); + // No visible label to reference, so the chevron carries a real string name, not a dangling id. + expect(chevron).toHaveAttribute("aria-label", "Team"); + expect(chevron).not.toHaveAttribute("aria-labelledby"); + }); + + it("should name the chevron in multi-select searchable (label and aria-label)", () => { + const { getByText, container, rerender } = renderDropdown({ multi: true, label: "Team" }); + + const chevron = () => container.querySelector("button[aria-expanded]"); + // With a visible label, the chevron is labelled by it (a computed name), not the listbox. + expect(chevron()?.getAttribute("aria-labelledby")).toBe(getByText("Team").getAttribute("id")); + expect(chevron()).not.toHaveAttribute("aria-label"); + + // Without a visible label, it falls back to a real aria-label string. + rerender(); + expect(chevron()).toHaveAttribute("aria-label", "Team"); + expect(chevron()).not.toHaveAttribute("aria-labelledby"); + }); + it("should be disabled when disabled prop is true", () => { const { getByPlaceholderText } = renderDropdown({ disabled: true @@ -249,19 +367,20 @@ describe("DropdownNew", () => { } }); - it("should show faded selected item when focused", () => { - const { getByPlaceholderText, container, getByText } = renderDropdown({ + it("should keep the selected value inside the input for searchable single select", () => { + const { getByPlaceholderText, getByText } = renderDropdown({ placeholder: "Select an option" }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); fireEvent.click(getByText("Option 1")); - fireEvent.focus(input); + // The selection lives inside the input (exposed to assistive technologies), not in a visual overlay. + expect(input).toHaveValue("Option 1"); - const selectedValue = container.querySelector(".selectedItem"); - expect(selectedValue).toHaveClass("faded"); + fireEvent.focus(input); + expect(input).toHaveValue("Option 1"); }); it("should hide selected value when typing in the input", () => { @@ -275,7 +394,7 @@ describe("DropdownNew", () => { expect(queryByText("Option 1")).not.toBeInTheDocument(); }); - it("should not display indent startElement in selected value", () => { + it("should not display indent startElement in selected value (non-searchable overlay)", () => { const optionsWithIndent: DropdownListGroup>>[] = [ { label: "Group 1", @@ -290,14 +409,13 @@ describe("DropdownNew", () => { } ]; - const { getByPlaceholderText, getByText, container } = renderDropdown({ - options: optionsWithIndent + // Non-searchable single select displays the selection via the overlay, where indent must be stripped. + const { container } = renderDropdown({ + options: optionsWithIndent, + searchable: false, + value: { label: "Option 1", value: "opt1", index: 0, startElement: { type: "indent" } } as any }); - const input = getByPlaceholderText("Select an option"); - fireEvent.click(input); - fireEvent.click(getByText("Option 1")); - const selectedValue = container.querySelector(".selectedItem"); expect(selectedValue).toBeInTheDocument(); @@ -608,11 +726,11 @@ describe("DropdownNew", () => { multi: true }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); - const option1 = getByText("Option 1"); - fireEvent.click(option1); + fireEvent.click(getByText("Option 1")); + // Selection is reflected as a chip. expect(getByTestId("dropdown-chip-opt1")).toBeInTheDocument(); }); @@ -636,12 +754,12 @@ describe("DropdownNew", () => { ]); }); - it("should render chips for selected items", () => { + it("should show selected items as chips", () => { const { getByPlaceholderText, getByText, getByTestId } = renderDropdown({ multi: true }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); fireEvent.click(getByText("Option 1")); @@ -651,55 +769,54 @@ describe("DropdownNew", () => { expect(getByTestId("dropdown-chip-opt3")).toBeInTheDocument(); }); - it("should remove an item when its chip is deleted", () => { + it("should remove an item when it is re-clicked in the dropdown", () => { const onChange = vi.fn(); - const { getByPlaceholderText, getByText, getAllByRole } = renderDropdown({ + const { getByPlaceholderText, getByRole, queryByTestId, getByTestId } = renderDropdown({ multi: true, onChange }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); + const listbox = getByRole("listbox"); - fireEvent.click(getByText("Option 1")); - fireEvent.click(getByText("Option 3")); - - const deleteButtons = getAllByRole("button").filter( - button => button.getAttribute("data-testid") === "chip-close" - ); + // The selected option stays in the open menu, so scope clicks to the listbox to avoid + // matching the chip that carries the same label. + fireEvent.click(within(listbox).getByText("Option 1")); + fireEvent.click(within(listbox).getByText("Option 3")); - fireEvent.click(deleteButtons[0]); + // Re-click Option 1 to deselect it. + fireEvent.click(within(listbox).getByText("Option 1")); - expect(onChange).toHaveBeenLastCalledWith( - expect.arrayContaining([expect.not.objectContaining({ value: "opt1" })]) - ); + expect(onChange).toHaveBeenLastCalledWith([expect.objectContaining({ value: "opt3" })]); + expect(queryByTestId("dropdown-chip-opt1")).not.toBeInTheDocument(); + expect(getByTestId("dropdown-chip-opt3")).toBeInTheDocument(); }); - it("should call onOptionRemove when an item is removed", () => { + it("should call onOptionRemove when a chip's remove button is clicked", () => { const onOptionRemove = vi.fn(); - const { getByPlaceholderText, getByText, getAllByRole } = renderDropdown({ + const { getByPlaceholderText, getByRole } = renderDropdown({ multi: true, onOptionRemove }); const input = getByPlaceholderText("Select an option"); fireEvent.click(input); + const listbox = getByRole("listbox"); - fireEvent.click(getByText("Option 1")); + fireEvent.click(within(listbox).getByText("Option 1")); + // Remove the chip via its × button. + fireEvent.click(getByRole("button", { name: "Remove Option 1" })); - const deleteButtons = getAllByRole("button").filter(button => - button.getAttribute("data-testid")?.includes("close") - ); - fireEvent.click(deleteButtons[0]); expect(onOptionRemove).toHaveBeenCalledWith(expect.objectContaining({ value: "opt1", label: "Option 1" })); }); - it("should show selected chips without counter", () => { - const { getByPlaceholderText, getByText, queryByTestId, getByLabelText } = renderDropdown({ + it("should keep selected chips after closing the menu", () => { + const { getByPlaceholderText, getByText, getByTestId } = renderDropdown({ multi: true }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); fireEvent.click(getByText("Option 1")); @@ -707,45 +824,113 @@ describe("DropdownNew", () => { fireEvent.keyDown(input, { key: "Escape", code: "Escape" }); - expect(getByLabelText("Option 1")).toBeInTheDocument(); - expect(getByLabelText("Option 3")).toBeInTheDocument(); - - expect(queryByTestId("dropdown-counter")).not.toBeInTheDocument(); + expect(getByTestId("dropdown-chip-opt1")).toBeInTheDocument(); + expect(getByTestId("dropdown-chip-opt3")).toBeInTheDocument(); }); - it("should show an overflow counter when more items are selected than can be displayed", () => { - const manyOptionsForCounter = [ + it("should render chips for selections from grouped options", () => { + const manyOptions = [ { - label: "Overflow Group", + label: "Group", options: [ - { label: "Chip Item 1", value: "chip1" }, - { label: "Chip Item 2", value: "chip2" }, - { label: "Chip Item 3", value: "chip3" } + { label: "Item 1", value: "item1" }, + { label: "Item 2", value: "item2" }, + { label: "Item 3", value: "item3" } ] } ]; const { getByPlaceholderText, getByText, getByTestId } = renderDropdown({ multi: true, - options: manyOptionsForCounter + options: manyOptions }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); - fireEvent.click(getByText("Chip Item 1")); - fireEvent.click(getByText("Chip Item 2")); - fireEvent.click(getByText("Chip Item 3")); + fireEvent.click(getByText("Item 1")); + fireEvent.click(getByText("Item 2")); + fireEvent.click(getByText("Item 3")); - fireEvent.keyDown(input, { key: "Escape", code: "Escape" }); + expect(getByTestId("dropdown-chip-item1")).toBeInTheDocument(); + expect(getByTestId("dropdown-chip-item2")).toBeInTheDocument(); + expect(getByTestId("dropdown-chip-item3")).toBeInTheDocument(); + }); + + it("should set aria-selected on options reflecting the multi-select state", () => { + const { getByRole, getByPlaceholderText } = renderDropdown({ multi: true }); + + fireEvent.click(getByPlaceholderText("Select an option")); + fireEvent.click(within(getByRole("listbox")).getByText("Option 1")); + + const selectedOption = within(getByRole("listbox")).getByText("Option 1").closest('[role="option"]'); + const unselectedOption = within(getByRole("listbox")).getByText("Option 3").closest('[role="option"]'); + + expect(selectedOption).toHaveAttribute("aria-selected", "true"); + expect(unselectedOption).toHaveAttribute("aria-selected", "false"); + }); + + it('should expose the chips wrapper as a group labelled "selected items"', () => { + const { getByRole, getByPlaceholderText, getByText } = renderDropdown({ multi: true }); + + fireEvent.click(getByPlaceholderText("Select an option")); + fireEvent.click(getByText("Option 1")); + + expect(getByRole("group", { name: "selected items" })).toBeInTheDocument(); + }); + + it("should render each chip with a labelled remove (×) button", () => { + const { getByRole, getByPlaceholderText, getByText, getByTestId } = renderDropdown({ multi: true }); + + fireEvent.click(getByPlaceholderText("Select an option")); + fireEvent.click(getByText("Option 1")); + + // The chip's × is the remove control, labelled for screen readers. + expect(getByTestId("dropdown-chip-opt1")).toBeInTheDocument(); + expect(getByRole("button", { name: "Remove Option 1" })).toBeInTheDocument(); + }); + + describe("interactiveChips", () => { + it("should keep the input clear rather than stuffing the selection into its value", () => { + const { getByRole } = renderDropdown({ multi: true, interactiveChips: true }); + + fireEvent.click(getByRole("combobox")); + fireEvent.click(within(getByRole("listbox")).getByText("Option 1")); + fireEvent.click(within(getByRole("listbox")).getByText("Option 3")); + + // The selection is announced via aria-describedby, not placed in the input value, + // so type-to-search and Backspace-to-chip stay available. + expect(getByRole("combobox")).toHaveValue(""); + }); + + it("should update the announced selection when a chip is removed", () => { + const { getByRole } = renderDropdown({ multi: true, interactiveChips: true }); + + fireEvent.click(getByRole("combobox")); + fireEvent.click(within(getByRole("listbox")).getByText("Option 1")); + fireEvent.click(within(getByRole("listbox")).getByText("Option 3")); - const counter = getByTestId("dropdown-overflow-counter"); - expect(counter).toBeInTheDocument(); - expect(counter).toHaveTextContent("+ 2"); + fireEvent.click(getByRole("button", { name: "Remove Option 1" })); - expect(getByTestId("dropdown-chip-chip1")).not.toHaveAttribute("aria-hidden", "true"); - expect(getByTestId("dropdown-chip-chip2")).toHaveAttribute("aria-hidden", "true"); - expect(getByTestId("dropdown-chip-chip3")).toHaveAttribute("aria-hidden", "true"); + const describedById = getByRole("combobox").getAttribute("aria-describedby"); + const description = document.getElementById(describedById!.split(" ").pop()!); + expect(description).toHaveTextContent("Option 3"); + expect(description).not.toHaveTextContent("Option 1"); + }); + + it("should announce the current selection via aria-describedby on the combobox", () => { + const { getByRole } = renderDropdown({ multi: true, interactiveChips: true }); + + fireEvent.click(getByRole("combobox")); + fireEvent.click(within(getByRole("listbox")).getByText("Option 1")); + fireEvent.click(within(getByRole("listbox")).getByText("Option 3")); + + const combobox = getByRole("combobox"); + const describedById = combobox.getAttribute("aria-describedby"); + expect(describedById).toBeTruthy(); + const description = document.getElementById(describedById!.split(" ").pop()!); + expect(description).toHaveTextContent("Option 1, Option 3"); + }); }); }); @@ -1072,7 +1257,7 @@ describe("DropdownNew", () => { }); it("should hide selected options from list when showSelectedOptions is false (multi select)", () => { - const { getByRole, getByTestId, getByPlaceholderText } = renderDropdown({ + const { getByRole, getByPlaceholderText } = renderDropdown({ options: showSelectedTestOptions, showSelectedOptions: false, multi: true, @@ -1084,7 +1269,6 @@ describe("DropdownNew", () => { let listbox = getByRole("listbox"); fireEvent.click(within(listbox).getByText("Option Alpha")); - expect(getByTestId("dropdown-chip-alpha")).toBeInTheDocument(); listbox = getByRole("listbox"); expect(within(listbox).queryByText("Option Alpha")).not.toBeInTheDocument(); @@ -1092,7 +1276,6 @@ describe("DropdownNew", () => { expect(within(listbox).getByText("Option Gamma")).toBeInTheDocument(); fireEvent.click(within(listbox).getByText("Option Gamma")); - expect(getByTestId("dropdown-chip-gamma")).toBeInTheDocument(); listbox = getByRole("listbox"); expect(within(listbox).queryByText("Option Alpha")).not.toBeInTheDocument(); @@ -1101,24 +1284,22 @@ describe("DropdownNew", () => { }); it("should keep selected options in list when showSelectedOptions is true (multi select)", () => { - const { getByPlaceholderText, getByRole, getByTestId } = renderDropdown({ + const { getByPlaceholderText, getByRole } = renderDropdown({ options: showSelectedTestOptions, showSelectedOptions: true, multi: true, placeholder: "Select multi true" }); - const input = getByPlaceholderText("Select multi true"); + const input = getByPlaceholderText("Select multi true") as HTMLInputElement; fireEvent.click(input); let listbox = getByRole("listbox"); fireEvent.click(within(listbox).getByText("Option Alpha")); - expect(getByTestId("dropdown-chip-alpha")).toBeInTheDocument(); listbox = getByRole("listbox"); expect(within(listbox).getByText("Option Alpha")).toBeInTheDocument(); expect(within(listbox).getByText("Option Beta")).toBeInTheDocument(); fireEvent.click(within(listbox).getByText("Option Beta")); - expect(getByTestId("dropdown-chip-beta")).toBeInTheDocument(); listbox = getByRole("listbox"); expect(within(listbox).getByText("Option Alpha")).toBeInTheDocument(); expect(within(listbox).getByText("Option Beta")).toBeInTheDocument(); @@ -1150,16 +1331,14 @@ describe("DropdownNew", () => { searchable: true }); - // Input should be visible - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; expect(input).toBeInTheDocument(); - // Menu should be visible const listbox = getByRole("listbox"); expect(listbox).toBeInTheDocument(); - // Select an option fireEvent.click(within(listbox).getByText("Option 1")); + // Selection is reflected as a chip. expect(getByTestId("dropdown-chip-opt1")).toBeInTheDocument(); }); diff --git a/packages/core/src/components/Dropdown/components/DropdownBase/DropdownBase.tsx b/packages/core/src/components/Dropdown/components/DropdownBase/DropdownBase.tsx index 36e2dcc68c..873cbdb24e 100644 --- a/packages/core/src/components/Dropdown/components/DropdownBase/DropdownBase.tsx +++ b/packages/core/src/components/Dropdown/components/DropdownBase/DropdownBase.tsx @@ -29,6 +29,7 @@ const DropdownBase = ({ dropdownRef, children }: DropdownBaseProps) => { isFocused, isOpen, helperText, + helperTextId, dir, tooltipProps, boxMode, @@ -66,7 +67,7 @@ const DropdownBase = ({ dropdownRef, children }: DropdownBaseProps) => { {coreDropdownElement} {helperText && ( - + {helperText} )} diff --git a/packages/core/src/components/Dropdown/components/DropdownBaseList/DropdownBaseList.tsx b/packages/core/src/components/Dropdown/components/DropdownBaseList/DropdownBaseList.tsx index e4e8dd5a87..3a85ccfa79 100644 --- a/packages/core/src/components/Dropdown/components/DropdownBaseList/DropdownBaseList.tsx +++ b/packages/core/src/components/Dropdown/components/DropdownBaseList/DropdownBaseList.tsx @@ -53,7 +53,11 @@ const DropdownBaseList = forwardRef( )} {group.options.map((option, itemIndex) => { - const itemProps = getItemProps?.({ item: option, index: option.index }) ?? {}; + // downshift's useCombobox sets aria-selected to mark a single item (the tracked + // selectedItem), which is wrong for multi-select. Drop it so BaseItem's aria-selected + // (derived from the full selectedItems list below) is authoritative. + const { "aria-selected": _downshiftAriaSelected, ...itemProps } = + getItemProps?.({ item: option, index: option.index }) ?? {}; const isHighlighted = highlightedIndex !== undefined && highlightedIndex === option.index && !option.disabled; const isSelected = diff --git a/packages/core/src/components/Dropdown/components/DropdownWrapperUI.tsx b/packages/core/src/components/Dropdown/components/DropdownWrapperUI.tsx index 48411efb1d..4bcae4899a 100644 --- a/packages/core/src/components/Dropdown/components/DropdownWrapperUI.tsx +++ b/packages/core/src/components/Dropdown/components/DropdownWrapperUI.tsx @@ -14,8 +14,12 @@ interface DropdownWrapperUIProps>>(props: DropdownWrapperUIProps) => { const { contextValue, dropdownRef } = props; + // Link the helper text to the combobox/trigger via aria-describedby (WCAG SC 1.3.1). + // Derived from the consumer-provided id, matching the convention used across the design system. + const helperTextId = contextValue.helperText && contextValue.id ? `${contextValue.id}-helper-text` : undefined; + return ( - + {contextValue.boxMode ? : } diff --git a/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx b/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx index 8ed9b62d04..950c0bad64 100644 --- a/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx +++ b/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx @@ -1,8 +1,9 @@ -import React, { useRef, useMemo, createRef } from "react"; +import React, { useRef, useMemo, useCallback, createRef } from "react"; import { type BaseItemData } from "../../../BaseItem"; import { Chips } from "../../../Chips"; import { Flex } from "@vibe/layout"; import { DialogContentContainer, Dialog } from "@vibe/dialog"; +import { useMergeRef } from "@vibe/shared"; import useItemsOverflow from "../../../../hooks/useItemsOverflow/useItemsOverflow"; import styles from "./MultiSelectedValues.module.scss"; import cx from "classnames"; @@ -17,6 +18,10 @@ type MultiSelectedValuesProps = { disabled?: boolean; readOnly?: boolean; minVisibleCount?: number; + /** Extra props (tabIndex, onKeyDown, etc.) to spread on each visible chip container. */ + getChipContainerProps?: (item: Item, index: number) => Record; + /** Ref forwarded to the +N overflow Chips element, for external keyboard focus management. */ + badgeRef?: React.Ref; }; function MultiSelectedValues>>({ @@ -25,10 +30,40 @@ function MultiSelectedValues>> renderInput, disabled, readOnly, - minVisibleCount = 0 + minVisibleCount = 0, + getChipContainerProps, + badgeRef }: MultiSelectedValuesProps) { const containerRef = useRef(null); const deductedSpaceRef = useRef(null); + // Content of the "+N" overflow dialog, and the +N badge that triggers it — used for focus management. + const dialogContentRef = useRef(null); + const localBadgeRef = useRef(null); + const mergedBadgeRef = useMergeRef(badgeRef, localBadgeRef); + + // When the overflow dialog opens, move focus to its first control (the first chip's remove button). + // onDialogDidShow fires just before the content mounts, so defer focus to the next frame. + const handleDialogDidShow = useCallback(() => { + requestAnimationFrame(() => { + const firstFocusable = dialogContentRef.current?.querySelector( + "button, [href], input, [tabindex]:not([tabindex='-1'])" + ); + firstFocusable?.focus(); + }); + }, []); + + // Return focus to the +N badge when the dialog is dismissed with Esc. Defer to the next frame: + // onDialogDidHide fires before the dialog content unmounts, and that teardown would otherwise + // reset focus to (the top of the page) after a synchronous focus call. + const handleDialogDidHide = useCallback((_event: unknown, eventName: string) => { + if (eventName !== "esckey") return; + requestAnimationFrame(() => { + const badge = + localBadgeRef.current ?? + deductedSpaceRef.current?.querySelector('[data-testid="dropdown-overflow-counter"]'); + badge?.focus(); + }); + }, []); const itemRefs = useMemo(() => selectedItems.map(() => createRef()), [selectedItems]); @@ -52,7 +87,7 @@ function MultiSelectedValues>> const dialogContent = useMemo(() => { return () => ( - + {hiddenItems.map(item => { return ( >> const chipElements = useMemo(() => { return selectedItems.map((item, index) => { const isVisible = index < visibleCount; + const extraProps = isVisible && getChipContainerProps ? getChipContainerProps(item, index) : {}; + const { ref: extraRef, ...extraAttrs } = extraProps; return (
{ + (itemRefs[index] as React.MutableRefObject).current = el; + if (typeof extraRef === "function") extraRef(el); + }} className={cx({ [styles.chipWrapperWithOverflow]: minVisibleCount !== undefined, [styles.hiddenChip]: !isVisible })} aria-hidden={!isVisible} data-testid={`dropdown-chip-${item.value}`} + {...extraAttrs} > >>
); }); - }, [selectedItems, visibleCount, onRemove, itemRefs, disabled, readOnly, minVisibleCount]); + }, [selectedItems, visibleCount, onRemove, itemRefs, disabled, readOnly, minVisibleCount, getChipContainerProps]); if (!selectedItems?.length) return null; @@ -107,6 +148,8 @@ function MultiSelectedValues>> wrap={false} gap="xs" ref={containerRef} + role="group" + aria-label="selected items" className={cx(styles.containerWrapper, { [styles.singleChip]: isSingleChip, [styles.measuring]: !hasMeasured @@ -123,6 +166,10 @@ function MultiSelectedValues>> }} onKeyDown={e => { e.stopPropagation(); + if (e.key === "ArrowLeft") { + e.preventDefault(); + (itemRefs[visibleCount - 1] as React.MutableRefObject)?.current?.focus(); + } }} onMouseDown={e => { e.stopPropagation(); @@ -131,13 +178,18 @@ function MultiSelectedValues>> { +// Builds the screen-reader announcement of the current multi-select selection (the chip labels), +// surfaced to the combobox via aria-describedby + a visually hidden element. This is how the selected +// chips are made accessible without depending on the chip buttons themselves carrying the semantics. +function getSelectedValueText(selectedItems: BaseItemData[]): string { + return selectedItems + .map(item => item.label || item.value || "") + .filter(Boolean) + .join(", "); +} + +const DropdownInput = ({ + inputSize, + fullWidth, + onKeyDown: externalKeyDown, + inputRef: externalInputRef +}: { + inputSize?: "small" | "medium" | "large"; + fullWidth?: boolean; + onKeyDown?: React.KeyboardEventHandler; + inputRef?: RefObject; +}) => { const { inputValue, autoFocus, @@ -17,44 +37,71 @@ const DropdownInput = ({ inputSize, fullWidth }: { inputSize?: "small" | "medium selectedItem, selectedItems = [], inputAriaLabel, + "aria-label": ariaLabel, searchable, size, label, isOpen, getDropdownProps, getLabelProps, - getInputProps + getInputProps, + interactiveChips, + helperTextId } = useDropdownContext(); - const inputRef = useRef(null); + const internalRef = useRef(null); + const inputRef = externalInputRef ?? internalRef; const hasSelection = multi ? selectedItems.length > 0 : !!selectedItem; - const multipleSelectionDropdownProps = getDropdownProps ? getDropdownProps({ preventKeyAction: isOpen }) : {}; + // interactiveChips: menu is always open, so isOpen would permanently suppress Backspace chip-nav. + // Instead suppress only when the input has text (Backspace should delete chars, not navigate chips). + const preventKeyAction = interactiveChips ? !!(inputValue && inputValue.length > 0) : isOpen; + const multipleSelectionDropdownProps = getDropdownProps ? getDropdownProps({ preventKeyAction }) : {}; + + // Stable id for the visually hidden element that announces the current selection. + // Only needed for multi-select chips; single-select already keeps the value inside the input. + const selectedValueId = useRef(`dropdown-selected-${Math.random().toString(36).slice(2, 9)}`).current; + const selectedValueText = useMemo(() => (multi ? getSelectedValueText(selectedItems) : ""), [multi, selectedItems]); + + // The combobox can be described by the helper text and/or the selection announcement. + const describedBy = + [helperTextId, selectedValueText ? selectedValueId : undefined].filter(Boolean).join(" ") || undefined; return ( <> {searchable ? ( - + <> + + + {selectedValueText} + + ) : ( <> {!hasSelection && placeholder && ( diff --git a/packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx b/packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx index 2b8d216603..7a7bdcca15 100644 --- a/packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx +++ b/packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef } from "react"; import { Flex } from "@vibe/layout"; import MultiSelectedValues from "../MultiSelectedValues/MultiSelectedValues"; import DropdownInput from "./DropdownInput"; @@ -14,6 +14,7 @@ const MultiSelectTrigger = () => { const { selectedItems = [], contextOnOptionRemove, + getSelectedItemProps, multiline, disabled, readOnly, @@ -24,9 +25,88 @@ const MultiSelectTrigger = () => { label, getLabelProps, "aria-label": ariaLabel, - minVisibleCount + minVisibleCount, + interactiveChips, + helperTextId } = useDropdownContext(); + const showChips = selectedItems.length > 0; + const overflowBadgeRef = useRef(null); + + const renderTriggerContent = () => { + if (interactiveChips && searchable && !readOnly) { + if (selectedItems.length === 0) { + return ; + } + return ( +
{ + if ( + e.key === "ArrowLeft" && + e.target instanceof HTMLInputElement && + !e.target.value && + overflowBadgeRef.current + ) { + overflowBadgeRef.current.focus(); + } + }} + > + contextOnOptionRemove?.(item)} + renderInput={() => } + getChipContainerProps={(item, index) => getSelectedItemProps?.({ selectedItem: item, index }) ?? {}} + badgeRef={overflowBadgeRef} + minVisibleCount={minVisibleCount} + /> +
+ ); + } + + // Default chips mode: original behavior. + if (showChips) { + return ( +
+ {!multiline ? ( + { + contextOnOptionRemove?.(item); + }} + renderInput={searchable ? () => : undefined} + minVisibleCount={minVisibleCount} + /> + ) : ( + + {selectedItems.map((item, index) => ( + +
+ { + contextOnOptionRemove?.(item); + }} + readOnly={readOnly} + disabled={disabled} + /> +
+ {index === selectedItems.length - 1 && } +
+ ))} +
+ )} +
+ ); + } + + return ; + }; + return (
{ ? getToggleButtonProps({ "aria-haspopup": "dialog", "aria-labelledby": label ? getLabelProps().id : undefined, - "aria-label": ariaLabel || (label ? undefined : getLabelProps()?.id), + "aria-label": label ? undefined : ariaLabel, + "aria-describedby": helperTextId, "aria-disabled": disabled ? "true" : undefined, "aria-invalid": error ? "true" : undefined, "aria-readonly": readOnly ? "true" : undefined }) : {})} > - {selectedItems.length > 0 ? ( -
- {!multiline ? ( - { - contextOnOptionRemove?.(item); - }} - renderInput={searchable ? () => : undefined} - minVisibleCount={minVisibleCount} - /> - ) : ( - - {selectedItems.map((item, index) => ( - -
- { - contextOnOptionRemove?.(item); - }} - readOnly={readOnly} - disabled={disabled} - /> -
- {index === selectedItems.length - 1 && } -
- ))} -
- )} -
- ) : ( - - )} + {renderTriggerContent()}
diff --git a/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx b/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx index 87941c5b22..e86fbad078 100644 --- a/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx +++ b/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx @@ -10,19 +10,18 @@ import { getStyle } from "@vibe/shared"; const SingleSelectTrigger = () => { const { - inputValue, selectedItem, searchable, size, valueRenderer, - isFocused, getToggleButtonProps, disabled, readOnly, error, label, getLabelProps, - "aria-label": ariaLabel + "aria-label": ariaLabel, + helperTextId } = useDropdownContext(); return ( @@ -33,7 +32,8 @@ 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-describedby": helperTextId, "aria-disabled": disabled ? "true" : undefined, "aria-invalid": error ? "true" : undefined, "aria-readonly": readOnly ? "true" : undefined @@ -42,16 +42,10 @@ const SingleSelectTrigger = () => { > - {!inputValue && selectedItem && ( -
+ {/* Non-searchable single select shows the selection via this overlay. In searchable mode the + selected value lives inside the input itself, so the overlay must not render. */} + {!searchable && selectedItem && ( +
{ getMenuProps, loading, clearAriaLabel, - boxMode + boxMode, + label, + getLabelProps, + "aria-label": ariaLabel, + inputAriaLabel } = useDropdownContext(); const hasSelection = multi ? selectedItems?.length > 0 : !!selectedItem; const iconButtonSize = sizeMap[size] || "small"; + // The chevron is a focusable control, so it needs a real accessible name (WCAG 4.1.2). With a + // visible label, reference it (a computed name via aria-labelledby); otherwise use the field's + // aria-label string directly. Referencing the listbox or the input would not yield a usable label + // (a textbox's name computes from its value, not its label), leaving the chevron effectively unnamed. + const chevronLabelledBy = label ? getLabelProps().id : undefined; + const chevronAriaLabel = label ? undefined : ariaLabel || inputAriaLabel; + const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); if (contextOnClear) { @@ -75,7 +86,8 @@ const TriggerActions = () => { disabled={disabled} aria-controls={getMenuProps().id} aria-expanded={isOpen} - aria-labelledby={getMenuProps().id} + aria-labelledby={chevronLabelledBy} + aria-label={chevronAriaLabel} tabIndex={-1} onClick={() => { toggleMenu(); diff --git a/packages/core/src/components/Dropdown/context/DropdownContext.types.ts b/packages/core/src/components/Dropdown/context/DropdownContext.types.ts index 17c0cb764c..153a9bc6b7 100644 --- a/packages/core/src/components/Dropdown/context/DropdownContext.types.ts +++ b/packages/core/src/components/Dropdown/context/DropdownContext.types.ts @@ -76,5 +76,9 @@ export interface DropdownContextProps void; removeSelectedItem?: (item: Item) => void; + getSelectedItemProps?: (options: { selectedItem: any; index: number }) => Record; isFocused?: boolean; + interactiveChips?: boolean; + /** Id of the helper text element, linked to the combobox/trigger via aria-describedby. */ + helperTextId?: string; } diff --git a/packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts b/packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts index ea54fee0ff..712d157297 100644 --- a/packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts +++ b/packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts @@ -51,24 +51,36 @@ function useDropdownCombobox>>( reset, openMenu, toggleMenu, - closeMenu + closeMenu, + selectItem } = useCombobox({ items: flatOptions, itemToString: item => item?.label ?? "", itemToKey: item => (item?.value !== undefined ? String(item.value) : ""), isItemDisabled: item => Boolean(item.disabled), - initialInputValue: inputValueProp || "", + // Seed the input with the selected item's label so a defaultValue/value is visible (and exposed to + // assistive technologies) on mount, now that the selection lives inside the input rather than in an overlay. + initialInputValue: inputValueProp || selectedItem?.label || "", selectedItem: selectedItem, isOpen: isMenuOpen, initialIsOpen: autoFocus, id, onIsOpenChange: ({ isOpen }) => { + // Reset the text filter when the menu closes so reopening always shows the full option list, + // even though the input keeps displaying the selected item's label. + if (!isOpen) { + filterOptions(""); + } isOpen ? onMenuClose?.() : onMenuOpen?.(); }, onInputValueChange: useCallback( - ({ inputValue }) => { - filterOptions(inputValue || ""); + ({ inputValue, type }) => { + // Only filter on actual user typing. Downshift also writes the selected item's label into the + // input on selection/blur — those changes must not filter the list. + if (type === useCombobox.stateChangeTypes.InputChange) { + filterOptions(inputValue || ""); + } onInputChange?.(inputValue); }, [onInputChange, filterOptions] @@ -91,24 +103,27 @@ function useDropdownCombobox>>( ), onStateChange: useCallback( ({ type }) => { - // Blur input after selection via click or Enter key + // Keep focus on the input after selecting via click or Enter so focus is never lost + // (the menu still closes through the stateReducer's isOpen change). if ( closeMenuOnSelect && (type === useCombobox.stateChangeTypes.ItemClick || type === useCombobox.stateChangeTypes.InputKeyDownEnter) ) { - inputRef.current?.blur(); + inputRef.current?.focus(); } }, [closeMenuOnSelect] ), stateReducer: (state, actionAndChanges) => { switch (actionAndChanges.type) { + // FunctionSelectItem (Space selecting the highlighted option, see getInputProps below) is + // handled the same as Enter/click selection. case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: - return { ...actionAndChanges.changes, inputValue: null, isOpen: !closeMenuOnSelect }; - case useCombobox.stateChangeTypes.InputBlur: - case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem: - return { ...actionAndChanges.changes, inputValue: null }; + case useCombobox.stateChangeTypes.FunctionSelectItem: + // Keep Downshift's default inputValue (the selected item's label) so the selection lives inside + // the input and is exposed to assistive technologies. Only override the open state. + return { ...actionAndChanges.changes, isOpen: !closeMenuOnSelect }; default: return actionAndChanges.changes; @@ -124,7 +139,24 @@ function useDropdownCombobox>>( getToggleButtonProps, getLabelProps, getMenuProps, - getInputProps: (options?: Parameters[0]) => getInputProps({ ...options, ref: inputRef }), + getInputProps: (options?: Parameters[0]) => + getInputProps({ + ...options, + ref: inputRef, + onKeyDown: event => { + options?.onKeyDown?.(event); + // Space selects the highlighted option instead of typing a literal space. It only applies + // when the user has arrowed to an option (highlightedIndex set, i.e. aria-activedescendant + // is set); while typing/filtering there is no highlight, so Space types normally. + if (event.key === " " && !event.defaultPrevented && isOpen && highlightedIndex >= 0) { + const item = flatOptions[highlightedIndex]; + if (item && !item.disabled) { + event.preventDefault(); + selectItem(item); + } + } + } + }), getItemProps, reset: () => { if (value === undefined) { diff --git a/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts b/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts index 4982989f84..d7c5448c95 100644 --- a/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts +++ b/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts @@ -20,7 +20,8 @@ function useDropdownMultiCombobox onOptionSelect?: (option: T) => void, filterOption?: (option: T, inputValue: string) => boolean, showSelectedOptions?: boolean, - id?: string + id?: string, + onOptionRemove?: (option: T) => void ) { // Use controlled value if provided, otherwise use internal state const currentSelectedItems = value !== undefined ? value : selectedItems; @@ -40,6 +41,17 @@ function useDropdownMultiCombobox setSelectedItems(selectedItems || []); } onChange?.(selectedItems || []); + }, + onStateChange: ({ type, selectedItems: newSelectedItems }) => { + // Notify onOptionRemove for keyboard-driven chip deletion (× button uses contextOnOptionRemove). + if ( + (type === useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace || + type === useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete) && + newSelectedItems + ) { + const removedItem = currentSelectedItems.find(item => !newSelectedItems.some(si => si.value === item.value)); + if (removedItem) onOptionRemove?.(removedItem); + } } }); @@ -55,7 +67,8 @@ function useDropdownMultiCombobox reset: downshiftReset, openMenu, toggleMenu, - closeMenu + closeMenu, + selectItem } = useCombobox({ items: flatOptions, itemToString: item => item?.label ?? "", @@ -63,20 +76,30 @@ function useDropdownMultiCombobox isItemDisabled: item => Boolean(item.disabled), isOpen: isMenuOpen, initialIsOpen: autoFocus, - initialInputValue: inputValueProp || "", + initialInputValue: inputValueProp ?? "", id, onIsOpenChange: ({ isOpen }) => { + // Reset the text filter on any open/close change so the full list is always ready. + filterOptions(""); isOpen ? onMenuClose?.() : onMenuOpen?.(); }, - onInputValueChange: ({ inputValue }) => { - filterOptions(inputValue || ""); - onInputChange?.(inputValue); - }, + onInputValueChange: useCallback( + ({ inputValue, type }) => { + // Only filter on actual user typing. Downshift also writes values into the input on + // open/close/selection — those changes must not filter the list. + if (type === useCombobox.stateChangeTypes.InputChange) { + filterOptions(inputValue || ""); + } + onInputChange?.(inputValue); + }, + [onInputChange, filterOptions] + ), onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { if (!newSelectedItem) return; const existingItem = currentSelectedItems.find(item => item.value === newSelectedItem.value); if (existingItem) { removeSelectedItem(existingItem); + onOptionRemove?.(existingItem); } else { addSelectedItem(newSelectedItem); } @@ -84,20 +107,29 @@ function useDropdownMultiCombobox filterOptions(""); }, stateReducer: (state, actionAndChanges) => { - switch (actionAndChanges.type) { + const { type, changes } = actionAndChanges; + + switch (type) { + // FunctionSelectItem (Space toggling the highlighted option, see getInputProps below) is + // handled the same as Enter/click selection. case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: + case useCombobox.stateChangeTypes.FunctionSelectItem: + // Keep the menu open and clear the input to restore the placeholder. return { - ...actionAndChanges.changes, + ...changes, inputValue: null, isOpen: true, - highlightedIndex: (actionAndChanges.changes.selectedItem?.index as number) ?? 0 + highlightedIndex: (changes.selectedItem?.index as number) ?? 0 }; case useCombobox.stateChangeTypes.InputBlur: case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem: - return { ...actionAndChanges.changes, inputValue: null }; + return { ...changes, inputValue: null }; default: - return actionAndChanges.changes; + if (!changes.isOpen && state.isOpen) { + return { ...changes, inputValue: null }; + } + return changes; } } }); @@ -121,7 +153,23 @@ function useDropdownMultiCombobox getToggleButtonProps, getLabelProps, getMenuProps, - getInputProps, + getInputProps: (options?: Parameters[0]) => + getInputProps({ + ...options, + onKeyDown: event => { + options?.onKeyDown?.(event); + // Space toggles the highlighted option instead of typing a literal space. It only applies + // when the user has arrowed to an option (highlightedIndex set, i.e. aria-activedescendant + // is set); while typing/filtering there is no highlight, so Space types normally. + if (event.key === " " && !event.defaultPrevented && isOpen && highlightedIndex >= 0) { + const item = flatOptions[highlightedIndex]; + if (item && !item.disabled) { + event.preventDefault(); + selectItem(item); + } + } + } + }), getItemProps, reset, removeSelectedItem, diff --git a/packages/core/src/components/Dropdown/modes/DropdownMultiComboboxController.tsx b/packages/core/src/components/Dropdown/modes/DropdownMultiComboboxController.tsx index edbb8bdcb4..5b3ed2553b 100644 --- a/packages/core/src/components/Dropdown/modes/DropdownMultiComboboxController.tsx +++ b/packages/core/src/components/Dropdown/modes/DropdownMultiComboboxController.tsx @@ -35,7 +35,8 @@ const DropdownMultiComboboxController = ( options, multiSelectedItemsState, @@ -74,7 +76,8 @@ const DropdownMultiComboboxController = = { @@ -124,6 +127,7 @@ const DropdownMultiComboboxController = ; diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx new file mode 100644 index 0000000000..9c47f25d50 --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx @@ -0,0 +1,63 @@ +import { Meta, Canvas } from "@storybook/blocks"; +import * as DropdownMultiSelectA11yStories from "./DropdownMultiSelectA11y.stories"; + + + +# Multi-select accessibility + +## The problem + +A multi-select Dropdown shows the selected values as **chips rendered next to the input**. By default these chips are purely visual: the only way to remove one is to click its **×** with a mouse. + +A keyboard-only user has no way to reach an individual chip or remove it, which fails **WCAG 2.1.1 Keyboard (Level A)** — all functionality must be operable through a keyboard. + +--- + +## interactiveChips + + + +## What changed — 2026-06-30 + +This iteration made several accessibility fixes to the multi-select dropdown: + +- **Chips keyboard + selection announcement** — with `interactiveChips`, the chips are focusable and keyboard-operable (**ArrowLeft** from the input moves to the last chip, **ArrowLeft / ArrowRight** move between chips, **Backspace / Delete** removes the focused chip, each chip keeps its labelled **×**). The current selection is announced via a visually hidden element referenced from the combobox with `aria-describedby` (e.g. _"Chip one, Chip two, Chip three"_) rather than written into the input value, so **type-to-search and Backspace-to-chip stay available**. +- **Space selects** — when an option is highlighted (`aria-activedescendant` set, i.e. the user arrowed to it), Space toggles that option instead of typing a literal space; while typing (no highlight), Space types normally. +- **Chevron label** — the expand/collapse chevron now has a real accessible name: `aria-labelledby` → the visible `label`, or an `aria-label` string (`aria-label` / `inputAriaLabel`) when there is no visible label — never the listbox. Applies to single and multi select. +- **Selected-items group** — the chips wrapper is exposed as `role="group"` with `aria-label="selected items"`, so screen readers present them as a single named set. + +This supersedes the **2026-06-25** approach below, which carried the selection in the combobox value (a short summary) and rendered each chip as a single `aria-label="Remove "` button inside a `role="group"`. That made type-to-search and Backspace-to-chip unavailable while a selection existed, so the selection moved to `aria-describedby` and the chips returned to a chip + labelled **×** button. + +## What changed — 2026-06-25 + +Several accessibility fixes for the selected chips and the combobox value. + +| Issue | Fix | +| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| The input stayed empty while chips carried the selection, so screen readers announced the focused field as blank (WCAG 4.1.2). | The combobox value now holds a short summary such as _"Chip one and 3 others"_, kept in sync across the menu, chip **×**, and keyboard removal. | +| The selected chips had no grouping, so assistive tech read them as loose, unlabelled controls. | The chips container is now a `role="group"` with `aria-label="selected items"`. | +| Each chip was a labelled `div` plus a separate **×** button — two elements per chip. | Each chip is now a single button with `aria-label="Remove "`; activating it removes the chip. | + +> Trade-off: because the input now carries the summary, type-to-search and Backspace-to-chip are unavailable while a selection exists. + +Chips remain visible but each one becomes a focusable, keyboard-operable control. A keyboard-only user can navigate to any chip and remove it without a mouse — something the default chip mode does not support because the chips are purely visual. + +Keyboard navigation: press **ArrowLeft** from the input to move focus to the last chip, then **ArrowLeft / ArrowRight** to move between chips, and **Backspace** or **Delete** to remove the focused chip. + +### Selection summary in the input value + +`interactiveChips` also writes a short summary of the selection into the combobox's **value**, for example _"Chip one and 3 others"_, and keeps it updated as items are added or removed (via the menu, a chip's **×**, or the keyboard). Because screen readers read an input's value, the current selection is announced when the field is focused — addressing **WCAG 4.1.2 Name, Role, Value**, which the default empty input does not. + +> Trade-off: because the input now carries the summary, type-to-search and Backspace-to-chip are unavailable while a selection exists. + +### Chip overflow + +When there are more selections than fit, the extra chips collapse into a **"+N" counter** (controlled by `minVisibleCount`). The visible chips stay individually focusable and removable via the keyboard, and the counter communicates how many more are selected. + +The **"+N" counter opens a dialog** listing the hidden chips. For keyboard users: + +- **Enter** (or click) on the counter opens the dialog. +- Focus moves to the **first chip** in the dialog when it opens. +- **Esc** closes the dialog and returns focus to the **"+N" counter**. + + diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx new file mode 100644 index 0000000000..8309d36b57 --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from "react"; +import { type Meta, type StoryObj } from "@storybook/react"; +import { createStoryMetaSettingsDecorator } from "../../../utils/createStoryMetaSettingsDecorator"; +import { Dropdown } from "@vibe/core"; + +type Story = StoryObj; + +const metaSettings = createStoryMetaSettingsDecorator({ + component: Dropdown, + actionPropsArray: ["onChange", "onOptionSelect", "onOptionRemove", "onClear"] +}); + +const meta: Meta = { + title: "Components/Dropdown/Multi-select accessibility", + component: Dropdown, + argTypes: metaSettings.argTypes, + decorators: metaSettings.decorators +}; + +export default meta; + +export const InteractiveChipsBasic: Story = { + render: () => { + const options = useMemo( + () => [ + { value: "1", label: "Chip one" }, + { value: "2", label: "Chip two" }, + { value: "3", label: "Chip three" }, + { value: "4", label: "Chip four" } + ], + [] + ); + + return ( +
+ +
+ ); + } +}; + +export const InteractiveChipsOverflow: Story = { + render: () => { + const options = useMemo( + () => [ + { value: "1", label: "Chip one" }, + { value: "2", label: "Chip two" }, + { value: "3", label: "Chip three" }, + { value: "4", label: "Chip four" }, + { value: "5", label: "Chip five" }, + { value: "6", label: "Chip six" } + ], + [] + ); + + return ( +
+ +
+ ); + } +}; + + diff --git a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx new file mode 100644 index 0000000000..b3076f80b5 --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx @@ -0,0 +1,123 @@ +import { Meta, Canvas } from "@storybook/blocks"; +import * as DropdownSearchableSingleSelectStories from "./DropdownSearchableSingleSelect.stories"; + + + +# Searchable single select — Accessibility + +A single reference for the accessibility behavior and the props that affect it for the **searchable single select** Dropdown (`searchable` with a single value). + +### Import + +```js +import { Dropdown } from "@vibe/core"; +``` + + + +## What changed — 2026-06-30 + +This iteration made two accessibility fixes to the searchable single select: + +- **Space selects** — because focus stays in the input, Space would type a literal space and corrupt the filter. Now, when an option is highlighted (`aria-activedescendant` set, i.e. the user arrowed to it), Space selects that option; while typing (no highlight), Space types normally. +- **Chevron label** — the expand/collapse chevron now has a real accessible name (WCAG 4.1.2): with a visible `label` it is labelled by it (`aria-labelledby` → the `…-label` id, reading as the field name); with no visible label it gets a real `aria-label` string from `aria-label` / `inputAriaLabel`. It no longer points at the listbox (which is not a real, computed label). + +## What changed — 2026-06-24 + +This iteration added three further accessibility fixes to the searchable dropdown: + +| Issue | Fix | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| The combobox did not advertise the popup it controls. | Set `aria-haspopup="dialog"` on the combobox input (single and multi), matching the dialog the menu renders in. | +| `helperText` rendered as a plain sibling with no link to the input, so the relationship was invisible to assistive tech (WCAG SC 1.3.1). | The helper text now has an id and is referenced from the combobox/trigger via `aria-describedby` (requires an `id` on the Dropdown). | +| Focus was lost when an option was selected, because the input was blurred on selection. | Focus now stays on the input after selecting an option; the menu still closes. | + +## What changed + +Previously, selecting an option in a searchable single select **cleared the input** and rendered the selected label as a **visual overlay** on top of the empty field. Because screen readers read the input's value — not the overlay — a field with a selection was announced as blank. + +The behavior was changed so the selected option's label is now set as the **value of the input itself**, and the overlay was removed. + +| Aspect | Before | After | +| --------------------------------- | -------------------------------------- | ------------------------- | +| Input value after selecting | Empty | The selected label | +| How the selection is shown | Visual overlay over an empty input | Inside the input | +| Screen reader on focus | _"…, blank"_ | _"…, the selected label"_ | +| `defaultValue` / `value` on mount | Shown via the overlay | Shown in the input | +| Collapsed selected value | Rich (icon / avatar / `valueRenderer`) | Text-only | + +This is scoped to **searchable single select**. Non-searchable single select and multi-select are unchanged. + +## The core behavior: the selected value lives inside the input + +When an option is selected, its label is set as the **value of the ``**. It is not painted as a separate visual layer on top of an empty field. + +This matters because assistive technologies read the input's value, not whatever is layered around it. With the value in the input: + +- Tabbing to a combobox that already has a selection announces _"Team, Engineering, combobox"_ — not _"Team, combobox, blank"_. +- The current value is exposed on mount for `defaultValue` / controlled `value`, not only after interaction. + + + +### What happens on open / reopen / type + +| Action | Behavior | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| Select an option | Label is placed in the input; menu closes (default) | +| Reopen with a selection | The **full** option list is shown; the selected option is marked `aria-selected="true"` and highlighted | +| Start typing | The list filters by the typed text | +| Clear text | The full list returns | + +Showing the full list on reopen (rather than filtering down to the single selected label) is intentional: it lets users browse and change their choice, and avoids a screen reader announcing _"1 of 1"_ when more options exist. + +> **WCAG 4.1.2 Name, Role, Value (Level A)** requires the **current value** of a form control to be programmatically determinable. Keeping the selected label in the input — rather than in a visual overlay — is what satisfies this. + +## Selected value display — trade-off + +Because the selected value now lives inside a native input (which can only hold a string), the **collapsed selected value is text-only**. Anything an option carries beyond its label is shown **in the option list** but not in the selected display: + +- `startElement` — leading icon / avatar / indent +- `endElement` — trailing icon / suffix / hint text +- a custom `valueRenderer` (applies only to **non-searchable** single select) + + + + + + + +## Accessibility-relevant props + +Layout props such as `size` are omitted — they do not affect accessibility. The props below do. + +### Naming — give the field an accessible name (required for 4.1.2) + +| Prop | Purpose | Notes | +| ---------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `label` | Visible text label, programmatically associated with the input (`aria-labelledby`). | **Preferred.** Visible to everyone and announced by screen readers. | +| `aria-label` | Accessible name when there is **no** visible `label`. | Use only when a visible label is not possible. | +| `inputAriaLabel` | Accessible name applied specifically to the inner search input. | Useful when the input needs a name distinct from the field label. | +| `menuAriaLabel` | Accessible name for the option list (`listbox`). | Helps orient users when the menu opens. | +| `clearAriaLabel` | Accessible name for the clear (✕) button. | **Important.** Without it the clear button is an icon-only control with no name — a 4.1.2 failure. Always set it when `clearable`. | + +### State — communicate status to assistive tech + +| Prop | Purpose | Notes | +| ------------ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `required` | Marks the field required (`aria-required`) and shows the indicator. | Pair with form-level validation. | +| `error` | Puts the field in an error state (`aria-invalid`). | **Always pair with `helperText`** describing the error — color alone is not sufficient (1.4.1). | +| `helperText` | Descriptive text associated via `aria-describedby`. | Use for instructions and for the error message text. | +| `disabled` | Non-interactive, not focusable; communicated to AT. | Removes the field from the tab order. | +| `readOnly` | Value is shown and announced but not editable. | Prefer over `disabled` when the user still needs to read the value. | + +### Feedback + +| Prop | Purpose | Notes | +| ------------------ | ----------------------------------------------- | ------------------------------------------------------------- | +| `noOptionsMessage` | Text announced when a search yields no results. | Give a meaningful message (e.g. "No teams found"), not empty. | + +### Placeholder is not a label + +| Prop | Purpose | Notes | +| ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `placeholder` | Hint text shown when the field is empty. | **Not a substitute for `label`.** It disappears once a value is present and is not a reliable accessible name. Always provide `label` or `aria-label` in addition. | diff --git a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx new file mode 100644 index 0000000000..5d2129305a --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx @@ -0,0 +1,564 @@ +import React, { useMemo, useState } from "react"; +import { type Meta, type StoryObj } from "@storybook/react"; +import { createStoryMetaSettingsDecorator } from "../../../utils/createStoryMetaSettingsDecorator"; +import person1 from "../Avatar/assets/person1.png"; +import person2 from "../Avatar/assets/person2.png"; +import person3 from "../Avatar/assets/person3.png"; +import { Attach, Email } from "@vibe/icons"; +import { Dropdown, type BaseDropdownProps, type DropdownOption, Flex, Text } from "@vibe/core"; + +type Story = StoryObj; + +const metaSettings = createStoryMetaSettingsDecorator({ + component: Dropdown, + actionPropsArray: [ + "onMenuOpen", + "onMenuClose", + "onFocus", + "onBlur", + "onChange", + "openMenuOnFocus", + "onOptionSelect", + "onClear", + "onInputChange", + "onKeyDown" + ] +}); + +const meta: Meta = { + title: "Components/Dropdown/Searchable single select", + component: Dropdown, + argTypes: metaSettings.argTypes, + decorators: metaSettings.decorators +}; + +export default meta; + +const basicOptions = [ + { value: "marketing", label: "Marketing" }, + { value: "design", label: "Design" }, + { value: "engineering", label: "Engineering" }, + { value: "product", label: "Product" }, + { value: "sales", label: "Sales" } +]; + +const dropdownTemplate = (props: BaseDropdownProps) => { + const options = useMemo(() => basicOptions, []); + + return ( +
+ +
+ ); +}; + +export const Overview: Story = { + render: dropdownTemplate.bind({}), + args: { + id: "searchable-single-overview", + "aria-label": "Searchable single select", + placeholder: "Search a team", + clearAriaLabel: "Clear" + }, + parameters: { + docs: { + liveEdit: { + isEnabled: false + } + } + } +}; + +export const Sizes: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + return ( + +
+ +
+
+ +
+
+ +
+
+ ); + } +}; + +export const States: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + return ( + + +
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+
+ ); + } +}; + +export const WithDefaultValue: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + return ( +
+ + The selected value lives inside the input, so it is exposed to screen readers on mount. + + +
+ ); + } +}; + +export const Controlled: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + const [value, setValue] = useState(options[1]); + + return ( + + Selected: {value?.label ?? "none"} + setValue(option)} + onClear={() => setValue(null)} + clearAriaLabel="Clear" + /> + + ); + } +}; + +export const WithIconsAndAvatars: Story = { + render: () => { + const iconOptions = useMemo( + () => [ + { value: "email", label: "Email", startElement: { type: "icon", value: Email } }, + { value: "attach", label: "Attach", startElement: { type: "icon", value: Attach } } + ], + [] + ); + const avatarOptions = useMemo( + () => [ + { value: "julia", label: "Julia Martinez", startElement: { type: "avatar", value: person1 } }, + { value: "sophia", label: "Sophia Johnson", startElement: { type: "avatar", value: person2 } }, + { value: "marco", label: "Marco DiAngelo", startElement: { type: "avatar", value: person3 } } + ], + [] + ); + + return ( + + + Each option is preselected. The icon / avatar shows in the option list, but the collapsed selected value + inside the input is text-only — a native input can only hold a string. + + +
+ +
+
+ +
+
+
+ ); + }, + parameters: { + docs: { + liveEdit: { + scope: { person1, person2, person3 } + } + } + } +}; + +export const WithEndElements: Story = { + render: () => { + const endIconOptions = useMemo( + () => [ + { value: "email", label: "Email", endElement: { type: "icon", value: Email } }, + { value: "attach", label: "Attach", endElement: { type: "icon", value: Attach } } + ], + [] + ); + const suffixOptions = useMemo( + () => [ + { value: "copy", label: "Copy", endElement: { type: "suffix", value: "⌘C" } }, + { value: "paste", label: "Paste", endElement: { type: "suffix", value: "⌘V" } } + ], + [] + ); + + return ( + + + Trailing icons and suffix / hint text appear in the option list, but are dropped from the collapsed selected + value (text-only). + + +
+ +
+
+ +
+
+
+ ); + } +}; + +export const WithValueRenderer: Story = { + render: () => { + const options = useMemo( + () => [ + { value: "julia", label: "Julia Martinez", startElement: { type: "avatar", value: person1 } }, + { value: "sophia", label: "Sophia Johnson", startElement: { type: "avatar", value: person2 } } + ], + [] + ); + + const valueRenderer = (option: DropdownOption) => ( + + + Custom: {option.label} + + ); + + return ( + + + A custom valueRenderer is provided and the value is preselected. For searchable single select it + is not applied to the collapsed selected value — the input shows the plain label text only. + (valueRenderer still applies to non-searchable single select.) + +
+ +
+
+ ); + }, + parameters: { + docs: { + liveEdit: { + scope: { person1, person2 } + } + } + } +}; + +export const WithGroups: Story = { + render: () => { + const groupedOptions = useMemo( + () => [ + { + label: "Engineering", + options: [ + { value: "frontend", label: "Frontend" }, + { value: "backend", label: "Backend" }, + { value: "infra", label: "Infrastructure" } + ] + }, + { + label: "Business", + options: [ + { value: "marketing", label: "Marketing" }, + { value: "sales", label: "Sales" } + ] + } + ], + [] + ); + + return ( + + + Grouped by category +
+ +
+
+ + Sticky group titles +
+ +
+
+ + Group by divider +
+ +
+
+
+ ); + } +}; + +export const WithTooltips: Story = { + render: () => { + const options = useMemo( + () => [ + { + value: "marketing", + label: "Marketing", + tooltipProps: { content: "Campaigns, content and brand." } + }, + { + value: "design", + label: "Design", + tooltipProps: { content: "Product and brand design." } + }, + { value: "engineering", label: "Engineering" } + ], + [] + ); + + return ( +
+ +
+ ); + } +}; + +export const ClearableAndMaxHeight: Story = { + render: () => { + const options = useMemo( + () => + Array.from({ length: 30 }, (_, index) => ({ + value: `option-${index + 1}`, + label: `Option ${index + 1}` + })), + [] + ); + + return ( + +
+ +
+
+ +
+
+ ); + } +}; + +export const CustomFilterAndNoOptions: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + + // Match only from the start of the label instead of the default substring match. + const startsWithFilter = (option: DropdownOption, inputValue: string) => + option.label.toLowerCase().startsWith(inputValue.toLowerCase()); + + return ( + + + Custom "starts with" filter +
+ +
+
+ + Custom empty message +
+ +
+
+
+ ); + } +};