"use client"; import cx from "classnames"; import MiniSearch from "minisearch"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import React from "react"; import packageJson from "../../../package.json"; import { TextInput } from "../Forms/TextInput"; import { Icon } from "../Icon"; import { Image } from "../Image"; import { Tag } from "../Tag"; export interface DocumentationSidebarItem { label: string; href?: string; target?: React.HTMLAttributeAnchorTarget; tag?: string; items?: DocumentationSidebarItem[]; content?: string; // Optional: MDX content for searching } export interface DocumentationSidebarProps { items: DocumentationSidebarItem[]; className?: string; /** Called when user selects a different item */ onChangeHref?: (href: string) => void; /** Content to render in the main area */ children?: React.ReactNode; } const CLASS_ROOT = "documentation-sidebar"; interface SearchResult { id: string; label: string; href: string; } interface IndexedItem { id: string; label: string; href: string; content: string; } interface MiniSearchResult { id: string; label?: string; href?: string; score?: number; } // Flatten and index all menu items for search const flattenMenuItems = ( items: DocumentationSidebarItem[], indexedItems: IndexedItem[] = [], ): IndexedItem[] => { items.forEach((item) => { if (item.href) { indexedItems.push({ id: item.href, label: item.label, href: item.href, content: `${item.label} ${item.content || ""}`, }); } if (item.items && item.items.length > 0) { flattenMenuItems(item.items, indexedItems); } }); return indexedItems; }; // Initialize MiniSearch with indexed items const initializeSearch = (items: DocumentationSidebarItem[]) => { const indexedItems = flattenMenuItems(items); const miniSearch = new MiniSearch({ fields: ["label", "content"], storeFields: ["id", "label", "href"], }); miniSearch.addAll(indexedItems); return miniSearch; }; // Merge search index from MDX files with menu items const mergeSearchIndex = ( items: DocumentationSidebarItem[], indexData: Array<{ href: string; content: string }>, ): DocumentationSidebarItem[] => { const contentMap = new Map( indexData.map((item) => [item.href, item.content]), ); const merge = ( items: DocumentationSidebarItem[], ): DocumentationSidebarItem[] => { return items.map((item) => { const mergedItem = { ...item }; // Add content from search index if available and not already set if (item.href && !item.content && contentMap.has(item.href)) { mergedItem.content = contentMap.get(item.href); } // Recursively merge child items if (item.items) { mergedItem.items = merge(item.items); } return mergedItem; }); }; return merge(items); }; const SidebarItem: React.FC<{ item: DocumentationSidebarItem; pathname: string; level?: number; onSelect: (href: string) => void; }> = ({ item, pathname, level = 0, onSelect }) => { const hasChildren = item.items && item.items.length > 0; const isActive = item.href === pathname; // Check if this item or any of its children is active const hasActiveChild = React.useMemo(() => { const checkActiveInChildren = ( items: DocumentationSidebarItem[], ): boolean => { return items.some((child) => { if (child.href === pathname) return true; if (child.items) return checkActiveInChildren(child.items); return false; }); }; return hasChildren ? checkActiveInChildren(item.items!) : false; }, [item.items, pathname, hasChildren]); // Auto-expand if this is a top level item, or if it has an active child const [isExpanded, setIsExpanded] = React.useState( level === 0 || hasActiveChild, ); // Update expansion state when pathname changes and this item has active children React.useEffect(() => { if (hasActiveChild) { setIsExpanded(true); } }, [hasActiveChild]); const handleToggle = () => { if (hasChildren) { setIsExpanded(!isExpanded); } }; // Create ref for active items to enable scrolling const itemRef = React.useRef(null); // Scroll to active item on mount and when it becomes active React.useEffect(() => { if (isActive && itemRef.current) { itemRef.current.scrollIntoView({ behavior: "smooth", block: "center", }); } }, [isActive]); return (
  • {hasChildren ? ( ) : ( item.href && onSelect(item.href)} > {item.label} {item.tag ? ( {item.tag} ) : null} )}
    {hasChildren && isExpanded && ( )}
  • ); }; export const DocumentationSidebar: React.FC = ( props, ) => { const { items, className, onChangeHref, children } = props; const [isMenuOpenOnMobile, setIsMenuOpenOnMobile] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(""); const [searchResults, setSearchResults] = React.useState([]); const [activeSearchIndex, setActiveSearchIndex] = React.useState(0); const [miniSearch, setMiniSearch] = React.useState(null); const pathname = usePathname(); const router = useRouter(); const sidebarRef = React.useRef(null); const searchInputRef = React.useRef(null); // Initialize search on mount React.useEffect(() => { const search = initializeSearch(items); setMiniSearch(search); // Load additional content from search index if available const loadSearchIndex = async () => { try { const response = await fetch("/search-index.json"); if (!response.ok) throw new Error("Failed to fetch search index"); const indexData = await response.json(); // Re-initialize search with both menu items and indexed MDX content const mergedItems = mergeSearchIndex(items, indexData); const updatedSearch = initializeSearch(mergedItems); setMiniSearch(updatedSearch); } catch (_err) { // Search index not found or fetch failed, continue with menu items only console.debug("Search index not available, using menu items only"); } }; loadSearchIndex(); }, [items]); // Handle search input const handleSearch = (query: string) => { setSearchQuery(query); if (query.trim() && miniSearch) { const results = miniSearch.search(query, { boost: { label: 10 }, fuzzy: (term) => { // Allow 1 character difference for short terms, more for longer terms return term.length > 3 ? 0.2 : 0.3; }, prefix: true, // Enable prefix matching (search "text" matches "text-secondary") combineWith: "AND", // Require all terms to match }) as unknown as SearchResult[]; setSearchResults(results.slice(0, 10)); // Limit to 10 results setActiveSearchIndex(0); // Reset active index when results change } else { setSearchResults([]); setActiveSearchIndex(0); } }; React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Handle Cmd+K or Ctrl+K to focus search if ((event.metaKey || event.ctrlKey) && event.key === "k") { event.preventDefault(); searchInputRef.current?.focus(); return; } // Handle Escape key if (event.key === "Escape") { setIsMenuOpenOnMobile(false); setSearchQuery(""); setSearchResults([]); setActiveSearchIndex(0); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, []); // Scroll to active item when pathname changes or component mounts React.useEffect(() => { if (sidebarRef.current) { const activeLink = sidebarRef.current.querySelector( `.${CLASS_ROOT}__link--active`, ); if (activeLink) { activeLink.scrollIntoView({ behavior: "smooth", block: "center", }); } } }, [pathname]); const handleSelect = (href: string) => { onChangeHref?.(href); setIsMenuOpenOnMobile(false); setSearchQuery(""); setSearchResults([]); setActiveSearchIndex(0); }; // Handle keyboard navigation in search results const handleSearchKeyDown = ( event: React.KeyboardEvent, ) => { if (searchResults.length === 0) return; switch (event.key) { case "ArrowDown": event.preventDefault(); setActiveSearchIndex((prev) => prev < searchResults.length - 1 ? prev + 1 : prev, ); break; case "ArrowUp": event.preventDefault(); setActiveSearchIndex((prev) => (prev > 0 ? prev - 1 : 0)); break; case "Enter": event.preventDefault(); if (searchResults[activeSearchIndex]) { const href = searchResults[activeSearchIndex].href; searchInputRef.current?.blur(); router.push(href); handleSelect(href); } break; } }; const classes = cx(CLASS_ROOT, className, { [`${CLASS_ROOT}--open`]: isMenuOpenOnMobile, }); return (
    {children}
    {isMenuOpenOnMobile && (
    ); }; DocumentationSidebar.displayName = "DocumentationSidebar";