From 1caadb181e4edf6892b4f67a4b7fb09c6f937ff1 Mon Sep 17 00:00:00 2001
From: Andreas Schultz
Date: Mon, 12 Oct 2026 12:12:56 +0200
Subject: [PATCH 01/19] Fix misused menu roles in selection popups and overlays
- Use listbox and option roles for autocomplete suggestions.
- Remove implicit menu semantics from generic ContextOverlay triggers.
- Preserve menu popup semantics for ContextMenu and React Flow tools.
- Add regression tests for popup semantics and selection behavior.
---
CHANGELOG.md | 1 +
.../AutoSuggestion/AutoSuggestion.tsx | 35 +++++++++-
.../AutoSuggestion/AutoSuggestionList.tsx | 14 +++-
.../tests/AutoSuggestion.test.tsx | 35 +++++++++-
.../tests/AutoSuggestionList.test.tsx | 5 +-
.../AutoCompleteField.test.tsx | 27 ++++++++
.../AutocompleteField/AutoCompleteField.tsx | 11 ++-
.../autoCompleteFieldUtils.tsx | 9 ++-
src/components/ContextOverlay/ContextMenu.tsx | 2 +
.../ContextOverlay/ContextOverlay.tsx | 12 ++++
.../ContextOverlay/tests/ContextMenu.test.tsx | 3 +
.../tests/ContextOverlay.test.tsx | 36 +++++++++-
src/components/Menu/MenuItem.tsx | 69 +++++++++----------
src/components/Select/Select.stories.tsx | 2 +-
.../codemirror/tests/CodeEditor.test.tsx | 8 +--
.../tests/EditorAppearanceConfigMenu.test.tsx | 12 ++--
.../toolbars/EditorAppearanceConfigMenu.tsx | 5 +-
.../react-flow/handles/HandleTools.tsx | 3 +-
src/extensions/react-flow/nodes/NodeTools.tsx | 3 +-
.../react-flow/tests/ToolsPopupKind.test.tsx | 55 +++++++++++++++
20 files changed, 286 insertions(+), 61 deletions(-)
create mode 100644 src/components/AutocompleteField/AutoCompleteField.test.tsx
create mode 100644 src/extensions/react-flow/tests/ToolsPopupKind.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 392f4abbe..370e45ecd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -96,6 +96,7 @@ 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
- 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
+- Fix misused menu roles in selection popups and overlays
### Deprecated
diff --git a/src/components/AutoSuggestion/AutoSuggestion.tsx b/src/components/AutoSuggestion/AutoSuggestion.tsx
index 89b76b4d7..dc1d2eb17 100644
--- a/src/components/AutoSuggestion/AutoSuggestion.tsx
+++ b/src/components/AutoSuggestion/AutoSuggestion.tsx
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
-import { Classes as BlueprintClassNames } from "@blueprintjs/core";
+import { Classes as BlueprintClassNames, PopupKind } from "@blueprintjs/core";
import { EditorView, Rect } from "@codemirror/view";
import { debounce } from "lodash";
@@ -218,6 +218,7 @@ export const CodeAutocompleteField = ({
CodeAutocompleteFieldSuggestionWithReplacementInfo | undefined
>(undefined);
const [cm, setCM] = React.useState();
+ const suggestionListId = React.useId();
const currentCm = React.useRef(undefined);
currentCm.current = cm;
const isFocused = React.useRef(false);
@@ -241,6 +242,36 @@ export const CodeAutocompleteField = ({
/** This is for the AutoSuggestionList component in order to re-render. */
const [focusedIndex, setFocusedIndex] = React.useState(0);
+
+ React.useEffect(() => {
+ if (!cm) return;
+ const editor = cm.contentDOM;
+ editor.setAttribute("role", "combobox");
+ editor.setAttribute("aria-autocomplete", "list");
+ editor.setAttribute("aria-haspopup", "listbox");
+ return () => {
+ editor.setAttribute("role", "textbox");
+ editor.removeAttribute("aria-autocomplete");
+ editor.removeAttribute("aria-haspopup");
+ editor.removeAttribute("aria-controls");
+ editor.removeAttribute("aria-expanded");
+ editor.removeAttribute("aria-activedescendant");
+ };
+ }, [cm]);
+
+ React.useEffect(() => {
+ if (!cm) return;
+ const editor = cm.contentDOM;
+ const suggestionsVisible = shouldShowDropdown && !suggestionsPending && suggestions.length > 0;
+ editor.setAttribute("aria-expanded", String(suggestionsVisible));
+ if (suggestionsVisible) {
+ editor.setAttribute("aria-controls", suggestionListId);
+ editor.setAttribute("aria-activedescendant", `${suggestionListId}-option-${focusedIndex}`);
+ } else {
+ editor.removeAttribute("aria-controls");
+ editor.removeAttribute("aria-activedescendant");
+ }
+ }, [cm, shouldShowDropdown, suggestionsPending, suggestions.length, focusedIndex, suggestionListId]);
const selectedTextRanges = React.useRef([]);
const pathIsValid = validationResponse?.valid ?? true;
@@ -709,6 +740,7 @@ export const CodeAutocompleteField = ({
} ${blueprintIntent ? BlueprintClassNames.intentClass(blueprintIntent as any) : ""}${inputIntentClass}`}
>
, "children"> {
+ listboxId?: string;
// The options of the drop down
options: Array;
// Called when an item has been selected from the drop down
@@ -76,6 +77,7 @@ const Item = React.forwardRef(ListItem);
/** A drop-down-like list that can be used in combination with other components to show and select items. */
export const AutoSuggestionList = ({
+ listboxId,
isOpen,
options,
loading,
@@ -86,6 +88,8 @@ export const AutoSuggestionList = ({
offsetValues,
...otherDivProps
}: AutoSuggestionListProps) => {
+ const generatedListboxId = React.useId();
+ const optionListId = listboxId ?? generatedListboxId;
const [hoveredItem, setHoveredItem] = React.useState<
CodeAutocompleteFieldSuggestionWithReplacementInfo | undefined
>(undefined);
@@ -141,10 +145,18 @@ export const AutoSuggestionList = ({
{loading ? (
Loader
) : (
-
}>
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Show information" })).toHaveAttribute("aria-haspopup", "false");
+ });
+
+ it.each([PopupKind.MENU, PopupKind.LISTBOX, PopupKind.DIALOG])(
+ "announces an explicit %s before and after replacing the placeholder",
+ async (popupKind) => {
+ render(
+ Popup}>
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Open popup" })).toHaveAttribute("aria-haspopup", popupKind);
+ fireEvent.click(screen.getByRole("button", { name: "Open popup" }));
+ expect(await screen.findByText("Popup")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Open popup" })).toHaveAttribute("aria-haspopup", popupKind);
+ },
+ );
+
+ it("does not advertise a menu to a custom target renderer", () => {
+ render(
+ Attached information}
+ renderTarget={({ isOpen, ...targetProps }) => }
+ />,
+ );
+ expect(screen.getByRole("button", { name: "Show information" })).toHaveAttribute("aria-haspopup", "false");
+ });
+
it("should not render placeholder automatically", () => {
const { container } = render();
checkForPlaceholderClass(container, 0);
diff --git a/src/components/Menu/MenuItem.tsx b/src/components/Menu/MenuItem.tsx
index bed6fa6a9..e34b2a4e6 100644
--- a/src/components/Menu/MenuItem.tsx
+++ b/src/components/Menu/MenuItem.tsx
@@ -39,42 +39,37 @@ export interface MenuItemProps
/**
* Single item, used as child inside `Menu`.
*/
-export const MenuItem = ({
- children,
- className = "",
- icon,
- onClick,
- href,
- text,
- tooltip,
- intent,
- ...restProps
-}: MenuItemProps) => {
- return (
-
- {text}
-
- ) : (
- text
- )
- }
- href={href}
- onClick={(e: React.MouseEvent) =>
- openInNewTab(e as React.MouseEvent, onClick, href)
- }
- className={classNames(`${eccgui}-menu__item`, className, {
- // control blueprint intent classes to enhance it by new options
- [`${BlueprintClasses.getClassNamespace()}-intent-${intent}`]: intent,
- })}
- icon={icon ? typeof icon === "string" || Array.isArray(icon) ? : icon : false}
- >
- {children ?? null}
-
- );
-};
+export const MenuItem = React.forwardRef(
+ ({ children, className = "", icon, onClick, href, text, tooltip, intent, ...restProps }, ref) => {
+ return (
+
+ {text}
+
+ ) : (
+ text
+ )
+ }
+ href={href}
+ onClick={(e: React.MouseEvent) =>
+ openInNewTab(e as React.MouseEvent, onClick, href)
+ }
+ className={classNames(`${eccgui}-menu__item`, className, {
+ // control blueprint intent classes to enhance it by new options
+ [`${BlueprintClasses.getClassNamespace()}-intent-${intent}`]: intent,
+ })}
+ icon={icon ? typeof icon === "string" || Array.isArray(icon) ? : icon : false}
+ >
+ {children ?? null}
+
+ );
+ },
+);
+
+MenuItem.displayName = "MenuItem";
export default MenuItem;
diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx
index 40ff4afeb..e8ca072ed 100644
--- a/src/components/Select/Select.stories.tsx
+++ b/src/components/Select/Select.stories.tsx
@@ -31,7 +31,7 @@ Default.args = {
return { label: item };
}),
itemRenderer: (item, props) => {
- return ;
+ return ;
},
fill: true,
};
diff --git a/src/extensions/codemirror/tests/CodeEditor.test.tsx b/src/extensions/codemirror/tests/CodeEditor.test.tsx
index 193c91306..362f3c813 100644
--- a/src/extensions/codemirror/tests/CodeEditor.test.tsx
+++ b/src/extensions/codemirror/tests/CodeEditor.test.tsx
@@ -59,8 +59,8 @@ describe("CodeEditor - markdown mode with toolbar", () => {
fireEvent.click(getConfigMenuOverlay(container));
- const wrapLinesItem = await screen.findByText("wrapLines");
- expect(wrapLinesItem.closest("[aria-selected='true']")).not.toBeNull();
+ const wrapLinesItem = await screen.findByRole("menuitemcheckbox", { name: "wrapLines" });
+ expect(wrapLinesItem).toHaveAttribute("aria-checked", "true");
});
it("defaults preventLineNumbers to true in markdown mode with toolbar", async () => {
@@ -68,8 +68,8 @@ describe("CodeEditor - markdown mode with toolbar", () => {
fireEvent.click(getConfigMenuOverlay(container));
- const preventLineNumbersItem = await screen.findByText("preventLineNumbers");
- expect(preventLineNumbersItem.closest("[aria-selected='true']")).not.toBeNull();
+ const preventLineNumbersItem = await screen.findByRole("menuitemcheckbox", { name: "preventLineNumbers" });
+ expect(preventLineNumbersItem).toHaveAttribute("aria-checked", "true");
});
it("locks wrapLines in config menu when wrapLines prop is explicitly provided", async () => {
diff --git a/src/extensions/codemirror/tests/EditorAppearanceConfigMenu.test.tsx b/src/extensions/codemirror/tests/EditorAppearanceConfigMenu.test.tsx
index ee059da01..64e710f6f 100644
--- a/src/extensions/codemirror/tests/EditorAppearanceConfigMenu.test.tsx
+++ b/src/extensions/codemirror/tests/EditorAppearanceConfigMenu.test.tsx
@@ -105,7 +105,7 @@ describe("EditorAppearanceConfigMenu", () => {
expect(preventLineNumbersItem.closest("[aria-disabled='true']")).toBeNull();
});
- it("selected config property is marked as selected in the menu", async () => {
+ it("enabled config property is exposed as a checked menu item", async () => {
const config = { wrapLines: true, preventLineNumbers: false };
const setConfig = jest.fn();
@@ -113,11 +113,11 @@ describe("EditorAppearanceConfigMenu", () => {
fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
- const wrapLinesItem = await screen.findByText("wrapLines");
- expect(wrapLinesItem.closest("[aria-selected='true']")).not.toBeNull();
+ const wrapLinesItem = await screen.findByRole("menuitemcheckbox", { name: "wrapLines" });
+ expect(wrapLinesItem).toHaveAttribute("aria-checked", "true");
});
- it("unselected config property is not marked as selected in the menu", async () => {
+ it("false config property is exposed as an unchecked menu item", async () => {
const config = { wrapLines: true, preventLineNumbers: false };
const setConfig = jest.fn();
@@ -125,7 +125,7 @@ describe("EditorAppearanceConfigMenu", () => {
fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
- const preventLineNumbersItem = await screen.findByText("preventLineNumbers");
- expect(preventLineNumbersItem.closest("[aria-selected='true']")).toBeNull();
+ const preventLineNumbersItem = await screen.findByRole("menuitemcheckbox", { name: "preventLineNumbers" });
+ expect(preventLineNumbersItem).toHaveAttribute("aria-checked", "false");
});
});
diff --git a/src/extensions/codemirror/toolbars/EditorAppearanceConfigMenu.tsx b/src/extensions/codemirror/toolbars/EditorAppearanceConfigMenu.tsx
index 413242a33..bba429242 100644
--- a/src/extensions/codemirror/toolbars/EditorAppearanceConfigMenu.tsx
+++ b/src/extensions/codemirror/toolbars/EditorAppearanceConfigMenu.tsx
@@ -41,8 +41,9 @@ export function EditorAppearanceConfigMenu({
return (