diff --git a/apps/gacha/src/app/page.tsx b/apps/gacha/src/app/page.tsx index eefefed..03c054b 100644 --- a/apps/gacha/src/app/page.tsx +++ b/apps/gacha/src/app/page.tsx @@ -224,14 +224,9 @@ export const Components: FC = (): ReactElement => { {/**Izin pake untuk debug modals gacha */}

Debug : Test Modals Gacha

- +
- {showModal && createPortal( - , - document.body - )} + {showModal && createPortal(, document.body)} {/**Izin pake untuk debug modals gacha */} ); diff --git a/libs/ui/src/molecules/index.ts b/libs/ui/src/molecules/index.ts index dbe5a6d..a0c6132 100644 --- a/libs/ui/src/molecules/index.ts +++ b/libs/ui/src/molecules/index.ts @@ -1,2 +1,3 @@ export * from './input-form'; export * from './pagination'; +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, +};