Dandy Now!
  • [React.js] 재귀 함수를 이용한 무한한 깊이의 체크 박스 구현
    2024년 12월 23일 14시 34분 35초에 업로드 된 글입니다.
    작성자: DandyNow
    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
    반응형
    댓글