nnuer
강희태@gmlxo9917

검색과 접기가 되는 문서 목차 트리 사이드바

기타 블록

빠른 검색창, 섹션 제목, 3단계 들여쓰기 목차 트리, 도움말 링크로 구성된 기술 문서용 목차 사이드바입니다. 가지를 펼치고 접고, 항목을 고르고, 검색어로 걸러낼 수 있습니다.

#docs#tree#toc#sidebar#search2026. 10. 16. 업데이트

블록 신고

운영자가 확인한 뒤 필요하면 블록을 숨깁니다.

사유
Claude Code · 모름 · React (TSX)
결과 코드펼치기
import { useMemo, useState } from "react";

const FONT_URL =
  "https://fonts.googleapis.com/css2?family=Gothic+A1:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap";

type Node = { id: string; label: string; children?: Node[] };
type Section = { title: string; items: Node[] };

const SECTIONS: Section[] = [
  {
    title: "Documentation",
    items: [
      {
        id: "1",
        label: "1. Getting Started",
        children: [
          { id: "1.1", label: "1.1 Introduction" },
          {
            id: "1.2",
            label: "1.2 Installation",
            children: [
              { id: "1.2.1", label: "1.2.1 macOS & Linux" },
              { id: "1.2.2", label: "1.2.2 Windows Setup" },
              { id: "1.2.3", label: "1.2.3 Docker Strategy" },
            ],
          },
          {
            id: "1.3",
            label: "1.3 Basic Configuration",
            children: [
              { id: "1.3.1", label: "1.3.1 Environment Variables" },
              { id: "1.3.2", label: "1.3.2 Config File" },
            ],
          },
        ],
      },
    ],
  },
  {
    title: "Advanced Guides",
    items: [
      {
        id: "2",
        label: "2. Architecture Overview",
        children: [
          { id: "2.1", label: "2.1 Core Modules" },
          { id: "2.2", label: "2.2 Data Flow" },
        ],
      },
      {
        id: "3",
        label: "3. API Reference",
        children: [
          { id: "3.1", label: "3.1 Endpoints" },
          { id: "3.2", label: "3.2 Error Codes" },
        ],
      },
      { id: "4", label: "4. Release Notes" },
      {
        id: "5",
        label: "5. Troubleshooting",
        children: [
          { id: "5.1", label: "5.1 Network Latency" },
          {
            id: "5.2",
            label: "5.2 Permission Errors",
            children: [
              { id: "5.2.1", label: "5.2.1 Root Access" },
              { id: "5.2.2", label: "5.2.2 Keyring Issues" },
            ],
          },
        ],
      },
    ],
  },
];

const INDENT = ["pl-3", "pl-8", "pl-[52px]"];

function filterTree(nodes: Node[], q: string): Node[] {
  if (!q) return nodes;
  const out: Node[] = [];
  for (const n of nodes) {
    const kids = n.children ? filterTree(n.children, q) : [];
    if (n.label.toLowerCase().includes(q) || kids.length > 0) {
      out.push({ ...n, children: n.children ? (kids.length > 0 ? kids : n.children) : undefined });
    }
  }
  return out;
}

function Chevron({ open }: { open: boolean }) {
  return (
    <svg
      width="12"
      height="12"
      viewBox="0 0 24 24"
      fill="none"
      stroke="#9ca3af"
      strokeWidth="2.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={`transition-transform duration-150 ${open ? "rotate-90" : ""}`}
      aria-hidden="true"
    >
      <path d="m9 18 6-6-6-6" />
    </svg>
  );
}

