From 3870dac1dda9027b6f859770c04c5f1a53fc515d Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 10 Nov 2026 16:15:03 +0100 Subject: [PATCH 01/12] fix inline mode after wrapper element is used by default all the time --- src/components/Select/Select.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 20c77152..8a7bcbc9 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -215,9 +215,15 @@ export function Select({ // Blueprint leaves a select-only popover open when Tab moves focus away. blueprintSelectRef.current?.setState({ isOpen: false }); } else if (tabDestination) { + const trigger = event.currentTarget; // Escape may still have a pending animation-frame focus restoration. requestAnimationFrame(() => { - if (tabDestination.isConnected) { + const active = document.activeElement; + // Only undo a focus restoration, never a later focus change by the user. + if ( + tabDestination.isConnected && + (active === trigger || active === document.body || active === null) + ) { tabDestination.focus(); } }); @@ -292,11 +298,14 @@ export function Select({ ); + const inlineWrapper = !otherSelectProps.fill ? { display: "inline-block" } : {}; + return !filterable || wrapperProps || dataTestId || dataTestid ? (
{selectContent} From bcc1aced8fdea619f721f097fde08daf4871cf94 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 10 Nov 2026 16:16:02 +0100 Subject: [PATCH 02/12] use FieldSet und fix options (remove empty) --- src/components/Select/Select.stories.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index 40ff4afe..9dd2728a 100644 --- a/src/components/Select/Select.stories.tsx +++ b/src/components/Select/Select.stories.tsx @@ -4,7 +4,7 @@ import { Meta, StoryFn } from "@storybook/react"; import { fn } from "storybook/test"; import { helpersArgTypes } from "../../../.storybook/helpers"; -import { Button, Depiction, MenuItem, Select } from "../../index"; +import { Button, Depiction, FieldItem, MenuItem, Select } from "../../index"; import { FullExample as DepictionExample } from "../Depiction/stories/Depiction.stories"; export default { @@ -20,13 +20,18 @@ export default { }, } as Meta; -const Template: StoryFn = (args) => + +); export const Default = Template.bind({}); Default.args = { items: loremIpsum({ p: 1, avgSentencesPerParagraph: 5, random: false }) .toString() .split(".") + .filter((item) => item.trim().length > 0) .map((item) => { return { label: item }; }), From cb4ad77e3e3fbbb4ede4431f0f68c076f1a34a47 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 10 Nov 2026 17:08:09 +0100 Subject: [PATCH 03/12] move combobox condition from FieldItem to Select --- CHANGELOG.md | 1 + src/components/Form/FieldItem.tsx | 14 ------ src/components/Form/tests/FieldItem.test.tsx | 12 ++--- src/components/Select/Select.test.tsx | 47 +++++++++++++++++++ src/components/Select/Select.tsx | 49 +++++++++++++------- 5 files changed, 83 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acf8b757..d64e579c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - 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. + - Hide the inner target button of a non-filterable Select from screen readers, also outside of a ``. - Keep the clear action reachable with Tab and Shift+Tab and support clearing with Enter or Space without opening the options. ## [26.2.1] - 2026-11-02 diff --git a/src/components/Form/FieldItem.tsx b/src/components/Form/FieldItem.tsx index 10ab79ca..0229fa59 100644 --- a/src/components/Form/FieldItem.tsx +++ b/src/components/Form/FieldItem.tsx @@ -72,7 +72,6 @@ export const FieldItem = ({ ...otherProps }: 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, ""); @@ -107,19 +106,6 @@ export const FieldItem = ({ const helpElement = ownPart(fieldItem.querySelectorAll(`.${eccgui}-fielditem__helpertext`)); const messageElement = ownPart(fieldItem.querySelectorAll(`.${eccgui}-fielditem__message`)); - // 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 (selectButton && selectButton.getAttribute("aria-hidden") !== "true") { - selectButton.setAttribute("aria-hidden", "true"); - hiddenSelectButtonRef.current = selectButton; - } - const setMissingId = (element: HTMLElement | undefined, id: string) => { if (element && !element.id) { element.id = id; diff --git a/src/components/Form/tests/FieldItem.test.tsx b/src/components/Form/tests/FieldItem.test.tsx index 965fcc7f..ecb0258b 100644 --- a/src/components/Form/tests/FieldItem.test.tsx +++ b/src/components/Form/tests/FieldItem.test.tsx @@ -1,7 +1,7 @@ import React from "react"; +import { createPortal } from "react-dom"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { createPortal } from "react-dom"; import "@testing-library/jest-dom"; @@ -187,7 +187,7 @@ describe("FieldItem", () => { expect(label).toHaveAttribute("for", input.id); }); }); - it("exposes the labelled Select combobox without a duplicate button", () => { + it("connects the Select combobox instead of its inner button", () => { const { fieldItem, label } = renderFieldItem({ labelProps: { text: "Filter by language" }, helperText: "Only matching values are shown.", @@ -205,7 +205,6 @@ describe("FieldItem", () => { 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"); }); @@ -230,7 +229,6 @@ describe("FieldItem", () => { 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({ @@ -247,7 +245,7 @@ describe("FieldItem", () => { expect(fieldItem.querySelector('[role="combobox"]')).toHaveAccessibleName("Custom type"); }); - it("restores the button when a Select becomes filterable", () => { + it("reconnects the label when a Select becomes filterable", () => { const { container, rerender } = render(
@@ -258,7 +256,7 @@ describe("FieldItem", () => { const button = container.querySelector("button"); const label = container.querySelector("label"); const selectTarget = container.querySelector('[role="combobox"]'); - expect(button).toHaveAttribute("aria-hidden", "true"); + expect(selectTarget).toHaveAccessibleName("Type"); rerender( @@ -267,7 +265,6 @@ 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"); @@ -281,7 +278,6 @@ describe("FieldItem", () => { ); 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`", () => { diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx index e750c6f1..64f551f1 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/Select.test.tsx @@ -373,3 +373,50 @@ describe("non-filterable Select keyboard navigation", () => { await waitFor(() => expect(combobox).toHaveAttribute("aria-expanded", "false")); }); }); + +describe("Select target button exposure", () => { + const renderSelect = (filterable: boolean, children?: React.ReactElement) => ( + + ); + + it.each([ + ["default target", undefined, false], + ["default target", undefined, true], + ["controlled target", , false], + ["controlled target", , true], + ])("hides the %s of a non-filterable Select (inside FieldItem: %#)", (_, children, inFieldItem) => { + const select = renderSelect(false, children); + const { container } = render( + inFieldItem ? {select} : select, + ); + + expect(container.querySelector("button")).toHaveAttribute("aria-hidden", "true"); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("exposes the target button again when the Select becomes filterable", () => { + const { container, rerender } = render( + {renderSelect(false)}, + ); + expect(container.querySelector("button")).toHaveAttribute("aria-hidden", "true"); + + rerender({renderSelect(true)}); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Type" })).not.toHaveAttribute("aria-hidden"); + + rerender({renderSelect(false)}); + expect(container.querySelector("button")).toHaveAttribute("aria-hidden", "true"); + expect(screen.getByRole("combobox", { name: "Type" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 8a7bcbc9..4c490aaa 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -91,6 +91,32 @@ export function Select({ } }); + /** + * Move focus to the Tab destination after Blueprint restored it to the combobox in the next animation frame. + * If focus was moved again in the meantime, e.g. by a further Tab, that newer destination is used instead. + */ + const keepTabDestination = (tabDestination: HTMLElement) => { + let destination = tabDestination; + const trackFocus = (event: FocusEvent) => { + if (event.target instanceof HTMLElement && event.target !== comboboxRef.current) { + destination = event.target; + } + }; + document.addEventListener("focusin", trackFocus); + // Registered after Blueprint's focus restoration, so it runs after it within the same frame. + requestAnimationFrame(() => { + document.removeEventListener("focusin", trackFocus); + const active = document.activeElement; + if ( + destination.isConnected && + destination !== active && + (active === comboboxRef.current || active === document.body || active === null) + ) { + destination.focus(); + } + }); + }; + const handleActiveItemChange: BlueprintSelectProps["onActiveItemChange"] = (activeItem, isCreateNewItem) => { if (!filterable && comboboxRef.current) { const matchingItem = itemIds.current.find(({ item }) => @@ -215,18 +241,8 @@ export function Select({ // Blueprint leaves a select-only popover open when Tab moves focus away. blueprintSelectRef.current?.setState({ isOpen: false }); } else if (tabDestination) { - const trigger = event.currentTarget; // Escape may still have a pending animation-frame focus restoration. - requestAnimationFrame(() => { - const active = document.activeElement; - // Only undo a focus restoration, never a later focus change by the user. - if ( - tabDestination.isConnected && - (active === trigger || active === document.body || active === null) - ) { - tabDestination.focus(); - } - }); + keepTabDestination(tabDestination); } } tabPressedRef.current = false; @@ -263,11 +279,7 @@ export function Select({ tabDestinationRef.current = null; if (tabDestination) { // Blueprint restores trigger focus in the same frame; let Tab keep its destination. - requestAnimationFrame(() => { - if (tabDestination.isConnected) { - tabDestination.focus(); - } - }); + keepTabDestination(tabDestination); } } contextOverlayProps?.onClosing?.(node); @@ -292,8 +304,9 @@ export function Select({ className={`${eccgui}-select` + (className ? ` ${className}` : "")} {...otherSelectProps} > - {!filterable && React.isValidElement<{ tabIndex?: number }>(target) - ? React.cloneElement(target, { tabIndex: -1 }) + {!filterable && React.isValidElement<{ tabIndex?: number; "aria-hidden"?: boolean }>(target) + ? // The wrapping combobox gets focus and the label, so the target button is only presentational. + React.cloneElement(target, { tabIndex: -1, "aria-hidden": true }) : target} ); From e919ea8ba4916190991ce7971fa152e1772a4f84 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 11 Nov 2026 09:55:06 +0100 Subject: [PATCH 04/12] Use non-focusable accessible target in combobox and improve a11y for clearance button --- CHANGELOG.md | 11 +- src/components/Select/Select.stories.tsx | 20 ++- src/components/Select/Select.test.tsx | 154 +++++++++++++++++------ src/components/Select/Select.tsx | 149 +++++++++++++++------- 4 files changed, 251 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d64e579c..5a141f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Changed + +- ` { )} onItemSelect={jest.fn()} - text="Value" + onClearanceHandler={jest.fn()} + text={text} + placeholder="Choose a type" > {children} ); + it.each([false, true])( + "renders the default target without a select button (inside FieldItem: %s)", + (inFieldItem) => { + const select = renderSelect(false); + render(inFieldItem ? {select} : select); + + const combobox = screen.getByRole("combobox"); + // the clear action is the only button inside the combobox + expect(screen.getByRole("button")).toHaveAccessibleName("Reset selection"); + expect(combobox).toContainElement(screen.getByRole("button")); + expect(combobox).toHaveTextContent("Value"); + // only decorative icons are hidden, the selected value and the clear action stay exposed + expect(combobox.querySelector('[aria-hidden="true"]:not(svg)')).not.toBeInTheDocument(); + if (inFieldItem) { + expect(combobox).toHaveAccessibleName("Type"); + } + }, + ); + + it("exposes the placeholder of the default target", () => { + render(renderSelect(false, undefined, "")); + expect(screen.getByRole("combobox")).toHaveTextContent("Choose a type"); + }); + it.each([ - ["default target", undefined, false], - ["default target", undefined, true], - ["controlled target", , false], - ["controlled target", , true], - ])("hides the %s of a non-filterable Select (inside FieldItem: %#)", (_, children, inFieldItem) => { - const select = renderSelect(false, children); + ["button", ], + [ + "fragment", + <> + + , + ], + [ + "array", + [ + , + Text, + ], + ], + [ + "div containing a button", +
+ +
, + ], + ["component without prop forwarding", ], + ])("keeps a custom %s target out of the Tab order", async (_, children) => { + const user = userEvent.setup(); const { container } = render( - inFieldItem ? {select} : select, + <> + {renderSelect(false, children)} + + , ); + const targetButton = container.querySelector(".eccgui-select button"); - expect(container.querySelector("button")).toHaveAttribute("aria-hidden", "true"); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); - expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(targetButton).toHaveAttribute("tabindex", "-1"); + expect(targetButton).toHaveAttribute("aria-hidden", "true"); + await user.tab(); + expect(screen.getByRole("combobox")).toHaveFocus(); + await user.tab(); + expect(screen.getByRole("button", { name: "After" })).toHaveFocus(); }); - it("exposes the target button again when the Select becomes filterable", () => { + it("restores a custom target when the Select becomes filterable", () => { + const children = ( + + ); const { container, rerender } = render( - {renderSelect(false)}, + {renderSelect(false, children)}, ); - expect(container.querySelector("button")).toHaveAttribute("aria-hidden", "true"); + const getTargetButton = () => container.querySelector(".eccgui-select button"); + expect(getTargetButton()).toHaveAttribute("tabindex", "-1"); + expect(getTargetButton()).toHaveAttribute("aria-hidden", "true"); + + rerender({renderSelect(true, children)}); + expect(getTargetButton()).toHaveAttribute("tabindex", "0"); + expect(getTargetButton()).toHaveAttribute("aria-hidden", "false"); + expect(screen.getByRole("button", { name: "Type" })).toBe(getTargetButton()); + + rerender({renderSelect(false, children)}); + expect(getTargetButton()).toHaveAttribute("tabindex", "-1"); + expect(getTargetButton()).toHaveAttribute("aria-hidden", "true"); + expect(screen.getByRole("combobox", { name: "Type" })).toBeInTheDocument(); + }); + + it("switches the default target between button and combobox content", () => { + const { rerender } = render({renderSelect(false)}); + // the clear action is the only button + expect(screen.getByRole("button")).toHaveAccessibleName("Reset selection"); rerender({renderSelect(true)}); expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Type" })).not.toHaveAttribute("aria-hidden"); + expect(screen.getByRole("button", { name: "Type" })).toHaveTextContent("Value"); rerender({renderSelect(false)}); - expect(container.querySelector("button")).toHaveAttribute("aria-hidden", "true"); - expect(screen.getByRole("combobox", { name: "Type" })).toBeInTheDocument(); + expect(screen.getByRole("button")).toHaveAccessibleName("Reset selection"); + expect(screen.getByRole("combobox", { name: "Type" })).toHaveTextContent("Value"); }); }); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 4c490aaa..9515911c 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Classes as BlueprintClasses, InputGroupProps } from "@blueprintjs/core"; import { executeItemsEqual, Select as BlueprintSelect, SelectProps as BlueprintSelectProps } from "@blueprintjs/select"; +import classNames from "classnames"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; import { Button, ButtonProps } from "../Button/Button"; @@ -39,6 +40,7 @@ export interface SelectProps /** * Tooltip to show for the clear button. * Only works with the uncontrolled default select target. + * Set your i18n label, otherwise it falls back to "Reset selection". */ onClearanceText?: string; /** @@ -48,6 +50,19 @@ export interface SelectProps wrapperProps?: React.HTMLAttributes; } +/** + * Elements inside custom `children` of a non-filterable Select that need to be removed from the Tab order. + */ +const focusableSelectors = "a[href], button, input, select, textarea, summary, [tabindex], [contenteditable=true]"; + +const restoreAttribute = (element: HTMLElement, attribute: string, value: string | null) => { + if (value === null) { + element.removeAttribute(attribute); + } else { + element.setAttribute(attribute, value); + } +}; + /** * Create a Select box without the HTML select element. * It is possible to filter options, as well as to add new options if necessary. @@ -79,16 +94,43 @@ export function Select({ const tabPressedRef = React.useRef(false); const wrapperRef = React.useRef(null); const itemIds = React.useRef>([]); + /** focusable elements of custom `children` that were hidden inside the combobox, with their original attributes */ + const hiddenTargetElements = React.useRef( + new Map(), + ); React.useLayoutEffect(() => { + let focusableChildren: HTMLElement[] = []; if (!filterable) { // Blueprint overwrites popoverTargetProps.tabIndex, so set it on the rendered combobox. const combobox = wrapperRef.current?.querySelector(`.${eccgui}-select[role="combobox"]`); if (combobox) { combobox.tabIndex = otherSelectProps.disabled ? -1 : 0; comboboxRef.current = combobox; + if (children) { + // The combobox is the only interactive element, custom targets can only be presentational. + focusableChildren = Array.from(combobox.querySelectorAll(focusableSelectors)); + } } } + const hiddenElements = hiddenTargetElements.current; + hiddenElements.forEach((original, element) => { + if (!focusableChildren.includes(element)) { + restoreAttribute(element, "tabindex", original.tabIndex); + restoreAttribute(element, "aria-hidden", original.ariaHidden); + hiddenElements.delete(element); + } + }); + focusableChildren.forEach((element) => { + if (!hiddenElements.has(element)) { + hiddenElements.set(element, { + tabIndex: element.getAttribute("tabindex"), + ariaHidden: element.getAttribute("aria-hidden"), + }); + } + element.setAttribute("tabindex", "-1"); + element.setAttribute("aria-hidden", "true"); + }); }); /** @@ -152,63 +194,85 @@ export function Select({ } }; - const handleClearKeyDown: React.KeyboardEventHandler = (event) => { + const handleEnterSpaceUsage = (event: React.KeyboardEvent, triggerClearance: boolean) => { event.stopPropagation(); if (event.key === "Enter" || event.key === " ") { event.preventDefault(); + + if (triggerClearance) { + handleClear(event); + } } }; - 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 clearAction = onClearanceHandler && text && ( + handleEnterSpaceUsage(e, false)} + onKeyUp={(e) => handleEnterSpaceUsage(e, true)} + > + + + ); + const defaultRightIcon = ( + <> + {clearAction} + {typeof rightIcon === "string" ? ( + + ) : ( + (rightIcon ?? ) + )} + + ); + const defaultText = {text || placeholder}; + const textClassName = text ? "" : BlueprintClasses.TEXT_MUTED; + const togglerTestIds = { + "data-test-id": dataTestId ? dataTestId + "_toggler" : undefined, + "data-testid": dataTestid ? dataTestid + "_toggler" : undefined, }; - const target = children ?? ( + const defaultTarget = filterable ? ( }; + }; + render( + <> + {renderSelect(false, )} + + , + ); + + act(() => showButton(true)); + const lateButton = screen.getByText("Late"); + await waitFor(() => expect(lateButton).toHaveAttribute("tabindex", "-1")); + expect(lateButton).toHaveAttribute("aria-hidden", "true"); + await user.tab(); + expect(screen.getByRole("combobox")).toHaveFocus(); + await user.tab(); + expect(screen.getByRole("button", { name: "After" })).toHaveFocus(); + }); + + it("keeps an element of a custom target out of the Tab order if it changes its tabindex later", async () => { + let setTabIndex: (tabIndex: number) => void = () => undefined; + const ChangingTarget = () => { + const [tabIndex, setIndex] = React.useState(-1); + setTabIndex = setIndex; + return Value; + }; + render(renderSelect(false, )); + const target = screen.getByText("Value"); + expect(target).toHaveAttribute("tabindex", "-1"); + + act(() => setTabIndex(0)); + await waitFor(() => expect(target).toHaveAttribute("tabindex", "-1")); + expect(target).toHaveAttribute("aria-hidden", "true"); + }); + it("restores a custom target when the Select becomes filterable", () => { const children = (