diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc06004fe..401bc19e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
## [Unreleased]
+### Added
+
+- ``, ``
+ - support `aria-label` and `aria-labelledby` for naming activity error report dialogs without a visible title
+ - support `fullScreenTogglerTexts` to set localized texts for the full screen toggler of the error report dialog
+- ``
+ - `fullScreenTogglerTexts` property: sets the texts of the full screen toggler for the `maximize` and `minimize` state, used as `title` and accessible name of the button
+
+### Changed
+
+- ``
+ - the full screen toggler has an accessible name and a `title` now, the default texts are "Show in full screen" and "Exit full screen", use `fullScreenTogglerTexts` to localize them
+- ``, ``
+ - error report dialogs without title, `aria-label` and `aria-labelledby` get a fallback accessible name, `translate("showErrorReport")` in `` and "Activity execution error report" in ``
+ - the error report dialog uses the full screen toggler of `` instead of an own implementation
+- ``
+ - the elements referenced by `aria-labelledby` are checked for existence when the modal is opened, invalid references are reported as console warning and removed from `aria-labelledby`
+ - 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
+
## [26.2.1] - 2026-11-02
### Fixed
diff --git a/src/cmem/ActivityControl/ActivityExecutionErrorReportModal.tsx b/src/cmem/ActivityControl/ActivityExecutionErrorReportModal.tsx
index be2e7a932..d986ee2d4 100644
--- a/src/cmem/ActivityControl/ActivityExecutionErrorReportModal.tsx
+++ b/src/cmem/ActivityControl/ActivityExecutionErrorReportModal.tsx
@@ -1,12 +1,20 @@
-import React, { useState } from "react";
+import React from "react";
import Button from "../../components/Button/Button";
-import SimpleDialog from "../../components/Dialog/SimpleDialog";
-import IconButton from "../../components/Icon/IconButton";
+import SimpleDialog, { SimpleDialogProps } from "../../components/Dialog/SimpleDialog";
import HtmlContentBlock from "../../components/Typography/HtmlContentBlock";
-interface ActivityExecutionErrorReportModalProps {
- // Title of the modal
+interface ActivityExecutionErrorReportModalProps extends Pick<
+ SimpleDialogProps,
+ "aria-label" | "aria-labelledby" | "fullScreenTogglerTexts"
+> {
+ /**
+ * Title of the modal.
+ * If set, it is used as the accessible name of the dialog via `aria-labelledby`
+ * (unless `aria-labelledby` is given explicitly), so `aria-label` is ignored.
+ * Use `aria-label` only for modals without title.
+ * If neither `title`, a non-empty `aria-label` nor `aria-labelledby` is set, then "Activity execution error report" is used as `aria-label`.
+ */
title?: string;
// Called when the close button is clicked
onDiscard: () => any;
@@ -23,13 +31,15 @@ interface ActivityExecutionErrorReportModalProps {
/** Shows the execution error report to the user and offers to download the report. */
export const ActivityExecutionErrorReportModal = ({
title,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledby,
onDiscard,
report,
downloadButtonValue,
closeButtonValue,
+ fullScreenTogglerTexts,
fetchErrorReport,
}: ActivityExecutionErrorReportModalProps) => {
- const [displayFullscreen, setDisplayFullscreen] = useState(false);
const fileName =
"Activity execution report from " +
new Date().toISOString().replace(/T/, " ").replace(/:/g, "-").substr(0, 19) +
@@ -51,15 +61,13 @@ export const ActivityExecutionErrorReportModal = ({
return (
setDisplayFullscreen(!displayFullscreen)}
- />
- }
actions={[
}
+ downloadButtonValue="Download"
+ closeButtonValue="Close"
+ fetchErrorReport={async () => "Error details"}
+ />,
+ );
+ expect(screen.getByRole("dialog", { name: "Activity execution error report" })).toBeInTheDocument();
+ },
+ );
+});
diff --git a/src/components/Dialog/AlertDialog.tsx b/src/components/Dialog/AlertDialog.tsx
index 24adecbbf..f82a785de 100644
--- a/src/components/Dialog/AlertDialog.tsx
+++ b/src/components/Dialog/AlertDialog.tsx
@@ -22,7 +22,8 @@ export interface AlertDialogProps extends Omit {
+ if (!ariaLabelledby) {
+ return true;
+ }
+ const rootNode = modalElement.getRootNode();
+ // targets can also be located inside a shadow DOM
+ const lookupRoot: Pick =
+ "getElementById" in rootNode ? (rootNode as Document | ShadowRoot) : modalElement.ownerDocument;
+ const referencedIds = ariaLabelledby.split(/\s+/).filter((id) => !!id);
+ const invalidIds = referencedIds.filter((id) => !lookupRoot.getElementById(id));
+ const validIds = referencedIds.filter((id) => !invalidIds.includes(id));
+ const hasValidTarget = validIds.length > 0;
+
+ if (hasValidTarget) {
+ // invalid references are removed, so screen readers cannot stumble over them
+ if (invalidIds.length > 0) {
+ modalElement.setAttribute("aria-labelledby", validIds.join(" "));
+ }
+ // `aria-label` is never rendered together with `aria-labelledby`, so it can only be a former fallback
+ modalElement.removeAttribute("aria-label");
+ } else {
+ modalElement.removeAttribute("aria-labelledby");
+ if (ariaLabel) {
+ modalElement.setAttribute("aria-label", ariaLabel);
+ }
+ }
+
+ if (invalidIds.length === 0 && hasValidTarget) {
+ return true;
+ }
+
+ const invalidTargets = invalidIds.length > 0 ? invalidIds.map((id) => `#${id}`).join(", ") : `"${ariaLabelledby}"`;
+ let fallbackInfo: string;
+ if (hasValidTarget) {
+ fallbackInfo = "They were removed, fallback to aria-label not necessary because other targets are valid.";
+ } else if (ariaLabel) {
+ fallbackInfo = `aria-labelledby was removed, fallback to aria-label="${ariaLabel}" was used.`;
+ } else {
+ fallbackInfo = `aria-labelledby was removed, fallback to aria-label not possible because it is not given, modal has no accessible name and role=${role} was removed.`;
+ }
+ // eslint-disable-next-line no-console
+ console.warn(`aria-labelledby of modal references invalid targets: ${invalidTargets}. ${fallbackInfo}`);
+ return hasValidTarget || !!ariaLabel;
+};
+
/**
* Displays contents on top of other elements, used to create dialogs.
* For most situations the usage of `SimpleDialog` and `AlertDialog` should be sufficient.
@@ -111,11 +169,18 @@ export const Modal = ({
};
}, []);
+ const modalElementRef = React.useRef(null);
+ // prevents that the same element is validated multiple times with unchanged properties
+ const lastLabelValidation = React.useRef<{ element?: HTMLElement; key?: string }>({});
+ // set if none of the `aria-labelledby` targets exists and no `aria-label` is given as fallback
+ const hasNoValidLabelTarget = React.useRef(false);
+
+ const hasLabelProperty = !!(ariaLabel || ariaLabelledby);
// always remove the role if there is no explanation
- const modalRole = ariaLabel || ariaLabelledby ? role : undefined;
+ const modalRole = hasLabelProperty && !hasNoValidLabelTarget.current ? role : undefined;
React.useEffect(() => {
- if (!modalRole && otherProps.isOpen) {
+ if (!hasLabelProperty && otherProps.isOpen) {
// eslint-disable-next-line no-console
console.warn(
`role=${role} removed from modal because neither aria-label nor aria-labelledby is available.`,
@@ -155,6 +220,8 @@ export const Modal = ({
// call the original event handler
onOpening(modalElement);
}
+ // content of the modal is available now, so targets inside of it can be found
+ checkLabelTargets();
if (usePortal && forceTopPosition) {
const parentalPortal = modalElement.closest(`.${BlueprintClassNames.PORTAL}`) as HTMLElement;
const highestTopIndex = (utils.getGlobalVar("highestModalTopIndex") as unknown as number) ?? 0;
@@ -184,6 +251,33 @@ export const Modal = ({
"aria-modal": modalRole ? isTopMostModal : undefined,
};
+ const checkLabelTargets = () => {
+ const element = modalElementRef.current;
+ const key = `${ariaLabelledby}|${ariaLabel}`;
+ if (!element || (lastLabelValidation.current.element === element && lastLabelValidation.current.key === key)) {
+ return;
+ }
+ lastLabelValidation.current = { element, key };
+ const hasAccessibleName = validateAriaLabelledby(element, role, ariaLabelledby, ariaLabel);
+ hasNoValidLabelTarget.current = !hasAccessibleName;
+ // role and modality are changed directly on the DOM element, following renderings respect the flag
+ if (!hasAccessibleName) {
+ element.removeAttribute("role");
+ element.removeAttribute("aria-modal");
+ } else if (hasLabelProperty && !element.hasAttribute("role")) {
+ // restore them if they were removed by a former check
+ element.setAttribute("role", role);
+ element.setAttribute("aria-modal", `${isTopMostModal}`);
+ }
+ };
+
+ React.useEffect(() => {
+ // only for changes while the modal is open, initial check is done when it is opening
+ if (otherProps.isOpen) {
+ checkLabelTargets();
+ }
+ }, [ariaLabelledby, ariaLabel]);
+
return (
{alteredChildren}
diff --git a/src/components/Dialog/SimpleDialog.tsx b/src/components/Dialog/SimpleDialog.tsx
index c118c2377..24d02876d 100644
--- a/src/components/Dialog/SimpleDialog.tsx
+++ b/src/components/Dialog/SimpleDialog.tsx
@@ -45,6 +45,15 @@ export interface SimpleDialogProps extends ModalProps, TestableComponent {
showFullScreenToggler?: boolean;
/** Starts the modal in full screen mode. The show full screen toggler will be automatically enabled. */
startInFullScreenMode?: boolean;
+ /**
+ * Texts for the full screen toggler, used as tooltip and accessible name of the button.
+ * `maximize` is shown while the dialog is in its normal size, `minimize` while it is in full screen mode.
+ * Defaults to "Show in full screen" and "Exit full screen".
+ */
+ fullScreenTogglerTexts?: {
+ maximize?: string;
+ minimize?: string;
+ };
/** Forward properties to the actions footer component. */
actionsProps?: Omit;
}
@@ -53,6 +62,8 @@ export interface SimpleDialogProps extends ModalProps, TestableComponent {
* Simplifies the dialog display by providing a direct `Card` template for the `Modal` element.
* If not given then aria attributes like `role` and `aria-labelledby` are set automatically.
* `aria-describedby` is only automatically when an `intent` state is set.
+ * With an `intent` state the intent is also used as fallback for `aria-label`,
+ * if no `aria-label` is given and there is no title or the elements referenced by `aria-labelledby` do not exist.
* Inherits all properties from `Modal`.
*/
export const SimpleDialog = ({
@@ -69,6 +80,7 @@ export const SimpleDialog = ({
headerOptions,
showFullScreenToggler = false,
startInFullScreenMode = false,
+ fullScreenTogglerTexts,
size,
role,
"aria-labelledby": ariaLabelledby,
@@ -80,6 +92,9 @@ export const SimpleDialog = ({
const dialogUniqueId = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [displayFullscreen, setDisplayFullscreen] = React.useState(startInFullScreenMode);
const showToggler = startInFullScreenMode || showFullScreenToggler;
+ const togglerText = displayFullscreen
+ ? (fullScreenTogglerTexts?.minimize ?? "Exit full screen")
+ : (fullScreenTogglerTexts?.maximize ?? "Show in full screen");
const intentClassName = intent ? `${eccgui}-intent--${intent}` : "";
const wrapperDivProps = {
...modalPreventEvents,
@@ -87,7 +102,9 @@ export const SimpleDialog = ({
};
const hasSemanticIntent = intent && ["success", "warning", "danger", "info"].includes(intent);
- const labelFallback = hasSemanticIntent && !title && !ariaLabel && !ariaLabelledby ? intent : undefined;
+ // the fallback is also given when there is a title or `aria-labelledby` because `Modal` ignores it then,
+ // but uses it if none of the elements referenced by `aria-labelledby` exists
+ const labelFallback = hasSemanticIntent && !ariaLabel ? intent : undefined;
return (
setDisplayFullscreen(!displayFullscreen)}
/>
)}
diff --git a/src/components/Dialog/tests/AlertDialog.test.tsx b/src/components/Dialog/tests/AlertDialog.test.tsx
index d73a116c9..6332b9b4b 100644
--- a/src/components/Dialog/tests/AlertDialog.test.tsx
+++ b/src/components/Dialog/tests/AlertDialog.test.tsx
@@ -95,10 +95,29 @@ describe("AlertDialog", () => {
expect(dialog).toHaveAttribute("aria-label", "Alert label");
});
it("should not use the fallback label if `aria-labelledby` is given", () => {
- const { dialog } = renderAlert({ "aria-labelledby": "externaltitle" });
+ const { dialog } = renderAlert({
+ "aria-labelledby": "externaltitle",
+ children:
External title
,
+ });
expect(dialog).not.toHaveAttribute("aria-label");
expect(dialog).toHaveAttribute("aria-labelledby", "externaltitle");
});
+ it("should use the alert level as `aria-label` fallback if no `aria-labelledby` target exists", () => {
+ const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
+ const { dialog } = renderAlert({ warning: true, "aria-labelledby": "missing" });
+ expect(dialog).toHaveAttribute("aria-label", "warning");
+ expect(dialog).not.toHaveAttribute("aria-labelledby");
+ expect(dialog).toHaveAttribute("role", "alertdialog");
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('aria-label="warning" was used'));
+ consoleWarnSpy.mockRestore();
+ });
+ it("should prefer a given `aria-label` over the alert level if no `aria-labelledby` target exists", () => {
+ const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
+ const { dialog } = renderAlert({ "aria-label": "Alert label", "aria-labelledby": "missing" });
+ expect(dialog).toHaveAttribute("aria-label", "Alert label");
+ consoleWarnSpy.mockRestore();
+ });
it("should always have an accessible name, so the role is never removed", () => {
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
expect(renderAlert().dialog).toHaveAttribute("role", "alertdialog");
diff --git a/src/components/Dialog/tests/Modal.test.tsx b/src/components/Dialog/tests/Modal.test.tsx
index 557baa1a3..89394ef77 100644
--- a/src/components/Dialog/tests/Modal.test.tsx
+++ b/src/components/Dialog/tests/Modal.test.tsx
@@ -9,6 +9,7 @@ import Modal, { ModalProps } from "../Modal";
import { ModalContext, ModalContextProps, useModalContext } from "../ModalContext";
const dialogWrapper = `${eccgui}-dialog__wrapper`;
+const customTitle =
Custom title
;
const renderModal = (props: Partial = {}) => {
const { children, ...otherProps } = props;
@@ -127,6 +128,7 @@ describe("Modal", () => {
const { modal } = renderModal({
"aria-labelledby": "customtitle",
"aria-describedby": "customdescription",
+ children: customTitle,
});
expect(modal).toHaveAttribute("aria-labelledby", "customtitle");
expect(modal).toHaveAttribute("aria-describedby", "customdescription");
@@ -138,13 +140,21 @@ describe("Modal", () => {
});
it("should ignore `aria-label` if `aria-labelledby` is given", () => {
// `aria-labelledby` wins over `aria-label`, so the label would never be used
- const { modal } = renderModal({ "aria-label": "Modal label", "aria-labelledby": "customtitle" });
+ const { modal } = renderModal({
+ "aria-label": "Modal label",
+ "aria-labelledby": "customtitle",
+ children: customTitle,
+ });
expect(modal).not.toHaveAttribute("aria-label");
expect(modal).toHaveAttribute("aria-labelledby", "customtitle");
});
it("should keep the role if only `aria-labelledby` is given", () => {
// the ignored `aria-label` must not affect the accessible name detection
- const { modal } = renderModal({ "aria-label": "Modal label", "aria-labelledby": "customtitle" });
+ const { modal } = renderModal({
+ "aria-label": "Modal label",
+ "aria-labelledby": "customtitle",
+ children: customTitle,
+ });
expect(modal).toHaveAttribute("role", "dialog");
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
@@ -158,7 +168,10 @@ describe("Modal", () => {
});
it("should keep the role if the modal has an accessible name", () => {
expect(renderModal({ "aria-label": "Modal label" }).modal).toHaveAttribute("role", "dialog");
- expect(renderModal({ "aria-labelledby": "customtitle" }).modal).toHaveAttribute("role", "dialog");
+ expect(renderModal({ "aria-labelledby": "customtitle", children: customTitle }).modal).toHaveAttribute(
+ "role",
+ "dialog",
+ );
});
it("should remove the role if there is only a description but no name", () => {
// a description alone does not make the dialog identifiable for screen readers
@@ -177,6 +190,169 @@ describe("Modal", () => {
});
});
+ describe("aria-labelledby targets", () => {
+ it("should not warn if all targets exist", () => {
+ renderModal({
+ "aria-labelledby": "customtitle othertitle",
+ children: [
+ customTitle,
+