React에서 onBlur를 활용한 케밥 옵션 닫기 기능 구현
by 담담이담
모달이 있고, 케밥 버튼을 누르면 수정하기 삭제하기 옵션이 나온다.
이때 케밥 버튼을 다시 클릭하지 않고,
케밥 버튼 이외의 다른 부분을 눌러도
옵션들이 닫히도록 구현해보았다.
import useApi from "@/hooks/useApi";
import EditInputModal from "@/modals/EditInputModal";
import CommentList from "@/modals/components/Comment/CommentList";
import { CardData } from "@/types/api.type";
import { getAccessTokenFromDocument } from "@/utils/getAccessToken";
import Image from "next/image";
import { Dispatch, FocusEvent, FormEvent, SetStateAction, useRef, useState } from "react";
import ChipTag from "../components/Chips/ChipTag/ChipTag";
import ChipTodo from "../components/Chips/ChipTodo/ChipTodo";
import AlertModal from "./AlertModal";
import ModalWrapper from "./ModalWrapper";
import styles from "./TaskCardModal.module.css";
import AssigneeAndDueDateInfo from "./components/AssigneeAndDueDateInfo/AssigneeAndDueDateInfo";
interface TaskCardInfoProps {
columnTitle: string;
data: CardData;
setCardList: Dispatch<SetStateAction<CardData[]>>;
handleModalClose: () => void;
}
const TaskCardModal = ({ data, columnTitle, setCardList, handleModalClose }: TaskCardInfoProps) => {
const [isKebabOpen, setIsKebabOpen] = useState(false);
const [isCardModifyModalOpen, setCardModifyModalOpen] = useState(false);
const [isCardDeleteModalOpen, setCardDeleteModalOpen] = useState(false);
const accessToken = getAccessTokenFromDocument("accessToken");
// 케밥 열고 닫기
const handleKebabToggle = () => {
setIsKebabOpen((prevValue) => !prevValue);
};
const optionsRef = useRef<HTMLDivElement>(null);
const handleKebabClose = (e: FocusEvent) => {
if (!optionsRef.current?.contains(e.relatedTarget)) {
setIsKebabOpen(false);
}
};
// 수정하기 모달 열고 닫기
const handleModifyModalToggle = () => {
setCardModifyModalOpen((prevValue) => !prevValue);
};
// 삭제하기 모달 열고 닫기
const handleDeleteModalToggle = () => {
setCardDeleteModalOpen((prevValue) => !prevValue);
};
const { pending, wrappedFunction: deleteData } = useApi("delete");
// 카드 삭제하기
const handleCardDelete = async (e: FormEvent) => {
e.preventDefault();
const res = await deleteData({ path: "card", id: data.id, accessToken });
if (pending) return;
if (res?.status === 204) {
handleDeleteModalToggle();
handleModalClose();
setCardList((prevValue) => {
const newCardList = prevValue.filter((card) => card.id !== data.id);
return newCardList;
});
}
};
return (
<ModalWrapper size="lg" handleModalClose={handleModalClose}>
<div className={styles.modal_wrapper}>
<div className={styles.header}>
<h1 className={styles.title}>{data.title}</h1>
<div className={styles.icons}>
<button className={styles.icon} onClick={handleKebabToggle} onBlur={handleKebabClose}>
<Image src="/icons/icon-kebab.svg" alt="케밥 아이콘" width={28} height={28} />
</button>
{isKebabOpen && (
<div className={styles.options} ref={optionsRef}>
<button className={styles.option} onClick={() => (handleModifyModalToggle(), setIsKebabOpen(false))}>
수정하기
</button>
<button className={styles.option} onClick={() => (handleDeleteModalToggle(), setIsKebabOpen(false))}>
삭제하기
</button>
</div>
)}
{isCardModifyModalOpen && (
<EditInputModal
initialvalue={data}
title="할 일 수정"
columnTitle={columnTitle}
buttonText="수정"
setCardList={setCardList}
handleModalClose={handleModifyModalToggle}
/>
)}
{isCardDeleteModalOpen && (
<AlertModal
handleSubmit={handleCardDelete}
alertText="카드를 삭제하시겠습니까?"
handleModalClose={handleDeleteModalToggle}
/>
)}
<button type="button" className={styles.icon} onClick={handleModalClose}>
<Image src="/icons/icon-close-black.svg" alt="창닫기 아이콘" width={32} height={32} />
</button>
</div>
</div>
<div className={styles.body}>
{/* 칩 부분 */}
<div className={styles.first}>
<div className={styles.chips}>
<ChipTodo size="lg" color="purple">
{columnTitle}
</ChipTodo>
<div className={styles.separator}></div>
<div className={styles.tags}>
{data?.tags.map((tag) => (
<ChipTag size="lg" key={tag}>
{tag}
</ChipTag>
))}
</div>
</div>
{/* 설명 및 사진 */}
<p className={styles.description}>{data.description}</p>
{data?.imageUrl && (
<div className={styles.image_wrapper}>
<Image priority fill src={data.imageUrl} alt="할 일 카드 이미지" />
</div>
)}
{/* 댓글 리스트 */}
<CommentList cardData={data} />
</div>
<AssigneeAndDueDateInfo data={data} />
</div>
</div>
</ModalWrapper>
);
};
export default TaskCardModal;
전체 코드는 다음과 같고 앞서 이야기 한 내용은
// 케밥 열고 닫기
const handleKebabToggle = () => {
setIsKebabOpen((prevValue) => !prevValue);
};
const optionsRef = useRef<HTMLDivElement>(null);
const handleKebabClose = (e: FocusEvent) => {
if (!optionsRef.current?.contains(e.relatedTarget)) {
setIsKebabOpen(false);
}
};
///////// return 문 안
<button className={styles.icon} onClick={handleKebabToggle} onBlur={handleKebabClose}>
<Image src="/icons/icon-kebab.svg" alt="케밥 아이콘" width={28} height={28} />
</button>
{isKebabOpen && (
<div className={styles.options} ref={optionsRef}>
<button className={styles.option} onClick={() => (handleModifyModalToggle(), setIsKebabOpen(false))}>
수정하기
</button>
<button className={styles.option} onClick={() => (handleDeleteModalToggle(), setIsKebabOpen(false))}>
삭제하기
</button>
</div>
)}
이 부분이다.
참고로 handleKebabToggle은 케밥 버튼을 클릭함으로써
옵션들이 열렸다 닫혔다를 구현해주는 함수이다.
먼저 useRef를 이용해서 옵션들을 담고 있는 div 태그에 optionsRef를 달아주고,
케밥 버튼에 onBlur로 handleKebabClose라는 핸들러 함수를 달아줬다.
e.relatedTarget은 react의 FocusEvent에만 존재하는 속성으로,
이벤트가 발생한 다음으로 포커스가 이동된 요소를 나타낸다.
즉, 이벤트 발생 후 처음으로 포커스 된 요소를 의미한다.
(참고로 div는 클릭하는 요소가 아니기에 focus가 되지 않는다)
즉, 이 코드는 케밥 버튼을 클릭한 다음으로(onBlur로 함수 동작)
클릭한 요소(e.relatedTarget)가
옵션 안에 존재하는 "수정하기"나 "삭제하기" 버튼이 아니라면
옵션들이 닫히게 만드는 구현 방식이다.
즉, 케밥을 클릭한 후, 다음 클릭으로
옵션들 내부의 버튼들을 클릭하지 않았다면, 옵션이 닫힌다.
한 줄 정리
케밥 클릭으로 옵션들이 열린 상태에서 다른 부분을 클릭하거나 포커스를 옮겼을 때,
해당 이벤트가 케밥 메뉴의 옵션(`optionsRef.current`)을 포함하지 않는 경우에만
`setIsKebabOpen(false)`를 통해 케밥 메뉴를 닫도록 하는 로직
- handleKebab 함수:
- 이 함수는 케밥 버튼이 클릭될 때 isKebabOpen 상태를 true와 false 사이에서 토글합니다.
- optionsRef Ref:
- optionsRef는 옵션(수정하기 및 삭제하기 버튼이 있는 부분)을 나타내는 div에 부착된 useRef입니다.
- handleKebabClose 함수:
- 이 함수는 케밥 버튼의 onBlur 이벤트에 연결됩니다.
- 케밥 버튼이 포커스를 잃으면(즉, 사용자가 버튼 외부를 클릭할 때), 이 함수가 호출됩니다.
- e.relatedTarget은 포커스를 받는 요소를 나타냅니다. 여기서는 사용자가 클릭한 요소입니다.
- 조건 확인:
- 조건문 !optionsRef.current?.contains(e.relatedTarget)은 클릭한 요소(relatedTarget)가 옵션 div 안에 있는지 확인합니다.
'프로젝트' 카테고리의 다른 글
CI를 위한 GitHub Action 설정하기 (0) | 2024.01.20 |
---|---|
NextJS 프로젝트 초기 설정 (feat 리액트 쿼리, 리덕스, 타입스크립트) (0) | 2024.01.20 |
nvm과 npm 그리고 npx의 차이 (0) | 2024.01.19 |
Component definition is missing display name 에러 (0) | 2024.01.06 |
모달 바깥 부분이나 ESC 키를 누르면 모달이 닫히는 기능 구현 (1) | 2024.01.03 |
블로그의 정보
유명한 담벼락
담담이담