본문으로 건너뛰기
블록

문서 사이드바

검색, 보기 범위, 접히는 분류가 있는 문서 왼쪽 메뉴예요.

  • #arc
  • #block
  • #sidebar
  • #navigation
  • #docs
  • #filter
  • #tree

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/docs-sidebar.json

docs-sidebar.module.csscss
.sidebar { display: flex; min-width: 0; flex-direction: column; gap: var(--space-5); }
.filters { display: flex; flex-direction: column; gap: var(--space-3); }
.scope { width: 100%; }
.sections { display: flex; flex-direction: column; gap: var(--space-4); }
.section { display: flex; flex-direction: column; gap: var(--space-1); }
.sectionTitle { margin: 0 0 var(--space-1); padding: 0 var(--space-3); color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--label-weight); }
.list { display: flex; margin: 0; padding: 0; flex-direction: column; gap: 1px; list-style: none; }
/* A nested list steps in by one notch, so depth reads from the indent rather than from a second kind of rule. */
.list .list { padding-inline-start: var(--space-5); }

.toggle { display: flex; width: 100%; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); border: 0; border-radius: var(--radius-control); padding: 0 var(--space-3); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); text-align: start; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.toggle[data-level="section"] { color: var(--foreground); font-weight: 500; }
.toggleLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.chevron { flex: none; transition: transform var(--duration-fast) var(--ease-standard); }
.toggle[aria-expanded="true"] .chevron { transform: rotate(90deg); }

.link { display: block; border-radius: var(--radius-control); padding: var(--space-2) var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.4; text-decoration: none; word-break: keep-all; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.link[aria-current="page"] { background: var(--accent-subtle); color: var(--accent); font-weight: 500; }

@media (hover: hover) and (pointer: fine) {
  .toggle:hover { background: var(--surface-muted); color: var(--foreground); }
  .link:hover { background: var(--surface-muted); color: var(--foreground); }
  .link[aria-current="page"]:hover { background: var(--accent-subtle); color: var(--accent); }
}
.toggle:active, .link:active { background: var(--surface-sunken); }
.link[aria-current="page"]:active { background: var(--accent-subtle); }

.empty { display: flex; flex-direction: column; gap: var(--space-2); margin: 0; padding: var(--space-4) var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; word-break: keep-all; }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .toggle, .link, .chevron { transition: none; } }
docs-sidebar.tsxtsx
"use client";

