From d1494ddc440ae118971506a6be37df758df39369 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Fri, 2 Oct 2026 17:30:17 +0200 Subject: [PATCH 1/4] 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]`, ); From 7d7c807b1eed47fa90d80a1cedc3a848f25d90f4 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Thu, 5 Nov 2026 13:36:58 +0100 Subject: [PATCH 3/4] Fix FieldItem control discovery for Select - Distinguish non-filterable comboboxes from filterable Select trigger buttons - Exclude hidden inputs from automatic field connections - Reuse generic label and description wiring and preserve custom label references - Clean up generated attributes when the control target changes - Add regression tests for Select discovery and mode changes --- src/components/Form/FieldItem.tsx | 72 +++++++++----------- src/components/Form/tests/FieldItem.test.tsx | 58 ++++++++++++++++ 2 files changed, 90 insertions(+), 40 deletions(-) diff --git a/src/components/Form/FieldItem.tsx b/src/components/Form/FieldItem.tsx index 35da63637..5f5fbf832 100644 --- a/src/components/Form/FieldItem.tsx +++ b/src/components/Form/FieldItem.tsx @@ -43,10 +43,11 @@ export interface FieldItemProps extends React.HTMLAttributes, Te * Input elements that could be connected to the label and the help texts of the field item. */ const connectableInputSelectors = [ - "input", + 'input:not([type="hidden"])', "textarea", "select", - `.${eccgui}-select button`, + `.${eccgui}-select[role="combobox"]`, + `.${eccgui}-select:not([role="combobox"]) button`, `.${eccgui}-codeeditor .cm-content`, ]; @@ -72,6 +73,7 @@ export const FieldItem = ({ }: FieldItemProps) => { const fieldItemRef = React.useRef(null); const hiddenSelectButtonRef = React.useRef(null); + const connectedInputRef = React.useRef(undefined); /** unique ID of this field item, used as suffix for the IDs of its parts */ const fieldItemId = React.useId().replace(/[^a-zA-Z0-9_-]/g, ""); @@ -105,30 +107,17 @@ export const FieldItem = ({ const helpElement = ownPart(fieldItem.querySelectorAll(`.${eccgui}-fielditem__helpertext`)); const messageElement = ownPart(fieldItem.querySelectorAll(`.${eccgui}-fielditem__message`)); - /** - * 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 inner button remains a - * pointer target, but exposing both elements as labelled controls can cause the same field - * to be announced twice. Hide the inner button from assistive technology and connect the - * label, helper text and message directly to the focusable combobox. - * - * 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"]`, - ), - ); - if (hiddenSelectButtonRef.current && (!selectCombobox || hiddenSelectButtonRef.current !== inputElement)) { + // Only expose the focusable combobox to assistive technology. + const selectButton = inputElement?.matches(`.${eccgui}-select[role="combobox"]`) + ? inputElement.querySelector("button") + : null; + if (hiddenSelectButtonRef.current && hiddenSelectButtonRef.current !== selectButton) { hiddenSelectButtonRef.current.removeAttribute("aria-hidden"); hiddenSelectButtonRef.current = null; } - if (selectCombobox && inputElement && inputElement.getAttribute("aria-hidden") !== "true") { - inputElement.setAttribute("aria-hidden", "true"); - hiddenSelectButtonRef.current = inputElement; + if (selectButton && selectButton.getAttribute("aria-hidden") !== "true") { + selectButton.setAttribute("aria-hidden", "true"); + hiddenSelectButtonRef.current = selectButton; } const setMissingId = (element: HTMLElement | undefined, id: string) => { @@ -141,10 +130,6 @@ export const FieldItem = ({ setMissingId(helpElement, `help_${fieldItemId}`); setMissingId(messageElement, `message_${fieldItemId}`); - if (!inputElement) { - return; - } - /** * 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. @@ -171,11 +156,24 @@ export const FieldItem = ({ } }; - if (selectCombobox) { - if (labelElement instanceof HTMLLabelElement && labelElement.htmlFor === inputElement.id) { - labelElement.removeAttribute("for"); + const previousInput = connectedInputRef.current; + if (previousInput && previousInput !== inputElement) { + if (previousInput.id === `input_${fieldItemId}`) { + previousInput.removeAttribute("id"); } - } else if (labelElement instanceof HTMLLabelElement && labelableElements.includes(inputElement.tagName)) { + updateReferences(previousInput, "aria-labelledby", [[undefined, `label_${fieldItemId}`]]); + updateReferences(previousInput, "aria-describedby", [ + [undefined, `message_${fieldItemId}`], + [undefined, `help_${fieldItemId}`], + ]); + } + connectedInputRef.current = inputElement; + + if (!inputElement) { + return; + } + + if (labelElement instanceof HTMLLabelElement && labelableElements.includes(inputElement.tagName)) { // an already set `for` is only kept if it refers to a labelable input element of this field item const labelTarget = labelElement.getAttribute("for"); const refersToOwnInput = inputElements.some( @@ -187,6 +185,9 @@ export const FieldItem = ({ } else if (labelElement) { // labels that are not `label` elements, e.g. of disabled field items, cannot use `for` // the same is true for input elements that cannot be referenced by `for` + if (labelElement instanceof HTMLLabelElement) { + labelElement.removeAttribute("for"); + } if (!inputElement.getAttribute("aria-labelledby")) { inputElement.setAttribute("aria-labelledby", labelElement.id); } @@ -199,15 +200,6 @@ export const FieldItem = ({ [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 bc44853db..d9fddb362 100644 --- a/src/components/Form/tests/FieldItem.test.tsx +++ b/src/components/Form/tests/FieldItem.test.tsx @@ -201,8 +201,49 @@ describe("FieldItem", () => { expect(combobox).toHaveAccessibleName("Filter by language"); expect(combobox).toHaveAccessibleDescription("Choose a language. Only matching values are shown."); + expect(combobox).toHaveAttribute("id", expect.stringMatching(/^input_/)); expect(label).not.toHaveAttribute("for"); expect(button).toHaveAttribute("aria-hidden", "true"); + expect(button).not.toHaveAttribute("id"); + expect(button).not.toHaveAttribute("aria-describedby"); + }); + it("ignores hidden inputs before a Select", () => { + const { fieldItem, label } = renderFieldItem({ + labelProps: { text: "Type" }, + helperText: "Choose a type.", + children: ( + <> + +
+ +
+ + ), + }); + const hiddenInput = fieldItem.querySelector("input"); + const combobox = fieldItem.querySelector('[role="combobox"]'); + + expect(combobox).toHaveAccessibleName("Type"); + expect(combobox).toHaveAccessibleDescription("Choose a type."); + expect(label).not.toHaveAttribute("for"); + expect(hiddenInput).not.toHaveAttribute("id"); + expect(hiddenInput).not.toHaveAttribute("aria-describedby"); + expect(hiddenInput).not.toHaveAttribute("aria-hidden"); + }); + it("keeps a custom accessible name on the Select combobox", () => { + const { fieldItem } = renderFieldItem({ + labelProps: { text: "Type" }, + children: ( + <> + Custom type +
+ +
+ + ), + }); + + expect(fieldItem.querySelector('[role="combobox"]')).toHaveAccessibleName("Custom type"); }); it("restores the button when a Select becomes filterable", () => { const { container, rerender } = render( @@ -213,6 +254,8 @@ describe("FieldItem", () => {
, ); const button = container.querySelector("button"); + const label = container.querySelector("label"); + const selectTarget = container.querySelector('[role="combobox"]'); expect(button).toHaveAttribute("aria-hidden", "true"); rerender( @@ -223,6 +266,21 @@ describe("FieldItem", () => {
, ); expect(button).not.toHaveAttribute("aria-hidden"); + expect(label).toHaveAttribute("for", button?.id); + expect(selectTarget).not.toHaveAttribute("id"); + expect(selectTarget).not.toHaveAttribute("aria-labelledby"); + + rerender( + +
+ +
+
, + ); + expect(label).not.toHaveAttribute("for"); + expect(container.querySelector('[role="combobox"]')).toHaveAccessibleName("Type"); + expect(button).toHaveAttribute("aria-hidden", "true"); + expect(button).not.toHaveAttribute("id"); }); it("should remove the reference to a removed label from `aria-labelledby`", () => { const { container, rerender } = render( From 4146ab4a903ab3eaefd9cd4bf2d5aa18c221c31c Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Thu, 5 Nov 2026 17:58:48 +0100 Subject: [PATCH 4/4] Fix Select keyboard navigation and FieldItem label focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Focus custom controls when their FieldItem label is clicked - Preserve native label behavior and respect disabled controls - Support Tab, Shift+Tab, Enter and Space on Select’s clear action - Prevent clearing and tooltip updates from opening options or stealing focus - Reuse Blueprint’s item comparison helper - Add regression tests for label focus and keyboard navigation --- CHANGELOG.md | 3 + src/components/Form/FieldItem.tsx | 28 +++ src/components/Form/tests/FieldItem.test.tsx | 172 +++++++++++++++- src/components/Select/Select.test.tsx | 195 ++++++++++++++++++- src/components/Select/Select.tsx | 76 ++++++-- 5 files changed, 451 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c802df61..acf8b7571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Fixed +- `` + - Clicking a label focuses its control when a native label association is not available, including Select comboboxes and CodeEditor. - `, + }); + + await user.click(screen.getByText("Name")); + + expect(screen.getByRole("textbox", { name: "Name" })).toHaveFocus(); + }); + + it("focuses the control inside a focusable ancestor", async () => { + const user = userEvent.setup(); + render( +
+ +
+ Value +
+
+
, + ); + + await user.click(screen.getByText("Type")); + + expect(screen.getByRole("combobox")).toHaveFocus(); + }); + + it("preserves native activation of an explicitly labelled second input", async () => { + const user = userEvent.setup(); + const { input } = renderFieldItem({ + labelProps: { text: "Second", htmlFor: "second" }, + children: ( + <> + + + + ), + }); + const focusFirst = jest.spyOn(input, "focus"); + + await user.click(screen.getByText("Second")); + + expect(screen.getByRole("textbox", { name: "Second" })).toHaveFocus(); + expect(focusFirst).not.toHaveBeenCalled(); + }); + + it.each([false, true])("respects a label click handler with preventDefault=%s", async (cancel) => { + const user = userEvent.setup(); + const onClick = jest.fn((event: React.MouseEvent) => { + if (cancel) event.preventDefault(); + }); + renderCombobox({ labelProps: { text: "Type", onClick } }); + + await user.click(screen.getByText("Type")); + + expect(onClick).toHaveBeenCalledTimes(1); + if (cancel) { + expect(screen.getByRole("combobox")).not.toHaveFocus(); + } else { + expect(screen.getByRole("combobox")).toHaveFocus(); + } + }); + + it.each(["link", "button", "focusable text"])("preserves clicks on a %s inside the label", async (kind) => { + const user = userEvent.setup(); + const help = + kind === "link" ? ( + Help + ) : kind === "button" ? ( + + ) : ( + Help + ); + renderCombobox({ labelProps: { text: "Type", additionalElements: help } }); + const focusControl = jest.spyOn(screen.getByRole("combobox"), "focus"); + + await user.click(screen.getByText("Help")); + + expect(focusControl).not.toHaveBeenCalled(); + expect(screen.getByText("Help")).toHaveFocus(); + }); + + it("preserves clicks on portaled label content", async () => { + const user = userEvent.setup(); + renderCombobox({ + labelProps: { + text: "Type", + additionalElements: createPortal(, document.body), + }, + }); + const focusControl = jest.spyOn(screen.getByRole("combobox"), "focus"); + + await user.click(screen.getByText("Help")); + + expect(focusControl).not.toHaveBeenCalled(); + expect(screen.getByText("Help")).toHaveFocus(); + }); + + it("does not focus the control of a disabled field", async () => { + const user = userEvent.setup(); + renderCombobox({ disabled: true }); + const focusControl = jest.spyOn(screen.getByRole("combobox"), "focus"); + + await user.click(screen.getByText("Type")); + + expect(focusControl).not.toHaveBeenCalled(); + }); + + it("does not add focus behavior when automatic connections are disabled", async () => { + const user = userEvent.setup(); + renderCombobox({ preventAriaAttribution: true }); + const focusControl = jest.spyOn(screen.getByRole("combobox"), "focus"); + + await user.click(screen.getByText("Type")); + + expect(focusControl).not.toHaveBeenCalled(); + }); +}); + describe("FieldItem with CodeEditor", () => { beforeAll(() => { // the code editor needs range support that is not provided by jsdom @@ -435,4 +581,28 @@ describe("FieldItem with CodeEditor", () => { expect(input).toHaveAttribute("aria-labelledby", label.id); expect(input).toHaveAttribute("aria-describedby", message.id); }); + + it.each([ + [false, false], + [false, true], + [true, false], + [true, true], + ])("handles label clicks with disabled=%s and readOnly=%s", async (disabled, readOnly) => { + const user = userEvent.setup(); + const { container } = render( + + + , + ); + const input = container.querySelector(".cm-content"); + await waitFor(() => expect(input).toHaveAccessibleName("Expression")); + + await user.click(screen.getByText("Expression")); + + if (disabled) { + expect(input).not.toHaveFocus(); + } else { + expect(input).toHaveFocus(); + } + }); }); diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx index ef1218c6a..e750c6f1a 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/Select.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; @@ -36,6 +36,199 @@ it("connects a filterable Select search input to its listbox", async () => { }); describe("non-filterable Select keyboard navigation", () => { + const renderClearableSelect = (onClearanceText = "Reset selection", disabled = false, filterable = false) => { + const onClear = jest.fn(); + const onItemSelect = jest.fn(); + const ClearableSelect = () => { + const [text, setText] = React.useState("Value"); + return ( + <> + + + ( + + )} + onItemSelect={jest.fn()} + text="Value" + /> + , + ); + const combobox = screen.getByRole("combobox", { name: "Type" }); + const focusControl = jest.spyOn(combobox, "focus"); + + await user.click(screen.getByText("Type")); + + if (disabled) { + expect(focusControl).not.toHaveBeenCalled(); + expect(combobox).not.toHaveFocus(); + } else { + expect(combobox).toHaveFocus(); + } + expect(combobox).toHaveAttribute("aria-expanded", "false"); + }); + it("keeps focus on the combobox while navigating and returns to it after Escape", async () => { const user = userEvent.setup(); const onItemSelect = jest.fn(); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 89fcccc31..20c771523 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Classes as BlueprintClasses, InputGroupProps } from "@blueprintjs/core"; -import { Select as BlueprintSelect, SelectProps as BlueprintSelectProps } from "@blueprintjs/select"; +import { executeItemsEqual, Select as BlueprintSelect, SelectProps as BlueprintSelectProps } from "@blueprintjs/select"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; import { Button, ButtonProps } from "../Button/Button"; @@ -93,17 +93,9 @@ export function Select({ const handleActiveItemChange: BlueprintSelectProps["onActiveItemChange"] = (activeItem, isCreateNewItem) => { if (!filterable && comboboxRef.current) { - const matchingItem = itemIds.current.find(({ item }) => { - if (otherSelectProps.itemsEqual === undefined) { - return item === activeItem; - } - if (typeof otherSelectProps.itemsEqual === "function") { - return activeItem != null && otherSelectProps.itemsEqual(item, activeItem); - } - return ( - activeItem != null && item[otherSelectProps.itemsEqual] === activeItem[otherSelectProps.itemsEqual] - ); - }); + const matchingItem = itemIds.current.find(({ item }) => + executeItemsEqual(otherSelectProps.itemsEqual, item, activeItem), + ); if (matchingItem && !isCreateNewItem) { comboboxRef.current.setAttribute("aria-activedescendant", matchingItem.id); } else { @@ -120,6 +112,36 @@ export function Select({ return itemRenderer(item, props); }; + const handleClear = (event: React.SyntheticEvent) => { + event.stopPropagation(); + if (otherSelectProps.disabled) { + return; + } + const trigger = event.currentTarget.closest("button"); + onClearanceHandler?.(); + if (filterable) { + trigger?.focus(); + } else { + comboboxRef.current?.focus(); + } + }; + + const handleClearKeyDown: React.KeyboardEventHandler = (event) => { + event.stopPropagation(); + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + } + }; + + const handleClearKeyUp: React.KeyboardEventHandler = (event) => { + event.stopPropagation(); + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + // Return focus after key release so the trigger cannot activate with the same key. + handleClear(event); + } + }; + const target = children ?? (