Files
design-system/src/design-system/layout/Sidebar/Sidebar.tsx
hsiegeln 50a1296a9d
All checks were successful
Build & Publish / publish (push) Successful in 2m1s
fix(sidebar): make entire section header row clickable
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>
2026-04-02 21:59:47 +02:00

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,
})