export default function DocsTreeSidebar() {
  const [expanded, setExpanded] = useState<Set<string>>(new Set(["1", "1.2", "5", "5.2"]));
  const [active, setActive] = useState("1.2.2");
  const [query, setQuery] = useState("");
  const q = query.trim().toLowerCase();

  const sections = useMemo(
    () => SECTIONS.map((s) => ({ ...s, items: filterTree(s.items, q) })).filter((s) => s.items.length > 0),
    [q],
  );

  const toggle = (id: string) =>
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  const renderNodes = (nodes: Node[], depth: number) =>
    nodes.map((n) => {
      const hasKids = !!n.children?.length;
      const isOpen = hasKids && (expanded.has(n.id) || q.length > 0);
      const isActive = active === n.id;
      const weight = isActive ? "font-semibold" : depth === 0 ? "font-medium" : "font-normal";
      const color = isActive ? "text-[#2563eb]" : depth === 2 ? "text-[#4b5563]" : "text-[#111827]";
      return (
        <li key={n.id} role="treeitem" aria-expanded={hasKids ? isOpen : undefined} aria-selected={isActive}>
          <button
            type="button"
            onClick={() => {
              if (hasKids) toggle(n.id);
              else setActive(n.id);
            }}
            className={`flex h-9 w-full items-center gap-2 rounded-md pr-3 text-left font-['Inter',sans-serif] text-[14px] leading-normal transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#2563eb] ${INDENT[depth]} ${
              isActive ? "bg-[#eff6ff]" : "hover:bg-[#f9fafb]"
            }`}
          >
            <span className="flex size-5 shrink-0 items-center justify-center">
              {hasKids ? <Chevron open={isOpen} /> : <span className={`size-1.5 rounded-full ${isActive ? "bg-[#2563eb]" : "bg-[#d1d5db]"}`} />}
            </span>
            <span className={`min-w-0 flex-1 truncate ${weight} ${color}`}>{n.label}</span>
            {isActive && <span className="h-4 w-[3px] shrink-0 rounded-full bg-[#2563eb]" aria-hidden="true" />}
          </button>
          {isOpen && (
            <ul role="group" className="flex flex-col gap-px">
              {renderNodes(n.children!, Math.min(depth + 1, 2))}
            </ul>
          )}
        </li>
      );
    });

  return (
    <section className="w-full bg-[#f5f5f5] px-4 py-10 sm:px-6">
      <link rel="stylesheet" href={FONT_URL} precedence="default" />
      <div className="mx-auto flex w-full max-w-[320px] flex-col sm:max-w-[300px]">
        <div className="flex min-h-[920px] flex-col justify-between rounded-2xl border border-[#e5e7eb] bg-[#fefeff] p-3 drop-shadow-[0px_4px_6px_rgba(0,0,0,0.03)]">
          <div className="flex flex-col gap-5 p-2.5">
            <label className="flex items-center gap-2.5 rounded-lg border border-[#e5e7eb] bg-white p-2.5 focus-within:border-[#2563eb]">
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
                <circle cx="11" cy="11" r="7" />
                <path d="m21 21-4.35-4.35" />
              </svg>
              <span className="sr-only">문서 목차 검색</span>
              <input
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="빠른 검색..."
                className="min-w-0 flex-1 bg-transparent font-['Gothic_A1',sans-serif] text-[14px] leading-normal text-[#111827] outline-none placeholder:text-[#94a3b8]"
              />
            </label>

            <nav aria-label="문서 목차">
              {sections.length === 0 && <p className="px-3 py-4 font-['Gothic_A1',sans-serif] text-[13px] text-[#9ca3af]">검색 결과가 없습니다.</p>}
              {sections.map((s, i) => (
                <div key={s.title}>
                  <p className={`pb-2 pl-3 font-['Inter',sans-serif] text-[11px] font-bold uppercase leading-normal text-[#9ca3af] ${i === 0 ? "pt-2.5" : "pt-5"}`}>
                    {s.title}
                  </p>
                  <ul role="tree" aria-label={s.title} className="flex flex-col gap-px">
                    {renderNodes(s.items, 0)}
                  </ul>
                </div>
              ))}
            </nav>
          </div>

          <a
            href="#"
            onClick={(e) => e.preventDefault()}
            className="flex items-center gap-2 rounded-md px-3 pb-2 pt-6 font-['Inter',sans-serif] text-[12px] leading-normal text-[#9ca3af] hover:text-[#4b5563] focus-visible:outline-2 focus-visible:outline-[#2563eb]"
          >
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <circle cx="12" cy="12" r="10" />
              <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01" />
            </svg>
            Need help with the TOC?
          </a>
        </div>
      </div>
    </section>
  );
}

적용 사이트 0

이 블록이 실제로 쓰인 사이트입니다.

로그인하고 내 사이트 추가

아직 등록된 사이트가 없습니다. 이 블록을 쓴 사이트가 있다면 알려 주세요.

로그인하고 재현 결과 올리기

재현 후기

v1 · 0개

아직 이 버전의 프롬프트로 직접 만들어 본 사람이 없습니다. 첫 번째로 재현해 보세요.