feat(ui-components): implement Modal component with headlessui (#54044)

Co-authored-by: Sboonny <muhammedelruby@gmail.com>
This commit is contained in:
Huyen Nguyen 2024-03-14 16:45:39 +07:00 committed by GitHub
parent 22b2356205
commit ce2bb3a369
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 465 additions and 1 deletions

View File

@ -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<typeof Modal>;
type Story = StoryObj<ModalProps & HeaderProps>;
const Spacer = () => <div style={{ height: '5px', width: '100%' }} />;
const DefaultTemplate: StoryFn<ModalProps & HeaderProps> = ({
showCloseButton,
...modalProps
}) => {
const [open, setOpen] = useState(false);
const handleClose = () => setOpen(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open modal</Button>
<Modal {...modalProps} open={open} onClose={handleClose}>
<Modal.Header showCloseButton={showCloseButton}>
Lorem ipsum
</Modal.Header>
<Modal.Body>
<p>
Laboriosam autem non et nisi. Ut voluptatem sit beatae assumenda
amet aliquam corporis.
</p>
<p>
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.
</p>
<p>
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.
</p>
</Modal.Body>
<Modal.Footer>
<Button block size='large'>
Submit
</Button>
<Spacer />
<Button block size='large' onClick={handleClose}>
Cancel
</Button>
</Modal.Footer>
</Modal>
</div>
);
};
const DangerTemplate: StoryFn<ModalProps> = args => {
const [open, setOpen] = useState(false);
const handleClose = () => setOpen(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open modal</Button>
<Modal {...args} open={open} onClose={handleClose}>
<Modal.Header>Lorem ipsum</Modal.Header>
<Modal.Body>
<p>
Laboriosam autem non et nisi. Ut voluptatem sit beatae assumenda
amet aliquam corporis.
</p>
<p>
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.
</p>
<p>
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.
</p>
</Modal.Body>
<Modal.Footer>
<Button block size='large' onClick={handleClose}>
Cancel
</Button>
<Spacer />
<Button block variant='danger' size='large'>
Submit
</Button>
</Modal.Footer>
</Modal>
</div>
);
};
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;

View File

@ -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('<Modal />', () => {
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<ModalProps & HeaderProps>) => {
render(
<Modal {...modalProps} open={open} onClose={onClose}>
<Modal.Header showCloseButton={showCloseButton}>
Lorem ipsum
</Modal.Header>
<Modal.Body>
<p>Laboriosam autem non et nisi.</p>
</Modal.Body>
<Modal.Footer>
<Button block size='large'>
Submit
</Button>
<Button block size='large'>
Cancel
</Button>
</Modal.Footer>
</Modal>
);
};
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);
});
});

View File

@ -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<Pick<ModalProps, 'onClose' | 'variant'>>({
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 (
<div className={classes}>
<Dialog.Title
className={`m-0 pl-[${TITLE_LEFT_PADDING}px] flex-1 text-md text-center`}
>
{children}
</Dialog.Title>
<CloseButton onClick={onClose} />
</div>
);
}
return (
<div className={classes}>
<Dialog.Title className='m-0 text-md text-center'>
{children}
</Dialog.Title>
</div>
);
};
const Body = ({ children }: { children: ReactNode }) => {
return (
<div className='p-[15px] border-b-1 border-solid border-foreground-secondary'>
{children}
</div>
);
};
const Footer = ({ children }: { children: ReactNode }) => {
return <div className='p-[15px]'>{children}</div>;
};
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 (
<ModalContext.Provider value={{ onClose, variant }}>
<Transition.Root show={open} as={Fragment}>
<Dialog onClose={onClose} className='relative z-50'>
{/* The backdrop, rendered as a fixed sibling to the panel container */}
<div aria-hidden className='fixed inset-0 bg-gray-900 opacity-50' />
{/* Full-screen container of the panel */}
<div className='fixed inset-0 w-screen flex items-start justify-center pt-[30px]'>
<Transition.Child
as={Fragment}
enter='transition-all duration-300 ease-out'
enterFrom='opacity-0 -translate-y-1/4'
enterTo='opacity-100 translate-y-0'
leave='transition-all duration-300 ease-out'
leaveFrom='opacity-100 translate-y-0'
leaveTo='opacity-0 -translate-y-1/4'
>
<Dialog.Panel className={panelClasses}>{children}</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</ModalContext.Provider>
);
};
Modal.Header = Header;
Modal.Body = Body;
Modal.Footer = Footer;

View File

@ -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;
}

View File

@ -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%;

View File

@ -98,7 +98,8 @@ module.exports = {
'43-px': '43px'
},
zIndex: {
2: '2'
2: '2',
50: '50'
}
}
},