From e0521a861336ecf47cfbfd315c6735e3d0d7c117 Mon Sep 17 00:00:00 2001 From: Chiman2937 Date: Sun, 25 Jan 2026 21:40:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20modal=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EB=B3=84=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/modal/index.tsx | 294 +----------------- .../ui/modal/modal-close-button/index.tsx | 17 + .../ui/modal/modal-content/index.tsx | 77 +++++ .../ui/modal/modal-description/index.tsx | 14 + .../ui/modal/modal-provider/index.tsx | 179 +++++++++++ src/components/ui/modal/modal-title/index.tsx | 14 + 6 files changed, 307 insertions(+), 288 deletions(-) create mode 100644 src/components/ui/modal/modal-close-button/index.tsx create mode 100644 src/components/ui/modal/modal-content/index.tsx create mode 100644 src/components/ui/modal/modal-description/index.tsx create mode 100644 src/components/ui/modal/modal-provider/index.tsx create mode 100644 src/components/ui/modal/modal-title/index.tsx diff --git a/src/components/ui/modal/index.tsx b/src/components/ui/modal/index.tsx index 0e8fa6f5..e9308e4c 100644 --- a/src/components/ui/modal/index.tsx +++ b/src/components/ui/modal/index.tsx @@ -1,288 +1,6 @@ -'use client'; -import React, { createContext, RefObject, useContext, useEffect, useRef, useState } from 'react'; -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; - modalContentRef: RefObject; -} - -const ModalContext = createContext(null); - -export const useModal = () => { - const context = useContext(ModalContext); - if (!context) throw new Error('useModal must be used in ModalProvider'); - return context; -}; - -interface ModalProviderProps { - children: React.ReactNode; -} - -export const ModalProvider = ({ children }: ModalProviderProps) => { - const [mounted, setMounted] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const [content, setContent] = useState(null); - - const previousFocusRef = useRef(null); - const lastInputTypeRef = useRef<'mouse' | 'keyboard'>('mouse'); - - const modalContentRef = useRef(null); - const isMouseDownInsideModal = useRef(false); - - const open = (modalContent: React.ReactNode) => { - setContent(modalContent); - setIsOpen(true); - if (lastInputTypeRef.current === 'keyboard') { - previousFocusRef.current = document.activeElement as HTMLElement; - } else { - previousFocusRef.current = null; - } - }; - - const close = () => { - setContent(null); - setIsOpen(false); - if (previousFocusRef.current) { - const el = previousFocusRef.current; - setTimeout(() => { - el.focus(); - }, 0); - - previousFocusRef.current = null; - } - }; - - // Modal을 Open 할 때 키보드로 진입했다면 Trigger 요소를 기억함 - useEffect(() => { - const handleMouseDown = () => { - lastInputTypeRef.current = 'mouse'; - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Tab' || e.key === 'Enter' || e.key === ' ') { - lastInputTypeRef.current = 'keyboard'; - } - }; - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('keydown', handleKeyDown); - }; - }, []); - - // Modal 외부 Mousedown => 내부 MouseUp 일 때 Modal이 닫히지 않음 - // Modal 내부 Mousedown => 외부 MouseUp 일 때 Modal이 닫히지 않음 - // Modal 외부 Mousedown => 외부 Mouseup 일 때 Modal 닫힘 - useEffect(() => { - if (!isOpen) return; - const handleMouseDown = (e: MouseEvent) => { - if (modalContentRef.current?.contains(e.target as Node)) { - isMouseDownInsideModal.current = true; - } else { - isMouseDownInsideModal.current = false; - } - }; - - const handleMouseUp = (e: MouseEvent) => { - if ( - !modalContentRef.current?.contains(e.target as Node) && - isMouseDownInsideModal.current === false - ) { - close(); - } - }; - - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isOpen]); - - // esc 입력 시 Modal close - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) close(); - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isOpen]); - - //Modal Open 상태일 시 body scroll 제거 - useEffect(() => { - if (isOpen) { - document.body.style.overflow = 'hidden'; - } else { - document.body.style.overflow = ''; - } - return () => { - document.body.style.overflow = ''; - }; - }, [isOpen]); - - // Modal Open 상태일 때 배경 요소들 무시 - useEffect(() => { - if (!isOpen) return; - const appRoot = document.getElementById('root'); - if (appRoot) { - appRoot.setAttribute('inert', ''); - appRoot.setAttribute('aria-hidden', 'true'); - } - return () => { - if (appRoot) { - appRoot.removeAttribute('inert'); - appRoot.removeAttribute('aria-hidden'); - } - }; - }, [isOpen]); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); - - return ( - - {children} - {mounted && - createPortal( - , - document.body, - )} - - ); -}; - -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 ( - - ); -}; +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..c9c19aa0 --- /dev/null +++ b/src/components/ui/modal/modal-content/index.tsx @@ -0,0 +1,77 @@ +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/modal-provider/index.tsx b/src/components/ui/modal/modal-provider/index.tsx new file mode 100644 index 00000000..b257d3d9 --- /dev/null +++ b/src/components/ui/modal/modal-provider/index.tsx @@ -0,0 +1,179 @@ +'use client'; +import React, { createContext, RefObject, useContext, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import * as m from 'motion/react-m'; + +interface ModalContextType { + open: (context: React.ReactNode) => void; + close: () => void; + modalContentRef: RefObject; +} + +const ModalContext = createContext(null); + +export const useModal = () => { + const context = useContext(ModalContext); + if (!context) throw new Error('useModal must be used in ModalProvider'); + return context; +}; + +interface ModalProviderProps { + children: React.ReactNode; +} + +export const ModalProvider = ({ children }: ModalProviderProps) => { + const [mounted, setMounted] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [content, setContent] = useState(null); + + const previousFocusRef = useRef(null); + const lastInputTypeRef = useRef<'mouse' | 'keyboard'>('mouse'); + + const modalContentRef = useRef(null); + const isMouseDownInsideModal = useRef(false); + + const open = (modalContent: React.ReactNode) => { + setContent(modalContent); + setIsOpen(true); + if (lastInputTypeRef.current === 'keyboard') { + previousFocusRef.current = document.activeElement as HTMLElement; + } else { + previousFocusRef.current = null; + } + }; + + const close = () => { + setContent(null); + setIsOpen(false); + if (previousFocusRef.current) { + const el = previousFocusRef.current; + setTimeout(() => { + el.focus(); + }, 0); + + previousFocusRef.current = null; + } + }; + + // Modal을 Open 할 때 키보드로 진입했다면 Trigger 요소를 기억함 + useEffect(() => { + const handleMouseDown = () => { + lastInputTypeRef.current = 'mouse'; + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Tab' || e.key === 'Enter' || e.key === ' ') { + lastInputTypeRef.current = 'keyboard'; + } + }; + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + // Modal 외부 Mousedown => 내부 MouseUp 일 때 Modal이 닫히지 않음 + // Modal 내부 Mousedown => 외부 MouseUp 일 때 Modal이 닫히지 않음 + // Modal 외부 Mousedown => 외부 Mouseup 일 때 Modal 닫힘 + useEffect(() => { + if (!isOpen) return; + const handleMouseDown = (e: MouseEvent) => { + if (modalContentRef.current?.contains(e.target as Node)) { + isMouseDownInsideModal.current = true; + } else { + isMouseDownInsideModal.current = false; + } + }; + + const handleMouseUp = (e: MouseEvent) => { + if ( + !modalContentRef.current?.contains(e.target as Node) && + isMouseDownInsideModal.current === false + ) { + close(); + } + }; + + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isOpen]); + + // esc 입력 시 Modal close + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) close(); + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen]); + + //Modal Open 상태일 시 body scroll 제거 + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + // Modal Open 상태일 때 배경 요소들 무시 + useEffect(() => { + if (!isOpen) return; + const appRoot = document.getElementById('root'); + if (appRoot) { + appRoot.setAttribute('inert', ''); + appRoot.setAttribute('aria-hidden', 'true'); + } + return () => { + if (appRoot) { + appRoot.removeAttribute('inert'); + appRoot.removeAttribute('aria-hidden'); + } + }; + }, [isOpen]); + + // portal은 mount 된 후에 생성 + useEffect(() => { + const onMount = () => { + setMounted(true); + }; + onMount(); + }, []); + + return ( + + {children} + {mounted && + createPortal( + , + document.body, + )} + + ); +}; 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 ( + + ); +}; From 6ea614aa1acb43f8e0d5bb90e1a83902df541b6d Mon Sep 17 00:00:00 2001 From: Chiman2937 Date: Sun, 25 Jan 2026 21:46:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20modal/index.tsx=20=3D>=20index.ts?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/modal/{index.tsx => index.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/components/ui/modal/{index.tsx => index.ts} (100%) diff --git a/src/components/ui/modal/index.tsx b/src/components/ui/modal/index.ts similarity index 100% rename from src/components/ui/modal/index.tsx rename to src/components/ui/modal/index.ts From b13709f3fd4c15f6b8ee615798f09429d81ee31a Mon Sep 17 00:00:00 2001 From: Chiman2937 Date: Sun, 25 Jan 2026 21:46:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20modalContent=EC=97=90=20use=20client?= =?UTF-8?q?=20=EC=A7=80=EC=8B=9C=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/modal/modal-content/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ui/modal/modal-content/index.tsx b/src/components/ui/modal/modal-content/index.tsx index c9c19aa0..7a602879 100644 --- a/src/components/ui/modal/modal-content/index.tsx +++ b/src/components/ui/modal/modal-content/index.tsx @@ -1,3 +1,4 @@ +'use client'; import { useEffect } from 'react'; import * as m from 'motion/react-m';