From d1494ddc440ae118971506a6be37df758df39369 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Fri, 2 Oct 2026 17:30:17 +0200 Subject: [PATCH 1/2] Select: Fix accesibility (label combobox) and restore keyboard navigation - Connect FieldItem labels, helper text, and messages to the non-filterable Select combobox. - Keep focus on the combobox and expose the active option to screen readers. - Restore predictable Enter, arrow key, Escape, and Tab behavior, with a visible focus indicator. - Add tests for accessible descriptions, option selection, and focus handling. --- CHANGELOG.md | 6 + src/components/Form/FieldItem.tsx | 44 ++++- src/components/Form/tests/FieldItem.test.tsx | 19 ++ src/components/Select/Select.test.tsx | 154 +++++++++++++++ src/components/Select/Select.tsx | 186 +++++++++++++++---- src/components/Select/_select.scss | 4 + 6 files changed, 373 insertions(+), 40 deletions(-) create mode 100644 src/components/Select/Select.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 392f4abbe..93b9009c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - for those alert intent states the content area gets an `id` and is referred by the dialog via `aria-describedby` - explicitly given values are never overwritten - if neither `title`, `aria-label` nor `aria-labelledby` is given for an alert, then the `intent` level is used as fallback for `aria-label`, so the alert dialog always has an accessible name +- `` + - Restore keyboard navigation for non-filterable Select. + - Restore predictable Enter, arrow key, Escape, and Tab behavior, with a visible focus indicator. + - Keep focus on the combobox and expose the active option to screen readers. ### Deprecated diff --git a/src/components/Form/FieldItem.tsx b/src/components/Form/FieldItem.tsx index 338b36158..83ef5a6d6 100644 --- a/src/components/Form/FieldItem.tsx +++ b/src/components/Form/FieldItem.tsx @@ -99,6 +99,22 @@ export const FieldItem = ({ connectableInputSelectors.map((selector) => `.${eccgui}-fielditem__inputfields ${selector}`).join(", "), ), ); + /** + * Blueprint renders a non-filterable Select as a trigger button inside a popover target with + * role="combobox". Our Select makes that outer target the keyboard focus stop, while the + * normal input selector above still finds the inner button. The label's `for` association + * and the button's `aria-describedby` do not transfer to the role-bearing parent, so the + * parent also needs explicit label, helper-text and message references below. + * + * Filterable Selects put the combobox role on their popup search input instead, and do not + * match this selector. Keeping the non-filterable case here lets consumers use FieldItem + * without repeating these wrapper-specific ARIA connections at every Select usage. + */ + const selectCombobox = ownPart( + fieldItem.querySelectorAll( + `.${eccgui}-fielditem__inputfields .${eccgui}-select[role="combobox"]`, + ), + ); const helpElement = ownPart(fieldItem.querySelectorAll(`.${eccgui}-fielditem__helpertext`)); const messageElement = ownPart(fieldItem.querySelectorAll(`.${eccgui}-fielditem__message`)); @@ -120,8 +136,12 @@ export const FieldItem = ({ * Update a list of ID references, only the IDs created by this field item are removed if their part is gone. * References set by the using application always stay untouched. */ - const updateReferences = (attribute: string, parts: [HTMLElement | undefined, string][]) => { - const references = (inputElement.getAttribute(attribute) ?? "").split(" ").filter(Boolean); + const updateReferences = ( + element: HTMLElement, + attribute: string, + parts: [HTMLElement | undefined, string][], + ) => { + const references = (element.getAttribute(attribute) ?? "").split(" ").filter(Boolean); parts.forEach(([element, ownId]) => { if (element) { if (!references.includes(element.id)) { @@ -132,9 +152,9 @@ export const FieldItem = ({ } }); if (references.length > 0) { - inputElement.setAttribute(attribute, references.join(" ")); + element.setAttribute(attribute, references.join(" ")); } else { - inputElement.removeAttribute(attribute); + element.removeAttribute(attribute); } }; @@ -150,13 +170,23 @@ export const FieldItem = ({ inputElement.setAttribute("aria-labelledby", labelElement.id); } } else { - updateReferences("aria-labelledby", [[undefined, `label_${fieldItemId}`]]); + updateReferences(inputElement, "aria-labelledby", [[undefined, `label_${fieldItemId}`]]); } - updateReferences("aria-describedby", [ + const descriptions: [HTMLElement | undefined, string][] = [ [messageElement, `message_${fieldItemId}`], [helpElement, `help_${fieldItemId}`], - ]); + ]; + updateReferences(inputElement, "aria-describedby", descriptions); + + if (selectCombobox) { + if (labelElement) { + updateReferences(selectCombobox, "aria-labelledby", [[labelElement, `label_${fieldItemId}`]]); + } else { + updateReferences(selectCombobox, "aria-labelledby", [[undefined, `label_${fieldItemId}`]]); + } + updateReferences(selectCombobox, "aria-describedby", descriptions); + } }, [fieldItemId, preventAriaAttribution]); React.useEffect(() => { diff --git a/src/components/Form/tests/FieldItem.test.tsx b/src/components/Form/tests/FieldItem.test.tsx index 08239e299..5d5a7ee75 100644 --- a/src/components/Form/tests/FieldItem.test.tsx +++ b/src/components/Form/tests/FieldItem.test.tsx @@ -162,6 +162,25 @@ describe("FieldItem", () => { expect(label).toHaveAttribute("for", input.id); }); }); + it("names and describes the Select combobox as well as its button", () => { + const { fieldItem } = renderFieldItem({ + labelProps: { text: "Filter by language" }, + helperText: "Only matching values are shown.", + messageText: "Choose a language.", + children: ( + + ), + }); + const combobox = fieldItem.querySelector('[role="combobox"]'); + const button = fieldItem.querySelector("button"); + + expect(combobox).toHaveAccessibleName("Filter by language"); + expect(combobox).toHaveAccessibleDescription("Choose a language. Only matching values are shown."); + expect(button).toHaveAccessibleName("Filter by language"); + expect(button).toHaveAccessibleDescription("Choose a language. Only matching values are shown."); + }); it("should remove the reference to a removed label from `aria-labelledby`", () => { const { container, rerender } = render( } />, diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx new file mode 100644 index 000000000..00c99850b --- /dev/null +++ b/src/components/Select/Select.test.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import "@testing-library/jest-dom"; + +import FieldItem from "../Form/FieldItem"; +import MenuItem from "../Menu/MenuItem"; + +import Select from "./Select"; + +describe("non-filterable Select keyboard navigation", () => { + it("keeps focus on the combobox while navigating and returns to it after Escape", async () => { + const user = userEvent.setup(); + const onItemSelect = jest.fn(); + + render( + <> + + + ( + + )} + onItemSelect={onItemSelect} + contextOverlayProps={{ transitionDuration: 0 }} + > + + + + , + ); + + const combobox = screen.getByRole("combobox"); + await user.tab(); + expect(combobox).toHaveFocus(); + await user.keyboard("{Enter}{ArrowDown}{Enter}"); + expect(onItemSelect).toHaveBeenCalledWith("Object", expect.anything()); + await waitFor(() => expect(combobox).toHaveAttribute("aria-expanded", "false")); + await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument()); + expect(combobox).toHaveFocus(); + await user.tab(); + expect(screen.getByRole("button", { name: "After" })).toHaveFocus(); + }); + + it("closes the list when Tab moves focus to the next control", async () => { + const user = userEvent.setup(); + render( + <> + ( + + )} + onItemSelect={jest.fn()} + > + + , + ); + + await user.click(screen.getByRole("button", { name: "Choose language" })); + expect(screen.getByRole("combobox")).toHaveAttribute("aria-controls", screen.getByRole("listbox").id); +}); + describe("non-filterable Select keyboard navigation", () => { it("keeps focus on the combobox while navigating and returns to it after Escape", async () => { const user = userEvent.setup(); @@ -48,6 +74,8 @@ describe("non-filterable Select keyboard navigation", () => { await user.tab(); const combobox = screen.getByRole("combobox"); expect(combobox).toHaveFocus(); + expect(combobox).toHaveAccessibleName("Type"); + expect(screen.queryByRole("button", { name: "Type" })).not.toBeInTheDocument(); await user.keyboard("{Enter}"); expect(combobox).toHaveAttribute("aria-expanded", "true"); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index f09038c87..89fcccc31 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -199,7 +199,14 @@ export function Select({ matchTargetWidth: otherSelectProps.fill ?? false, ...contextOverlayProps, onOpening: (node) => { - if (!filterable) { + if (filterable) { + // Blueprint puts aria-controls on the trigger, but focus moves to the search combobox. + const input = node.querySelector('input[role="combobox"]'); + const listbox = node.querySelector('[role="listbox"][id]'); + if (input && listbox && !input.hasAttribute("aria-controls")) { + input.setAttribute("aria-controls", listbox.id); + } + } else { const activeOption = node.querySelector( `.${BlueprintClasses.ACTIVE}[role="option"][id]`, );