diff --git a/src/components/ui/modal/index.ts b/src/components/ui/modal/index.ts new file mode 100644 index 00000000..e9308e4c --- /dev/null +++ b/src/components/ui/modal/index.ts @@ -0,0 +1,6 @@ +export { ModalCloseButton } from './modal-close-button'; +export { ModalContent } from './modal-content'; +export { ModalDescription } from './modal-description'; +export { ModalProvider } from './modal-provider'; +export { useModal } from './modal-provider'; +export { ModalTitle } from './modal-title'; diff --git a/src/components/ui/modal/modal-close-button/index.tsx b/src/components/ui/modal/modal-close-button/index.tsx new file mode 100644 index 00000000..089025d9 --- /dev/null +++ b/src/components/ui/modal/modal-close-button/index.tsx @@ -0,0 +1,17 @@ +import { Icon } from '@/components/icon'; + +import { useModal } from '../modal-provider'; + +export const ModalCloseButton = () => { + const { close } = useModal(); + return ( + + ); +}; diff --git a/src/components/ui/modal/modal-content/index.tsx b/src/components/ui/modal/modal-content/index.tsx new file mode 100644 index 00000000..7a602879 --- /dev/null +++ b/src/components/ui/modal/modal-content/index.tsx @@ -0,0 +1,78 @@ +'use client'; +import { useEffect } from 'react'; + +import * as m from 'motion/react-m'; + +import { cn } from '@/lib/utils'; + +import { ModalCloseButton } from '../modal-close-button'; +import { useModal } from '../modal-provider'; + +interface ModalContentProps { + children: React.ReactNode; + className?: string; +} + +export const ModalContent = ({ children, className }: ModalContentProps) => { + const { modalContentRef } = useModal(); + + // focus trap 처리 + useEffect(() => { + if (!modalContentRef.current) return; + + const modal = modalContentRef.current; + const focusableElements = modal.querySelectorAll( + 'button:not([disabled]), a[href]:not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + const firstElement = focusableElements[0] as HTMLElement; + const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; + + firstElement?.focus(); + + const handleTab = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + if (focusableElements.length === 0) { + e.preventDefault(); + return; + } + + if (e.shiftKey) { + // Shift + Tab + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement?.focus(); + } + } else { + // Tab + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement?.focus(); + } + } + }; + + modal.addEventListener('keydown', handleTab); + return () => modal.removeEventListener('keydown', handleTab); + }, [children, modalContentRef]); + + return ( + { + e.stopPropagation(); + }} + > +
+ {children} + +
+
+ ); +}; diff --git a/src/components/ui/modal/modal-description/index.tsx b/src/components/ui/modal/modal-description/index.tsx new file mode 100644 index 00000000..38763029 --- /dev/null +++ b/src/components/ui/modal/modal-description/index.tsx @@ -0,0 +1,14 @@ +import { cn } from '@/lib/utils'; + +interface ModalDescriptionProps { + children: string; + className?: string; +} + +export const ModalDescription = ({ children, className }: ModalDescriptionProps) => { + return ( + + ); +}; diff --git a/src/components/ui/modal/index.tsx b/src/components/ui/modal/modal-provider/index.tsx similarity index 63% rename from src/components/ui/modal/index.tsx rename to src/components/ui/modal/modal-provider/index.tsx index 0e8fa6f5..b257d3d9 100644 --- a/src/components/ui/modal/index.tsx +++ b/src/components/ui/modal/modal-provider/index.tsx @@ -4,9 +4,6 @@ import { createPortal } from 'react-dom'; import * as m from 'motion/react-m'; -import { Icon } from '@/components/icon'; -import { cn } from '@/lib/utils'; - interface ModalContextType { open: (context: React.ReactNode) => void; close: () => void; @@ -145,9 +142,12 @@ export const ModalProvider = ({ children }: ModalProviderProps) => { }; }, [isOpen]); + // portal은 mount 된 후에 생성 useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); + const onMount = () => { + setMounted(true); + }; + onMount(); }, []); return ( @@ -177,112 +177,3 @@ export const ModalProvider = ({ children }: ModalProviderProps) => { ); }; - -interface ModalContentProps { - children: React.ReactNode; - className?: string; -} - -export const ModalContent = ({ children, className }: ModalContentProps) => { - const { modalContentRef } = useModal(); - - // focus 처리 - useEffect(() => { - if (!modalContentRef.current) return; - - const modal = modalContentRef.current; - const focusableElements = modal.querySelectorAll( - 'button:not([disabled]), a[href]:not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', - ); - const firstElement = focusableElements[0] as HTMLElement; - const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; - - firstElement?.focus(); - - const handleTab = (e: KeyboardEvent) => { - if (e.key !== 'Tab') return; - - if (focusableElements.length === 0) { - e.preventDefault(); - return; - } - - if (e.shiftKey) { - // Shift + Tab - if (document.activeElement === firstElement) { - e.preventDefault(); - lastElement?.focus(); - } - } else { - // Tab - if (document.activeElement === lastElement) { - e.preventDefault(); - firstElement?.focus(); - } - } - }; - - modal.addEventListener('keydown', handleTab); - return () => modal.removeEventListener('keydown', handleTab); - }, [children, modalContentRef]); - - return ( - { - e.stopPropagation(); - }} - > -
- {children} - -
-
- ); -}; - -interface ModalTitleProps { - children: React.ReactNode; - className?: string; -} - -export const ModalTitle = ({ children, className }: ModalTitleProps) => { - return ( - - ); -}; - -interface ModalDescriptionProps { - children: string; - className?: string; -} - -export const ModalDescription = ({ children, className }: ModalDescriptionProps) => { - return ( - - ); -}; - -export const ModalCloseButton = () => { - const { close } = useModal(); - return ( - - ); -}; diff --git a/src/components/ui/modal/modal-title/index.tsx b/src/components/ui/modal/modal-title/index.tsx new file mode 100644 index 00000000..a3f8b076 --- /dev/null +++ b/src/components/ui/modal/modal-title/index.tsx @@ -0,0 +1,14 @@ +import { cn } from '@/lib/utils'; + +interface ModalTitleProps { + children: React.ReactNode; + className?: string; +} + +export const ModalTitle = ({ children, className }: ModalTitleProps) => { + return ( + + ); +};