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
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ export function DetailHeader({ tags, isOwner, onEdit, onRemove }: DetailHeaderPr
<div className='ml-auto flex-none'>
<MoreMenu>
<MoreMenuItem onClick={onEdit}>수정하기</MoreMenuItem>
<MoreMenuItem onClick={onRemove}>삭제하기</MoreMenuItem>
<MoreMenuItem
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
삭제하기
</MoreMenuItem>
</MoreMenu>
</div>
)}
Expand Down
8 changes: 6 additions & 2 deletions src/app/(after-login)/mypage/_components/MyProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ export default function MyProfile() {
<div className='flex flex-col items-center gap-2 lg:gap-4'>
{session && (
<>
<div onClick={() => open('ProfileEdit')} className='cursor-pointer'>
<button
type='button'
onClick={() => open('ProfileEdit')}
className='cursor-pointer'
>
<Avatar
src={session.user?.image ?? undefined}
alt={session.user?.nickname}
className='h-20 w-20 border-2 border-blue-300 text-xl lg:h-[120px] lg:w-[120px] lg:text-3xl'
/>
</div>
</button>
<p className='text-black-950 text-lg font-medium lg:text-2xl'>
{session.user?.nickname}
</p>
Expand Down
10 changes: 7 additions & 3 deletions src/app/(after-login)/mypage/_components/ProfileEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,15 @@ export default function ProfileEditModal() {
if (!isOpen || type !== 'ProfileEdit') return null;

return (
<ModalBase>
<ModalBase titleId='profile-edit-modal-title'>
<form
onSubmit={handleSubmit(onSubmit)}
className='mt-2 flex w-full flex-col justify-center gap-6'
>
<h2 id='profile-edit-modal-title' className='sr-only'>
프로필 수정
</h2>

<Controller
name='image'
control={control}
Expand Down Expand Up @@ -107,14 +111,14 @@ export default function ProfileEditModal() {
<Button
type='button'
onClick={close}
className='text-black-700 w-full bg-blue-200 font-medium hover:bg-blue-200 focus:ring-0 focus:ring-offset-0 active:border-blue-200 active:bg-blue-200'
className='text-black-700 w-full bg-blue-200 font-medium hover:bg-blue-200 active:border-blue-200 active:bg-blue-200'
>
취소
</Button>
<Button
type='submit'
disabled={isSubmitting || !isDirty || !isValid}
className='w-full bg-blue-900 hover:bg-blue-950 active:bg-blue-950'
className='w-full bg-blue-900 hover:bg-blue-950 focus:ring-black active:bg-blue-950'
>
저장
</Button>
Expand Down
14 changes: 11 additions & 3 deletions src/components/DeleteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,16 @@ export default function DeleteModal({
isSubmitting = false,
}: DeleteModalProps) {
return (
<ModalBase isOpen={isOpen} onClose={onClose} className='px-4 py-6 md:px-[38px] md:py-8'>
<ModalBase
isOpen={isOpen}
onClose={onClose}
className='px-4 py-6 md:px-[38px] md:py-8'
titleId='delete-modal-title'
>
<div className='flex justify-center'>
<h2 id='delete-modal-title' className='sr-only'>
{type === 'comment' ? '댓글 삭제' : '게시물 삭제'}
</h2>
<Image
src={noticeIcon}
alt='삭제 경고 아이콘'
Expand All @@ -48,13 +56,13 @@ export default function DeleteModal({
<div className='flex flex-row gap-2 lg:gap-4'>
<Button
onClick={onClose}
className='text-black-700 bg-blue-200 font-medium hover:bg-blue-200 focus:ring-0 focus:ring-offset-0 active:border-blue-200 active:bg-blue-200'
className='text-black-700 bg-blue-200 font-medium hover:bg-blue-200 active:border-blue-200 active:bg-blue-200'
>
취소
</Button>
<Button
onClick={onDelete}
className='bg-blue-900 hover:bg-blue-950 active:bg-blue-950'
className='bg-blue-900 hover:bg-blue-950 focus:ring-black active:bg-blue-950'
disabled={isSubmitting}
>
삭제하기
Expand Down
48 changes: 10 additions & 38 deletions src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
useState,
} from 'react';
import { AnimatePresence, HTMLMotionProps, motion } from 'motion/react';
import { focusTrapWithArrow } from '@/utils/focusTrap';
import { cn } from '@/utils/helper';

const DropdownStateContext = createContext<{
Expand Down Expand Up @@ -61,6 +62,9 @@ export function Dropdown({
}, []);

useEffect(() => {
const currentDropdown = dropdownRef.current;
if (!currentDropdown) return;

function handleKeyDown(e: KeyboardEvent) {
if (!isOpen) return;

Expand All @@ -70,10 +74,10 @@ export function Dropdown({
}
}

document.addEventListener('keydown', handleKeyDown);
currentDropdown.addEventListener('keydown', handleKeyDown);

return () => {
document.removeEventListener('keydown', handleKeyDown);
currentDropdown.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);

Expand Down Expand Up @@ -121,7 +125,7 @@ export function DropdownMenu({
const menuRef = useRef<HTMLUListElement>(null);

const focusFirstElement = () => {
if (menuRef.current) {
if (menuRef.current && isOpen) {
const focusable = menuRef.current.querySelector<HTMLElement>(
'button, [href], [tabindex]:not([tabindex="-1"])',
);
Expand All @@ -135,43 +139,11 @@ export function DropdownMenu({

useEffect(() => {
if (!isOpen || !menuRef.current) return;

const menu = menuRef.current;

function handleKeyDown(e: KeyboardEvent) {
const focusable = Array.from(
menu.querySelectorAll<HTMLElement>('button, [href], [tabindex]:not([tabindex="-1"])'),
);
const currentIndex = focusable.findIndex((el) => el === document.activeElement);

if (currentIndex === -1) return;

const isForward =
(direction === 'vertical' && e.key === 'ArrowDown') ||
(direction === 'horizontal' && e.key === 'ArrowRight');

const isBackward =
(direction === 'vertical' && e.key === 'ArrowUp') ||
(direction === 'horizontal' && e.key === 'ArrowLeft');

const focusableLength = focusable.length;
let targetIndex: number | null = null;

if (isForward) {
targetIndex = currentIndex + 1 < focusableLength ? currentIndex + 1 : 0;
} else if (isBackward) {
targetIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : focusableLength - 1;
}

if (targetIndex !== null) {
e.preventDefault();
focusable[targetIndex]?.focus();
}
}

menu.addEventListener('keydown', handleKeyDown);
menu.addEventListener('keydown', (e) => focusTrapWithArrow(e, direction));
return () => {
menu.removeEventListener('keydown', handleKeyDown);
menu.removeEventListener('keydown', (e) => focusTrapWithArrow(e, direction));
};
}, [isOpen, direction]);

Expand All @@ -186,7 +158,7 @@ export function DropdownMenu({
transition={{ duration: 0.1 }}
className={className}
role='menu'
onAnimationComplete={focusFirstElement}
onAnimationStart={focusFirstElement}
{...props}
>
{children}
Expand Down
40 changes: 37 additions & 3 deletions src/components/ModalBase.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,83 @@
'use client';

import { useRef, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import useModal from '@/hooks/useModal';
import useModalStore from '@/hooks/useModalStore';
import { focusTrap } from '@/utils/focusTrap';
import { cn } from '@/utils/helper';

interface ModalBaseProps {
children: React.ReactNode;
isOpen?: boolean;
onClose?: () => void;
className?: string;
titleId?: string;
}

export default function ModalBase({
children,
isOpen: propIsOpen,
onClose: propOnClose,
className,
titleId,
}: ModalBaseProps) {
const { isOpen: storeIsOpen, close: storeClose } = useModalStore();

const isOpen = propIsOpen ?? storeIsOpen;
const onClose = propOnClose ?? storeClose;

const { mounted } = useModal(isOpen, onClose);

const modalRef = useRef<HTMLDivElement>(null);
const initialFocusRef = useRef<HTMLDivElement>(null);
const previousFocusedElementRef = useRef<HTMLElement | null>(null);

useLayoutEffect(() => {
if (isOpen) {
previousFocusedElementRef.current = document.activeElement as HTMLElement;

setTimeout(() => {
if (modalRef.current) {
const inputElement = modalRef.current.querySelector<HTMLElement>('input');
if (inputElement) {
inputElement.focus();
} else {
initialFocusRef.current?.focus();
}
}
}, 0);
} else {
previousFocusedElementRef.current?.focus();
}
}, [isOpen]);

const portalRoot =
typeof document !== 'undefined' ? document.getElementById('portal-root') : null;

if (!mounted || !portalRoot || !isOpen) return null;

return createPortal(
<div
className={'fixed inset-0 z-50 flex items-center justify-center bg-black/60'}
className='fixed inset-0 z-50 flex items-center justify-center bg-black/60'
onClick={onClose}
data-testid='modal-overlay'
aria-hidden={!isOpen}
>
<div
ref={modalRef}
role='dialog'
aria-modal='true'
aria-labelledby={titleId}
className={cn(
'w-full max-w-[320px] rounded-3xl bg-white px-[38px] py-8 md:max-w-[372px] lg:max-w-[452px] lg:py-10',
className,
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => focusTrap(e, modalRef.current)}
>
{children}
<div ref={initialFocusRef} tabIndex={-1}>
{children}
</div>
</div>
</div>,
portalRoot,
Expand Down
4 changes: 2 additions & 2 deletions src/components/MoreMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { HTMLAttributes, PropsWithChildren } from 'react';
import { HTMLAttributes, MouseEvent, PropsWithChildren } from 'react';
import { cn } from '@/utils/helper';
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from './Dropdown';
import Icon from './Icon';
Expand All @@ -23,7 +23,7 @@ export function MoreMenuItem({
children,
onClick,
...props
}: PropsWithChildren<HTMLAttributes<HTMLLIElement> & { onClick: () => void }>) {
}: PropsWithChildren<HTMLAttributes<HTMLLIElement> & { onClick: (e: MouseEvent) => void }>) {
const ItemClassName = cn(
'text-md lg:text-2lg cursor-pointer px-6 py-2 lg:px-8 lg:py-3 whitespace-nowrap hover:bg-blue-200 rounded-xl',
className,
Expand Down
4 changes: 4 additions & 0 deletions src/components/ProfileModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ export default function ProfileModal({ isOpen, writer, onClose }: ProfileModalPr
isOpen={isOpen}
onClose={onClose}
className='max-w-[300px] px-6 pt-4 pb-6 md:max-w-[328px] lg:max-w-[360px] lg:px-10 lg:pt-6 lg:pb-8'
titleId='profile-modal-title'
>
<div className='flex justify-end'>
<h2 id='profile-modal-title' className='sr-only'>
프로필
</h2>
<button onClick={onClose} className='cursor-pointer'>
<Icon name='close' size={20} />
</button>
Expand Down
72 changes: 72 additions & 0 deletions src/utils/focusTrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export function getFocusableElements(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
}

export function focusTrap(e: React.KeyboardEvent<HTMLElement>, container: HTMLElement | null) {
if (e.key !== 'Tab' || !container) return;

const focusableElements = getFocusableElements(container);
if (focusableElements.length === 0) return;

const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
const isShift = e.shiftKey;
const activeElement = document.activeElement;

if (isShift) {
if (activeElement === first || !container.contains(activeElement)) {
e.preventDefault();
last.focus();
}
} else {
if (activeElement === last || !container.contains(activeElement)) {
e.preventDefault();
first.focus();
}
}
}

/**
* keyboard arrow의 조작으로 변경되는 tab focus
* - React DOM에 직접적으로 연결이 아니라, useEffect 내부에서 eventListener를 등록, 해제하는 방향으로 작성 (window KeyobardEvent 사용)
* - 넘겨진 event에서 currentTarget으로 eventListener가 걸린 HTMLElement를 container로 등록하고 사용
*/

export function focusTrapWithArrow(e: KeyboardEvent, direction: 'vertical' | 'horizontal') {
const container = e.currentTarget as HTMLElement;
if (!container) return;

const isForward =
(direction === 'vertical' && e.key === 'ArrowDown') ||
(direction === 'horizontal' && e.key === 'ArrowRight');

const isBackward =
(direction === 'vertical' && e.key === 'ArrowUp') ||
(direction === 'horizontal' && e.key === 'ArrowLeft');

if (!isForward && !isBackward) return;

const focusableElements = getFocusableElements(container);
if (focusableElements.length === 0) return;

const currentIndex = focusableElements.findIndex((el) => el === document.activeElement);
if (currentIndex === -1) return;

const focusableLength = focusableElements.length;
let targetIndex: number | null = null;

if (isForward) {
targetIndex = currentIndex + 1 < focusableLength ? currentIndex + 1 : 0;
} else if (isBackward) {
targetIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : focusableLength - 1;
}

if (targetIndex !== null) {
e.preventDefault();
focusableElements[targetIndex]?.focus();
}
}