learning_ai_common_plat/packages/dashboard-shell/src/Sidebar.tsx
saravanakumardb1 1fda345d38 feat(dashboard-shell): add @bytelyst/dashboard-shell package (4.3) — 41 tests
Components:
- DashboardShell — main layout combining sidebar + topbar + content area
- Sidebar — collapsible nav with sections, badges, active state, auto-settings link
- TopBar — user avatar/menu, notifications bell, sign out, custom actions
- ProfilePage — avatar, name/email form, loading/error/success states
- BillingPage — current plan card, status badge, trial info, plan comparison grid
- SettingsPage — section-based layout with empty state

Features:
- NavItem[] or NavSection[] for flat or grouped navigation
- ShellFeatures toggle: profile, billing, settings, notifications, themeToggle
- ShellUser with avatar, role, initials fallback
- onNavigate callback for SPA routers (Next.js, etc.)
- Collapsible sidebar with toggle button
- All styled via --bl-shell-* CSS custom properties with fallbacks
- 41 tests covering all components
2026-03-19 20:54:28 -07:00

237 lines
6.8 KiB
TypeScript

import type { ReactNode } from 'react';
import type { SidebarProps, NavItem, NavSection } from './types.js';
function isNavSections(nav: NavItem[] | NavSection[]): nav is NavSection[] {
return nav.length > 0 && 'items' in nav[0];
}
function NavLink({
item,
active,
collapsed,
onNavigate,
}: {
item: NavItem;
active: boolean;
collapsed: boolean;
onNavigate?: (href: string) => void;
}): ReactNode {
if (item.hidden) return null;
const handleClick = (e: React.MouseEvent) => {
if (onNavigate) {
e.preventDefault();
onNavigate(item.href);
}
};
return (
<a
href={item.href}
onClick={handleClick}
data-testid={`bl-nav-${item.href.replace(/\//g, '-').replace(/^-/, '')}`}
title={collapsed ? item.label : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '10px 0' : '10px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
borderRadius: 8,
fontSize: 14,
fontWeight: active ? 600 : 400,
color: active
? 'var(--bl-shell-nav-active-text, var(--color-foreground, #111827))'
: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
background: active
? 'var(--bl-shell-nav-active-bg, var(--color-muted, #f3f4f6))'
: 'transparent',
textDecoration: 'none',
transition: 'background 0.15s, color 0.15s',
}}
>
{item.icon && (
<span style={{ fontSize: 16, width: 20, textAlign: 'center', flexShrink: 0 }}>
{item.icon}
</span>
)}
{!collapsed && <span style={{ flex: 1 }}>{item.label}</span>}
{!collapsed && item.badge !== undefined && (
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: '2px 6px',
borderRadius: 10,
background: 'var(--bl-shell-badge-bg, var(--color-primary, #2563eb))',
color: 'var(--bl-shell-badge-text, #fff)',
}}
>
{item.badge}
</span>
)}
</a>
);
}
export function Sidebar({
productName,
logo,
version,
nav,
pathname,
features = {},
onNavigate,
footer,
collapsed = false,
onToggleCollapse,
}: SidebarProps): ReactNode {
const sections: NavSection[] = isNavSections(nav) ? nav : [{ items: nav }];
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
// Add built-in settings nav if enabled and not already present
const hasSettings = features.settings !== false;
const allItems = sections.flatMap(s => s.items);
const settingsExists = allItems.some(i => i.href === '/settings');
return (
<aside
data-testid="bl-shell-sidebar"
style={{
width: collapsed ? 64 : 220,
minHeight: '100vh',
background: 'var(--bl-shell-sidebar-bg, var(--color-surface, #fff))',
borderRight: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
padding: '16px 0',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
transition: 'width 0.2s ease',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
padding: collapsed ? '0 8px 16px' : '0 16px 16px',
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
marginBottom: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}
>
<div style={{ overflow: 'hidden', flex: 1 }}>
{logo ? (
<div data-testid="bl-shell-logo">{logo}</div>
) : (
<span
data-testid="bl-shell-product-name"
style={{
fontSize: collapsed ? 14 : 18,
fontWeight: 700,
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
whiteSpace: 'nowrap',
}}
>
{collapsed ? productName.charAt(0) : productName}
</span>
)}
</div>
{onToggleCollapse && (
<button
data-testid="bl-shell-collapse-toggle"
onClick={onToggleCollapse}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
fontSize: 16,
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
}}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? '▸' : '◂'}
</button>
)}
</div>
{/* Navigation */}
<nav
data-testid="bl-shell-nav"
style={{
display: 'flex',
flexDirection: 'column',
gap: 2,
padding: collapsed ? '0 8px' : '0 8px',
flex: 1,
}}
>
{sections.map((section, si) => (
<div key={si}>
{section.title && !collapsed && (
<div
style={{
padding: '12px 12px 4px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
}}
>
{section.title}
</div>
)}
{section.items.map(item => (
<NavLink
key={item.href}
item={item}
active={isActive(item.href)}
collapsed={collapsed}
onNavigate={onNavigate}
/>
))}
</div>
))}
{/* Built-in settings link */}
{hasSettings && !settingsExists && (
<div style={{ marginTop: 'auto' }}>
<NavLink
item={{ href: '/settings', label: 'Settings', icon: '⚙' }}
active={isActive('/settings')}
collapsed={collapsed}
onNavigate={onNavigate}
/>
</div>
)}
</nav>
{/* Footer */}
<div
data-testid="bl-shell-sidebar-footer"
style={{
marginTop: 'auto',
padding: collapsed ? '12px 8px' : '12px 16px',
borderTop: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
fontSize: 12,
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
textAlign: collapsed ? 'center' : 'left',
}}
>
{footer
? footer
: version
? collapsed
? `v${version}`
: `${productName} v${version}`
: null}
</div>
</aside>
);
}