Fazekit

Code

"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { docsContent, type DocPage, type DocsContent } from "./data";
import { TopBar } from "./sections/topbar";
import { MobileDrawer, NavTree } from "./sections/sidebar";
import { SearchDialog, type SearchEntry } from "./sections/search";
import { DocArticle } from "./sections/content";
import { MobileToc, Toc, useScrollSpy, type TocItem } from "./sections/toc";
import { DocsFooter } from "./sections/footer";
import { PmContext, scrollToHeading, type PackageManager } from "./sections/ui";

export type { DocsContent, DocPage };

export interface DocsTemplateProps {
  /** Override brand, versions, navigation, pages or footer. Missing keys fall back to the demo docs. */
  content?: Partial<DocsContent>;
  /** Page shown first. Defaults to the first page of the first nav group. */
  initialPage?: string;
  /** Called whenever the reader navigates to another page. */
  onPageChange?: (pageId: string) => void;
  className?: string;
}

/**
 * Orbit Docs — a developer documentation site.
 * Sticky top bar with version switcher and ⌘K fuzzy search, collapsible nav tree (drawer on mobile),
 * rich content blocks (callouts, package-manager tabs, highlighted code, API tables, steps),
 * a scroll-spy "on this page" TOC, prev/next links, a feedback widget and a footer.
 */
export function DocsTemplate({ content, initialPage, onPageChange, className }: DocsTemplateProps) {
  const c: DocsContent = { ...docsContent, ...content };
  const pages = React.useMemo(() => new Map(c.pages.map((p) => [p.id, p])), [c.pages]);
  const order = React.useMemo(() => c.nav.flatMap((g) => g.pages).filter((id) => pages.has(id)), [c.nav, pages]);

  const [pageId, setPageId] = React.useState(initialPage && pages.has(initialPage) ? initialPage : (order[0] ?? c.pages[0].id));
  const [version, setVersion] = React.useState(c.versions[0]?.id ?? "");
  const [pm, setPm] = React.useState<PackageManager>("npm");
  const [searchOpen, setSearchOpen] = React.useState(false);
  const [drawerOpen, setDrawerOpen] = React.useState(false);
  const [isMac, setIsMac] = React.useState(true);
  const pendingHeading = React.useRef<string | null | undefined>(undefined);

  const page = pages.get(pageId) ?? c.pages[0];
  const idx = order.indexOf(page.id);
  const group = c.nav.find((g) => g.pages.includes(page.id))?.title ?? "Docs";

  const toc: TocItem[] = page.blocks.flatMap((b) => (b.t === "h2" || b.t === "h3" ? [{ id: b.id, text: b.text, level: b.t === "h2" ? 2 : 3 } as TocItem] : []));
  const active = useScrollSpy(toc.map((t) => t.id));

  React.useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setIsMac(/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent));
  }, []);

  // ⌘K / Ctrl+K and "/" open search.
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement | null;
      const typing = !!target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        setSearchOpen((o) => !o);
      } else if (e.key === "/" && !typing) {
        e.preventDefault();
        setSearchOpen(true);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  // After a page switch: jump to the requested heading, or to the top and focus the title.
  React.useLayoutEffect(() => {
    const target = pendingHeading.current;
    if (target === undefined) return;
    pendingHeading.current = undefined;
    if (target) requestAnimationFrame(() => scrollToHeading(target));
    else {
      window.scrollTo({ top: 0, behavior: "auto" });
      document.getElementById("od-title")?.focus({ preventScroll: true });
    }
  }, [pageId]);

  const navigate = React.useCallback(
    (id: string, heading?: string) => {
      setDrawerOpen(false);
      setSearchOpen(false);
      if (id === pageId) {
        if (heading) scrollToHeading(heading);
        else window.scrollTo({ top: 0, behavior: "smooth" });
        return;
      }
      pendingHeading.current = heading ?? null;
      setPageId(id);
      onPageChange?.(id);
    },
    [pageId, onPageChange],
  );

  const entries: SearchEntry[] = React.useMemo(() => {
    const out: SearchEntry[] = [];
    for (const g of c.nav) {
      for (const pid of g.pages) {
        const p = pages.get(pid);
        if (!p) continue;
        out.push({ key: p.id, pageId: p.id, title: p.title, group: g.title, page: p.title });
        for (const b of p.blocks) {
          if (b.t === "h2" || b.t === "h3") out.push({ key: `${p.id}#${b.id}`, pageId: p.id, headingId: b.id, title: b.text, group: g.title, page: p.title });
        }
      }
    }
    return out;
  }, [c.nav, pages]);
  const suggested = entries.filter((e) => !e.headingId && ["quick-start", "installation", "create-client", "webhooks"].includes(e.pageId));

  const versionNote = c.versions.find((v) => v.id === version)?.note;
  const tree = (key: string) => <NavTree groups={c.nav} pages={pages} active={page.id} onNavigate={(id) => navigate(id)} layoutKey={key} />;

  return (
    <PmContext.Provider value={{ pm, setPm }}>
      <div id="top" className={cn("relative w-full bg-background text-foreground antialiased", className)}>
        <a
          href="#od-title"
          className="sr-only z-[90] rounded-md bg-foreground px-3 py-2 text-sm text-background focus:not-sr-only focus:fixed focus:left-3 focus:top-3"
        >
          Skip to content
        </a>
        <TopBar
          brand={c.brand}
          versions={c.versions}
          version={version}
          onVersion={setVersion}
          links={c.topLinks}
          activePage={page.id}
          onNavigate={(id) => navigate(id)}
          onSearch={() => setSearchOpen(true)}
          onMenu={() => setDrawerOpen(true)}
          menuOpen={drawerOpen}
          isMac={isMac}
        />

        <div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:grid lg:grid-cols-[250px_minmax(0,1fr)] lg:gap-10 xl:grid-cols-[250px_minmax(0,1fr)_220px]">
          <div className="hidden border-r lg:block">
            <aside className="sticky top-16 h-[calc(100dvh-4rem)] overflow-y-auto py-8 pr-4 [scrollbar-width:thin]" aria-label="Sidebar">
              {tree("od-side")}
            </aside>
          </div>

          <main className="mx-auto w-full min-w-0 max-w-3xl py-8 lg:py-12">
            <DocArticle
              page={page}
              group={group}
              prev={idx > 0 ? pages.get(order[idx - 1]) : undefined}
              next={idx >= 0 && idx < order.length - 1 ? pages.get(order[idx + 1]) : undefined}
              versionNote={versionNote}
              onNavigate={(id) => navigate(id)}
              mobileToc={<MobileToc items={toc} active={active} />}
            />
          </main>

          <aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto py-12 xl:block" aria-label="Page outline">
            <Toc items={toc} active={active} />
          </aside>
        </div>

        <DocsFooter brand={c.brand} content={c.footer} />

        <MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} brand={c.brand}>
          {tree("od-drawer")}
        </MobileDrawer>
        <SearchDialog
          open={searchOpen}
          onClose={() => setSearchOpen(false)}
          entries={entries}
          suggested={suggested}
          onSelect={(e) => navigate(e.pageId, e.headingId)}
        />
      </div>
    </PmContext.Provider>
  );
}

export default DocsTemplate;

More in Blog & Docs

View all →