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
237 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|