All checks were successful
Build & Publish / publish (push) Successful in 2m1s
The toggle was only on the chevron button. Now the full row (chevron + icon + label) triggers onToggle on click or Enter/Space. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
244 lines
6.9 KiB
TypeScript
244 lines
6.9 KiB
TypeScript
import { type ReactNode } from 'react'
|
|
import {
|
|
Search,
|
|
X,
|
|
ChevronsLeft,
|
|
ChevronsRight,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
} from 'lucide-react'
|
|
import styles from './Sidebar.module.css'
|
|
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
|
|
|
// ── Sub-component props ─────────────────────────────────────────────────────
|
|
|
|
interface SidebarHeaderProps {
|
|
logo: ReactNode
|
|
title: string
|
|
version?: string
|
|
onClick?: () => void
|
|
className?: string
|
|
}
|
|
|
|
interface SidebarSectionProps {
|
|
icon: ReactNode
|
|
label: string
|
|
open: boolean
|
|
onToggle: () => void
|
|
active?: boolean
|
|
children: ReactNode
|
|
className?: string
|
|
}
|
|
|
|
interface SidebarFooterProps {
|
|
children: ReactNode
|
|
className?: string
|
|
}
|
|
|
|
interface SidebarFooterLinkProps {
|
|
icon: ReactNode
|
|
label: string
|
|
active?: boolean
|
|
onClick?: () => void
|
|
className?: string
|
|
}
|
|
|
|
interface SidebarRootProps {
|
|
collapsed?: boolean
|
|
onCollapseToggle?: () => void
|
|
searchValue?: string
|
|
onSearchChange?: (query: string) => void
|
|
children: ReactNode
|
|
className?: string
|
|
}
|
|
|
|
// ── Sub-components ──────────────────────────────────────────────────────────
|
|
|
|
function SidebarHeader({ logo, title, version, onClick, className }: SidebarHeaderProps) {
|
|
const { collapsed } = useSidebarContext()
|
|
|
|
return (
|
|
<div
|
|
className={`${styles.logo} ${className ?? ''}`}
|
|
onClick={onClick}
|
|
style={onClick ? { cursor: 'pointer' } : undefined}
|
|
role={onClick ? 'button' : undefined}
|
|
tabIndex={onClick ? 0 : undefined}
|
|
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
|
>
|
|
{logo}
|
|
{!collapsed && (
|
|
<div>
|
|
<span className={styles.brand}>{title}</span>
|
|
{version && <span className={styles.version}>{version}</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SidebarSection({
|
|
icon,
|
|
label,
|
|
open,
|
|
onToggle,
|
|
active,
|
|
children,
|
|
className,
|
|
}: SidebarSectionProps) {
|
|
const { collapsed, onCollapseToggle } = useSidebarContext()
|
|
|
|
// In icon-rail (collapsed) mode, render a centered icon with tooltip
|
|
if (collapsed) {
|
|
return (
|
|
<div
|
|
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
|
|
title={label}
|
|
onClick={() => {
|
|
// Expand sidebar and open the section
|
|
onCollapseToggle?.()
|
|
onToggle()
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
onCollapseToggle?.()
|
|
onToggle()
|
|
}
|
|
}}
|
|
>
|
|
<span className={styles.sectionIcon}>{icon}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
|
|
<div
|
|
className={styles.treeSectionToggle}
|
|
onClick={onToggle}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-expanded={open}
|
|
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }}
|
|
>
|
|
<span className={styles.treeSectionChevronBtn} aria-hidden="true">
|
|
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
</span>
|
|
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
|
<span className={styles.treeSectionLabel}>{label}</span>
|
|
</div>
|
|
{open && children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SidebarFooter({ children, className }: SidebarFooterProps) {
|
|
return (
|
|
<div className={`${styles.bottom} ${className ?? ''}`}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SidebarFooterLink({ icon, label, active, onClick, className }: SidebarFooterLinkProps) {
|
|
const { collapsed } = useSidebarContext()
|
|
|
|
return (
|
|
<div
|
|
className={[
|
|
styles.bottomItem,
|
|
active ? styles.bottomItemActive : '',
|
|
className ?? '',
|
|
].filter(Boolean).join(' ')}
|
|
onClick={onClick}
|
|
role="button"
|
|
tabIndex={0}
|
|
title={collapsed ? label : undefined}
|
|
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
|
>
|
|
<span className={styles.bottomIcon}>{icon}</span>
|
|
{!collapsed && (
|
|
<div className={styles.itemInfo}>
|
|
<div className={styles.itemName}>{label}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Root component ──────────────────────────────────────────────────────────
|
|
|
|
function SidebarRoot({
|
|
collapsed = false,
|
|
onCollapseToggle,
|
|
searchValue,
|
|
onSearchChange,
|
|
children,
|
|
className,
|
|
}: SidebarRootProps) {
|
|
return (
|
|
<SidebarContext.Provider value={{ collapsed, onCollapseToggle }}>
|
|
<aside
|
|
className={[
|
|
styles.sidebar,
|
|
collapsed ? styles.sidebarCollapsed : '',
|
|
className ?? '',
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{/* Collapse toggle */}
|
|
{onCollapseToggle && (
|
|
<button
|
|
className={styles.collapseToggle}
|
|
onClick={onCollapseToggle}
|
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
>
|
|
{collapsed ? <ChevronsRight size={14} /> : <ChevronsLeft size={14} />}
|
|
</button>
|
|
)}
|
|
|
|
{/* Search (only when expanded and handler provided) */}
|
|
{onSearchChange && !collapsed && (
|
|
<div className={styles.searchWrap}>
|
|
<div className={styles.searchInner}>
|
|
<span className={styles.searchIcon} aria-hidden="true">
|
|
<Search size={12} />
|
|
</span>
|
|
<input
|
|
className={styles.searchInput}
|
|
type="text"
|
|
placeholder="Filter..."
|
|
value={searchValue ?? ''}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
/>
|
|
{searchValue && (
|
|
<button
|
|
type="button"
|
|
className={styles.searchClear}
|
|
onClick={() => onSearchChange('')}
|
|
aria-label="Clear search"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{children}
|
|
</aside>
|
|
</SidebarContext.Provider>
|
|
)
|
|
}
|
|
|
|
// ── Compound export ─────────────────────────────────────────────────────────
|
|
|
|
export const Sidebar = Object.assign(SidebarRoot, {
|
|
Header: SidebarHeader,
|
|
Section: SidebarSection,
|
|
Footer: SidebarFooter,
|
|
FooterLink: SidebarFooterLink,
|
|
})
|