Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1caadb1
Fix misused menu roles in selection popups and overlays
andreas-schultz Oct 12, 2026
d7f4319
Fix MultiSelect popup semantics and keyboard behavior
andreas-schultz Oct 12, 2026
bb7766f
Fix MultiSelect option merging and query reset behavior
andreas-schultz Oct 13, 2026
f3f1067
add process to fulfil role attribute of Menu element
haschek Nov 4, 2026
7bf3bd3
improve MenuDivider, so that it is compatible to role=list parents
haschek Nov 4, 2026
c9691df
update changelog
haschek Nov 4, 2026
d53e3fd
fix intern Menu usage in ContextMenu
haschek Nov 4, 2026
ef398da
fix usage of role and aria-hidden in MenuDivider
haschek Nov 4, 2026
b0ce61a
fix role support limitations
haschek Nov 4, 2026
f414fae
imporve MenuDivider usage for role=listbox and prevent unncessary ren…
haschek Nov 4, 2026
a158737
prevent too many same warnings
haschek Nov 4, 2026
b403cac
add check for role values set manually
haschek Nov 5, 2026
c6c7ca6
Merge remote-tracking branch 'origin/fix/menuRoles-CMEM-7708' into fe…
haschek Nov 5, 2026
a885fcc
align role and popupKind for ContextMenu
haschek Nov 5, 2026
562bd04
allow Menu properties via ContextMenu
haschek Nov 5, 2026
2f9249f
validate Menu.role against roleStructure
haschek Nov 5, 2026
82dc89a
editor appearance button is a menu now
haschek Nov 5, 2026
54e5ffa
Merge remote-tracking branch 'origin/develop' into fix/menuRoles-CMEM…
haschek Nov 5, 2026
9e693d5
Merge remote-tracking branch 'origin/fix/menuRoles-CMEM-7708' into fe…
haschek Nov 5, 2026
6c99908
remove double content from wrong section
haschek Nov 5, 2026
792f7b9
improve documentation
haschek Nov 10, 2026
6f0aa31
Merge pull request #440 from eccenca/feature/improveMenuRoleSupport-C…
andreas-schultz Nov 10, 2026
190403c
Replace root import by direct component imports to avoid circular dep…
andreas-schultz Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

## [Unreleased]

### Added

- `<Menu />`
- `role` property: `menu` (default), `navigation`, `list`, `listbox`, `none` or `presentation` get special support, other ARIA roles are set as they are
- `navigation` is rendered as `list`, the `nav` wrapper must be added by yourself
- sets `roleStructure` of `<MenuItem />` children automatically according to the `role` if it is not given, otherwise a console warning is emitted if it does not fit
- emits a console warning if an explicitly set `role` of a `<MenuItem />` child does not fit to its (explicitly or automatically set) `roleStructure`
- sets `role="listitem"` (lists) or `role="none"` (listboxes) on `<MenuDivider />` children, and `aria-hidden` if the divider has no `title`; explicitly set values are kept, except `role="separator"`
- `<MenuDivider />`
- accepts all attributes of the `li` element, e.g. `role` and `aria-hidden`
- `<ContextMenu />`
- `role` property: `menu` (default) or `listbox`, forwarded to the included `<Menu />` and used for `aria-haspopup` of the toggler

### Fixed

- Fix misused menu roles in selection popups and overlays
- `<MultiSuggestField />`
- improve keyboard navigation, tag removal and focus handling
- improve screen reader labels and selection announcements
- preserve filters and keep the dropdown open when toggling existing options

## [26.2.1] - 2026-11-02

### Fixed
Expand Down Expand Up @@ -115,6 +136,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Explicitly added `assert` and `util` dependencies
- the linter of `<CodeEditor />` 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

### Deprecated

- `preventReactFlowActionsClasses`: use `ClassNames.ReactFlow.preventAllActions`

## [26.1.0] - 2026-08-20

### Added
Expand Down
3 changes: 2 additions & 1 deletion src/cmem/ActivityControl/SilkActivityControl.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useEffect, useRef, useState } from "react";
import { Intent } from "@blueprintjs/core/src/common/intent";

