import React, { forwardRef } from 'react'; export interface SidebarProps extends React.HTMLAttributes {} export const Sidebar = forwardRef( ({ className = '', children, ...rest }, ref) => { const classes = ['sidebar', className].filter(Boolean).join(' '); return ( ); } ); Sidebar.displayName = 'Sidebar'; export interface SidebarSectionProps extends React.HTMLAttributes { label?: React.ReactNode; /** * When true, renders the section as a `
` pair so users can * fold/unfold. Default: false (emits the original `
`). */ collapsible?: boolean; defaultOpen?: boolean; } export const SidebarSection = forwardRef( ( { label, className = '', children, collapsible = false, defaultOpen = true, ...rest }, ref ) => { if (collapsible) { const classes = [ 'sidebar__section', 'sidebar__section--collapsible', className ] .filter(Boolean) .join(' '); return (
} className={classes} open={defaultOpen} {...(rest as React.HTMLAttributes)} > {label !== undefined && ( {label} )} {children}
); } const classes = ['sidebar__section', className].filter(Boolean).join(' '); return (
{label !== undefined &&
{label}
} {children}
); } ); SidebarSection.displayName = 'SidebarSection'; type AnchorProps = React.AnchorHTMLAttributes; type ButtonProps = React.ButtonHTMLAttributes; export interface SidebarItemCommonProps { active?: boolean; icon?: React.ReactNode; } export type SidebarItemProps = SidebarItemCommonProps & Omit; export const SidebarItem = forwardRef< HTMLAnchorElement | HTMLButtonElement, SidebarItemProps >((props, ref) => { const { active, icon, className = '', children, ...rest } = props; const classes = ['sidebar__item', className].filter(Boolean).join(' '); const extras = { 'aria-current': active ? 'page' : undefined, 'data-active': active ? 'true' : undefined } as const; const inner = ( <> {icon !== undefined && {icon}} {children} ); const href = (rest as AnchorProps).href; if (href !== undefined) { return ( } className={classes} {...extras} {...(rest as AnchorProps)} > {inner} ); } return ( ); }); SidebarItem.displayName = 'SidebarItem'; /** * Normalise a path by stripping trailing slashes. Root (`/`) is preserved. */ function normalisePath(value: string): string { if (value === '/') return '/'; return value.replace(/\/+$/, '') || '/'; } export interface IsActiveHrefOptions { /** When true (default), require exact match. When false, descendant hrefs match. */ exact?: boolean; } export function isActiveHref( path: string, href: string, options?: IsActiveHrefOptions ): boolean { const exact = options?.exact ?? true; const p = normalisePath(path); const h = normalisePath(href); if (p === h) return true; if (exact) return false; if (h === '/') return true; return p.startsWith(h + '/'); } export function isActiveHrefWithHash( currentPath: string, currentHash: string, href: string ): boolean { const hashIdx = href.indexOf('#'); if (hashIdx === -1) return isActiveHref(currentPath, href); const targetPath = hashIdx === 0 ? currentPath : href.slice(0, hashIdx); const targetHash = '#' + href.slice(hashIdx + 1); // A bare `#frag` href is treated as same-route fragment. const path = normalisePath(targetPath); const cur = normalisePath(currentPath); if (path !== cur) return false; return currentHash === targetHash; }