import { useId, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { ArcLink } from "../../lib/arc-provider";
import SegmentedControl from "../../segmented-control/segmented-control";
import { SearchField } from "../../search-field/search-field";
import { playUiSound } from "../../sound/play";
import { UiIcon } from "../../theme/ui-icon";
import styles from "./docs-sidebar.module.css";

export interface DocsLink {
  href: string;
  title: string;
  /** Extra words the filter also searches, such as an English name or tags. */
  keywords?: string;
}

export interface DocsCategory {
  id: string;
  title: string;
  links: DocsLink[];
}

export interface DocsSection {
  id: string;
  title: string;
  /** A plain list of links, or categories that fold on their own. */
  links?: DocsLink[];
  categories?: DocsCategory[];
  /** A link to the section's own page, shown first and hidden while filtering. */
  overview?: DocsLink;
}

export interface DocsScope {
  value: string;
  label: string;
  /** The section ids this scope keeps. Leave out to keep everything. */
  sections?: string[];
}

export interface DocsSidebarProps {
  sections: DocsSection[];
  /** The path of the page being read, matched against each link's href. */
  pathname: string;
  /** The 전체 / 컴포넌트 / 블록 row. Left out, no scope row shows. */
  scopes?: DocsScope[];
  /** The accessible name of the navigation. */
  label?: string;
  searchLabel?: string;
  searchPlaceholder?: string;
  /** Shown when the filter matches nothing. */
  empty?: (query: string) => ReactNode;
  onNavigate?: () => void;
  className?: string;
}

const pathOf = (href: string) => href.split("#")[0];
const matches = (link: DocsLink, q: string) => !q || link.title.toLowerCase().includes(q) || (link.keywords ?? "").toLowerCase().includes(q);

/** The section and category ids holding the current page, so they are open on arrival. */
function openFor(sections: DocsSection[], pathname: string): string[] {
  const ids: string[] = [];
  for (const section of sections) {
    for (const category of section.categories ?? []) {
      if (category.links.some(l => pathOf(l.href) === pathname)) ids.push(section.id, category.id);
    }
  }
  return ids;
}

/**
 * The left column of a documentation site: a filter, an optional scope row, then the links in folding groups.
 * The group holding the current page opens by itself, and while the filter has text everything opens — hiding a
 * match inside a closed group is the one thing a filter must never do.
 */
export function DocsSidebar({
  sections,
  pathname,
  scopes,
  label = "문서",
  searchLabel = "메뉴에서 찾기",
  searchPlaceholder = "이름으로 찾기",
  empty,
  onNavigate,
  className,
}: DocsSidebarProps) {
  const baseId = useId();
  const [query, setQuery] = useState("");
  const [scope, setScope] = useState(scopes?.[0]?.value ?? "all");
  const [closedSections, setClosedSections] = useState<Set<string>>(() => new Set());
  const [openCategories, setOpenCategories] = useState<Set<string>>(() => new Set(openFor(sections, pathname)));
  const [seenPath, setSeenPath] = useState(pathname);

  // A new page opens its group, adjusted while rendering so it lands in the same paint.
  if (seenPath !== pathname) {
    setSeenPath(pathname);
    const ids = openFor(sections, pathname);
    if (ids.length > 0) {
      setOpenCategories(prev => new Set([...prev, ...ids]));
      setClosedSections(prev => new Set([...prev].filter(id => !ids.includes(id))));
    }
  }

  const q = query.trim().toLowerCase();
  const filtering = q.length > 0;
  const keep = scopes?.find(s => s.value === scope)?.sections;

  const visible = useMemo(
    () =>
      sections.flatMap((section): DocsSection[] => {
        if (keep && !keep.includes(section.id)) return [];
        if (section.categories) {
          const categories = section.categories
            // A query naming the category ("차트") keeps all of its items, not only those with the word in their title.
            .map(c => ({ ...c, links: q && c.title.toLowerCase().includes(q) ? c.links : c.links.filter(l => matches(l, q)) }))
            .filter(c => c.links.length > 0);
          return categories.length > 0 ? [{ ...section, categories }] : [];
        }
        const links = (section.links ?? []).filter(l => matches(l, q));
        return links.length > 0 || !filtering ? [{ ...section, links }] : [];
      }),
    [sections, keep, q, filtering],
  );

  const hasResults = visible.some(s => (s.categories ? s.categories.length > 0 : (s.links ?? []).length > 0));

  const toggle = (set: Set<string>, setter: (next: Set<string>) => void, id: string) => {
    playUiSound("tick");
    const next = new Set(set);
    if (next.has(id)) next.delete(id); else next.add(id);
    setter(next);
  };

  const renderLink = (link: DocsLink) => {
    const current = pathOf(link.href) === pathname && !link.href.includes("#");
    return (
      <li key={link.href}>
        <ArcLink
          href={link.href}
          aria-current={current ? "page" : undefined}
          className={styles.link}
          onClick={() => { playUiSound("tick"); onNavigate?.(); }}
        >
          {link.title}
        </ArcLink>
      </li>
    );
  };

  return (
    <div className={[styles.sidebar, className].filter(Boolean).join(" ")}>
      <div className={styles.filters}>
        <SearchField label={searchLabel} placeholder={searchPlaceholder} value={query} onValueChange={setQuery} autoComplete="off" />
        {scopes?.length ? (
          <SegmentedControl options={scopes.map(s => ({ value: s.value, label: s.label }))} value={scope} onValueChange={setScope} label="보기 범위" className={styles.scope} />
        ) : null}
      </div>

      <nav aria-label={label} className={styles.sections}>
        {visible.map(section => {
          const panelId = `${baseId}-${section.id}`;
          const open = filtering || !closedSections.has(section.id);
          const count = section.categories ? section.categories.reduce((n, c) => n + c.links.length, 0) : (section.links ?? []).length;
          if (!section.categories && !section.overview) {
            return (
              <div key={section.id} className={styles.section}>
                <p className={styles.sectionTitle}>{section.title}</p>
                <ul className={styles.list}>{(section.links ?? []).map(renderLink)}</ul>
              </div>
            );
          }
          return (
            <div key={section.id} className={styles.section}>
              <button type="button" className={styles.toggle} data-level="section" aria-expanded={open} aria-controls={panelId} onClick={() => toggle(closedSections, setClosedSections, section.id)}>
                <UiIcon iconRole="chevron-right" size={14} strokeWidth={1.75} aria-hidden="true" className={styles.chevron} />
                <span className={styles.toggleLabel}>{section.title}</span>
                <span className={styles.count}>{count}<span className={styles.srOnly}>개</span></span>
              </button>
              <div id={panelId} hidden={!open}>
                <ul className={styles.list}>
                  {!filtering && section.overview ? renderLink(section.overview) : null}
                  {section.categories
                    ? section.categories.map(category => {
                        const listId = `${baseId}-${category.id}`;
                        const catOpen = filtering || openCategories.has(category.id);
                        return (
                          <li key={category.id}>
                            <button type="button" className={styles.toggle} aria-expanded={catOpen} aria-controls={listId} onClick={() => toggle(openCategories, setOpenCategories, category.id)}>
                              <UiIcon iconRole="chevron-right" size={14} strokeWidth={1.75} aria-hidden="true" className={styles.chevron} />
                              <span className={styles.toggleLabel}>{category.title}</span>
                              <span className={styles.count}>{category.links.length}<span className={styles.srOnly}>개</span></span>
                            </button>
                            <ul id={listId} hidden={!catOpen} className={styles.list}>{category.links.map(renderLink)}</ul>
                          </li>
                        );
                      })
                    : (section.links ?? []).map(renderLink)}
                </ul>
              </div>
            </div>
          );
        })}
        {!hasResults ? (
          <p className={styles.empty} role="status">
            {empty ? empty(query.trim()) : `'${query.trim()}'에 맞는 항목이 없어요. 다른 낱말로 찾아 볼까요?`}
          </p>
        ) : null}
      </nav>
    </div>
  );
}

DocsSidebar.displayName = "DocsSidebar";

export default DocsSidebar;

사용된 항목