블록
문서 사이드바
검색, 보기 범위, 접히는 분류가 있는 문서 왼쪽 메뉴예요.
- #arc
- #block
- #sidebar
- #navigation
- #docs
- #filter
- #tree
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/docs-sidebar.json파일
components/arc/blocks/docs-sidebar/docs-sidebar.module.csscomponents/arc/blocks/docs-sidebar/docs-sidebar.tsx
docs-sidebar.module.cssdocs-sidebar.module.cssdocs-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.tsxdocs-sidebar.tsxdocs-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;