diff --git a/src/App.tsx b/src/App.tsx index 60b9ed36..822c7e64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,6 @@ import { useEffect } from 'react'; import { Toaster } from 'react-hot-toast'; import { RouterProvider } from 'react-router-dom'; import { router } from '@/routes/router.tsx'; -import ModalProvider from '@/common/components/Modal/ModalProvider'; -import GlobalErrorBoundary from '@/shared/components/ErrorBoundary/GlobalErrorBoundary/GlobalErrorBoundary'; import queryClient from './queryClient'; import './shared/styles/index.css'; @@ -33,10 +31,7 @@ const App = () => { return ( - - - - + ); diff --git a/src/common/components/FocusTrap/FocusTrap.tsx b/src/common/components/FocusTrap/FocusTrap.tsx new file mode 100644 index 00000000..1e082c86 --- /dev/null +++ b/src/common/components/FocusTrap/FocusTrap.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { useEffect, useRef } from 'react'; + +// 자식 요소 내부의 포커스 가능한 요소들로 포커스 가두는 컴포넌트 +const FocusTrap = ({ children }: { children: ReactNode }) => { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + + if (!container) return; + + const focusableElements = container.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]' + ); + + if (focusableElements.length === 0) return; + + const firstFocusableElement = focusableElements[0]; + const lastFocusableElement = focusableElements[focusableElements.length - 1]; + + // 랜더링시 첫 요소에 포커스 줘서 포커스 가두기 + firstFocusableElement.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + // shift + Tab 으로 역방향인 경우 + if (document.activeElement === firstFocusableElement) { + e.preventDefault(); + lastFocusableElement.focus(); + } + } else { + // Tab 으로 정방향인 경우 + if (document.activeElement === lastFocusableElement) { + e.preventDefault(); + firstFocusableElement.focus(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + return
{children}
; +}; + +export default FocusTrap; diff --git a/src/common/components/Modal/Modal.tsx b/src/common/components/Modal/Modal.tsx index d2fabd89..e343623b 100644 --- a/src/common/components/Modal/Modal.tsx +++ b/src/common/components/Modal/Modal.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; -import ModalLayout from '@/common/components/Modal/ModalLayout'; +import BoxButton from '@/common/components/BoxButton/BoxButton'; +import FocusTrap from '@/common/components/FocusTrap/FocusTrap'; import { contentStyle, containerStyle, @@ -8,7 +9,6 @@ import { closeButtonStyle, descriptionStyle, } from '@/common/components/Modal/modal.css'; -import BoxButton from '@/common/components/BoxButton/BoxButton'; import { vars } from '@/shared/styles/theme.css'; interface DialogProps { @@ -60,8 +60,12 @@ const Modal = ({ }; return ( - -
+ + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + e.stopPropagation()} + className={description ? containerNoGapStyle : containerStyle}>
{content}
{description &&
{description}
}
@@ -87,8 +91,8 @@ const Modal = ({ )}
-
-
+ + ); }; diff --git a/src/common/components/Modal/ModalLayout.tsx b/src/common/components/Modal/ModalLayout.tsx index 83bcc8e2..055934ce 100644 --- a/src/common/components/Modal/ModalLayout.tsx +++ b/src/common/components/Modal/ModalLayout.tsx @@ -1,12 +1,23 @@ -import type { PropsWithChildren } from 'react'; +import { useEffect, type PropsWithChildren } from 'react'; import { layoutStyle } from '@/common/components/Modal/modal.css'; +import { useModalStore } from '@/common/stores/modal'; -interface ModalLayoutProps extends PropsWithChildren { - onClose: () => void; -} +const ModalLayout = ({ children }: PropsWithChildren) => { + const { closeLastModal } = useModalStore(); + + // esc 키 누르면 모달 닫기 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + closeLastModal(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [closeLastModal]); -const ModalLayout = ({ onClose, children }: ModalLayoutProps) => { return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events
{ onClick={(e) => { if (e.target === e.currentTarget) { e.stopPropagation(); - onClose(); - } - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - onClose(); + closeLastModal(); } }} className={layoutStyle}> diff --git a/src/common/components/Modal/ModalProvider.tsx b/src/common/components/Modal/ModalProvider.tsx index 41fc1c7f..acf63604 100644 --- a/src/common/components/Modal/ModalProvider.tsx +++ b/src/common/components/Modal/ModalProvider.tsx @@ -1,14 +1,22 @@ -import { Fragment, useEffect } from 'react'; +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import ModalLayout from '@/common/components/Modal/ModalLayout'; import { useModalStore } from '@/common/stores/modal'; const ModalProvider = () => { const { modalStore, resetStore, closeModal } = useModalStore(); + const location = useLocation(); // 언마운트시 모달 리셋 useEffect(() => { return () => resetStore(); }, [resetStore]); + // 라우팅 변경시 모달 리셋 + useEffect(() => { + resetStore(); + }, [location.pathname, resetStore]); + // 모달 오버레이시 배경 스크롤 방지 useEffect(() => { if (modalStore.length > 0) { @@ -18,14 +26,14 @@ const ModalProvider = () => { } }, [modalStore]); + if (modalStore.length === 0) return null; + return ( - <> - {modalStore.map(({ id, render }) => ( - - {render({ isOpen: modalStore.some((modal) => modal.id === id), close: () => closeModal(id) })} - - ))} - + + {modalStore.map(({ id, render }) => + render({ isOpen: modalStore.some((modal) => modal.id === id), close: () => closeModal(id) }) + )} + ); }; diff --git a/src/common/stores/modal.ts b/src/common/stores/modal.ts index e7864884..353871cb 100644 --- a/src/common/stores/modal.ts +++ b/src/common/stores/modal.ts @@ -9,6 +9,7 @@ interface ModalStore { openModal: (render: RenderProps) => void; closeModal: (id: string) => void; + closeLastModal: () => void; resetStore: () => void; } @@ -22,5 +23,12 @@ export const useModalStore = create((set) => ({ closeModal: (id) => set((state) => ({ modalStore: state.modalStore.filter((modal) => modal.id !== id) })), + closeLastModal: () => set((state) => ({ modalStore: state.modalStore.slice(0, -1) })), + resetStore: () => set(() => ({ modalStore: [] })), })); + +export const useOpenModal = () => useModalStore((state) => state.openModal); +export const useCloseModal = () => useModalStore((state) => state.closeModal); +export const useCloseLastModal = () => useModalStore((state) => state.closeLastModal); +export const useResetModalStore = () => useModalStore((state) => state.resetStore); diff --git a/src/layout/Layout.tsx b/src/layout/Layout.tsx index d0d0f4e9..7f3357de 100644 --- a/src/layout/Layout.tsx +++ b/src/layout/Layout.tsx @@ -1,6 +1,7 @@ import { Outlet, ScrollRestoration, useLocation } from 'react-router-dom'; import { ROUTES_CONFIG } from '@/routes/routesConfig'; import Header from '@/common/components/Header/Header'; +import ModalProvider from '@/common/components/Modal/ModalProvider'; import { ApiErrorBoundary } from '@/shared/components/ErrorBoundary/ApiErrorBoundary/ApiErrorBoundary'; import GlobalErrorBoundary from '@/shared/components/ErrorBoundary/GlobalErrorBoundary/GlobalErrorBoundary'; @@ -19,6 +20,7 @@ const Layout = () => { {shouldShowHeader &&
} + diff --git a/src/pages/instructor/classDetail/components/StudentCard/StudentCard.tsx b/src/pages/instructor/classDetail/components/StudentCard/StudentCard.tsx index 462c35be..c843f48c 100644 --- a/src/pages/instructor/classDetail/components/StudentCard/StudentCard.tsx +++ b/src/pages/instructor/classDetail/components/StudentCard/StudentCard.tsx @@ -11,7 +11,7 @@ import Head from '@/common/components/Head/Head'; import Modal from '@/common/components/Modal/Modal'; import Text from '@/common/components/Text/Text'; import { notify } from '@/common/components/Toast/Toast'; -import { useModalStore } from '@/common/stores/modal'; +import { useOpenModal } from '@/common/stores/modal'; import ApplyTag from '@/shared/components/ApplyTag/ApplyTag'; import { teacherKeys } from '@/shared/constants/queryKey'; import { WITHDRAW_USER_NAME } from '@/shared/constants/withdrawUser'; @@ -36,7 +36,7 @@ interface StudentCardPropTypes { } const StudentCard = ({ studentData, index, lessonId, selectedTab }: StudentCardPropTypes) => { - const { openModal } = useModalStore(); + const openModal = useOpenModal(); const { text: buttonText, variant: buttonVariant } = STATUS_BUTTON_MAP[studentData.reservationStatus]; diff --git a/src/pages/mypage/components/CancelConfirmPage/CancelConfirmPage.tsx b/src/pages/mypage/components/CancelConfirmPage/CancelConfirmPage.tsx index d6667afd..0d637c70 100644 --- a/src/pages/mypage/components/CancelConfirmPage/CancelConfirmPage.tsx +++ b/src/pages/mypage/components/CancelConfirmPage/CancelConfirmPage.tsx @@ -21,7 +21,7 @@ import ApplicantInfo from '@/pages/reservation/components/ApplicantInfo/Applican import ClassInfo from '@/pages/reservation/components/ClassInfo/ClassInfo'; import BlurBotton from '@/common/components/BlurButton/BlurButton'; import Modal from '@/common/components/Modal/Modal'; -import { useModalStore } from '@/common/stores/modal'; +import { useOpenModal } from '@/common/stores/modal'; import { useGetBankList } from '@/shared/apis/queries'; import BankBottomSheet from '@/shared/components/BankBottomSheet/BankBottomSheet'; import BoxButton from '@/common/components/BoxButton/BoxButton'; @@ -48,7 +48,7 @@ const CancelConfirmPage = () => { const { data: myPageData } = useGetMyPage(); const { data: bankList } = useGetBankList(); - const { openModal } = useModalStore(); + const openModal = useOpenModal(); const { mutate: cancelReservation, isPending } = useCancelReservation(); const navigationState = location.state as NavigationState | null; diff --git a/src/pages/mypage/components/Withdraw/components/NoticeStep/NoticeStep.tsx b/src/pages/mypage/components/Withdraw/components/NoticeStep/NoticeStep.tsx index 1aca053e..13d7bf65 100644 --- a/src/pages/mypage/components/Withdraw/components/NoticeStep/NoticeStep.tsx +++ b/src/pages/mypage/components/Withdraw/components/NoticeStep/NoticeStep.tsx @@ -15,16 +15,16 @@ import { } from '@/pages/mypage/components/Withdraw/components/NoticeStep/noticeStep.css'; import { NOTICE_CONTENTS } from '@/pages/mypage/components/Withdraw/constants'; import BlurButton from '@/common/components/BlurButton/BlurButton'; -import Modal from '@/common/components/Modal/Modal'; -import { useModalStore } from '@/common/stores/modal'; -import { useWithdrawStore } from '@/common/stores/withdraw'; -import IcCheckcircleGray0524 from '@/shared/assets/svg/IcCheckcircleGray0524'; -import IcCheckcircleMain0324 from '@/shared/assets/svg/IcCheckcircleMain0324'; import BoxButton from '@/common/components/BoxButton/BoxButton'; import Divider from '@/common/components/Divider/Divider'; import Head from '@/common/components/Head/Head'; +import Modal from '@/common/components/Modal/Modal'; import Text from '@/common/components/Text/Text'; import { notify } from '@/common/components/Toast/Toast'; +import { useOpenModal } from '@/common/stores/modal'; +import { useWithdrawStore } from '@/common/stores/withdraw'; +import IcCheckcircleGray0524 from '@/shared/assets/svg/IcCheckcircleGray0524'; +import IcCheckcircleMain0324 from '@/shared/assets/svg/IcCheckcircleMain0324'; import { vars } from '@/shared/styles/theme.css'; import { clearStorage } from '@/shared/utils/handleToken'; @@ -34,7 +34,9 @@ interface NoticeStepPropTypes { const NoticeStep = ({ onNext }: NoticeStepPropTypes) => { const [isAgreed, setIsAgreed] = useState(false); - const { openModal } = useModalStore(); + + const openModal = useOpenModal(); + const titleId = useId(); const handleAgreeToggle = () => setIsAgreed((prev) => !prev); diff --git a/src/shared/styles/reset.css.ts b/src/shared/styles/reset.css.ts index c6704685..8a0e3ba4 100644 --- a/src/shared/styles/reset.css.ts +++ b/src/shared/styles/reset.css.ts @@ -84,3 +84,7 @@ globalStyle('button', { globalStyle('input, textarea', { outline: 'none', }); + +globalStyle('dialog', { + border: 'none', +});