Table Of Contents Panel

Side panel rendering a table of contents for page navigation.

A table of contents panel with progress tracking.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.com/r/table-of-contents-panel.json
bash

Code

"use client";

import { memo, useEffect, useRef } from "react";

import type { ReactNode } from "react";

import { cn } from "../../lib/utils";

export type TOCSection = {
  id: string;
  title: string;
};

export type TableOfContentsPanelProps = {
  className?: string;
  closeIcon?: ReactNode;
  completedSections: Set<string>;
  completionCount: number;
  currentSectionIndex: number;
  isOpen: boolean;
  onClose: () => void;
  onReset?: () => void;
  onSelectSection: (index: number) => void;
  progressLabel?: string;
  resetLabel?: string;
  sections: TOCSection[];
  title?: string;
  totalSections: number;
};

// eslint-disable-next-line max-lines-per-function -- Complex panel with progress and section list
function TableOfContentsPanelImpl({
  className,
  closeIcon,
  completedSections,
  completionCount,
  currentSectionIndex,
  isOpen,
  onClose,
  onReset,
  onSelectSection,
  progressLabel = "Progress",
  resetLabel = "Reset Progress",
  sections,
  title = "Table of Contents",
  totalSections,
}: TableOfContentsPanelProps): React.ReactNode {
  const panelRef = useRef<HTMLDivElement>(null);
  const closeButtonRef = useRef<HTMLButtonElement>(null);

  // Focus trap and close on Escape
  useEffect(() => {
    if (!isOpen) return;

    closeButtonRef.current?.focus();

    const handleKeyDown = (event: KeyboardEvent): void => {
      if (event.key === "Escape") {
        event.preventDefault();
        onClose();
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, [isOpen, onClose]);

  // Prevent body scroll when open
  useEffect(() => {
    document.body.style.overflow = isOpen ? "hidden" : "";
    return () => {
      document.body.style.overflow = "";
    };
  }, [isOpen]);

  if (!isOpen) return null;

  const completionPercent =
    totalSections > 0 ? Math.round((completionCount / totalSections) * 100) : 0;

  return (
    <div
      aria-labelledby="toc-title"
      aria-modal="true"
      className="fixed inset-0 z-50"
      role="dialog"
    >
      {/* Backdrop */}
      <div
        aria-hidden="true"
        className="absolute inset-0 bg-black/50 backdrop-blur-sm"
        onClick={onClose}
      />

      {/* Panel */}
      <div
        className={cn(
          "absolute right-0 top-0 h-full w-full max-w-md bg-background shadow-xl",
          className,
        )}
        ref={panelRef}
      >
        <div className="flex h-full flex-col">
          {/* Header */}
          <div className="flex items-center justify-between border-b border-border px-4 py-3">
            <h2 className="text-lg font-semibold" id="toc-title">
              {title}
            </h2>
            <button
              aria-label="Close table of contents"
              className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted"
              onClick={onClose}
              ref={closeButtonRef}
              type="button"
            >
              {closeIcon ?? (
                <svg
                  className="h-5 w-5"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <path
                    d="M6 18L18 6M6 6l12 12"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                  />
                </svg>
              )}
            </button>
          </div>

          {/* Progress */}
          <div className="border-b border-border px-4 py-3">
            <div className="flex items-center justify-between text-sm">
              <span className="text-muted-foreground">{progressLabel}</span>
              <span className="font-medium">
                {completionCount} / {totalSections} ({completionPercent}%)
              </span>
            </div>
            <div className="mt-2 h-2 rounded-full bg-muted">
              <div
                className="h-full rounded-full bg-primary transition-all duration-300"
                style={{ width: `${completionPercent}%` }}
              />
            </div>
          </div>

          {/* Sections List */}
          <nav
            aria-label="Sections"
            className="flex-1 overflow-y-auto px-4 py-3"
          >
            <ol className="space-y-1">
              {sections.map((section, index) => {
                const isCompleted = completedSections.has(section.id);
                const isCurrent = index === currentSectionIndex;

                return (
                  <li key={`${section.id}-${index}`}>
                    <button
                      className={cn(
                        "flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors",
                        isCurrent
                          ? "bg-primary/10 text-primary font-medium"
                          : "hover:bg-muted text-foreground",
                      )}
                      onClick={() => {
                        onSelectSection(index);
                        onClose();
                      }}
                      type="button"
                    >
                      <span
                        className={cn(
                          "flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
                          isCompleted
                            ? "border-primary bg-primary text-primary-foreground"
                            : "border-muted-foreground",
                        )}
                      >
                        {isCompleted ? (
                          <svg
                            className="h-3 w-3"
                            fill="none"
                            stroke="currentColor"
                            viewBox="0 0 24 24"
                          >
                            <path
                              d="M5 13l4 4L19 7"
                              strokeLinecap="round"
                              strokeLinejoin="round"
                              strokeWidth={2}
                            />
                          </svg>
                        ) : (
                          <span className="text-xs">{index + 1}</span>
                        )}
                      </span>
                      <span
                        className={isCompleted ? "line-through opacity-60" : ""}
                      >
                        {section.title}
                      </span>
                    </button>
                  </li>
                );
              })}
            </ol>
          </nav>

          {/* Footer */}
          {completionCount > 0 && onReset ? (
            <div className="border-t border-border px-4 py-3">
              <button
                className="w-full rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
                onClick={onReset}
                type="button"
              >
                {resetLabel}
              </button>
            </div>
          ) : null}
        </div>
      </div>
    </div>
  );
}

export const TableOfContentsPanel = memo(TableOfContentsPanelImpl);
TableOfContentsPanel.displayName = "TableOfContentsPanel";
typescript