From e22a7e328dfecb6e219ad357fe85241f935e81cb Mon Sep 17 00:00:00 2001 From: jmainguytalend <102585684+jmainguytalend@users.noreply.github.com> Date: Fri, 12 Jan 2024 10:35:14 +0100 Subject: [PATCH] feat(DFD-424): allow close btn but without interactive backdrop (#5110) --- .changeset/pink-roses-shout.md | 5 ++ packages/design-system/package.json | 3 + .../src/components/Modal/Modal.test.tsx | 67 ++++++++++++++++++- .../src/components/Modal/Modal.tsx | 28 ++++++-- .../src/stories/layout/Modal.mdx | 8 ++- .../src/stories/layout/Modal.stories.tsx | 55 ++++++++++++--- 6 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 .changeset/pink-roses-shout.md diff --git a/.changeset/pink-roses-shout.md b/.changeset/pink-roses-shout.md new file mode 100644 index 00000000000..26f0e95fd59 --- /dev/null +++ b/.changeset/pink-roses-shout.md @@ -0,0 +1,5 @@ +--- +'@talend/design-system': minor +--- + +DS (modal) : allow to use close button without using interactive backdrop diff --git a/packages/design-system/package.json b/packages/design-system/package.json index bec650faba4..50891f4fc88 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -73,8 +73,10 @@ "@talend/scripts-config-typescript": "^11.2.0", "@talend/storybook-docs": "^2.3.0", "@testing-library/cypress": "^10.0.1", + "@testing-library/jest-dom": "^6.1.5", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/classnames": "^2.3.1", + "@types/jest": "^29.5.11", "@types/jest-axe": "^3.5.9", "@types/react-is": "^18.2.4", "@types/react": "^18.2.43", @@ -105,6 +107,7 @@ "@talend/icons": "^7.0.0", "@talend/locales-design-system": "^7.15.1", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.1", "i18next": "^23.7.7", "react": "^16.14.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0", diff --git a/packages/design-system/src/components/Modal/Modal.test.tsx b/packages/design-system/src/components/Modal/Modal.test.tsx index 6d3761187ec..8e0260f5626 100644 --- a/packages/design-system/src/components/Modal/Modal.test.tsx +++ b/packages/design-system/src/components/Modal/Modal.test.tsx @@ -1,8 +1,37 @@ /* eslint-disable import/no-extraneous-dependencies */ -import { describe, it, expect } from '@jest/globals'; +import { useState } from 'react'; + +import { describe, expect, it } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; -import { render } from '@testing-library/react'; -import { Modal } from './'; + +import { ButtonPrimary } from '../Button'; +import { Modal, ModalPropsType } from './'; + +function ModalComponentTester(props: Partial) { + const [modalOpen, setModalOpen] = useState(false); + + return ( + <> + setModalOpen(true)} data-testid="open-modal"> + See + + + {modalOpen && ( + { + setModalOpen(false); + }} + {...props} + /> + )} + + ); +} describe('Message', () => { it('should render a11y html', async () => { @@ -18,4 +47,36 @@ describe('Message', () => { const results = await axe(document.body); expect(results).toHaveNoViolations(); }); + + it('should close the modal on backdrop click', async () => { + const user = userEvent.setup(); + + render(); + + const openModalBtn = screen.getByTestId('open-modal'); + expect(openModalBtn).toBeInTheDocument(); + user.click(openModalBtn); + + const modalBackdrop = await screen.findByTestId('open-modal'); + expect(modalBackdrop).toBeInTheDocument(); + user.click(modalBackdrop); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('should not close the modal on backdrop click', async () => { + const user = userEvent.setup(); + + render(); + + const openModalBtn = screen.getByTestId('open-modal'); + expect(openModalBtn).toBeInTheDocument(); + user.click(openModalBtn); + + const modalBackdrop = await screen.findByTestId('open-modal'); + expect(modalBackdrop).toBeInTheDocument(); + user.click(modalBackdrop); + + expect(await screen.findByTestId('modal')).toBeInTheDocument(); + }); }); diff --git a/packages/design-system/src/components/Modal/Modal.tsx b/packages/design-system/src/components/Modal/Modal.tsx index c8d70ee0daa..22d6ba0d57e 100644 --- a/packages/design-system/src/components/Modal/Modal.tsx +++ b/packages/design-system/src/components/Modal/Modal.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, cloneElement } from 'react'; -import type { ReactNode, ReactElement, MouseEvent as ReactMouseEvent } from 'react'; +import { cloneElement, useCallback, useEffect, useMemo, useRef } from 'react'; +import type { ReactElement, MouseEvent as ReactMouseEvent, ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { DeprecatedIconNames } from '../../types'; @@ -42,6 +42,7 @@ export type ModalPropsType = { secondaryAction?: ButtonSecondaryPropsType<'M'>; preventEscaping?: boolean; children: ReactNode | ReactNode[]; + preventInteractiveBackdrop?: boolean; } & DialogPropsType; function PrimaryAction(props: PrimaryActionPropsType) { @@ -63,6 +64,7 @@ export function Modal(props: ModalPropsType): ReactElement { secondaryAction, preventEscaping, children, + preventInteractiveBackdrop, ...rest } = props; const hasDisclosure = 'disclosure' in props; @@ -77,13 +79,25 @@ export function Modal(props: ModalPropsType): ReactElement { dialogRef.current?.focus(); }, [dialogRef]); - const onCloseHandler = hasDisclosure ? () => dialog.hide() : () => onClose && onClose(); + const onCloseHandler = useMemo( + () => (hasDisclosure ? () => dialog.hide() : () => onClose && onClose()), + [dialog, hasDisclosure, onClose], + ); + + const onClickBackdropHandler = useCallback( + (event: ReactMouseEvent) => { + if (!preventEscaping && !preventInteractiveBackdrop && event.target === backdropRef.current) { + onCloseHandler(); + } + }, + [onCloseHandler, preventInteractiveBackdrop, preventEscaping], + ); - const onClickBackdropHandler = (event: ReactMouseEvent) => { - if (event.target === backdropRef.current) { + const onHideDialog = useCallback(() => { + if (!preventEscaping && !preventInteractiveBackdrop) { onCloseHandler(); } - }; + }, [onCloseHandler, preventInteractiveBackdrop, preventEscaping]); return ( <> @@ -108,7 +122,7 @@ export function Modal(props: ModalPropsType): ReactElement { data-test="modal" data-testid="modal" className={styles.modal} - hide={preventEscaping ? () => undefined : () => onCloseHandler()} + hide={onHideDialog} aria-labelledby={titleId} ref={dialogRef} > diff --git a/packages/design-system/src/stories/layout/Modal.mdx b/packages/design-system/src/stories/layout/Modal.mdx index 5186746ac94..2d9649af36a 100644 --- a/packages/design-system/src/stories/layout/Modal.mdx +++ b/packages/design-system/src/stories/layout/Modal.mdx @@ -76,6 +76,10 @@ Transparent layer behind every modal. Clicking it closes the modal by default, b +**With no clickable backdrop** + + + **With actions** @@ -84,9 +88,9 @@ Transparent layer behind every modal. Clicking it closes the modal by default, b -**With non closing backdrop** +**With no Escape** - + **With overflowing header** diff --git a/packages/design-system/src/stories/layout/Modal.stories.tsx b/packages/design-system/src/stories/layout/Modal.stories.tsx index 2fa00575c9b..44f47f5a868 100644 --- a/packages/design-system/src/stories/layout/Modal.stories.tsx +++ b/packages/design-system/src/stories/layout/Modal.stories.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; -import { StoryFn } from '@storybook/react'; + import { action } from '@storybook/addon-actions'; +import { StoryFn } from '@storybook/react'; -import { ButtonPrimary, Modal } from '../../'; +import { ButtonPrimary, LinkAsButton, Modal, StackVertical } from '../../'; import { type ModalPropsType } from '../../components/Modal'; export default { @@ -183,6 +184,16 @@ export const WithDescription: StoryFn = props => ( ); +export const WithNoClickableBackdrop: StoryFn = props => ( + +

A basic modal with title, a text content and an icon.

+
+); + export const WithActions: StoryFn = props => ( = props => ( ); -export const WithNonClosingBackdrop: StoryFn = props => ( - -

- A modal that doesn't trigger onClose when the backdrop is clicked and without the - close button -

-
-); +export const WithNoEscape: StoryFn = () => { + const [modalOpen, setModalOpen] = useState(false); + return ( + <> + setModalOpen(true)} data-test="open-modal"> + See + + + {modalOpen && ( + { + action('onClose'); + setModalOpen(false); + }} + preventEscaping + > + +

+ A modal that doesn't trigger onClose when the backdrop is clicked and + without the close button +

+ + setModalOpen(false)} data-test="close-modal"> + Close me ! + +
+
+ )} + + ); +}; export const WithOverflowingHeader: StoryFn = props => (