Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dda8149ba | ||
|
|
cc21735468 | ||
|
|
a6bc6de72e |
@@ -1,3 +1,3 @@
|
||||
export * from './input-form';
|
||||
export * from './pagination';
|
||||
export * from './modal';
|
||||
export * from './modal/modal';
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Modal } from './modal';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('Modal Component', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
children: <div>Modal Content</div>,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders when isOpen is true', () => {
|
||||
render(<Modal {...defaultProps} />);
|
||||
expect(screen.getByText('Modal Content')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Close modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<Modal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
render(<Modal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByLabelText('Close modal'));
|
||||
await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', async () => {
|
||||
render(<Modal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('presentation'));
|
||||
await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('closes on Escape key press when disableEscapeKeyDown is false', async () => {
|
||||
render(<Modal {...defaultProps} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('does not close on Escape key press when disableEscapeKeyDown is true', async () => {
|
||||
render(<Modal {...defaultProps} disableEscapeKeyDown={true} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => expect(defaultProps.onClose).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('renders sub-components correctly', () => {
|
||||
render(
|
||||
<Modal {...defaultProps}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>Modal Title</Modal.Title>
|
||||
<Modal.Description>Modal Description</Modal.Description>
|
||||
</Modal.Header>
|
||||
<Modal.Content>Modal Content</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<button>Footer Button</button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Modal Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Modal Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Modal Content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Footer Button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom classNames', () => {
|
||||
render(
|
||||
<Modal
|
||||
{...defaultProps}
|
||||
className="custom-modal"
|
||||
overlayClassName="custom-overlay"
|
||||
closeButtonClassName="custom-close"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('dialog')).toHaveClass('custom-modal');
|
||||
expect(screen.getByRole('presentation')).toHaveClass('custom-overlay');
|
||||
expect(screen.getByLabelText('Close modal')).toHaveClass('custom-close');
|
||||
});
|
||||
|
||||
it('sets ARIA attributes correctly', () => {
|
||||
render(
|
||||
<Modal
|
||||
{...defaultProps}
|
||||
aria-label="Test Modal"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-desc"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Title id="modal-title">Title</Modal.Title>
|
||||
<Modal.Description id="modal-desc">Description</Modal.Description>
|
||||
</Modal.Header>
|
||||
</Modal>
|
||||
);
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(modal).toHaveAttribute('aria-label', 'Test Modal');
|
||||
expect(modal).toHaveAttribute('aria-labelledby', 'modal-title');
|
||||
expect(modal).toHaveAttribute('aria-describedby', 'modal-desc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Modal } from './modal';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof Modal> = {
|
||||
title: 'Components/Modal',
|
||||
component: Modal,
|
||||
argTypes: {
|
||||
isOpen: { control: 'boolean' },
|
||||
onClose: { action: 'closed' },
|
||||
className: { control: 'text' },
|
||||
overlayClassName: { control: 'text' },
|
||||
closeButtonClassName: { control: 'text' },
|
||||
disableEscapeKeyDown: { control: 'boolean' },
|
||||
'aria-label': { control: 'text' },
|
||||
'aria-labelledby': { control: 'text' },
|
||||
'aria-describedby': { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Modal>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isOpen: true,
|
||||
onClose: () => {
|
||||
console.log('Modal closed');
|
||||
},
|
||||
children: (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<Modal.Title>Modal Title</Modal.Title>
|
||||
<Modal.Description>
|
||||
This is a simple modal description.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
<Modal.Content>This is the main content of the modal.</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<button className="px-4 py-2 bg-blue-500 text-white rounded">
|
||||
Confirm
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-gray-300 rounded">Cancel</button>
|
||||
</Modal.Footer>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomClasses: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
className: 'bg-red-100',
|
||||
overlayClassName: 'bg-opacity-50',
|
||||
closeButtonClassName: 'text-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
export const NoEscapeClose: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
disableEscapeKeyDown: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAria: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
'aria-label': 'Custom Modal',
|
||||
'aria-labelledby': 'modal-title',
|
||||
'aria-describedby': 'modal-desc',
|
||||
children: (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<Modal.Title id="modal-title">Modal Title</Modal.Title>
|
||||
<Modal.Description id="modal-desc">
|
||||
This is a modal with ARIA attributes.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
<Modal.Content>Modal content here.</Modal.Content>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import React, { useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
closeButtonClassName?: string;
|
||||
disableEscapeKeyDown?: boolean;
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
'aria-describedby'?: string;
|
||||
}
|
||||
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
overlayClassName,
|
||||
closeButtonClassName,
|
||||
disableEscapeKeyDown = false,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
}: ModalProps) => {
|
||||
const handleEscapeKey = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !disableEscapeKeyDown) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isOpen, onClose, disableEscapeKeyDown]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleEscapeKey);
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}, [isOpen, handleEscapeKey]);
|
||||
|
||||
const modalNode = useMemo(() => document.createElement('div'), []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.appendChild(modalNode);
|
||||
return () => {
|
||||
document.body.removeChild(modalNode);
|
||||
};
|
||||
}, [modalNode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-gray-900/80 transition-opacity duration-200',
|
||||
isOpen ? 'opacity-100' : 'opacity-0',
|
||||
overlayClassName
|
||||
)}
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-[#F0F8FF] rounded-lg p-6 shadow-xl transition-all duration-200',
|
||||
'sm:rounded-lg sm:max-w-md',
|
||||
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
||||
className
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-sm p-1 text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2',
|
||||
closeButtonClassName
|
||||
)}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<CloseOutlined className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
modalNode
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalHeaderProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalHeader = ({ className, children }: ModalHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-4 flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalContentProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalContent = ({ className, children }: ModalContentProps) => (
|
||||
<div className={cn('mb-4', className)}>{children}</div>
|
||||
);
|
||||
|
||||
interface ModalFooterProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalFooter = ({ className, children }: ModalFooterProps) => (
|
||||
<div className={cn('flex gap-2 sm:flex-row sm:justify-end', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalTitleProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ModalTitle = ({ className, children, id }: ModalTitleProps) => (
|
||||
<h2
|
||||
id={id}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
interface ModalDescriptionProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ModalDescription = ({
|
||||
className,
|
||||
children,
|
||||
id,
|
||||
}: ModalDescriptionProps) => (
|
||||
<p id={id} className={cn('text-sm text-gray-500', className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
Modal.Header = ModalHeader;
|
||||
Modal.Content = ModalContent;
|
||||
Modal.Footer = ModalFooter;
|
||||
Modal.Title = ModalTitle;
|
||||
Modal.Description = ModalDescription;
|
||||
|
||||
export { Modal };
|
||||
export type {
|
||||
ModalProps,
|
||||
ModalHeaderProps,
|
||||
ModalContentProps,
|
||||
ModalFooterProps,
|
||||
ModalTitleProps,
|
||||
ModalDescriptionProps,
|
||||
};
|
||||
Reference in New Issue
Block a user