diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe232e53..18fcc613 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- already set `id` values and connections are never overwritten
- ID references created by the field item are removed again if their part is removed from the field item
- `preventAriaAttribution` property: prevents this automatic connection of the field item parts
+- ``
+ - `role`, `aria-label`, `aria-labelledby` and `aria-describedby` properties: they are set on the dialog element inside the modal overlay
+ - `role` is `dialog` by default, but it is removed again if neither a label nor a description is available; a console warning points this out when the modal is opened
+ - `aria-modal` is set together with the `role`, so it is left out as well if the `role` was removed
+ - it is `true` for the modal that was opened last according to the `ModalContext`, otherwise it is `false`
+ - if no `ModalContext` is provided, then the modals cannot know about each other, so each of them claims modality
+- ``
+ - `role` and the aria attributes are set automatically now if they are not given
+ - `role` is `alertdialog` if an `intent` state is set that describes an alert (`success`, `warning`, `danger` or `info`), otherwise it is `dialog`
+ - 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, then the alert level is used as fallback for `aria-label`, so the dialog always has an accessible name
- new `utils` methods:
- `truncateMarkdownDisplay`: helper function to iterate over `Markdown` renderings to improve the experienced `cutOff` value
- new icons:
@@ -40,6 +53,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- the build of the ESM distribution needs a synchronous `import.meta.resolve`, which is only available since this version
- ``
- the used `Label` element gets the `eccgui-fielditem__label` class now
+- ``
+ - always uses `role="alertdialog"` now
+ - the `role` property is not accepted anymore
+- `ModalContext`
+ - a change of the stack of open modals re-renders the consumers of the context now, this way modals can react on modals that are opened on top of them, e.g. to hand over `aria-modal`
+ - before only an internal reference was updated, which never triggered any re-render
+ - the component that provides the context via `useModalContext` is re-rendered on every change of the stack, but not if a change does not affect it, e.g. when a modal is closed that was never registered as open
+ - `openModalStack()` still returns the current stack synchronously, also directly after `setModalOpen()` was called
- ``
- `allowedHtmlElementsInPreview` option is set to inline elements on default
- uses now the `Markdown.cutOff` property
diff --git a/package.json b/package.json
index 8f3bf94c..1320a32c 100644
--- a/package.json
+++ b/package.json
@@ -191,18 +191,15 @@
},
"lint-staged": {
"*.(json|md)": [
- "prettier --write",
- "git add"
+ "prettier --write"
],
"*.(js|ts|tsx)": [
"eslint --fix",
- "prettier --write",
- "git add"
+ "prettier --write"
],
"*.(scss)": [
"stylelint --fix",
- "prettier --write",
- "git add"
+ "prettier --write"
]
},
"jest": {
diff --git a/src/common/utils/truncateMarkdownDisplay.ts b/src/common/utils/truncateMarkdownDisplay.ts
index 2754ee04..450d665a 100644
--- a/src/common/utils/truncateMarkdownDisplay.ts
+++ b/src/common/utils/truncateMarkdownDisplay.ts
@@ -8,7 +8,7 @@ interface MarkdownWithCutOffProps extends Omit {
cutOff: NonNullable;
}
-interface TruncateMarkdownDisplayType {
+export interface TruncateMarkdownDisplayType {
(
/**
* Markdown element with mandatory `cutOff` property.
diff --git a/src/components/Dialog/AlertDialog.tsx b/src/components/Dialog/AlertDialog.tsx
index 271286b2..46ba8aaa 100644
--- a/src/components/Dialog/AlertDialog.tsx
+++ b/src/components/Dialog/AlertDialog.tsx
@@ -4,7 +4,7 @@ import { Definitions as IntentStates, IntentTypes } from "../../common/Intent";
import SimpleDialog, { SimpleDialogProps } from "./SimpleDialog";
-export interface AlertDialogProps extends Omit {
+export interface AlertDialogProps extends Omit {
/**
* set to true if alert dialog displays a success message
*/
@@ -21,7 +21,8 @@ export interface AlertDialogProps extends Omit {
/**
* Special element to display alert notification in modal dialogs.
- * Inherits all properties from `SimpleDialog`, except `intent`.
+ * 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`.
*/
export const AlertDialog = ({
children,
@@ -41,8 +42,17 @@ export const AlertDialog = ({
intentLevel = IntentStates.DANGER;
}
+ const labelFallback = !otherProps.title && !otherProps["aria-label"] && !otherProps["aria-labelledby"] ? { "aria-label": intentLevel } : {};
+
return (
-
+
{children}
);
diff --git a/src/components/Dialog/Modal.tsx b/src/components/Dialog/Modal.tsx
index 8c79f5f3..a3cf4ca8 100644
--- a/src/components/Dialog/Modal.tsx
+++ b/src/components/Dialog/Modal.tsx
@@ -1,6 +1,7 @@
import React from "react";
import {
Classes as BlueprintClassNames,
+ DialogProps as BlueprintDialogProps,
Overlay2 as BlueprintOverlay,
Overlay2Props as BlueprintOverlayProps,
} from "@blueprintjs/core";
@@ -11,9 +12,13 @@ import { CLASSPREFIX as eccgui } from "../../configuration/constants";
import { TestableComponent } from "../interfaces";
import { Card } from "./../Card";
-import { ModalContext } from "./ModalContext";
+import { isModalContextProvided, ModalContext } from "./ModalContext";
-export interface ModalProps extends TestableComponent, BlueprintOverlayProps {
+export interface ModalProps
+ extends
+ TestableComponent,
+ BlueprintOverlayProps,
+ Pick {
children: React.ReactNode | React.ReactNode[];
/**
* A space-delimited list of class names to pass along to the BlueprintJS `Overlay` element that is used to create the modal.
@@ -52,6 +57,10 @@ export interface ModalProps extends TestableComponent, BlueprintOverlayProps {
* Prevents that pan and zooming actions of an existing react-flow instance are triggered while this Modal is open.
*/
preventReactFlowEvents?: boolean;
+ /**
+ * Set this if there is no visible title element that is used for `aria-labelledby`.
+ */
+ "aria-label"?: string;
}
export type ModalSize = "tiny" | "small" | "regular" | "large" | "xlarge" | "fullscreen";
@@ -78,6 +87,10 @@ export const Modal = ({
"data-test-id": dataTestId,
"data-testid": dataTestid,
modalId,
+ role = "dialog",
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ "aria-label": ariaLabel,
preventReactFlowEvents = true,
...otherProps
}: ModalProps) => {
@@ -94,6 +107,10 @@ export const Modal = ({
}, []);
React.useEffect(() => {
+ if (!(ariaLabel || ariaLabelledby || ariaDescribedby) && role && otherProps.isOpen) {
+ // eslint-disable-next-line no-console
+ console.warn(`role=${role} removed from modal because no label or description is available.`);
+ }
modalContext.setModalOpen(uniqueModalId.current, otherProps.isOpen);
}, [otherProps.isOpen]);
@@ -140,6 +157,26 @@ export const Modal = ({
}
};
+ // always remove the role if there is no explanation
+ const modalRole = ariaLabel || ariaLabelledby || ariaDescribedby ? role : undefined;
+
+ // Only the modal that was opened last constrains assistive technologies to its contents.
+ // Without a provided ModalContext the modals do not know about each other, then each of them
+ // has to consider itself as the one that constrains.
+ const openModalStack = modalContext.openModalStack() ?? [];
+ const isTopMostModal = isModalContextProvided(modalContext)
+ ? openModalStack[openModalStack.length - 1] === uniqueModalId.current
+ : true;
+
+ const modalAriaAttributes = {
+ role: modalRole,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ // modality can only be expressed together with a dialog role
+ "aria-modal": modalRole ? isTopMostModal : undefined,
+ };
+
return (
{alteredChildren}
diff --git a/src/components/Dialog/ModalContext.tsx b/src/components/Dialog/ModalContext.tsx
index c0c0b114..e254f6ca 100644
--- a/src/components/Dialog/ModalContext.tsx
+++ b/src/components/Dialog/ModalContext.tsx
@@ -8,49 +8,76 @@ export interface ModalContextProps {
openModalStack(): string[] | undefined;
}
-/** Can be provided in the application to react to modal related changes. */
-export const ModalContext = React.createContext({
+/** Used as long as no `ModalContext` is provided by the application, it does not track anything. */
+const unprovidedModalContext: ModalContextProps = {
setModalOpen: () => {},
openModalStack: () => [],
-});
+};
+
+/** Can be provided in the application to react to modal related changes. */
+export const ModalContext = React.createContext(unprovidedModalContext);
+
+/** Checks if the given modal context is provided by the application, so it really tracks open modals.
+ * Without a provided context the modals cannot know about each other.
+ **/
+export const isModalContextProvided = (modalContext: ModalContextProps): boolean =>
+ modalContext !== unprovidedModalContext;
+
+/** Calculates the stack of open modals after a modal was opened or closed.
+ * Returns the given stack unchanged if it is not affected.
+ **/
+const updatedOpenModalStack = (stack: string[], modalId: string, isOpen: boolean): string[] => {
+ if (isOpen) {
+ // an already registered modal must not be added twice, otherwise closing it would
+ // consider modals as closed that are still open
+ return stack.includes(modalId) ? stack : [...stack, modalId];
+ }
+
+ const idx = stack.findIndex((id) => modalId === id);
+ if (idx === -1) {
+ // Trying to close modal that has not been registered as open!
+ return stack;
+ }
+
+ // If a modal in between is closed, then all modals after it are considered as closed, too.
+ return stack.slice(0, idx);
+};
/** Default implementation for modal context props.
* Tracks open modals in a stack representation.
**/
export const useModalContext = (): ModalContextProps => {
// A stack of modal IDs. These should reflect a stacked opening of modals on top of each other.
+ // It is kept in a ref, so that it can always be read synchronously, even directly after
+ // `setModalOpen` was called.
const currentOpenModalStack = React.useRef([]);
-
- const setOpenModalStack = (stackUpdateFunction: (old: string[]) => string[]) => {
- currentOpenModalStack.current = stackUpdateFunction([...currentOpenModalStack.current]);
- };
+ // Counts the changes of the stack. This way a changed stack re-renders all consumers of the
+ // context, e.g. modals that are not the top most one anymore.
+ const [stackChangeCount, setStackChangeCount] = React.useState(0);
const setModalOpen = React.useCallback((modalId: string, isOpen: boolean) => {
- setOpenModalStack((old) => {
- if (isOpen) {
- return [...old, modalId];
- } else {
- const idx = old.findIndex((id) => modalId === id);
- switch (idx) {
- case -1:
- // Trying to close modal that has not been registered as open!
- return old;
- case old.length - 1:
- return old.slice(0, idx);
- default:
- // Modal in between is closed. Consider all modals after it also as closed.
- return old.slice(0, idx);
- }
- }
- });
+ const updatedStack = updatedOpenModalStack(currentOpenModalStack.current, modalId, isOpen);
+ if (updatedStack !== currentOpenModalStack.current) {
+ currentOpenModalStack.current = updatedStack;
+ setStackChangeCount((count) => count + 1);
+ }
}, []);
- const openModalStack = React.useCallback(() => {
- return currentOpenModalStack.current.length ? [...currentOpenModalStack.current] : undefined;
- }, []);
+ const openModalStack = React.useCallback(
+ () => {
+ return currentOpenModalStack.current.length ? [...currentOpenModalStack.current] : undefined;
+ },
+ // the identity changes with every stack change, so consumers receive a changed context value
+ [stackChangeCount],
+ );
- return {
- openModalStack,
- setModalOpen,
- };
+ // the context value only changes when the stack itself changed, so consumers are not
+ // re-rendered by unrelated re-renders of the providing component
+ return React.useMemo(
+ () => ({
+ openModalStack,
+ setModalOpen,
+ }),
+ [openModalStack, setModalOpen],
+ );
};
diff --git a/src/components/Dialog/SimpleDialog.tsx b/src/components/Dialog/SimpleDialog.tsx
index 54b4e44a..8a061fe6 100644
--- a/src/components/Dialog/SimpleDialog.tsx
+++ b/src/components/Dialog/SimpleDialog.tsx
@@ -23,7 +23,7 @@ export interface SimpleDialogProps extends ModalProps, TestableComponent {
*/
actions?: React.ReactNode | React.ReactNode[];
/**
- * If populated with elements, then a second contant area is included before the action footer.
+ * If populated with elements, then a second content area is included before the action footer.
* Mainly provided to include `Notification` elements.
*/
notifications?: React.ReactNode | React.ReactNode[];
@@ -32,7 +32,7 @@ export interface SimpleDialogProps extends ModalProps, TestableComponent {
*/
headerOptions?: null | React.JSX.Element | React.JSX.Element[];
/**
- * If enabled neither closing via `esc` key or clicking outside of the component will work, except explicitly specified.
+ * If enabled neither closing via `esc` key or clicking outside the component will work, except explicitly specified.
*/
preventSimpleClosing?: boolean;
/**
@@ -51,6 +51,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.
* Inherits all properties from `Modal`.
*/
export const SimpleDialog = ({
@@ -68,9 +70,14 @@ export const SimpleDialog = ({
showFullScreenToggler = false,
startInFullScreenMode = false,
size,
+ role,
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ "aria-label": ariaLabel,
actionsProps,
...otherProps
}: SimpleDialogProps) => {
+ const dialogUniqueId = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [displayFullscreen, setDisplayFullscreen] = React.useState(startInFullScreenMode);
const showToggler = startInFullScreenMode || showFullScreenToggler;
const intentClassName = intent ? `${eccgui}-intent--${intent}` : "";
@@ -78,6 +85,8 @@ export const SimpleDialog = ({
...modalPreventEvents,
...otherProps.wrapperDivProps,
};
+
+ const hasSemanticIntent = intent && ["success", "warning", "danger", "info"].includes(intent);
return (
{title || headerOptions || showToggler ? (
- {title}
+
+ {title}
+
{headerOptions || showToggler ? (
{headerOptions}
@@ -109,7 +124,7 @@ export const SimpleDialog = ({
) : null}
{hasBorder && }
- {children}
+ {children}
{hasBorder && }
{!!notifications && (
{notifications}
diff --git a/src/components/Dialog/stories/ModalContext.stories.tsx b/src/components/Dialog/stories/ModalContext.stories.tsx
index f5b90e7c..719eb829 100644
--- a/src/components/Dialog/stories/ModalContext.stories.tsx
+++ b/src/components/Dialog/stories/ModalContext.stories.tsx
@@ -4,18 +4,18 @@ import { Meta } from "@storybook/react";
import {
Button,
- Card,
- CardContent,
- Modal,
ModalContext,
ModalContextProps,
ModalSize,
+ SimpleDialog,
Spacing,
useModalContext,
} from "./../../../../index";
/**
* `ModalContext` can be used as provider to track a stack of modals.
+ * Always use it when you open modal from inside other modals.
+ * Otherise screen readers may not recognize the correct modal to work with.
*
* ```(Javascript)
* import { ModalContext, SimpleDialog } from "@eccenca/gui-elements";
@@ -72,14 +72,6 @@ export const Usage = () => {
);
};
-const ModalContent = ({ children }: React.HTMLAttributes) => {
- return (
-
- {children}
-
- );
-};
-
/** Component for nested modals. */
const ExampleModal = ({
id,
@@ -98,8 +90,14 @@ const ExampleModal = ({
}, []);
return (
- setIsOpen(false)}>
+ Close
+
+ }
size={size}
isOpen={isOpen}
usePortal={true}
@@ -110,18 +108,11 @@ const ExampleModal = ({
document.body.classList.remove(Classes.OVERLAY_OPEN);
}}
>
-
- Modal with constant modal ID "{id}".
-
-
-
- {children}
-
-
-
-
+
+
+ {children}
+
+
);
};
diff --git a/src/components/Dialog/tests/AlertDialog.test.tsx b/src/components/Dialog/tests/AlertDialog.test.tsx
new file mode 100644
index 00000000..d73a116c
--- /dev/null
+++ b/src/components/Dialog/tests/AlertDialog.test.tsx
@@ -0,0 +1,131 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import "@testing-library/jest-dom";
+
+import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
+import AlertDialog, { AlertDialogProps } from "../AlertDialog";
+
+const dialogWrapper = `${eccgui}-dialog__wrapper`;
+
+const renderAlert = (props: Partial = {}) => {
+ const { children, ...otherProps } = props;
+ const utils = render(
+
+ {children ?? "alert content"}
+ ,
+ );
+ const dialog = utils.container.getElementsByClassName(dialogWrapper)[0] as HTMLElement;
+ return {
+ ...utils,
+ dialog,
+ card: dialog?.getElementsByClassName(`${eccgui}-card`)[0] as HTMLElement,
+ title: dialog?.getElementsByClassName(`${eccgui}-card__title`)[0] as HTMLElement,
+ content: dialog?.getElementsByClassName(`${eccgui}-card__content`)[0] as HTMLElement,
+ };
+};
+
+describe("AlertDialog", () => {
+ describe("rendering", () => {
+ it("should render its content", () => {
+ const { content } = renderAlert();
+ expect(content).toHaveTextContent("alert content");
+ });
+ it("should use the tiny size", () => {
+ const { dialog } = renderAlert();
+ expect(dialog).toHaveClass(`${dialogWrapper}--tiny`);
+ });
+ it("should allow to overwrite the size", () => {
+ const { dialog } = renderAlert({ size: "large" });
+ expect(dialog).toHaveClass(`${dialogWrapper}--large`);
+ });
+ it("should pass on other dialog properties", () => {
+ const { dialog, title } = renderAlert({
+ title: "Alert title",
+ actions: ,
+ hasBorder: true,
+ });
+ expect(title).toHaveTextContent("Alert title");
+ expect(dialog.querySelector("footer")).toContainElement(screen.getByText("confirm"));
+ expect(dialog.getElementsByClassName(`${eccgui}-separation__divider-horizontal`).length).toBe(2);
+ });
+ });
+
+ describe("alert level", () => {
+ it("should use the `info` intent by default", () => {
+ const { card } = renderAlert();
+ expect(card).toHaveClass(`${eccgui}-intent--info`);
+ });
+ it("should use the intent of the set alert level", () => {
+ expect(renderAlert({ success: true }).card).toHaveClass(`${eccgui}-intent--success`);
+ expect(renderAlert({ warning: true }).card).toHaveClass(`${eccgui}-intent--warning`);
+ expect(renderAlert({ danger: true }).card).toHaveClass(`${eccgui}-intent--danger`);
+ });
+ it("should use the most severe alert level if more than one is set", () => {
+ expect(renderAlert({ success: true, warning: true }).card).toHaveClass(`${eccgui}-intent--warning`);
+ expect(renderAlert({ warning: true, danger: true }).card).toHaveClass(`${eccgui}-intent--danger`);
+ expect(renderAlert({ success: true, warning: true, danger: true }).card).toHaveClass(
+ `${eccgui}-intent--danger`,
+ );
+ });
+ });
+
+ describe("aria attributes", () => {
+ it("should always use the `alertdialog` role", () => {
+ expect(renderAlert().dialog).toHaveAttribute("role", "alertdialog");
+ expect(renderAlert({ danger: true }).dialog).toHaveAttribute("role", "alertdialog");
+ });
+ it("should connect the content via `aria-describedby`", () => {
+ const { dialog, content } = renderAlert();
+ expect(content.id).toMatch(/^description_/);
+ expect(dialog).toHaveAttribute("aria-describedby", content.id);
+ });
+ it("should use the alert level as `aria-label` fallback if there is neither title nor label", () => {
+ expect(renderAlert().dialog).toHaveAttribute("aria-label", "info");
+ expect(renderAlert({ warning: true }).dialog).toHaveAttribute("aria-label", "warning");
+ expect(renderAlert({ danger: true }).dialog).toHaveAttribute("aria-label", "danger");
+ });
+ it("should not use the fallback label if a title is given", () => {
+ const { dialog, title } = renderAlert({ title: "Alert title" });
+ expect(dialog).not.toHaveAttribute("aria-label");
+ expect(dialog).toHaveAttribute("aria-labelledby", title.id);
+ });
+ it("should not use the fallback label if a label is given", () => {
+ const { dialog } = renderAlert({ "aria-label": "Alert label" });
+ 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" });
+ expect(dialog).not.toHaveAttribute("aria-label");
+ expect(dialog).toHaveAttribute("aria-labelledby", "externaltitle");
+ });
+ 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");
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ consoleWarnSpy.mockRestore();
+ });
+ });
+
+ describe("closing behaviour", () => {
+ it("should not close on `esc` key or outside click", () => {
+ const onClose = jest.fn();
+ const { container } = renderAlert({ onClose });
+ fireEvent.keyDown(container.getElementsByClassName(dialogWrapper)[0], { key: "Escape" });
+ fireEvent.mouseDown(container.getElementsByClassName(`${eccgui}-dialog__backdrop`)[0]);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ it("should allow to explicitly re-enable closing on `esc` key", () => {
+ const onClose = jest.fn();
+ const { container } = renderAlert({ onClose, canEscapeKeyClose: true });
+ fireEvent.keyDown(container.getElementsByClassName(dialogWrapper)[0], { key: "Escape" });
+ expect(onClose).toHaveBeenCalled();
+ });
+ it("should allow to explicitly re-enable closing on outside click", () => {
+ const onClose = jest.fn();
+ const { container } = renderAlert({ onClose, canOutsideClickClose: true });
+ fireEvent.mouseDown(container.getElementsByClassName(`${eccgui}-dialog__backdrop`)[0]);
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/Dialog/tests/Modal.test.tsx b/src/components/Dialog/tests/Modal.test.tsx
new file mode 100644
index 00000000..1c0536ad
--- /dev/null
+++ b/src/components/Dialog/tests/Modal.test.tsx
@@ -0,0 +1,351 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import "@testing-library/jest-dom";
+
+import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
+import { Card, CardContent } from "../../Card";
+import Modal, { ModalProps } from "../Modal";
+import { ModalContext, ModalContextProps, useModalContext } from "../ModalContext";
+
+const dialogWrapper = `${eccgui}-dialog__wrapper`;
+
+const renderModal = (props: Partial = {}) => {
+ const { children, ...otherProps } = props;
+ const utils = render(
+
+ {children ?? "modal content"}
+ ,
+ );
+ return {
+ ...utils,
+ modal: utils.container.getElementsByClassName(dialogWrapper)[0] as HTMLElement,
+ };
+};
+
+describe("Modal", () => {
+ let consoleWarnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ // a modal without an accessible name warns about its removed role, this is asserted separately
+ consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ consoleWarnSpy.mockRestore();
+ });
+
+ describe("rendering", () => {
+ it("should not render anything if it is not open", () => {
+ const { container } = renderModal({ isOpen: false });
+ expect(container.getElementsByClassName(dialogWrapper).length).toBe(0);
+ expect(screen.queryByText("modal content")).toBeNull();
+ });
+ it("should render its children if it is open", () => {
+ const { modal } = renderModal();
+ expect(modal).toBeVisible();
+ expect(screen.getByText("modal content")).toBeVisible();
+ });
+ it("should use the regular size by default", () => {
+ const { modal } = renderModal();
+ expect(modal).toHaveClass(`${dialogWrapper}--regular`);
+ });
+ it("should use the given size", () => {
+ const { modal } = renderModal({ size: "fullscreen" });
+ expect(modal).toHaveClass(`${dialogWrapper}--fullscreen`);
+ });
+ it("should add a given class name to the modal wrapper", () => {
+ const { modal } = renderModal({ className: "custom-modal" });
+ expect(modal).toHaveClass(dialogWrapper, "custom-modal");
+ });
+ it("should add a given overlay class name to the overlay element", () => {
+ const { container } = renderModal({ overlayClassName: "custom-overlay" });
+ expect(container.getElementsByClassName("custom-overlay").length).toBe(1);
+ });
+ it("should display a backdrop by default", () => {
+ const { container } = renderModal();
+ expect(container.getElementsByClassName(`${eccgui}-dialog__backdrop`).length).toBe(1);
+ });
+ it("should not display a backdrop if `preventBackdrop` is set", () => {
+ const { container } = renderModal({ preventBackdrop: true });
+ expect(container.getElementsByClassName(`${eccgui}-dialog__backdrop`).length).toBe(0);
+ });
+ it("should use a `Card` child only as layout element with a raised elevation", () => {
+ const { modal } = renderModal({
+ children: (
+
+ card content
+
+ ),
+ });
+ const card = modal.getElementsByClassName(`${eccgui}-card`)[0] as HTMLElement;
+ // `isOnlyLayout` prevents the additional `section` wrapper around the card
+ expect(card.parentElement).toBe(modal);
+ expect(card).toHaveClass("bp6-elevation-4");
+ });
+ it("should not alter children that are no `Card` elements", () => {
+ const { modal } = renderModal({ children:
plain child
});
+ expect(modal.querySelector("[data-testid='plain']")).not.toBeNull();
+ });
+ it("should forward properties to the wrapper div element", () => {
+ const { container } = renderModal({ wrapperDivProps: { title: "wrapper title" } });
+ const wrapper = container.querySelector("[title='wrapper title']") as HTMLElement;
+ expect(wrapper).not.toBeNull();
+ expect(wrapper.getElementsByClassName(dialogWrapper).length).toBe(1);
+ });
+ });
+
+ describe("test ids", () => {
+ it("should use a fallback test id because it cannot be routed through the overlay", () => {
+ const { container } = renderModal();
+ expect(container.querySelector("[data-test-id='simpleDialogWidget']")).not.toBeNull();
+ });
+ it("should use the given test ids", () => {
+ const { container } = renderModal({ "data-test-id": "myModal", "data-testid": "myModalTestid" });
+ expect(container.querySelector("[data-test-id='myModal']")).not.toBeNull();
+ expect(container.querySelector("[data-testid='myModalTestid']")).not.toBeNull();
+ });
+ });
+
+ describe("aria attributes", () => {
+ it("should use the `dialog` role by default", () => {
+ const { modal } = renderModal({ "aria-label": "Modal label" });
+ expect(modal.tagName).toBe("SECTION");
+ expect(modal).toHaveAttribute("role", "dialog");
+ });
+ it("should use a given role", () => {
+ const { modal } = renderModal({ role: "alertdialog", "aria-label": "Modal label" });
+ expect(modal).toHaveAttribute("role", "alertdialog");
+ });
+ it("should not set any label or description attribute automatically", () => {
+ const { modal } = renderModal();
+ expect(modal).not.toHaveAttribute("aria-label");
+ expect(modal).not.toHaveAttribute("aria-labelledby");
+ expect(modal).not.toHaveAttribute("aria-describedby");
+ });
+ it("should set given label and description attributes on the modal element", () => {
+ const { modal } = renderModal({
+ "aria-label": "Modal label",
+ "aria-labelledby": "customtitle",
+ "aria-describedby": "customdescription",
+ });
+ expect(modal).toHaveAttribute("aria-label", "Modal label");
+ expect(modal).toHaveAttribute("aria-labelledby", "customtitle");
+ expect(modal).toHaveAttribute("aria-describedby", "customdescription");
+ });
+ it("should remove the role if there is neither a label nor a description", () => {
+ const { modal } = renderModal();
+ expect(modal).not.toHaveAttribute("role");
+ });
+ it("should also remove an explicitly given role if there is no label or description", () => {
+ const { modal } = renderModal({ role: "alertdialog" });
+ expect(modal).not.toHaveAttribute("role");
+ });
+ it("should keep the role if any label or description is available", () => {
+ expect(renderModal({ "aria-label": "Modal label" }).modal).toHaveAttribute("role", "dialog");
+ expect(renderModal({ "aria-labelledby": "customtitle" }).modal).toHaveAttribute("role", "dialog");
+ expect(renderModal({ "aria-describedby": "customdescription" }).modal).toHaveAttribute("role", "dialog");
+ });
+ it("should warn about a removed role", () => {
+ renderModal({ role: "alertdialog" });
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("role=alertdialog removed"));
+ });
+ it("should not warn if the modal has an accessible name", () => {
+ renderModal({ "aria-label": "Modal label" });
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("focus and closing behaviour", () => {
+ it("should be focusable by default", () => {
+ const { container } = renderModal();
+ const wrapper = container.getElementsByClassName("bp6-dialog-container")[0] as HTMLElement;
+ expect(wrapper).toHaveAttribute("tabindex", "0");
+ });
+ it("should not close on `esc` key or outside click by default", () => {
+ const onClose = jest.fn();
+ const { container } = renderModal({ onClose });
+ fireEvent.keyDown(container.getElementsByClassName(dialogWrapper)[0], { key: "Escape" });
+ fireEvent.mouseDown(container.getElementsByClassName(`${eccgui}-dialog__backdrop`)[0]);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ it("should close on `esc` key if `canEscapeKeyClose` is set", () => {
+ const onClose = jest.fn();
+ const { container } = renderModal({ onClose, canEscapeKeyClose: true });
+ fireEvent.keyDown(container.getElementsByClassName(dialogWrapper)[0], { key: "Escape" });
+ expect(onClose).toHaveBeenCalled();
+ });
+ it("should close on outside click if `canOutsideClickClose` is set", () => {
+ const onClose = jest.fn();
+ const { container } = renderModal({ onClose, canOutsideClickClose: true });
+ fireEvent.mouseDown(container.getElementsByClassName(`${eccgui}-dialog__backdrop`)[0]);
+ expect(onClose).toHaveBeenCalled();
+ });
+ it("should make the backdrop focusable if only the `esc` key can close the modal", () => {
+ const { container } = renderModal({ canEscapeKeyClose: true, canOutsideClickClose: false });
+ const backdrop = container.getElementsByClassName(`${eccgui}-dialog__backdrop`)[0] as HTMLElement;
+ expect(backdrop).toHaveAttribute("tabindex", "0");
+ });
+ });
+
+ describe("react-flow event prevention", () => {
+ it("should prevent react-flow events by default", () => {
+ const { container } = renderModal();
+ const overlay = container.firstElementChild as HTMLElement;
+ expect(overlay).toHaveClass("nodrag", "nopan", "nowheel");
+ });
+ it("should not prevent react-flow events if switched off", () => {
+ const { container } = renderModal({ preventReactFlowEvents: false });
+ const overlay = container.firstElementChild as HTMLElement;
+ expect(overlay).not.toHaveClass("nodrag");
+ });
+ });
+
+ describe("event handler", () => {
+ it("should still call a given `onOpening` handler", () => {
+ const onOpening = jest.fn();
+ renderModal({ onOpening });
+ expect(onOpening).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("modality", () => {
+ const TrackedModals = ({ children }: { children: React.ReactNode }) => {
+ const modalContext = useModalContext();
+ return {children};
+ };
+
+ it("should claim modality if no modal context is provided", () => {
+ // without a provided context the modals do not know about each other
+ const { modal } = renderModal({ "aria-label": "Modal label" });
+ expect(modal).toHaveAttribute("aria-modal", "true");
+ });
+ it("should not claim modality if a provided context does not track this modal", () => {
+ const untrackedContext: ModalContextProps = { setModalOpen: () => undefined, openModalStack: () => [] };
+ const { container } = render(
+
+
+ untracked content
+
+ ,
+ );
+ const modal = container.getElementsByClassName(dialogWrapper)[0] as HTMLElement;
+ expect(modal).toHaveAttribute("aria-modal", "false");
+ });
+ it("should claim modality for the only open modal", () => {
+ const { container } = render(
+
+
+ only content
+
+ ,
+ );
+ const modal = container.getElementsByClassName(dialogWrapper)[0] as HTMLElement;
+ expect(modal).toHaveAttribute("aria-modal", "true");
+ });
+ it("should claim modality only for the modal that was opened last", () => {
+ const { container } = render(
+
+
+ below content
+
+
+ content on top
+
+ ,
+ );
+ const modalBelow = container.querySelector(`.${dialogWrapper}[aria-label='below']`) as HTMLElement;
+ const modalOnTop = container.querySelector(`.${dialogWrapper}[aria-label='ontop']`) as HTMLElement;
+ expect(modalOnTop).toHaveAttribute("aria-modal", "true");
+ expect(modalBelow).toHaveAttribute("aria-modal", "false");
+ });
+ it("should hand over modality to a modal that is opened on top", () => {
+ const modalStack = (secondOpen: boolean) => (
+
+
+ below content
+
+
+ content on top
+
+
+ );
+ const { container, rerender } = render(modalStack(false));
+ const modalBelow = container.querySelector(`.${dialogWrapper}[aria-label='below']`) as HTMLElement;
+ expect(modalBelow).toHaveAttribute("aria-modal", "true");
+
+ rerender(modalStack(true));
+ const modalOnTop = container.querySelector(`.${dialogWrapper}[aria-label='ontop']`) as HTMLElement;
+ expect(modalOnTop).toHaveAttribute("aria-modal", "true");
+ expect(modalBelow).toHaveAttribute("aria-modal", "false");
+ });
+ it("should not claim modality if the role was removed", () => {
+ const { container } = render(
+
+
+ content without any label
+
+ ,
+ );
+ const modal = container.getElementsByClassName(dialogWrapper)[0] as HTMLElement;
+ expect(modal).not.toHaveAttribute("role");
+ expect(modal).not.toHaveAttribute("aria-modal");
+ });
+ });
+
+ describe("modal context", () => {
+ const renderWithContext = (props: Partial = {}) => {
+ const setModalOpen = jest.fn();
+ const modalContext: ModalContextProps = { setModalOpen, openModalStack: () => [] };
+ const { unmount, rerender } = render(
+
+
+ modal content
+
+ ,
+ );
+ const rerenderModal = (newProps: Partial) =>
+ rerender(
+
+
+ modal content
+
+ ,
+ );
+ return { setModalOpen, unmount, rerender: rerenderModal };
+ };
+
+ it("should register the open state of the modal", () => {
+ const { setModalOpen } = renderWithContext();
+ expect(setModalOpen).toHaveBeenCalledWith("testmodal", true);
+ });
+ it("should register a state change of the modal", () => {
+ const { setModalOpen, rerender } = renderWithContext();
+ setModalOpen.mockClear();
+ rerender({ isOpen: false });
+ expect(setModalOpen).toHaveBeenCalledWith("testmodal", false);
+ });
+ it("should register the modal as closed when it is removed", () => {
+ const { setModalOpen, unmount } = renderWithContext();
+ setModalOpen.mockClear();
+ unmount();
+ expect(setModalOpen).toHaveBeenCalledWith("testmodal", false);
+ });
+ it("should create a unique modal ID if none is given", () => {
+ const setModalOpen = jest.fn();
+ const modalContext: ModalContextProps = { setModalOpen, openModalStack: () => [] };
+ render(
+
+
+ first
+
+
+ second
+
+ ,
+ );
+ const registeredIds = setModalOpen.mock.calls.map(([modalId]) => modalId);
+ expect(registeredIds[0]).not.toBe(registeredIds[1]);
+ });
+ });
+});
diff --git a/src/components/Dialog/tests/ModalContext.test.tsx b/src/components/Dialog/tests/ModalContext.test.tsx
new file mode 100644
index 00000000..e0a288b2
--- /dev/null
+++ b/src/components/Dialog/tests/ModalContext.test.tsx
@@ -0,0 +1,65 @@
+import { act, renderHook } from "@testing-library/react";
+
+import { useModalContext } from "../ModalContext";
+
+describe("useModalContext", () => {
+ it("should provide no stack as long as no modal is open", () => {
+ const { result } = renderHook(() => useModalContext());
+ expect(result.current.openModalStack()).toBeUndefined();
+ });
+ it("should provide the stack synchronously, even directly after a change", () => {
+ const { result } = renderHook(() => useModalContext());
+ act(() => {
+ result.current.setModalOpen("first", true);
+ // the stack must be readable without waiting for a re-render
+ expect(result.current.openModalStack()).toEqual(["first"]);
+ result.current.setModalOpen("second", true);
+ expect(result.current.openModalStack()).toEqual(["first", "second"]);
+ });
+ expect(result.current.openModalStack()).toEqual(["first", "second"]);
+ });
+ it("should order the stack by the time the modals were opened", () => {
+ const { result } = renderHook(() => useModalContext());
+ act(() => {
+ ["first", "second", "third"].forEach((modalId) => result.current.setModalOpen(modalId, true));
+ });
+ expect(result.current.openModalStack()).toEqual(["first", "second", "third"]);
+ });
+ it("should consider modals as closed that were opened after a closed modal", () => {
+ const { result } = renderHook(() => useModalContext());
+ act(() => {
+ ["first", "second", "third"].forEach((modalId) => result.current.setModalOpen(modalId, true));
+ result.current.setModalOpen("second", false);
+ });
+ expect(result.current.openModalStack()).toEqual(["first"]);
+ });
+ it("should not register the same modal twice", () => {
+ const { result } = renderHook(() => useModalContext());
+ act(() => {
+ result.current.setModalOpen("same", true);
+ result.current.setModalOpen("same", true);
+ });
+ expect(result.current.openModalStack()).toEqual(["same"]);
+ });
+ it("should provide a changed context value whenever the stack changed", () => {
+ const { result } = renderHook(() => useModalContext());
+ const contextValueBefore = result.current;
+ act(() => {
+ result.current.setModalOpen("first", true);
+ });
+ expect(result.current).not.toBe(contextValueBefore);
+ });
+ it("should keep the context value if nothing changed, so consumers are not re-rendered", () => {
+ const { result } = renderHook(() => useModalContext());
+ act(() => {
+ result.current.setModalOpen("first", true);
+ });
+ const contextValueBefore = result.current;
+ act(() => {
+ // closing a modal that was never registered as open changes nothing
+ result.current.setModalOpen("unknown", false);
+ });
+ expect(result.current).toBe(contextValueBefore);
+ expect(result.current.openModalStack()).toEqual(["first"]);
+ });
+});
diff --git a/src/components/Dialog/tests/SimpleDialog.test.tsx b/src/components/Dialog/tests/SimpleDialog.test.tsx
new file mode 100644
index 00000000..f75d800d
--- /dev/null
+++ b/src/components/Dialog/tests/SimpleDialog.test.tsx
@@ -0,0 +1,295 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import "@testing-library/jest-dom";
+
+import { IntentTypes } from "../../../common/Intent";
+import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
+import SimpleDialog, { SimpleDialogProps } from "../SimpleDialog";
+
+const dialogWrapper = `${eccgui}-dialog__wrapper`;
+
+const renderDialog = (props: Partial = {}) => {
+ const { children, ...otherProps } = props;
+ const utils = render(
+
+ {children ?? "dialog content"}
+ ,
+ );
+ const dialog = utils.container.getElementsByClassName(dialogWrapper)[0] as HTMLElement;
+ return {
+ ...utils,
+ dialog,
+ card: dialog?.getElementsByClassName(`${eccgui}-card`)[0] as HTMLElement,
+ title: dialog?.getElementsByClassName(`${eccgui}-card__title`)[0] as HTMLElement,
+ content: dialog?.getElementsByClassName(`${eccgui}-card__content`)[0] as HTMLElement,
+ toggler: dialog?.querySelector(`.${eccgui}-card__options button`) as HTMLElement,
+ };
+};
+
+describe("SimpleDialog", () => {
+ let consoleWarnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ // a dialog without an accessible name warns about its removed role, this is asserted separately
+ consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ consoleWarnSpy.mockRestore();
+ });
+
+ describe("rendering", () => {
+ it("should render the content inside a card", () => {
+ const { card, content } = renderDialog();
+ expect(card).toBeVisible();
+ expect(content).toHaveTextContent("dialog content");
+ });
+ it("should render a header only if there is a title, header options or a toggler", () => {
+ const { dialog } = renderDialog();
+ expect(dialog.querySelector("header")).toBeNull();
+
+ expect(renderDialog({ title: "My title" }).title).toHaveTextContent("My title");
+ expect(renderDialog({ headerOptions: option }).dialog.querySelector("header")).not.toBeNull();
+ expect(renderDialog({ showFullScreenToggler: true }).dialog.querySelector("header")).not.toBeNull();
+ });
+ it("should render actions in a footer", () => {
+ const { dialog } = renderDialog({ actions: });
+ const footer = dialog.querySelector("footer") as HTMLElement;
+ expect(footer).toHaveClass(`${eccgui}-card__actions--inversedirection`);
+ expect(footer).toContainElement(screen.getByText("action button"));
+ });
+ it("should forward properties to the actions footer", () => {
+ const { dialog } = renderDialog({
+ actions: ,
+ actionsProps: { className: "custom-actions" },
+ });
+ expect(dialog.querySelector("footer")).toHaveClass("custom-actions");
+ });
+ it("should not render a footer if there are no actions", () => {
+ const { dialog } = renderDialog();
+ expect(dialog.querySelector("footer")).toBeNull();
+ });
+ it("should render notifications in an own content area", () => {
+ const { dialog } = renderDialog({ notifications: notification });
+ const notifications = dialog.getElementsByClassName(`${eccgui}-dialog__notifications`)[0] as HTMLElement;
+ expect(notifications).toContainElement(screen.getByText("notification"));
+ });
+ it("should not render dividers by default", () => {
+ const { dialog } = renderDialog({ actions: });
+ expect(dialog.getElementsByClassName(`${eccgui}-separation__divider-horizontal`).length).toBe(0);
+ });
+ it("should render dividers around the content if `hasBorder` is set", () => {
+ const { dialog } = renderDialog({ hasBorder: true, actions: });
+ expect(dialog.getElementsByClassName(`${eccgui}-separation__divider-horizontal`).length).toBe(2);
+ });
+ it("should use a fallback test id", () => {
+ const { container } = renderDialog();
+ expect(container.querySelector("[data-test-id='simpleDialogWidget']")).not.toBeNull();
+ });
+ it("should use a given test id", () => {
+ const { container } = renderDialog({ "data-test-id": "myDialog" });
+ expect(container.querySelector("[data-test-id='myDialog']")).not.toBeNull();
+ });
+ });
+
+ describe("intent state", () => {
+ it("should not add any intent class name by default", () => {
+ const { card } = renderDialog();
+ expect(card.className).not.toContain(`${eccgui}-intent--`);
+ });
+ it("should add the intent class name to card, title and actions", () => {
+ const { card, title, dialog } = renderDialog({
+ intent: "warning",
+ title: "My title",
+ actions: ,
+ });
+ expect(card).toHaveClass(`${eccgui}-intent--warning`);
+ expect(title).toHaveClass(`${eccgui}-intent--warning`);
+ expect(dialog.querySelector("footer")).toHaveClass(`${eccgui}-intent--warning`);
+ });
+ });
+
+ describe("aria attributes", () => {
+ it("should use the `dialog` role if there is no alert intent state", () => {
+ const { dialog } = renderDialog({ title: "My title" });
+ expect(dialog).toHaveAttribute("role", "dialog");
+ });
+ it("should use the `alertdialog` role if an intent state is set", () => {
+ const { dialog } = renderDialog({ intent: "danger" });
+ expect(dialog).toHaveAttribute("role", "alertdialog");
+ });
+ it("should use a given role", () => {
+ expect(renderDialog({ role: "dialog", intent: "danger" }).dialog).toHaveAttribute("role", "dialog");
+ expect(renderDialog({ role: "alertdialog", title: "My title" }).dialog).toHaveAttribute(
+ "role",
+ "alertdialog",
+ );
+ });
+ it("should remove the role if there is neither title, label nor alert intent state", () => {
+ const { dialog } = renderDialog();
+ expect(dialog).not.toHaveAttribute("role");
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("removed from modal"));
+ });
+ it("should keep the `dialog` role if only a label is given", () => {
+ const { dialog } = renderDialog({ "aria-label": "Dialog label" });
+ expect(dialog).toHaveAttribute("role", "dialog");
+ });
+ it("should connect the title to the dialog via `aria-labelledby`", () => {
+ const { dialog, title } = renderDialog({ title: "My title" });
+ expect(title.id).toMatch(/^title_/);
+ expect(dialog).toHaveAttribute("aria-labelledby", title.id);
+ });
+ it("should not set `aria-labelledby` if there is no title", () => {
+ const { dialog, content } = renderDialog();
+ expect(dialog).not.toHaveAttribute("aria-labelledby");
+ expect(content.id).toBe("");
+ });
+ it("should use a given `aria-labelledby`", () => {
+ const { dialog } = renderDialog({ title: "My title", "aria-labelledby": "customtitle" });
+ expect(dialog).toHaveAttribute("aria-labelledby", "customtitle");
+ });
+ it("should connect the content to the dialog via `aria-describedby` if an intent state is set", () => {
+ const { dialog, content } = renderDialog({ intent: "info" });
+ expect(content.id).toMatch(/^description_/);
+ expect(dialog).toHaveAttribute("aria-describedby", content.id);
+ });
+ it("should not set `aria-describedby` if there is no intent state", () => {
+ const { dialog, content } = renderDialog({ title: "My title" });
+ expect(dialog).not.toHaveAttribute("aria-describedby");
+ expect(content.id).toBe("");
+ });
+ it("should use a given `aria-describedby`", () => {
+ const { dialog } = renderDialog({ intent: "info", "aria-describedby": "customdescription" });
+ expect(dialog).toHaveAttribute("aria-describedby", "customdescription");
+ });
+ it("should forward a given `aria-label`", () => {
+ const { dialog } = renderDialog({ "aria-label": "Dialog label" });
+ expect(dialog).toHaveAttribute("aria-label", "Dialog label");
+ });
+ it("should use alert semantics for each alert intent state", () => {
+ (["success", "warning", "danger", "info"] as IntentTypes[]).forEach((intent) => {
+ const { dialog, content } = renderDialog({ intent, title: "My title" });
+ expect(dialog).toHaveAttribute("role", "alertdialog");
+ expect(content.id).toMatch(/^description_/);
+ expect(dialog).toHaveAttribute("aria-describedby", content.id);
+ });
+ });
+ it("should not use alert semantics for intent states that describe no alert", () => {
+ (["none", "primary", "accent", "neutral"] as IntentTypes[]).forEach((intent) => {
+ const { dialog, card, content } = renderDialog({ intent, title: "My title" });
+ expect(dialog).toHaveAttribute("role", "dialog");
+ expect(dialog).not.toHaveAttribute("aria-describedby");
+ expect(content.id).toBe("");
+ // the intent is still displayed, it only carries no alert semantics
+ expect(card).toHaveClass(`${eccgui}-intent--${intent}`);
+ });
+ });
+ it("should create unique IDs for each dialog", () => {
+ const { container } = render(
+ <>
+
+ first content
+
+
+ second content
+
+ >,
+ );
+ const [first, second] = Array.from(container.getElementsByClassName(dialogWrapper)) as HTMLElement[];
+ expect(first.getAttribute("aria-labelledby")).not.toBe(second.getAttribute("aria-labelledby"));
+ expect(first.getAttribute("aria-describedby")).not.toBe(second.getAttribute("aria-describedby"));
+ });
+ });
+
+ describe("full screen mode", () => {
+ it("should not show a toggler by default", () => {
+ const { toggler } = renderDialog({ title: "My title" });
+ expect(toggler).toBeNull();
+ });
+ it("should switch to full screen mode by using the toggler", () => {
+ const { dialog, toggler } = renderDialog({ showFullScreenToggler: true, size: "small" });
+ expect(dialog).toHaveClass(`${dialogWrapper}--small`);
+
+ fireEvent.click(toggler);
+ expect(dialog).toHaveClass(`${dialogWrapper}--fullscreen`);
+
+ fireEvent.click(toggler);
+ expect(dialog).toHaveClass(`${dialogWrapper}--small`);
+ });
+ it("should start in full screen mode and enable the toggler", () => {
+ const { dialog, toggler } = renderDialog({ startInFullScreenMode: true, size: "small" });
+ expect(dialog).toHaveClass(`${dialogWrapper}--fullscreen`);
+ expect(toggler).not.toBeNull();
+
+ fireEvent.click(toggler);
+ expect(dialog).toHaveClass(`${dialogWrapper}--small`);
+ });
+ it("should display header options beside the toggler", () => {
+ const { dialog } = renderDialog({
+ showFullScreenToggler: true,
+ headerOptions: option,
+ });
+ const options = dialog.getElementsByClassName(`${eccgui}-card__options`)[0] as HTMLElement;
+ expect(options).toContainElement(screen.getByText("option"));
+ expect(options.querySelectorAll("button").length).toBe(1);
+ });
+ });
+
+ describe("closing behaviour", () => {
+ it("should close on `esc` key and outside click by default", () => {
+ const onClose = jest.fn();
+ const { container } = renderDialog({ onClose });
+ fireEvent.keyDown(container.getElementsByClassName(dialogWrapper)[0], { key: "Escape" });
+ expect(onClose).toHaveBeenCalled();
+
+ onClose.mockClear();
+ fireEvent.mouseDown(container.getElementsByClassName(`${eccgui}-dialog__backdrop`)[0]);
+ expect(onClose).toHaveBeenCalled();
+ });
+ it("should not close on `esc` key or outside click if `preventSimpleClosing` is set", () => {
+ const onClose = jest.fn();
+ const { container } = renderDialog({ onClose, preventSimpleClosing: true });
+ fireEvent.keyDown(container.getElementsByClassName(dialogWrapper)[0], { key: "Escape" });
+ fireEvent.mouseDown(container.getElementsByClassName(`${eccgui}-dialog__backdrop`)[0]);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ it("should allow to explicitly re-enable closing on `esc` key", () => {
+ const onClose = jest.fn();
+ const { container } = renderDialog({ onClose, preventSimpleClosing: true, canEscapeKeyClose: true });
+ fireEvent.keyDown(container.getElementsByClassName(dialogWrapper)[0], { key: "Escape" });
+ expect(onClose).toHaveBeenCalled();
+ });
+ it("should allow to explicitly re-enable closing on outside click", () => {
+ const onClose = jest.fn();
+ const { container } = renderDialog({ onClose, preventSimpleClosing: true, canOutsideClickClose: true });
+ fireEvent.mouseDown(container.getElementsByClassName(`${eccgui}-dialog__backdrop`)[0]);
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ describe("event prevention", () => {
+ it("should prevent that certain events bubble up from the dialog", () => {
+ const onClick = jest.fn();
+ const onContextMenu = jest.fn();
+ const { container } = render(
+