diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc06004fe..acf8b7571 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
## [Unreleased]
+### Fixed
+
+- ``
+ - Clicking a label focuses its control when a native label association is not available, including Select comboboxes and CodeEditor.
+- ``
+ - 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.
+
## [26.2.1] - 2026-11-02
### Fixed
@@ -50,6 +60,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
+- ``
+ - Connect FieldItem labels, helper text, and messages to the non-filterable Select combobox.
- new `utils` methods:
- `truncateMarkdownDisplay`: helper function to iterate over `Markdown` renderings to find a `cutOff` value that matches the visible text length
- `ClassNames`
@@ -114,6 +126,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- the webpack configuration excludes `dist/` from module resolution now, so the sources are always used even if a story references the package root
- Explicitly added `assert` and `util` dependencies
- the linter of `` uses `jshint`, which imports `console-browserify`, and this package requires the node core modules `assert` and `util` without declaring them; bundlers based on webpack 5 do not provide shims for node core modules anymore, so both polyfills are part of the delivery now
+- Added explicitly `assert` dependency
+ - the linter of `` uses `jshint`, which imports `console-browserify`, and this package requires the node core modules `assert` and `util` without declaring them; bundlers based on webpack 5 do not provide shims for node core modules anymore, so the polyfill (and `util` together with it) is part of the delivery now
+
+### Deprecated
+
+- `preventReactFlowActionsClasses`: use `ClassNames.ReactFlow.preventAllActions`
## [26.1.0] - 2026-08-20
diff --git a/src/components/Form/FieldItem.tsx b/src/components/Form/FieldItem.tsx
index 410d52753..10ab79caf 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`,
];
@@ -71,6 +72,8 @@ 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, "");
@@ -104,6 +107,19 @@ 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;
@@ -114,16 +130,16 @@ 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.
*/
- 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)) {
@@ -134,12 +150,29 @@ export const FieldItem = ({
}
});
if (references.length > 0) {
- inputElement.setAttribute(attribute, references.join(" "));
+ element.setAttribute(attribute, references.join(" "));
} else {
- inputElement.removeAttribute(attribute);
+ element.removeAttribute(attribute);
}
};
+ const previousInput = connectedInputRef.current;
+ if (previousInput && previousInput !== inputElement) {
+ if (previousInput.id === `input_${fieldItemId}`) {
+ previousInput.removeAttribute("id");
+ }
+ 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");
@@ -152,17 +185,21 @@ 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);
}
} 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);
}, [fieldItemId, preventAriaAttribution]);
React.useEffect(() => {
@@ -183,9 +220,37 @@ export const FieldItem = ({
return () => partsObserver.disconnect();
}, [connectParts, preventAriaAttribution]);
+ const handleLabelClick: React.MouseEventHandler = (event) => {
+ labelProps?.onClick?.(event);
+ if (event.defaultPrevented || disabled || preventAriaAttribution) {
+ return;
+ }
+ const labelElement = event.currentTarget;
+ if (labelElement instanceof HTMLLabelElement && labelElement.control) {
+ return;
+ }
+ if (!(event.target instanceof Node) || !labelElement.contains(event.target)) {
+ return;
+ }
+ const interactiveTarget =
+ event.target instanceof Element
+ ? event.target.closest(
+ "a[href], button, input, select, textarea, summary, [tabindex], [contenteditable=true], [role=button], [role=link]",
+ )
+ : null;
+ if (interactiveTarget && labelElement.contains(interactiveTarget)) {
+ return;
+ }
+ const control = connectedInputRef.current;
+ if (control && !control.matches(':disabled, [aria-disabled="true"], [contenteditable="false"]')) {
+ control.focus();
+ }
+ };
+
const label = (
diff --git a/src/components/Form/tests/FieldItem.test.tsx b/src/components/Form/tests/FieldItem.test.tsx
index 1ea4f683e..965fcc7f1 100644
--- a/src/components/Form/tests/FieldItem.test.tsx
+++ b/src/components/Form/tests/FieldItem.test.tsx
@@ -1,5 +1,7 @@
import React from "react";
-import { render, waitFor } from "@testing-library/react";
+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";
@@ -185,6 +187,103 @@ describe("FieldItem", () => {
expect(label).toHaveAttribute("for", input.id);
});
});
+ it("exposes the labelled Select combobox without a duplicate button", () => {
+ const { fieldItem, label } = renderFieldItem({
+ labelProps: { text: "Filter by language" },
+ helperText: "Only matching values are shown.",
+ messageText: "Choose a language.",
+ children: (
+