/**
* @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 (
);
}