mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-06-05 21:04:28 +08:00
feat(ui-components): implement Modal component with headlessui (#54044)
Co-authored-by: Sboonny <muhammedelruby@gmail.com>
This commit is contained in:
parent
22b2356205
commit
ce2bb3a369
155
tools/ui-components/src/modal/modal.stories.tsx
Normal file
155
tools/ui-components/src/modal/modal.stories.tsx
Normal 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;
|
||||
162
tools/ui-components/src/modal/modal.test.tsx
Normal file
162
tools/ui-components/src/modal/modal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
124
tools/ui-components/src/modal/modal.tsx
Normal file
124
tools/ui-components/src/modal/modal.tsx
Normal 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;
|
||||
14
tools/ui-components/src/modal/types.ts
Normal file
14
tools/ui-components/src/modal/types.ts
Normal 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;
|
||||
}
|
||||
8
tools/ui-components/src/normalize.css
vendored
8
tools/ui-components/src/normalize.css
vendored
@ -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%;
|
||||
|
||||
@ -98,7 +98,8 @@ module.exports = {
|
||||
'43-px': '43px'
|
||||
},
|
||||
zIndex: {
|
||||
2: '2'
|
||||
2: '2',
|
||||
50: '50'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user