From ce2bb3a369ce9049a2097182d07a419df309fd84 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Thu, 14 Mar 2024 16:45:39 +0700 Subject: [PATCH] feat(ui-components): implement Modal component with headlessui (#54044) Co-authored-by: Sboonny --- .../ui-components/src/modal/modal.stories.tsx | 155 +++++++++++++++++ tools/ui-components/src/modal/modal.test.tsx | 162 ++++++++++++++++++ tools/ui-components/src/modal/modal.tsx | 124 ++++++++++++++ tools/ui-components/src/modal/types.ts | 14 ++ tools/ui-components/src/normalize.css | 8 + tools/ui-components/tailwind.config.js | 3 +- 6 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 tools/ui-components/src/modal/modal.stories.tsx create mode 100644 tools/ui-components/src/modal/modal.test.tsx create mode 100644 tools/ui-components/src/modal/modal.tsx create mode 100644 tools/ui-components/src/modal/types.ts diff --git a/tools/ui-components/src/modal/modal.stories.tsx b/tools/ui-components/src/modal/modal.stories.tsx new file mode 100644 index 00000000000..e326682c5e0 --- /dev/null +++ b/tools/ui-components/src/modal/modal.stories.tsx @@ -0,0 +1,155 @@ +import React, { useState } from 'react'; +import { StoryObj, StoryFn, Meta } from '@storybook/react'; + +import { Button } from '../button'; +import { Modal } from './modal'; +import { type ModalProps, type HeaderProps } from './types'; + +const story = { + title: 'Example/Modal', + component: Modal, + args: { + size: 'medium', + variant: 'default' + }, + argTypes: { + open: { + control: false + }, + onClose: { + control: false + }, + size: { + options: ['medium', 'large'] + }, + variant: { + options: ['default', 'danger'] + } + } +} satisfies Meta; + +type Story = StoryObj; + +const Spacer = () =>
; + +const DefaultTemplate: StoryFn = ({ + showCloseButton, + ...modalProps +}) => { + const [open, setOpen] = useState(false); + + const handleClose = () => setOpen(false); + + return ( +
+ + + + Lorem ipsum + + +

+ Laboriosam autem non et nisi. Ut voluptatem sit beatae assumenda + amet aliquam corporis. +

+

+ Dolores voluptas omnis et cupiditate ducimus delectus vel. Voluptas + atque cumque incidunt quia. A praesentium neque quis odit totam + praesentium illum est. Ut doloribus quisquam ut. Incidunt vel + suscipit accusamus consequuntur repellendus dolor sunt. Vel + accusamus nesciunt perspiciatis sunt est. +

+

+ Tempore quis voluptas aut voluptatem praesentium nisi. Qui et quo ut + et vel dolores facilis dignissimos. Omnis facere quisquam recusandae + accusantium. Sit ut consectetur non id velit est odio. Laboriosam + soluta tenetur asperiores. Excepturi reprehenderit rerum sint + tempore molestiae vitae aliquid. Ea est sunt at atque ducimus + doloribus quas sit. +

+
+ + + + + +
+
+ ); +}; + +const DangerTemplate: StoryFn = args => { + const [open, setOpen] = useState(false); + + const handleClose = () => setOpen(false); + + return ( +
+ + + Lorem ipsum + +

+ Laboriosam autem non et nisi. Ut voluptatem sit beatae assumenda + amet aliquam corporis. +

+

+ Dolores voluptas omnis et cupiditate ducimus delectus vel. Voluptas + atque cumque incidunt quia. A praesentium neque quis odit totam + praesentium illum est. Ut doloribus quisquam ut. Incidunt vel + suscipit accusamus consequuntur repellendus dolor sunt. Vel + accusamus nesciunt perspiciatis sunt est. +

+

+ Tempore quis voluptas aut voluptatem praesentium nisi. Qui et quo ut + et vel dolores facilis dignissimos. Omnis facere quisquam recusandae + accusantium. Sit ut consectetur non id velit est odio. Laboriosam + soluta tenetur asperiores. Excepturi reprehenderit rerum sint + tempore molestiae vitae aliquid. Ea est sunt at atque ducimus + doloribus quas sit. +

+
+ + + + + +
+
+ ); +}; + +export const Default: Story = { + render: DefaultTemplate +}; + +export const Large: Story = { + render: DefaultTemplate, + args: { + size: 'large' + } +}; + +export const Danger: Story = { + render: DangerTemplate, + args: { + variant: 'danger' + } +}; + +export const WithoutCloseButton: Story = { + render: DefaultTemplate, + args: { + showCloseButton: false + } +}; + +export default story; diff --git a/tools/ui-components/src/modal/modal.test.tsx b/tools/ui-components/src/modal/modal.test.tsx new file mode 100644 index 00000000000..d74f40cb373 --- /dev/null +++ b/tools/ui-components/src/modal/modal.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Button } from '../button'; +import { Modal } from './modal'; +import { type ModalProps, type HeaderProps } from './types'; + +const originalWindow = window; + +describe('', () => { + beforeAll(() => { + // The Modal component uses `ResizeObserver` under the hood. + // However, this property is not available in JSDOM, so we need to manually add it to the window object. + // Ref: https://github.com/jsdom/jsdom/issues/3368 + Object.defineProperty(window, 'ResizeObserver', { + writable: true, + value: jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn() + })) + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + Object.defineProperty(globalThis, 'window', { + value: originalWindow + }); + }); + + const setup = ({ + showCloseButton, + open = false, + onClose = () => {}, + ...modalProps + }: Partial) => { + render( + + + Lorem ipsum + + +

Laboriosam autem non et nisi.

+
+ + + + +
+ ); + }; + + it('should not appear if `open` is `false`', () => { + setup({ open: false }); + + expect( + screen.queryByRole('dialog', { name: 'Lorem ipsum' }) + ).not.toBeInTheDocument(); + }); + + it('should appear and render the content properly if `open` is `true`', async () => { + setup({ open: true }); + + const dialog = await screen.findByRole('dialog', { name: 'Lorem ipsum' }); + expect(dialog).toBeInTheDocument(); + + expect( + within(dialog).getByRole('heading', { level: 2 }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: 'Close' }) + ).toBeInTheDocument(); + expect( + within(dialog).getByText('Laboriosam autem non et nisi.') + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: 'Submit' }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: 'Cancel' }) + ).toBeInTheDocument(); + }); + + it('should hide the close button if `showCloseButton` is `false`', async () => { + setup({ open: true, showCloseButton: false }); + + const dialog = await screen.findByRole('dialog', { name: 'Lorem ipsum' }); + expect(dialog).toBeInTheDocument(); + + expect( + within(dialog).getByRole('heading', { level: 2 }) + ).toBeInTheDocument(); + expect( + within(dialog).queryByRole('button', { name: 'Close' }) + ).not.toBeInTheDocument(); + expect( + within(dialog).getByText('Laboriosam autem non et nisi.') + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: 'Submit' }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: 'Cancel' }) + ).toBeInTheDocument(); + }); + + it('should automatically focus on the close button when open', async () => { + setup({ open: true }); + + const dialog = await screen.findByRole('dialog', { name: 'Lorem ipsum' }); + expect(dialog).toBeInTheDocument(); + + const closeButton = within(dialog).getByRole('button', { name: 'Close' }); + expect(closeButton).toHaveFocus(); + }); + + it('should automatically focus on the first focusable element if `showCloseButton` is `false`', async () => { + setup({ open: true, showCloseButton: false }); + + const dialog = await screen.findByRole('dialog', { name: 'Lorem ipsum' }); + expect(dialog).toBeInTheDocument(); + + const submitButton = within(dialog).getByRole('button', { name: 'Submit' }); + expect(submitButton).toHaveFocus(); + }); + + it('should trigger the `onClose` prop on close button click', async () => { + const onClose = jest.fn(); + + setup({ open: true, onClose }); + + const dialog = await screen.findByRole('dialog', { name: 'Lorem ipsum' }); + expect(dialog).toBeInTheDocument(); + + const closeButton = within(dialog).getByRole('button', { name: 'Close' }); + await userEvent.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should trigger the `onClose` prop on Escape key press', async () => { + const onClose = jest.fn(); + + setup({ open: true, onClose }); + + const dialog = await screen.findByRole('dialog', { name: 'Lorem ipsum' }); + expect(dialog).toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tools/ui-components/src/modal/modal.tsx b/tools/ui-components/src/modal/modal.tsx new file mode 100644 index 00000000000..fd5763819ba --- /dev/null +++ b/tools/ui-components/src/modal/modal.tsx @@ -0,0 +1,124 @@ +import React, { + type ReactNode, + createContext, + useContext, + Fragment +} from 'react'; +import { Dialog, Transition } from '@headlessui/react'; + +import { CloseButton } from '../close-button'; +import { type ModalProps, type HeaderProps } from './types'; + +// There is a close button on the right side of the modal title. +// Some extra padding needs to be added to the left of the title text +// so that the title is properly centered. +// The value of the left padding is the width of the close button. +const TITLE_LEFT_PADDING = 24; + +const PANEL_DEFAULT_CLASSES = + 'flex flex-col border-solid border-1 border-foreground-secondary bg-background-secondary'; + +const HEADER_DEFAULT_CLASSES = + 'p-[15px] border-b-1 border-solid border-foreground-secondary'; + +const ModalContext = createContext>({ + onClose: () => {}, + variant: 'default' +}); + +const Header = ({ children, showCloseButton = true }: HeaderProps) => { + const { onClose, variant } = useContext(ModalContext); + + let classes = HEADER_DEFAULT_CLASSES; + + if (variant === 'danger') { + classes = classes.concat(' ', 'bg-foreground-danger'); + } + + if (showCloseButton) { + classes = classes.concat(' ', 'flex items-center justify-between'); + + return ( +
+ + {children} + + +
+ ); + } + + return ( +
+ + {children} + +
+ ); +}; + +const Body = ({ children }: { children: ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +const Footer = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +export const Modal = ({ + children, + open, + onClose, + size = 'medium', + variant = 'default' +}: ModalProps) => { + let panelClasses = PANEL_DEFAULT_CLASSES; + + if (size === 'medium') { + panelClasses = panelClasses.concat(' ', 'w-[600px]'); + } else if (size === 'large') { + panelClasses = panelClasses.concat(' ', 'w-[900px]'); + } + + if (variant === 'default') { + panelClasses = panelClasses.concat(' ', 'text-foreground-secondary'); + } else if (variant === 'danger') { + panelClasses = panelClasses.concat(' ', 'text-background-danger'); + } + + return ( + + + + {/* The backdrop, rendered as a fixed sibling to the panel container */} +
+ + {/* Full-screen container of the panel */} +
+ + {children} + +
+
+
+
+ ); +}; + +Modal.Header = Header; +Modal.Body = Body; +Modal.Footer = Footer; diff --git a/tools/ui-components/src/modal/types.ts b/tools/ui-components/src/modal/types.ts new file mode 100644 index 00000000000..853368649c4 --- /dev/null +++ b/tools/ui-components/src/modal/types.ts @@ -0,0 +1,14 @@ +import { type ReactNode } from 'react'; + +export interface ModalProps { + children: ReactNode; + open: boolean; + onClose: () => void; + size?: 'large' | 'medium'; + variant?: 'default' | 'danger'; +} + +export interface HeaderProps { + children: ReactNode; + showCloseButton?: boolean; +} diff --git a/tools/ui-components/src/normalize.css b/tools/ui-components/src/normalize.css index 7ed5a9369c7..e0c81535635 100644 --- a/tools/ui-components/src/normalize.css +++ b/tools/ui-components/src/normalize.css @@ -1,4 +1,12 @@ /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ +*, +::before, +::after { + /* Override the browser default border width in order to style individual border sides + * Ref: https://stackoverflow.com/a/76961084 + */ + border-width: 0; +} html { font-family: sans-serif; -ms-text-size-adjust: 100%; diff --git a/tools/ui-components/tailwind.config.js b/tools/ui-components/tailwind.config.js index c6f25ce7c68..7adeb9f2373 100644 --- a/tools/ui-components/tailwind.config.js +++ b/tools/ui-components/tailwind.config.js @@ -98,7 +98,8 @@ module.exports = { '43-px': '43px' }, zIndex: { - 2: '2' + 2: '2', + 50: '50' } } },