Skip to content
Merged
21 changes: 21 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

- `<SilkActivityControl />`, `<ActivityExecutionErrorReportModal />`
- 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
- `<SimpleDialog />`
- `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

- `<SimpleDialog />`
- 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
- `<SilkActivityControl />`, `<ActivityExecutionErrorReportModal />`
- error report dialogs without title, `aria-label` and `aria-labelledby` get a fallback accessible name, `translate("showErrorReport")` in `<SilkActivityControl />` and "Activity execution error report" in `<ActivityExecutionErrorReportModal />`
- the error report dialog uses the full screen toggler of `<SimpleDialog />` instead of an own implementation
- `<Modal />`
- 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
- `<SimpleDialog />`, `<AlertDialog />`
- 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
Expand Down
34 changes: 21 additions & 13 deletions src/cmem/ActivityControl/ActivityExecutionErrorReportModal.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<boolean>(false);
const fileName =
"Activity execution report from " +
new Date().toISOString().replace(/T/, " ").replace(/:/g, "-").substr(0, 19) +
Expand All @@ -51,15 +61,13 @@ export const ActivityExecutionErrorReportModal = ({
return (
<SimpleDialog
title={title}
aria-label={ariaLabel || "Activity execution error report"}
aria-labelledby={ariaLabelledby}
isOpen={true}
size={displayFullscreen ? "fullscreen" : "large"}
size="large"
showFullScreenToggler
fullScreenTogglerTexts={fullScreenTogglerTexts}
onClose={onDiscard}
headerOptions={
<IconButton
name={displayFullscreen ? "toggler-minimize" : "toggler-maximize"}
onClick={() => setDisplayFullscreen(!displayFullscreen)}
/>
}
actions={[
<Button data-test-id={"error-report-download-btn"} affirmative onClick={handleDownload} key="download">
{downloadButtonValue}
Expand Down
17 changes: 15 additions & 2 deletions src/cmem/ActivityControl/SilkActivityControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Intent } from "@blueprintjs/core/src/common/intent";

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

Expand Down Expand Up @@ -90,8 +91,17 @@ const defaultLayout: SilkActivityControlLayoutProps = {
visualization: "spinner",
};

interface IErrorReportAction {
// The title of the error report modal
interface IErrorReportAction extends Pick<
SimpleDialogProps,
"aria-label" | "aria-labelledby" | "fullScreenTogglerTexts"
> {
/**
* The title of the error report 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 `translate("showErrorReport")` is used as `aria-label`.
*/
title?: string;
// The element that will be rendered in the modal, either as Markdown or object
renderReport: (report: string | SilkActivityExecutionReportProps) => React.JSX.Element;
Expand Down Expand Up @@ -362,9 +372,12 @@ export function useSilkActivityControl({
{errorReport && failureReportAction && (
<ActivityExecutionErrorReportModal
title={failureReportAction.title}
aria-label={failureReportAction["aria-label"] || translate("showErrorReport")}
aria-labelledby={failureReportAction["aria-labelledby"]}
key={"error-report-modal"}
closeButtonValue={failureReportAction.closeButtonValue}
downloadButtonValue={failureReportAction.downloadButtonValue}
fullScreenTogglerTexts={failureReportAction.fullScreenTogglerTexts}
fetchErrorReport={async () => {
return (await failureReportAction.fetchErrorReport(true)) as string | undefined;
}}
Expand Down
132 changes: 132 additions & 0 deletions src/cmem/ActivityControl/tests/SilkActivityControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React from "react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";

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

import { ActivityExecutionErrorReportModal } from "../ActivityExecutionErrorReportModal";
import { SilkActivityControl, SilkActivityControlProps } from "../SilkActivityControl";

type FailureReportAction = NonNullable<SilkActivityControlProps["failureReportAction"]>;

const renderFailedActivity = (
failureReportAction: Partial<FailureReportAction>,
additionalContent?: React.ReactNode,
) => {
const props: SilkActivityControlProps = {
label: "Execution",
initialStatus: {
activity: "execute",
activityLabel: "Execution",
isRunning: false,
failed: true,
statusName: "Finished",
progress: 100,
lastUpdateTime: 0,
message: "Failed",
cancelled: false,
concreteStatus: "Failed",
},
registerForUpdates: jest.fn(),
unregisterFromUpdates: jest.fn(),
showStartAction: false,
showStopAction: false,
showReloadAction: false,
executeActivityAction: jest.fn(),
translate: (key) => key,
failureReportAction: {
renderReport: () => <p>Error details</p>,
renderMarkdown: true,
fetchErrorReport: async () => "Error details",
downloadButtonValue: "Download",
closeButtonValue: "Close",
...failureReportAction,
},
};
const utils = render(
<>
{additionalContent}
<SilkActivityControl {...props} />
</>,
);
const openErrorReport = () =>
fireEvent.click(utils.container.querySelector("[data-test-id='activity-show-error-report']") as HTMLElement);
return { ...utils, openErrorReport };
};

describe("SilkActivityControl error report", () => {
it("uses the title as accessible name of the error report dialog", async () => {
const { openErrorReport } = renderFailedActivity({ title: "Error report" });
openErrorReport();
expect(await screen.findByRole("dialog", { name: "Error report" })).toHaveAttribute("aria-modal", "true");
});

it("forwards `aria-label` to a titleless error report dialog", async () => {
const { openErrorReport } = renderFailedActivity({
title: "",
"aria-label": "Activity execution error report",
});
openErrorReport();
const dialog = await screen.findByRole("dialog", { name: "Activity execution error report" });
expect(dialog).toHaveAttribute("aria-modal", "true");

fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});

it("uses the translated `showErrorReport` text as fallback name for a titleless error report dialog", async () => {
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const { openErrorReport } = renderFailedActivity({ title: "", "aria-label": "" });
openErrorReport();
expect(await screen.findByRole("dialog", { name: "showErrorReport" })).toHaveAttribute(
"aria-modal",
"true",
);
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});

it("forwards the full screen toggler texts to the error report dialog", async () => {
const { openErrorReport } = renderFailedActivity({
title: "Error report",
fullScreenTogglerTexts: { maximize: "Vollbild anzeigen", minimize: "Vollbild verlassen" },
});
openErrorReport();
const dialog = await screen.findByRole("dialog", { name: "Error report" });
const toggler = within(dialog).getByRole("button", { name: "Vollbild anzeigen" });

fireEvent.click(toggler);
expect(toggler).toHaveAccessibleName("Vollbild verlassen");
});

it("forwards `aria-labelledby` to a titleless error report dialog", async () => {
const { openErrorReport } = renderFailedActivity(
{ "aria-labelledby": "external-report-label" },
<span id="external-report-label">Referenced error report label</span>,
);
openErrorReport();
const dialog = await screen.findByRole("dialog", { name: "Referenced error report label" });
expect(dialog).toHaveAttribute("aria-labelledby", "external-report-label");
});
});

describe("ActivityExecutionErrorReportModal", () => {
it.each([undefined, ""])(
"uses an english fallback name if neither title nor aria attributes are given (aria-label: %p)",
(ariaLabel) => {
render(
<ActivityExecutionErrorReportModal
aria-label={ariaLabel}
onDiscard={jest.fn()}
report={<p>Error details</p>}
downloadButtonValue="Download"
closeButtonValue="Close"
fetchErrorReport={async () => "Error details"}
/>,
);
expect(screen.getByRole("dialog", { name: "Activity execution error report" })).toBeInTheDocument();
},
);
});
3 changes: 2 additions & 1 deletion src/components/Dialog/AlertDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export interface AlertDialogProps extends Omit<SimpleDialogProps, "intent" | "ro
/**
* Special element to display alert notification in modal dialogs.
* Inherits all properties from `SimpleDialog`, except `intent` and `role`.
* If `title`, `aria-label` nor a `aria-labelledby` is given then the alert level automatically used as fallback for `aria-label`.
* If no `aria-label` is given then the alert level is automatically used as fallback for `aria-label`.
* It is only used if there is no `title` and no valid `aria-labelledby` reference.
*/
export const AlertDialog = ({
children,
Expand Down
Loading
Loading