import { Icon, Spacing } from "../../";
import { IntentTypes } from "../../common/Intent";
import { Icon } from "../../components/Icon/Icon";
import { TestableComponent } from "../../components/interfaces";
import { Spacing } from "../../components/Separation/Spacing";
import { ElapsedDateTimeDisplay, ElapsedDateTimeDisplayUnits } from "../DateTimeDisplay/ElapsedDateTimeDisplay";

import { SilkActivityStatusConcrete, SilkActivityStatusProps } from "./ActivityControlTypes";
Expand Down
19 changes: 7 additions & 12 deletions src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import React from "react";

import getColorConfiguration from "../../../common/utils/getColorConfiguration";
import { CodeEditor } from "../../../extensions";
import { Button } from "../../../components/Button/Button";
import { SimpleDialog, type SimpleDialogProps } from "../../../components/Dialog/SimpleDialog";
import { FieldItem } from "../../../components/Form/FieldItem";
import { Icon } from "../../../components/Icon/Icon";
import Tag from "../../../components/Tag/Tag";
import TagList from "../../../components/Tag/TagList";
import { CodeEditor, type CodeEditorProps } from "../../../extensions/codemirror/CodeMirror";
import { ReactFlowHotkeyContext } from "../extensions/ReactFlowHotkeyContext";

import {
Button,
CodeEditorProps,
FieldItem,
Icon,
SimpleDialog,
SimpleDialogProps,
Tag,
TagList,
} from "./../../../index";

export type StickyNoteModalTranslationKeys = "modalTitle" | "noteLabel" | "colorLabel" | "saveButton" | "cancelButton";

