언어·프레임워크/React.js
[React.js] 재귀 함수를 이용한 무한한 깊이의 체크 박스 구현
DandyNow
2024. 12. 23. 14:34
728x90
반응형
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
반응형