언어·프레임워크/React.js

[React.js] 재귀 함수를 이용한 무한한 깊이의 체크 박스 구현

DandyNow 2024. 12. 23. 14:34
728x90
반응형

1. 무한한 깊이(depth)의 체크 박스 구현

[그림 1]과 같이 무한한 깊이의 체크 박스를 구현할 필요가 있었다. 목차에 사용되는 데이터는 category 배열인데 이 배열의 요소는 객체이다. 이 객체 하나가 체크 박스 하나를 구성한다. 객체는 subcategory를 가지고 있어서 이를 통해 무한한 깊이의 목차를 만들 수 있다.

[그림 1] 무한한 depth의 체크 박스

 

구현하고자 하는 기능은 다음과 같다.

  1. 부모와 자식 체크 박스를 렌더링 할 때 시각적으로 구분이 가능하도록 margin-left 값을 자동으로 부여해야 한다.
  2. 부모 체크 박스를 체크/해제하면 자식 체크 박스도 부모와 동일하게 체크/해제되어야 한다.
  3. 자식 체크 박스를 체크/해제할 때는 부모 체크 박스에 영향을 주지 않아야 한다.

 

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
반응형