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 (
+
+ {children} +
+ ); +}; 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 ( -- {children} -
- ); -}; - -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 ( +