diff --git a/src/App.tsx b/src/App.tsx index a1214c90d..822c7e64d 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 GlobalErrorBoundary from '@/shared/components/ErrorBoundary/GlobalErrorBoundary/GlobalErrorBoundary'; -import ModalProvider from '@/shared/components/ModalProvider/ModalProvier'; 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 000000000..1e082c86d --- /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 413ae2009..e5d746204 100644 --- a/src/common/components/Modal/Modal.tsx +++ b/src/common/components/Modal/Modal.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import ModalLayout from '@/common/components/Modal/ModalLayout'; +import FocusTrap from '@/common/components/FocusTrap/FocusTrap'; import { contentStyle, containerStyle, @@ -30,12 +30,13 @@ const Modal = ({ onClickHandler(); } - onClose(); + // onClose(); }; return ( - -
+ + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + e.stopPropagation()} className={containerStyle}>
{content}
{type === 'default' && ( @@ -54,8 +55,8 @@ const Modal = ({ )}
-
-
+ + ); }; diff --git a/src/common/components/Modal/ModalLayout.tsx b/src/common/components/Modal/ModalLayout.tsx index 6572b430d..508487cf5 100644 --- a/src/common/components/Modal/ModalLayout.tsx +++ b/src/common/components/Modal/ModalLayout.tsx @@ -1,16 +1,27 @@ -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, jsx-a11y/no-static-element-interactions
{ e.stopPropagation(); - onClose(); + closeLastModal(); }} className={layoutStyle}> {children} diff --git a/src/common/stores/modal.ts b/src/common/stores/modal.ts index e78648843..353871cb6 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 4801d136d..ab663d907 100644 --- a/src/layout/Layout.tsx +++ b/src/layout/Layout.tsx @@ -3,6 +3,7 @@ import { ROUTES_CONFIG } from '@/routes/routesConfig'; import { ApiErrorBoundary } from '@/shared/components/ErrorBoundary/ApiErrorBoundary/ApiErrorBoundary'; import GlobalErrorBoundary from '@/shared/components/ErrorBoundary/GlobalErrorBoundary/GlobalErrorBoundary'; import Header from '@/shared/components/Header/Header'; +import ModalProvider from '@/shared/components/ModalProvider/ModalProvier'; const Layout = () => { const { pathname } = useLocation(); @@ -19,6 +20,7 @@ const Layout = () => { {shouldShowHeader &&
} + diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index ee32083df..24904ee66 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -1,8 +1,13 @@ +import { useNavigate } from 'react-router-dom'; import Footer from '@/pages/home/components/Footer/Footer'; import HomeCarousel from '@/pages/home/components/HomeCarousel/HomeCarousel'; import LatestLessons from '@/pages/home/components/LatestLessons/LatestLessons'; import PopularGenre from '@/pages/home/components/PopularGenre/PopularGenre'; import UpcomingLessons from '@/pages/home/components/UpcomingLessons/UpcomingLessons'; +import { ROUTES_CONFIG } from '@/routes/routesConfig'; +import Modal from '@/common/components/Modal/Modal'; +import { useOpenModal } from '@/common/stores/modal'; +import BoxButton from '@/shared/components/BoxButton/BoxButton'; import { FetchErrorBoundary } from '@/shared/components/ErrorBoundary/FetchErrorBoundary/FetchErrorBoundary'; const images = '/images/image_kkukgirl.webp'; @@ -15,8 +20,32 @@ const preload = (imageArray: string) => { const Home = () => { preload(images); + const openModal = useOpenModal(); + + const navigate = useNavigate(); + const handleModalOpen = () => { + openModal(({ close }) => ( + { + navigate(ROUTES_CONFIG.mypage.path); + }} + /> + )); + }; + return (
+ + openModal(({ close }) => ( + + )) + }> + 모달오픈 + diff --git a/src/pages/instructor/classDetail/components/StudentCard/StudentCard.tsx b/src/pages/instructor/classDetail/components/StudentCard/StudentCard.tsx index 4c783578e..43aff651f 100644 --- a/src/pages/instructor/classDetail/components/StudentCard/StudentCard.tsx +++ b/src/pages/instructor/classDetail/components/StudentCard/StudentCard.tsx @@ -7,7 +7,7 @@ import { formatPhoneNumber } from '@/pages/instructor/utils/format'; import { STATUS_KOREAN_MAP } from '@/pages/mypage/components/mypageReservation/constants/statusMap'; import type { ReservationStatus } from '@/pages/mypage/components/mypageReservation/types/reservationStatus'; import Modal from '@/common/components/Modal/Modal'; -import { useModalStore } from '@/common/stores/modal'; +import { useOpenModal } from '@/common/stores/modal'; import ApplyTag from '@/shared/components/ApplyTag/ApplyTag'; import BoxButton from '@/shared/components/BoxButton/BoxButton'; import Head from '@/shared/components/Head/Head'; @@ -35,7 +35,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 10a56bcbc..9f2c30c1c 100644 --- a/src/pages/mypage/components/CancelConfirmPage/CancelConfirmPage.tsx +++ b/src/pages/mypage/components/CancelConfirmPage/CancelConfirmPage.tsx @@ -9,7 +9,7 @@ import { CANCEL_CONFIRM_MESSAGE } from '@/pages/mypage/constants/modalMessage'; import ApplicantInfo from '@/pages/reservation/components/ApplicantInfo/ApplicantInfo'; import ClassInfo from '@/pages/reservation/components/ClassInfo/ClassInfo'; 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 BlurBotton from '@/shared/components/BlurButton/BlurButton'; @@ -38,7 +38,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 f49e62aad..23e76d73a 100644 --- a/src/pages/mypage/components/Withdraw/components/NoticeStep/NoticeStep.tsx +++ b/src/pages/mypage/components/Withdraw/components/NoticeStep/NoticeStep.tsx @@ -13,7 +13,7 @@ import { } from '@/pages/mypage/components/Withdraw/components/NoticeStep/noticeStep.css'; import { BULLET_LIST } from '@/pages/mypage/components/Withdraw/constants'; import Modal from '@/common/components/Modal/Modal'; -import { useModalStore } from '@/common/stores/modal'; +import { useOpenModal } from '@/common/stores/modal'; import IcCheckcircleGray0524 from '@/shared/assets/svg/IcCheckcircleGray0524'; import IcCheckcircleMain0324 from '@/shared/assets/svg/IcCheckcircleMain0324'; import BlurButton from '@/shared/components/BlurButton/BlurButton'; @@ -28,7 +28,7 @@ interface NoticeStepPropTypes { const NoticeStep = ({ onNext }: NoticeStepPropTypes) => { const [isAgreed, setIsAgreed] = useState(false); - const { openModal } = useModalStore(); + const openModal = useOpenModal(); const titleId = useId(); diff --git a/src/shared/components/ModalProvider/ModalProvier.tsx b/src/shared/components/ModalProvider/ModalProvier.tsx index 211cec386..acf636041 100644 --- a/src/shared/components/ModalProvider/ModalProvier.tsx +++ b/src/shared/components/ModalProvider/ModalProvier.tsx @@ -1,14 +1,22 @@ 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,12 +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) }) )} - + ); }; diff --git a/src/shared/styles/reset.css.ts b/src/shared/styles/reset.css.ts index c67046856..8a0e3ba48 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', +});