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( +
+ + dialog content + +
, + ); + const content = container.getElementsByClassName(`${eccgui}-card__content`)[0] as HTMLElement; + fireEvent.click(content); + fireEvent.contextMenu(content); + expect(onClick).not.toHaveBeenCalled(); + expect(onContextMenu).not.toHaveBeenCalled(); + }); + it("should keep given properties of the modal wrapper div element", () => { + const onMouseEnter = jest.fn(); + const { container } = renderDialog({ wrapperDivProps: { onMouseEnter } }); + fireEvent.mouseEnter(container.getElementsByClassName("bp6-dialog-container")[0]); + expect(onMouseEnter).toHaveBeenCalled(); + }); + }); +});