diff --git a/CHANGELOG.md b/CHANGELOG.md index eeef7375..ce774911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,16 +26,26 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - if none of the elements referenced by `aria-labelledby` exists then a given `aria-label` is used as fallback, without `aria-label` the `role` and `aria-modal` attributes are removed - ``, `` - the intent state is also used as `aria-label` fallback if none of the elements referenced by `aria-labelledby` exists +- `` - 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. - Keep the clear action reachable with Tab and Shift+Tab and support clearing with Enter or Space without opening the options. + - Expose the clear action as a button named by `onClearanceText`, and as disabled if the Select is disabled. + - If `onClearanceText` is empty, the clear action stays keyboard accessible but is hidden from screen readers. + - Prevent any interaction with the clear action of a disabled Select. + - Without `fill`, the wrapper element used for `wrapperProps` and test IDs is displayed inline, like the Select itself. ## [26.2.1] - 2026-11-02 diff --git a/src/components/Form/FieldItem.tsx b/src/components/Form/FieldItem.tsx index 10ab79ca..9d22e8aa 100644 --- a/src/components/Form/FieldItem.tsx +++ b/src/components/Form/FieldItem.tsx @@ -72,8 +72,9 @@ export const FieldItem = ({ ...otherProps }: FieldItemProps) => { const fieldItemRef = React.useRef(null); - const hiddenSelectButtonRef = React.useRef(null); const connectedInputRef = React.useRef(undefined); + /** label ID that was added to `aria-labelledby` by the field item itself, it may be set by the application */ + const labelledByRef = React.useRef<{ element: HTMLElement; id: string } | undefined>(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, ""); @@ -103,23 +104,13 @@ export const FieldItem = ({ connectableInputSelectors.map((selector) => `.${eccgui}-fielditem__inputfields ${selector}`).join(", "), ), ); - const inputElement = inputElements[0]; + // an explicit `for` of the label selects the connected input element, otherwise the first one is used + const labelTarget = labelElement instanceof HTMLLabelElement ? labelElement.getAttribute("for") : null; + const inputElement = + inputElements.find((element) => labelTarget && element.id === labelTarget) ?? inputElements[0]; 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; @@ -169,17 +160,27 @@ export const FieldItem = ({ } connectedInputRef.current = inputElement; + // remove the label ID added before if the label is not connected to that input element via `aria-labelledby` anymore + const connectsNatively = + labelElement instanceof HTMLLabelElement && + !!inputElement && + labelableElements.includes(inputElement.tagName); + const labelledBy = labelledByRef.current; + if ( + labelledBy && + (labelledBy.element !== inputElement || labelledBy.id !== labelElement?.id || connectsNatively) + ) { + updateReferences(labelledBy.element, "aria-labelledby", [[undefined, labelledBy.id]]); + labelledByRef.current = undefined; + } + 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( - (element) => element.id === labelTarget && labelableElements.includes(element.tagName), - ); - if (!refersToOwnInput) { + if (connectsNatively) { + // a `for` that does not refer to an input element of this field item is replaced + if (labelTarget !== inputElement.id) { labelElement.setAttribute("for", inputElement.id); } } else if (labelElement) { @@ -190,6 +191,7 @@ export const FieldItem = ({ } if (!inputElement.getAttribute("aria-labelledby")) { inputElement.setAttribute("aria-labelledby", labelElement.id); + labelledByRef.current = { element: inputElement, id: labelElement.id }; } } else { updateReferences(inputElement, "aria-labelledby", [[undefined, `label_${fieldItemId}`]]); diff --git a/src/components/Form/tests/FieldItem.test.tsx b/src/components/Form/tests/FieldItem.test.tsx index 965fcc7f..66133058 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"; @@ -73,8 +73,9 @@ describe("FieldItem", () => { expect(label).toHaveAttribute("for", input.id); }); it("should keep a `for` that refers to another input element of the field item", () => { - const { label } = renderFieldItem({ + const { label, fieldItem } = renderFieldItem({ labelProps: { text: "Label text", htmlFor: "otherinput" }, + helperText: "Help text", children: ( <> @@ -83,6 +84,45 @@ describe("FieldItem", () => { ), }); expect(label).toHaveAttribute("for", "otherinput"); + // the explicitly labelled input element is the connected one, also for the descriptions + expect(fieldItem.querySelector("#otherinput")).toHaveAccessibleDescription("Help text"); + expect(fieldItem.querySelector("#custominput")).not.toHaveAttribute("aria-describedby"); + }); + it.each([ + [ + "a non-filterable Select", +
+ Value +
, + ], + [ + "a code editor", +
+
+
, + ], + ])("should keep a `for` that refers to an input element after %s", async (_, firstElement) => { + const user = userEvent.setup(); + const { label, fieldItem } = renderFieldItem({ + labelProps: { text: "Label text", htmlFor: "otherinput" }, + helperText: "Help text", + children: ( + <> + {firstElement} + + + ), + }); + const otherInput = fieldItem.querySelector("#otherinput"); + + expect(label).toHaveAttribute("for", "otherinput"); + expect(otherInput).toHaveAccessibleName("Label text"); + expect(otherInput).toHaveAccessibleDescription("Help text"); + expect(fieldItem.querySelector("[aria-labelledby]")).not.toBeInTheDocument(); + expect(fieldItem.querySelector("[aria-describedby]")).toBe(otherInput); + + await user.click(screen.getByText("Label text")); + expect(otherInput).toHaveFocus(); }); it("should replace a `for` that refers to an input element outside of the field item", () => { const { container } = render( @@ -187,7 +227,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 +245,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 +269,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 +285,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 +296,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 +305,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 +318,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`", () => { @@ -348,6 +384,59 @@ describe("FieldItem", () => { expect(input).toHaveAttribute("aria-labelledby", "customlabel"); expect(input).toHaveAttribute("aria-describedby", "customhelp"); }); + it("should remove a label ID of the application from `aria-labelledby` if another input element is connected", () => { + const fieldItem = (htmlFor?: string) => ( + +
+
+
+ + + ); + const { container, rerender } = render(fieldItem()); + const editor = container.querySelector("#editor"); + const otherInput = container.querySelector("#otherinput"); + expect(editor).toHaveAttribute("aria-labelledby", "customlabel"); + + rerender(fieldItem("otherinput")); + expect(editor).not.toHaveAttribute("aria-labelledby"); + expect(container.querySelector("label")).toHaveAttribute("for", "otherinput"); + expect(otherInput).toHaveAccessibleName("Label text"); + + rerender(fieldItem()); + expect(editor).toHaveAttribute("aria-labelledby", "customlabel"); + expect(container.querySelector("label")).not.toHaveAttribute("for"); + }); + it("should remove a label ID of the application from `aria-labelledby` if the label is removed", () => { + const editor = ( +
+
+
+ ); + const { container, rerender } = render( + {editor}, + ); + expect(container.querySelector("#editor")).toHaveAttribute("aria-labelledby", "customlabel"); + + rerender({editor}); + expect(container.querySelector("#editor")).not.toHaveAttribute("aria-labelledby"); + }); + it("should keep an `aria-labelledby` of the application if another input element is connected", () => { + const fieldItem = (htmlFor?: string) => ( + +
+
+
+ + + ); + const { container, rerender } = render(fieldItem()); + const editor = container.querySelector("#editor"); + expect(editor).toHaveAttribute("aria-labelledby", "otherlabel"); + + rerender(fieldItem("otherinput")); + expect(editor).toHaveAttribute("aria-labelledby", "otherlabel"); + }); it("should connect a code editor input element via `aria-labelledby`", () => { const { container } = render( diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index 40ff4afe..dfe78282 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, Tag } from "../../index"; import { FullExample as DepictionExample } from "../Depiction/stories/Depiction.stories"; export default { @@ -20,19 +20,36 @@ 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 }; }), - itemRenderer: (item, props) => { - return ; - }, + itemRenderer: (item, { handleClick, handleFocus, id, modifiers }) => ( + + ), fill: true, }; @@ -57,6 +74,38 @@ ControlledTarget.args = { onActiveItemChange: fn(), }; +/** + * A controlled select target can also be used for a non-filterable select. + * Then the select itself is the focusable combobox, it gets the label and handles the keyboard navigation. + * + * **The controlled target must not be or contain any focusable element in this case**, + * e.g. use a `Tag` without `onClick` or a text instead of a `Button`. + * Focusable elements are removed from the Tab order and hidden from screen readers by the select, + * so they would not work as expected and their text is not announced as the selected value. + * + * If you need a focusable element like a `Button` anyway, then add the selected value in a hidden `span` + * that is still accessible to screen readers: + * + * ```tsx + * + * ``` + */ +export const ControlledTargetNotFilterable = Template.bind({}); +ControlledTargetNotFilterable.args = { + ...Default.args, + fill: false, + filterable: false, + children: Controlled select target, + onActiveItemChange: fn(), +}; + /** * If the selection is not mandatory it is suggested to add an option letting the user reset the select value. * `onClearanceHandler` follows the pattern to add a button on the right side of the element. diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx index e750c6f1..6316097a 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/Select.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; @@ -35,6 +35,42 @@ it("connects a filterable Select search input to its listbox", async () => { expect(screen.getByRole("combobox")).toHaveAttribute("aria-controls", screen.getByRole("listbox").id); }); +describe("Select clear action accessible name", () => { + it.each([ + [false, undefined, "Reset selection", "Reset selection"], + [false, "", "Reset selection", null], + [false, " ", "Reset selection", null], + [false, "Clear type", "Clear type", "Clear type"], + [true, undefined, "Reset selection", "Reset selection"], + [true, "", "Reset selection", null], + [true, " ", "Reset selection", null], + [true, "Clear type", "Clear type", "Clear type"], + ])( + "filterable %s with onClearanceText %j is named %j", + (filterable, clearanceText, expectedName, expectedTitle) => { + render( + ( + + )} + onItemSelect={jest.fn()} + text="Value" + contextOverlayProps={{ transitionDuration: 0 }} + />, + ); + const combobox = screen.getByRole("combobox"); + combobox.focus(); + + // `fireEvent` returns `false` if the default action was prevented + expect(fireEvent.keyDown(combobox, { key: " ", code: "Space" })).toBe(false); + expect(combobox).toHaveAttribute("aria-expanded", "true"); + expect(fireEvent.keyDown(combobox, { key: " ", code: "Space" })).toBe(false); + // other keys keep their default action, e.g. Tab + expect(fireEvent.keyDown(combobox, { key: "Tab", code: "Tab" })).toBe(true); + }); + it("closes the list when Tab moves focus to the next control", async () => { const user = userEvent.setup(); render( @@ -373,3 +445,168 @@ describe("non-filterable Select keyboard navigation", () => { await waitFor(() => expect(combobox).toHaveAttribute("aria-expanded", "false")); }); }); + +describe("non-filterable Select target", () => { + const CustomTarget = ({ label }: { label: string }) => ; + + const renderSelect = (filterable: boolean, children?: React.ReactNode, text: string | undefined = "Value") => ( + + ); + + 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([ + ["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( + <> + {renderSelect(false, children)} + + , + ); + const targetButton = container.querySelector(".eccgui-select button"); + + 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("keeps focusable elements out of the Tab order that a custom target renders later", async () => { + const user = userEvent.setup(); + let showButton: (show: boolean) => void = () => undefined; + const LateTarget = () => { + const [show, setShow] = React.useState(false); + showButton = setShow; + return Value{show && }; + }; + 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 = ( + + ); + const { container, rerender } = render( + {renderSelect(false, children)}, + ); + 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" })).toHaveTextContent("Value"); + + rerender({renderSelect(false)}); + 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 20c77152..f9cc5867 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,8 @@ 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". + * An empty text only removes the tooltip, the clear button keeps "Reset selection" as accessible name. */ onClearanceText?: string; /** @@ -48,6 +51,21 @@ export interface SelectProps wrapperProps?: React.HTMLAttributes; } +const onClearanceTextDefault = "Reset selection"; + +/** + * 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. @@ -67,7 +85,7 @@ export function Select({ filterable = true, itemRenderer, onActiveItemChange, - onClearanceText = "Reset selection", + onClearanceText = onClearanceTextDefault, "data-test-id": dataTestId, "data-testid": dataTestid, wrapperProps, @@ -79,6 +97,47 @@ 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(), + ); + + /** + * The combobox of a non-filterable Select is the only interactive element, so a custom target can only be presentational. + * Focusable elements of custom `children` are removed from the Tab order and hidden from screen readers, + * their original attributes are restored if they are not inside the combobox anymore. + */ + const secureCustomTarget = () => { + const combobox = !filterable && children ? comboboxRef.current : null; + const focusableChildren = combobox + ? 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"), + }); + } + // only set changed attributes, otherwise the mutation observer below would be triggered endlessly + if (element.getAttribute("tabindex") !== "-1") { + element.setAttribute("tabindex", "-1"); + } + if (element.getAttribute("aria-hidden") !== "true") { + element.setAttribute("aria-hidden", "true"); + } + }); + }; + const secureCustomTargetRef = React.useRef(secureCustomTarget); + secureCustomTargetRef.current = secureCustomTarget; React.useLayoutEffect(() => { if (!filterable) { @@ -89,8 +148,55 @@ export function Select({ comboboxRef.current = combobox; } } + secureCustomTarget(); }); + /** + * Custom `children` may render focusable elements later on, e.g. after loading data, + * without an update of the Select itself. + */ + const hasCustomTarget = !!children; + React.useEffect(() => { + const wrapper = wrapperRef.current; + if (filterable || !hasCustomTarget || !wrapper) { + return; + } + const targetObserver = new MutationObserver(() => secureCustomTargetRef.current()); + targetObserver.observe(wrapper, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["tabindex", "aria-hidden", "href", "contenteditable"], + }); + return () => targetObserver.disconnect(); + }, [filterable, hasCustomTarget]); + + /** + * 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 }) => @@ -126,63 +232,86 @@ 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 clearanceText = onClearanceText.trim(); + 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 ? (