방명록
- [React.js] 재귀 함수를 이용한 무한한 깊이의 체크 박스 구현2024년 12월 23일 14시 34분 35초에 업로드 된 글입니다.작성자: DandyNow728x90반응형
1. 무한한 깊이(depth)의 체크 박스 구현
[그림 1]과 같이 무한한 깊이의 체크 박스를 구현할 필요가 있었다. 목차에 사용되는 데이터는 category 배열인데 이 배열의 요소는 객체이다. 이 객체 하나가 체크 박스 하나를 구성한다. 객체는 subcategory를 가지고 있어서 이를 통해 무한한 깊이의 목차를 만들 수 있다.
구현하고자 하는 기능은 다음과 같다.
- 부모와 자식 체크 박스를 렌더링 할 때 시각적으로 구분이 가능하도록 margin-left 값을 자동으로 부여해야 한다.
- 부모 체크 박스를 체크/해제하면 자식 체크 박스도 부모와 동일하게 체크/해제되어야 한다.
- 자식 체크 박스를 체크/해제할 때는 부모 체크 박스에 영향을 주지 않아야 한다.
2. 구현 코드
위 기능을 구현함에 있어 중요한 포인트는 재귀 함수를 이용하는 것이다.
// PopoverIndex 컴포넌트 import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { TableOfContents } from "lucide-react"; import { CheckboxComponent } from "./CheckboxComponent"; import React, { useState } from "react"; // 카테고리 인터페이스 정의: 계층 구조로 이루어진 체크박스 항목의 구조를 정의 interface Category { id: string; // 카테고리의 고유 ID content: string; // 카테고리의 내용 checked: boolean; // 체크 여부 subcategory: Category[]; // 하위 카테고리 목록 } // 초기 카테고리 데이터: Popover 컴포넌트에서 사용할 기본 상태 const initailCategory: Category[] = [ { id: "1", content: "컴퓨터 구조 시작하기", checked: true, subcategory: [ { id: "4", content: "컴퓨터 구조를 알아야 하는 이유", checked: true, subcategory: [ { id: "9", checked: true, content: "문제 해결", subcategory: [], }, { id: "10", checked: true, content: "성능, 용량, 비용", subcategory: [ { id: "11", checked: false, content: "더 깊이...", subcategory: [], }, ], }, ], }, { id: "5", checked: true, content: "컴퓨터 구조의 큰 그림", subcategory: [], }, ], }, { id: "2", content: "데이터", checked: false, subcategory: [ { id: "6", checked: false, content: "0과 1로 숫자를 표현하는 방법", subcategory: [], }, { id: "7", checked: true, content: "0과 1로 문자를 표현하는 방법", subcategory: [], }, ], }, { id: "3", content: "명령어", checked: false, subcategory: [ { id: "8", checked: true, content: "소스 코드와 명령어", subcategory: [], }, ], }, ]; // PopoverIndex 컴포넌트: 학습 항목을 선택할 수 있는 팝업 UI export function PopoverIndex() { // 상태 관리: 현재 카테고리 데이터 상태 const [categoryObj, setCategoryObj] = useState(initailCategory); // 체크박스 상태를 토글하는 재귀 함수 const toggleChecked = (category: Category, isChecked: boolean): Category => { return { ...category, // 현재 카테고리 정보 복사 checked: isChecked, // 체크 상태 업데이트 subcategory: category.subcategory?.map((sub) => toggleChecked(sub, isChecked) // 하위 카테고리에도 재귀적으로 동일한 상태 적용 ), }; }; // 특정 ID를 가진 카테고리의 체크 상태를 변경 const handleCheckbox = (id: string) => { const toggleCategoryChecked = (categories: Category[]): Category[] => { return categories.map((category: Category) => { if (category.id === id) { // 해당 ID의 카테고리 체크 상태를 토글 return toggleChecked(category, !category.checked); } return { ...category, // 현재 카테고리 복사 subcategory: category.subcategory ? toggleCategoryChecked(category.subcategory) // 하위 카테고리에도 재귀적으로 적용 : category.subcategory, }; }); }; // 상태 업데이트 setCategoryObj((prev) => toggleCategoryChecked(prev)); }; // JSX 렌더링 return ( <div onClick={(e) => e.stopPropagation() /* 이벤트 버블링 방지 */}> <Popover> {/* 팝오버 트리거 버튼 */} <PopoverTrigger asChild> <Button asChild={true} variant="outline" size="icon" className="p-2"> <TableOfContents /> </Button> </PopoverTrigger> {/* 팝오버 내용 */} <PopoverContent className="w-80"> <div className="grid gap-4"> <div className="space-y-2"> <h4 className="font-medium leading-none">목차</h4> <p className="text-sm text-muted-foreground"> 학습하실 항목을 선택하세요! </p> </div> <div className=""> {/* 카테고리 렌더링 */} {categoryObj.map((category) => { // 재귀적으로 카테고리와 하위 항목 렌더링 const renderCategory = ( category: Category, depth: number = 0 // 들여쓰기 깊이 ) => { return ( <div key={category.id} // 고유 키 style={ { paddingLeft: `${depth}rem`, // 들여쓰기 스타일 } as React.CSSProperties } onClick={(e) => { e.stopPropagation(); // 이벤트 버블링 방지 handleCheckbox(category.id); // 체크박스 상태 변경 }} > {/* 체크박스 컴포넌트 */} <CheckboxComponent id={category.id} content={category.content} checked={category.checked} /> {category.subcategory && category.subcategory.map((sub) => renderCategory(sub, depth + 1) // 하위 카테고리 렌더링 )} </div> ); }; return renderCategory(category); // 최상위 카테고리 렌더링 })} </div> </div> </PopoverContent> </Popover> </div> ); }
// CheckboxComponent 컴포넌트 import { Checkbox } from "@/components/ui/checkbox"; interface CheckboxComponentProps { id: string; content: string; checked: boolean; } export function CheckboxComponent({ id, content, checked, }: CheckboxComponentProps) { return ( <div className="flex items-center mt-3 space-x-2"> <Checkbox id={id} checked={checked} /> <label htmlFor={id} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" onClick={() => document.getElementById(id)?.click()} > {content} </label> </div> ); }
2024-12-24 추가
initialCategory 배열의 구조를 변경하였다. subcategory 속성을 제거하고 부모 객체의 id를 참조하는 refId를 추가하였다. 따라서 initialCategory 배열에서 모든 객체는 동일한 깊이를 가지고 있으며 렌더링될 때 refId를 통해 체크 박스의 깊이가 결정되도록 하였다. 이렇게 함으로써 initialCategory의 구조가 더욱 간결해졌다!
import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { TableOfContents } from "lucide-react"; import { CheckboxComponent } from "./CheckboxComponent"; import React, { useState } from "react"; interface Category { id: string; content: string; checked: boolean; refId?: string; } // 부모 객체의 id를 참조하는 refId를 추가하여 체크 박스의 계층을 구현하고자 하였다. const initialCategory: Category[] = [ { id: "1", content: "컴퓨터 구조 시작하기", checked: true, }, { id: "4", content: "컴퓨터 구조를 알아야 하는 이유", checked: true, refId: "1", }, { id: "9", checked: true, content: "문제 해결", refId: "4", }, { id: "10", checked: true, content: "성능, 용량, 비용", refId: "4", }, { id: "11", checked: false, content: "더 깊이...", refId: "10", }, { id: "5", checked: true, content: "컴퓨터 구조의 큰 그림", refId: "1", }, { id: "2", content: "데이터", checked: false, }, { id: "6", checked: false, content: "0과 1로 숫자를 표현하는 방법", refId: "2", }, { id: "7", checked: true, content: "0과 1로 문자를 표현하는 방법", refId: "2", }, { id: "3", content: "명령어", checked: false, }, { id: "8", checked: true, content: "소스 코드와 명령어", refId: "3", }, ]; export function PopoverIndex() { const [categoryObj, setCategoryObj] = useState(initialCategory); /** * 특정 부모 ID에 속한 모든 하위 항목의 ID를 재귀적으로 수집하는 함수 * @param parentId 부모 카테고리 ID * @returns 모든 하위 항목의 ID 배열 */ const getAllChildIds = (parentId: string): string[] => { const children = categoryObj.filter(item => item.refId === parentId); let allIds: string[] = []; children.forEach(child => { allIds.push(child.id); allIds = [...allIds, ...getAllChildIds(child.id)]; }); return allIds; }; /** * 체크박스 클릭 핸들러 * 선택된 항목과 그 하위 항목들의 체크 상태를 모두 변경 * @param id 선택된 카테고리 ID */ const handleCheckbox = (id: string) => { setCategoryObj(prev => { const newState = [...prev]; const targetIndex = newState.findIndex(item => item.id === id); if (targetIndex === -1) return prev; // 새로운 체크 상태 결정 const newCheckedState = !newState[targetIndex].checked; // 선택된 항목 업데이트 newState[targetIndex] = { ...newState[targetIndex], checked: newCheckedState, }; // 모든 하위 항목 업데이트 const childIds = getAllChildIds(id); childIds.forEach(childId => { const childIndex = newState.findIndex(item => item.id === childId); if (childIndex !== -1) { newState[childIndex] = { ...newState[childIndex], checked: newCheckedState, }; } }); return newState; }); }; /** * 카테고리를 재귀적으로 렌더링하는 함수 * @param parentId 부모 카테고리 ID (없으면 최상위 항목) * @param depth 현재 깊이 (들여쓰기 수준) * @returns 렌더링된 카테고리 컴포넌트 */ const renderCategories = (parentId?: string, depth: number = 0) => { // 현재 레벨의 카테고리 필터링 const categories = categoryObj.filter(item => parentId ? item.refId === parentId : !item.refId ); return categories.map(category => ( <div key={category.id}> <div style={{ paddingLeft: `${depth}rem` }} onClick={(e) => { e.stopPropagation(); // 이벤트 버블링 방지 handleCheckbox(category.id); }} > <CheckboxComponent id={category.id} content={category.content} checked={category.checked} /> </div> {/* 재귀적으로 하위 카테고리 렌더링 */} {renderCategories(category.id, depth + 1)} </div> )); }; return ( // 외부 클릭 이벤트 방지 <div onClick={(e) => e.stopPropagation()}> <Popover> <PopoverTrigger asChild> <Button asChild={true} variant="outline" size="icon" className="p-2"> <TableOfContents /> </Button> </PopoverTrigger> <PopoverContent className="w-80"> <div className="grid gap-4"> <div className="space-y-2"> <h4 className="font-medium leading-none">목차</h4> <p className="text-sm text-muted-foreground"> 학습하실 항목을 선택하세요! </p> </div> {/* 최상위 카테고리부터 렌더링 시작 */} <div className=""> {renderCategories()} </div> </div> </PopoverContent> </Popover> </div> ); } export default PopoverIndex;
728x90반응형'언어·프레임워크 > React.js' 카테고리의 다른 글
[React.js] 네이버 지도 API 리사이즈 트리거 (0) 2024.12.17 [React.js] 네이버 지도 API 지도 좌표 경계 확인하여 렌더링 줄이기 (0) 2024.12.04 [React.js] 네이버 지도 API 지도 센터 위경도 값 변경 감지 성능 개선 (1) 2024.12.03 [React.js] jsPDF를 이용한 웹 화면 PDF 내보내기 중 이슈: 페이지 오버플로우 이미지 잘림 문제 (4) 2024.07.24 [React.js] 네이버 지도 API 마커 줌인아웃 레벨 값 이용하기 (0) 2024.03.04 다음글이 없습니다.이전글이 없습니다.댓글