Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -33,10 +31,7 @@ const App = () => {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<GlobalErrorBoundary>
<RouterProvider router={router} />
<ModalProvider />
</GlobalErrorBoundary>
<RouterProvider router={router} />
<Toaster containerStyle={{ margin: '0 auto' }} />
</QueryClientProvider>
);
Expand Down
50 changes: 50 additions & 0 deletions src/common/components/FocusTrap/FocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react';
import { useEffect, useRef } from 'react';

// 자식 요소 내부의 포커스 가능한 요소들로 포커스 가두는 컴포넌트
const FocusTrap = ({ children }: { children: ReactNode }) => {
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const container = containerRef.current;

if (!container) return;

const focusableElements = container.querySelectorAll<HTMLElement>(
'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 <div ref={containerRef}>{children}</div>;
};

export default FocusTrap;
13 changes: 7 additions & 6 deletions src/common/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -30,12 +30,13 @@ const Modal = ({
onClickHandler();
}

onClose();
// onClose();
};

return (
<ModalLayout onClose={onClose}>
<div className={containerStyle}>
<FocusTrap>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}
<dialog aria-modal="true" onClick={(e) => e.stopPropagation()} className={containerStyle}>
<div className={contentStyle}>{content}</div>
<div className={buttonWrapperStyle}>
{type === 'default' && (
Expand All @@ -54,8 +55,8 @@ const Modal = ({
</BoxButton>
)}
</div>
</div>
</ModalLayout>
</dialog>
</FocusTrap>
);
};

Expand Down
23 changes: 17 additions & 6 deletions src/common/components/Modal/ModalLayout.tsx
Original file line number Diff line number Diff line change
@@ -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
<div
onClick={(e) => {
e.stopPropagation();
onClose();
closeLastModal();
}}
className={layoutStyle}>
{children}
Expand Down
8 changes: 8 additions & 0 deletions src/common/stores/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface ModalStore {

openModal: (render: RenderProps) => void;
closeModal: (id: string) => void;
closeLastModal: () => void;
resetStore: () => void;
}

Expand All @@ -22,5 +23,12 @@ export const useModalStore = create<ModalStore>((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);
2 changes: 2 additions & 0 deletions src/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -19,6 +20,7 @@ const Layout = () => {
<ApiErrorBoundary>
{shouldShowHeader && <Header />}
<Outlet />
<ModalProvider />
<ScrollRestoration />
</ApiErrorBoundary>
</GlobalErrorBoundary>
Expand Down
29 changes: 29 additions & 0 deletions src/pages/home/Home.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,8 +20,32 @@ const preload = (imageArray: string) => {
const Home = () => {
preload(images);

const openModal = useOpenModal();

const navigate = useNavigate();
const handleModalOpen = () => {
openModal(({ close }) => (
<Modal
content="모달 오픈2"
type="default"
onClose={close}
onClickHandler={() => {
navigate(ROUTES_CONFIG.mypage.path);
}}
/>
));
};

return (
<main>
<BoxButton
onClick={() =>
openModal(({ close }) => (
<Modal content="모달 오픈1" type="default" onClose={close} onClickHandler={handleModalOpen} />
))
}>
모달오픈
</BoxButton>
<HomeCarousel />
<FetchErrorBoundary>
<LatestLessons />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,7 +28,7 @@ interface NoticeStepPropTypes {

const NoticeStep = ({ onNext }: NoticeStepPropTypes) => {
const [isAgreed, setIsAgreed] = useState(false);
const { openModal } = useModalStore();
const openModal = useOpenModal();

const titleId = useId();

Expand Down
14 changes: 12 additions & 2 deletions src/shared/components/ModalProvider/ModalProvier.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -18,12 +26,14 @@ const ModalProvider = () => {
}
}, [modalStore]);

if (modalStore.length === 0) return null;

return (
<>
<ModalLayout>
{modalStore.map(({ id, render }) =>
render({ isOpen: modalStore.some((modal) => modal.id === id), close: () => closeModal(id) })
)}
</>
</ModalLayout>
);
};

Expand Down
4 changes: 4 additions & 0 deletions src/shared/styles/reset.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,7 @@ globalStyle('button', {
globalStyle('input, textarea', {
outline: 'none',
});

globalStyle('dialog', {
border: 'none',
});