Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export default function RootLayout({
<html lang='ko'>
<body className={`${pretendard.className} ${pretendard.variable} antialiased`}>
<Providers>
<LayoutWrapper>{children}</LayoutWrapper>
<div id='root'>
<LayoutWrapper>{children}</LayoutWrapper>
</div>
</Providers>
</body>
</html>
Expand Down
3 changes: 1 addition & 2 deletions src/components/pages/profile/profile-edit-modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,12 @@ export const ProfileEditModal = ({ user }: Props) => {
});

return (
<ModalContent>
<ModalContent className='max-w-82.5'>
<ModalTitle>프로필 수정</ModalTitle>
<ModalDescription className='sr-only'>
이 모달은 자신의 프로필을 수정할 수 있는 모달입니다.
</ModalDescription>
<form
className='w-full max-w-70.5'
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
Expand Down
1 change: 1 addition & 0 deletions src/components/ui/imageinput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export const ImageInput = ({
style={{ display: 'none' }}
accept={accept}
multiple={multiple}
tabIndex={-1}
type='file'
onChange={handleFileChange}
/>
Expand Down
118 changes: 91 additions & 27 deletions src/components/ui/modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import React, { createContext, 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';

Expand All @@ -23,12 +25,16 @@ interface ModalProviderProps {
}

export const ModalProvider = ({ children }: ModalProviderProps) => {
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [content, setContent] = useState<React.ReactNode>(null);

const previousFocusRef = useRef<HTMLElement | null>(null);
const lastInputTypeRef = useRef<'mouse' | 'keyboard'>('mouse');

const modalWrapperRef = useRef<HTMLDivElement | null>(null);
const isMouseDownInsideModal = useRef(false);

const open = (modalContent: React.ReactNode) => {
setContent(modalContent);
setIsOpen(true);
Expand All @@ -43,11 +49,16 @@ export const ModalProvider = ({ children }: ModalProviderProps) => {
setContent(null);
setIsOpen(false);
if (previousFocusRef.current) {
previousFocusRef.current.focus();
const el = previousFocusRef.current;
setTimeout(() => {
el.focus();
}, 0);

previousFocusRef.current = null;
}
};

// Modal을 Open 할 때 키보드로 진입했다면 Trigger 요소를 기억함
useEffect(() => {
const handleMouseDown = () => {
lastInputTypeRef.current = 'mouse';
Expand All @@ -65,6 +76,37 @@ export const ModalProvider = ({ children }: ModalProviderProps) => {
};
}, []);

// Modal 외부 Mousedown => 내부 MouseUp 일 때 Modal이 닫히지 않음
// Modal 내부 Mousedown => 외부 MouseUp 일 때 Modal이 닫히지 않음
// Modal 외부 Mousedown => 외부 Mouseup 일 때 Modal 닫힘
Comment on lines +79 to +81
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 것까지 생각하신다니.. 대단하십니다👍👍

useEffect(() => {
if (!isOpen) return;
const handleMouseDown = (e: MouseEvent) => {
if (modalWrapperRef.current?.contains(e.target as Node)) {
isMouseDownInsideModal.current = true;
} else {
isMouseDownInsideModal.current = false;
}
};

const handleMouseUp = (e: MouseEvent) => {
if (
!modalWrapperRef.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) => {
Expand All @@ -89,7 +131,7 @@ export const ModalProvider = ({ children }: ModalProviderProps) => {
// Modal Open 상태일 때 배경 요소들 무시
useEffect(() => {
if (!isOpen) return;
const appRoot = document.getElementById('__next') || document.getElementById('root');
const appRoot = document.getElementById('root');
if (appRoot) {
appRoot.setAttribute('inert', '');
appRoot.setAttribute('aria-hidden', 'true');
Expand All @@ -102,20 +144,47 @@ export const ModalProvider = ({ children }: ModalProviderProps) => {
};
}, [isOpen]);

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);

return (
<ModalContext.Provider value={{ open, close }}>
{children}
{isOpen && content}
{mounted &&
createPortal(
<div id='modal-root'>
{isOpen && (
<m.div
className='fixed inset-0 z-9999 flex items-center justify-center bg-black/50'
animate={{
opacity: 1,
}}
aria-describedby='modal-description'
aria-labelledby='modal-title'
aria-modal='true'
initial={{ opacity: 0 }}
role='dialog'
>
<div ref={modalWrapperRef} className='flex w-full max-w-110 justify-center px-4'>
{content}
</div>
</m.div>
)}
</div>,
document.body,
)}
</ModalContext.Provider>
);
};

interface ModalContentProps {
children: React.ReactNode;
className?: string;
}

export const ModalContent = ({ children }: ModalContentProps) => {
const { close } = useModal();
export const ModalContent = ({ children, className }: ModalContentProps) => {
const modalRef = useRef<HTMLDivElement>(null);

// focus 처리
Expand All @@ -124,7 +193,7 @@ export const ModalContent = ({ children }: ModalContentProps) => {

const modal = modalRef.current;
const focusableElements = modal.querySelectorAll(
'button:not([disabled]), a[href]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
'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;
Expand Down Expand Up @@ -158,29 +227,24 @@ export const ModalContent = ({ children }: ModalContentProps) => {
return () => modal.removeEventListener('keydown', handleTab);
}, [children]);

return createPortal(
<div
className='fixed inset-0 z-9999 flex items-center justify-center bg-black/50'
aria-describedby='modal-description'
aria-labelledby='modal-title'
aria-modal='true'
role='dialog'
onClick={close}
return (
<m.div
ref={modalRef}
className={cn('w-full rounded-3xl bg-white p-5', className)}
animate={{
opacity: 1,
scale: 1,
}}
initial={{ opacity: 0, scale: 0.1 }}
onClick={(e) => {
e.stopPropagation();
}}
>
<div
ref={modalRef}
className='rounded-3xl bg-white p-5'
onClick={(e) => {
e.stopPropagation();
}}
>
<div className='relative'>
{children}
<ModalCloseButton />
</div>
<div className='relative'>
{children}
<ModalCloseButton />
</div>
</div>,
document.body,
</m.div>
);
};

Expand Down