diff --git a/libs/ui/src/molecules/index.ts b/libs/ui/src/molecules/index.ts
index e13b7fd..a0c6132 100644
--- a/libs/ui/src/molecules/index.ts
+++ b/libs/ui/src/molecules/index.ts
@@ -1,3 +1,3 @@
export * from './input-form';
export * from './pagination';
-export * from './modal';
+export * from './modal/modal';
diff --git a/libs/ui/src/molecules/modal/modal.spec.tsx b/libs/ui/src/molecules/modal/modal.spec.tsx
new file mode 100644
index 0000000..d4bf088
--- /dev/null
+++ b/libs/ui/src/molecules/modal/modal.spec.tsx
@@ -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:
Modal Content
,
+ };
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders when isOpen is true', () => {
+ render();
+ 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();
+ expect(screen.queryByText('Modal Content')).not.toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('calls onClose when close button is clicked', async () => {
+ render();
+ fireEvent.click(screen.getByLabelText('Close modal'));
+ await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalledTimes(1));
+ });
+
+ it('calls onClose when overlay is clicked', async () => {
+ render();
+ fireEvent.click(screen.getByRole('presentation'));
+ await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalledTimes(1));
+ });
+
+ it('closes on Escape key press when disableEscapeKeyDown is false', async () => {
+ render();
+ 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();
+ fireEvent.keyDown(window, { key: 'Escape' });
+ await waitFor(() => expect(defaultProps.onClose).not.toHaveBeenCalled());
+ });
+
+ it('renders sub-components correctly', () => {
+ render(
+
+
+ Modal Title
+ Modal Description
+
+ Modal Content
+
+
+
+
+ );
+
+ 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(
+
+ );
+ 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(
+
+
+ Title
+ Description
+
+
+ );
+ 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');
+ });
+});
diff --git a/libs/ui/src/molecules/modal/modal.stories.tsx b/libs/ui/src/molecules/modal/modal.stories.tsx
new file mode 100644
index 0000000..c3ea08a
--- /dev/null
+++ b/libs/ui/src/molecules/modal/modal.stories.tsx
@@ -0,0 +1,84 @@
+import { Modal } from './modal';
+import { Meta, StoryObj } from '@storybook/react';
+
+const meta: Meta = {
+ 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;
+
+export const Default: Story = {
+ args: {
+ isOpen: true,
+ onClose: () => {
+ console.log('Modal closed');
+ },
+ children: (
+ <>
+
+ Modal Title
+
+ This is a simple modal description.
+
+
+ This is the main content of the modal.
+
+
+
+
+ >
+ ),
+ },
+};
+
+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 Title
+
+ This is a modal with ARIA attributes.
+
+
+ Modal content here.
+ >
+ ),
+ },
+};
diff --git a/libs/ui/src/molecules/modal/modal.tsx b/libs/ui/src/molecules/modal/modal.tsx
new file mode 100644
index 0000000..2f0d9ba
--- /dev/null
+++ b/libs/ui/src/molecules/modal/modal.tsx
@@ -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(
+
+
+
+ {children}
+
+
+
,
+ modalNode
+ );
+};
+
+interface ModalHeaderProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+const ModalHeader = ({ className, children }: ModalHeaderProps) => (
+
+ {children}
+
+);
+
+interface ModalContentProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+const ModalContent = ({ className, children }: ModalContentProps) => (
+ {children}
+);
+
+interface ModalFooterProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+const ModalFooter = ({ className, children }: ModalFooterProps) => (
+
+ {children}
+
+);
+
+interface ModalTitleProps {
+ className?: string;
+ children: React.ReactNode;
+ id?: string;
+}
+
+const ModalTitle = ({ className, children, id }: ModalTitleProps) => (
+
+ {children}
+
+);
+
+interface ModalDescriptionProps {
+ className?: string;
+ children: React.ReactNode;
+ id?: string;
+}
+
+const ModalDescription = ({
+ className,
+ children,
+ id,
+}: ModalDescriptionProps) => (
+
+ {children}
+
+);
+
+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,
+};