/** * @fileoverview Sidebar component for collection and content navigation * * This component provides a collapsible sidebar panel for navigating * collections and content items. Similar to TocPanel in Writenex Editor. * * Features: * - Arrow key navigation for collections and content lists * - ARIA tab pattern for filter tabs * - Screen reader announcements for search results * - Proper aria-current for selected items * * @module @writenex/astro/client/components/Sidebar */ import { CheckCircle, FileEdit, Folder, Plus, RefreshCw, Search, X, } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAnnounce } from "../../hooks/useAnnounce"; import type { Collection, ContentSummary } from "../../hooks/useApi"; import { useArrowNavigation } from "../../hooks/useArrowNavigation"; import "./Sidebar.css"; /** * Props for CollectionItem component */ interface CollectionItemProps { collection: Collection; isSelected: boolean; isFocused: boolean; onSelect: (name: string) => void; id: string; } /** * Individual collection item in the sidebar */ const CollectionItem = memo(function CollectionItem({ collection, isSelected, isFocused, onSelect, id, }: CollectionItemProps) { const handleClick = useCallback(() => { onSelect(collection.name); }, [collection.name, onSelect]); const className = [ "wn-collection-item", isSelected ? "wn-collection-item--selected" : "", ] .filter(Boolean) .join(" "); return (
  • ); }); /** * Props for ContentListItem component */ interface ContentItemProps { item: ContentSummary; isSelected: boolean; isFocused: boolean; onSelect: (id: string) => void; id: string; } /** * Individual content item in the sidebar */ const ContentListItem = memo(function ContentListItem({ item, isSelected, isFocused, onSelect, id, }: ContentItemProps) { const handleClick = useCallback(() => { onSelect(item.id); }, [item.id, onSelect]); const className = [ "wn-content-item", isSelected ? "wn-content-item--selected" : "", ] .filter(Boolean) .join(" "); return (
  • ); }); /** * Format date string to readable format */ function formatDate(dateStr: string): string { try { const date = new Date(dateStr); return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }); } catch { return dateStr; } } /** * Props for Sidebar component */ interface SidebarProps { /** Whether the sidebar is open */ isOpen: boolean; /** Callback to close the sidebar */ onClose: () => void; /** List of collections */ collections: Collection[]; /** Whether collections are loading */ collectionsLoading: boolean; /** Currently selected collection name */ selectedCollection: string | null; /** Callback when a collection is selected */ onSelectCollection: (name: string) => void; /** List of content items in selected collection */ contentItems: ContentSummary[]; /** Whether content is loading */ contentLoading: boolean; /** Currently selected content ID */ selectedContent: string | null; /** Callback when content is selected */ onSelectContent: (id: string) => void; /** Callback to create new content */ onCreateContent: () => void; /** Callback to refresh collections */ onRefreshCollections: () => void; /** Callback to refresh content */ onRefreshContent: () => void; } /** * Collapsible sidebar panel for collection and content navigation. * * @component */ export function Sidebar({ isOpen, onClose, collections, collectionsLoading, selectedCollection, onSelectCollection, contentItems, contentLoading, selectedContent, onSelectContent, onCreateContent, onRefreshCollections, onRefreshContent, }: SidebarProps): React.ReactElement { const [searchQuery, setSearchQuery] = useState(""); const [filterDraft, setFilterDraft] = useState<"all" | "published" | "draft">( "all" ); // Focus indices for arrow navigation const [collectionFocusIndex, setCollectionFocusIndex] = useState(0); const [contentFocusIndex, setContentFocusIndex] = useState(0); const [tabFocusIndex, setTabFocusIndex] = useState(0); // Refs for list containers const collectionListRef = useRef(null); const contentListRef = useRef(null); const tabListRef = useRef(null); // Announcement hook for search results const { announce } = useAnnounce(); // Previous filtered items count for announcements const prevFilteredCountRef = useRef(null); useEffect(() => { onRefreshCollections(); }, [onRefreshCollections]); useEffect(() => { if (selectedCollection) { onRefreshContent(); } }, [selectedCollection, onRefreshContent]); useEffect(() => { setSearchQuery(""); }, []); const draftCount = useMemo( () => contentItems.filter((item) => item.draft).length, [contentItems] ); const publishedCount = useMemo( () => contentItems.filter((item) => !item.draft).length, [contentItems] ); const filteredItems = useMemo(() => { let items = contentItems; if (filterDraft === "published") { items = items.filter((item) => !item.draft); } else if (filterDraft === "draft") { items = items.filter((item) => item.draft); } if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); items = items.filter( (item) => item.title.toLowerCase().includes(query) || item.id.toLowerCase().includes(query) ); } return items; }, [contentItems, searchQuery, filterDraft]); // Generate IDs for collection items const collectionIds = useMemo( () => collections.map((col) => `wn-collection-${col.name}`), [collections] ); // Generate IDs for content items const contentIds = useMemo( () => filteredItems.map((item) => `wn-content-${item.id}`), [filteredItems] ); // Tab IDs for filter tabs const tabIds = useMemo( () => ["wn-tab-all", "wn-tab-published", "wn-tab-draft"], [] ); // Arrow navigation for collections const { handleKeyDown: handleCollectionKeyDown } = useArrowNavigation({ items: collectionIds, currentIndex: collectionFocusIndex, onIndexChange: setCollectionFocusIndex, onSelect: (index) => { if (collections[index]) { onSelectCollection(collections[index].name); } }, orientation: "vertical", loop: true, enabled: collections.length > 0, }); // Arrow navigation for content items const { handleKeyDown: handleContentKeyDown } = useArrowNavigation({ items: contentIds, currentIndex: contentFocusIndex, onIndexChange: setContentFocusIndex, onSelect: (index) => { if (filteredItems[index]) { onSelectContent(filteredItems[index].id); } }, orientation: "vertical", loop: true, enabled: filteredItems.length > 0, }); // Arrow navigation for filter tabs (horizontal) const { handleKeyDown: handleTabKeyDown } = useArrowNavigation({ items: tabIds, currentIndex: tabFocusIndex, onIndexChange: setTabFocusIndex, onSelect: (index) => { const filters: Array<"all" | "published" | "draft"> = [ "all", "published", "draft", ]; const filter = filters[index]; if (filter) { setFilterDraft(filter); } }, orientation: "horizontal", loop: true, enabled: true, }); // Update tab focus index when filter changes useEffect(() => { const filterToIndex = { all: 0, published: 1, draft: 2 }; setTabFocusIndex(filterToIndex[filterDraft]); }, [filterDraft]); // Reset content focus index when filtered items change useEffect(() => { setContentFocusIndex(0); }, []); // Announce search results when they change useEffect(() => { // Only announce if we have a search query and the count has changed if (searchQuery.trim()) { const currentCount = filteredItems.length; if (prevFilteredCountRef.current !== currentCount) { const message = currentCount === 0 ? "No results found" : currentCount === 1 ? "1 result found" : `${currentCount} results found`; announce(message, "polite"); } prevFilteredCountRef.current = currentCount; } else { prevFilteredCountRef.current = null; } }, [filteredItems.length, searchQuery, announce]); const sidebarClassName = [ "wn-sidebar", isOpen ? "wn-sidebar--open" : "wn-sidebar--closed", ] .filter(Boolean) .join(" "); return ( ); }