export type StickyNoteMetadataType = { note: string; color: string };
Expand Down
42 changes: 40 additions & 2 deletions src/components/AutoSuggestion/AutoSuggestion.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
/* 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";

import { IntentTypes } from "../../common/Intent";
import { CLASSPREFIX as eccgui } from "../../configuration/constants";
import { SupportedCodeEditorModes } from "../../extensions/codemirror/hooks/useCodemirrorModeExtension.hooks";
import { ContextOverlay } from "../ContextOverlay/ContextOverlay";
import { FieldItem } from "../Form/FieldItem";
import { IconButton } from "../Icon/IconButton";
import { Spinner } from "../Spinner/Spinner";
import { Toolbar } from "../Toolbar/Toolbar";
import { ToolbarSection } from "../Toolbar/ToolbarSection";

import { ContextOverlay, FieldItem, IconButton, Spinner, Toolbar, ToolbarSection } from "./../../";
import { markText, removeMarkFromText } from "./extensions/markText";
import { AutoSuggestionList } from "./AutoSuggestionList";
//custom components
Expand Down Expand Up @@ -218,6 +223,7 @@ export const CodeAutocompleteField = ({
CodeAutocompleteFieldSuggestionWithReplacementInfo | undefined
>(undefined);
const [cm, setCM] = React.useState<EditorView>();
const suggestionListId = React.useId();
const currentCm = React.useRef<EditorView>(undefined);
currentCm.current = cm;
const isFocused = React.useRef(false);
Expand All @@ -241,6 +247,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<IRange[]>([]);

const pathIsValid = validationResponse?.valid ?? true;
Expand Down Expand Up @@ -709,6 +745,7 @@ export const CodeAutocompleteField = ({
} ${blueprintIntent ? BlueprintClassNames.intentClass(blueprintIntent as any) : ""}${inputIntentClass}`}
>
<ContextOverlay
popupKind={PopupKind.LISTBOX}
minimal
fill
isOpen={shouldShowDropdown}
Expand All @@ -719,6 +756,7 @@ export const CodeAutocompleteField = ({
content={
<AutoSuggestionList
id={id + "__dropdown"}
listboxId={suggestionListId}
offsetValues={dropdownXYoffset.current}
loading={suggestionsPending}
options={suggestions}
Expand Down
37 changes: 23 additions & 14 deletions src/components/AutoSuggestion/AutoSuggestionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@ import React from "react";
import { compute } from "compute-scroll-into-view";

import { CLASSPREFIX as eccgui } from "../../configuration/constants";

import {
Highlighter,
Menu,
MenuItem,
OverflowText,
OverviewItem,
OverviewItemDescription,
OverviewItemLine,
Spacing,
Spinner,
Tooltip,
} from "./../../";
import { Menu } from "../Menu/Menu";
import { MenuItem } from "../Menu/MenuItem";
import { OverviewItem } from "../OverviewItem/OverviewItem";
import { OverviewItemDescription } from "../OverviewItem/OverviewItemDescription";
import { OverviewItemLine } from "../OverviewItem/OverviewItemLine";
import { Spacing } from "../Separation/Spacing";
import { Spinner } from "../Spinner/Spinner";
import { Tooltip } from "../Tooltip/Tooltip";
import { Highlighter } from "../Typography/Highlighter";
import { OverflowText } from "../Typography/OverflowText";
import { CodeAutocompleteFieldSuggestionWithReplacementInfo } from "./AutoSuggestion";

export interface AutoSuggestionListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
listboxId?: string;
// The options of the drop down
options: Array<CodeAutocompleteFieldSuggestionWithReplacementInfo>;
// Called when an item has been selected from the drop down
Expand Down Expand Up @@ -76,6 +74,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,
Expand All @@ -86,6 +85,8 @@ export const AutoSuggestionList = ({
offsetValues,
...otherDivProps
}: AutoSuggestionListProps) => {
const generatedListboxId = React.useId();
const optionListId = listboxId ?? generatedListboxId;
const [hoveredItem, setHoveredItem] = React.useState<
CodeAutocompleteFieldSuggestionWithReplacementInfo | undefined
>(undefined);
Expand Down Expand Up @@ -141,10 +142,18 @@ export const AutoSuggestionList = ({
{loading ? (
Loader
) : (
<Menu>
<Menu role="listbox" id={optionListId} aria-label="Suggestions">
{options.map((item, index) => (
<MenuItem
key={index}
roleStructure="listoption"
tabIndex={-1}
ref={(element) => {
if (element) {
element.id = `${optionListId}-option-${index}`;
element.setAttribute("aria-selected", String(currentlyFocusedIndex === index));
}
}}
active={currentlyFocusedIndex === index}
onMouseDown={(e: any) => e.preventDefault()}
onClick={() => onItemSelectionChange(item)}
Expand Down
35 changes: 34 additions & 1 deletion src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,39 @@ describe("CodeAutocompleteField", () => {
expect(getByText(props.label!)).toBeTruthy();
});

it("connects the focused editor to the active suggestion", async () => {
const { container } = render(
<CodeAutocompleteField
{...props}
initialValue="value"
autoCompletionRequestDelay={0}
fetchSuggestions={(inputString, cursorPosition) => ({
inputString,
cursorPosition,
replacementResults: [
{
replacementInterval: { from: 0, length: 5 },
extractedQuery: "",
replacements: [{ value: "first" }, { value: "second" }],
},
],
})}
/>,
);
const editor = screen.getByRole("combobox");
act(() => editor.focus());
const options = await screen.findAllByRole("option");
expect(container.querySelector('[aria-haspopup="menu"]')).toBeNull();
expect(editor).toHaveAttribute("aria-haspopup", "listbox");
expect(editor).toHaveAttribute("aria-controls", options[0].parentElement?.id);
expect(editor).toHaveAttribute("aria-activedescendant", options[0].id);
fireEvent.keyDown(editor, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 });
await waitFor(() => expect(editor).toHaveAttribute("aria-activedescendant", options[1].id));
fireEvent.keyDown(editor, { key: "Escape", code: "Escape", keyCode: 27 });
await waitFor(() => expect(editor).toHaveAttribute("aria-expanded", "false"));
expect(editor).not.toHaveAttribute("aria-activedescendant");
});

it.each([false, true])("updates the container height (multiline: %s)", (multiline) => {
const { container, rerender } = render(<CodeAutocompleteField {...props} multiline={multiline} height={120} />);
const editorContainer = container.querySelector<HTMLElement>(".eccgui-codeeditor");
Expand Down Expand Up @@ -92,7 +125,7 @@ describe("CodeAutocompleteField", () => {
})}
/>,
);
const editor = screen.getByRole("textbox");
const editor = screen.getByRole("combobox");
act(() => editor.focus());
expect(await screen.findByText("completion")).toBeVisible();
const view = EditorView.findFromDOM(editor);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { Classes } from "@blueprintjs/core";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";

import "@testing-library/jest-dom";

Expand Down Expand Up @@ -65,6 +65,9 @@ describe("Dropdown list", () => {
const dropdownItems = container.getElementsByTagName("li");
expect(container).not.toBeEmptyDOMElement();
expect(dropdownItems.length).toBe(2);
expect(screen.getByRole("listbox")).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(2);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});

it("should move horizontally for every cursor movement", async () => {
Expand Down
27 changes: 27 additions & 0 deletions src/components/AutocompleteField/AutoCompleteField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";

import "@testing-library/jest-dom";

import { SuggestField } from "./AutoCompleteField";

describe("SuggestField", () => {
it("renders suggestions as listbox options", async () => {
render(
<SuggestField<string, string>
onSearch={() => ["Alpha"]}
itemRenderer={(item) => item}
itemValueRenderer={(item) => item}
itemValueSelector={(item) => item}
itemValueString={(item) => item}
noResultText="No results"
/>,
);

fireEvent.focus(screen.getByRole("combobox"));

const listbox = await screen.findByRole("listbox");
expect(listbox.querySelectorAll('[role="option"]')).toHaveLength(1);
expect(listbox.querySelectorAll('[role="menuitem"]')).toHaveLength(0);
});
});
29 changes: 16 additions & 13 deletions src/components/AutocompleteField/AutoCompleteField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,14 @@ import { Suggest as BlueprintSuggest } from "@blueprintjs/select";

import { preventReactFlowDragClass } from "../../cmem/react-flow/ReactFlow/constants";
import { CLASSPREFIX as eccgui } from "../../configuration/constants";
import {
ContextOverlayProps,
Highlighter,
IconButton,
Menu,
MenuItem,
Notification,
OverflowText,
Spinner,
} from "../../index";
import type { ContextOverlayProps } from "../ContextOverlay/ContextOverlay";
import { IconButton } from "../Icon/IconButton";
import { Menu } from "../Menu/Menu";
import { MenuItem } from "../Menu/MenuItem";
import { Notification } from "../Notification/Notification";
import { Spinner } from "../Spinner/Spinner";
import { Highlighter } from "../Typography/Highlighter";
import { OverflowText } from "../Typography/OverflowText";

import { SuggestFieldItemRendererModifierProps } from "./interfaces";

Expand Down Expand Up @@ -341,6 +339,7 @@ export function SuggestField<T, UPDATE_VALUE>(props: SuggestFieldProps<T, UPDATE
if (typeof renderedItem === "string") {
return (
<MenuItem
roleStructure="listoption"
active={modifiers.active}
disabled={modifiers.disabled}
key={itemKey(item)}
Expand Down Expand Up @@ -475,7 +474,7 @@ export function SuggestField<T, UPDATE_VALUE>(props: SuggestFieldProps<T, UPDATE
inputValueRenderer={selectedItem !== undefined ? itemValueRenderer : () => ""}
itemRenderer={requestError ? requestErrorRenderer : optionRenderer}
itemsEqual={areEqualItems}
noResults={<MenuItem disabled={true} text={noResultText} />}
noResults={<MenuItem roleStructure="listoption" disabled={true} text={noResultText} />}
onItemSelect={onSelectionChange}
onQueryChange={(q) => setQuery(q)}
resetOnQuery={false}
Expand All @@ -495,8 +494,12 @@ export function SuggestField<T, UPDATE_VALUE>(props: SuggestFieldProps<T, UPDATE
itemListRenderer={
listLoading
? () => (
<Menu>
<MenuItem disabled={true} text={<Spinner position={"inline"} />} />
<Menu role="listbox">
<MenuItem
roleStructure="listoption"
disabled={true}
text={<Spinner position={"inline"} />}
/>
</Menu>
)
: undefined
Expand Down
Loading
Loading