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
This commit is contained in:
saravanakumardb1 2026-03-19 20:54:28 -07:00
parent 6354711f97
commit 1fda345d38
12 changed files with 1584 additions and 0 deletions

View File

@ -0,0 +1,36 @@
{
"name": "@bytelyst/dashboard-shell",
"version": "0.1.0",
"description": "Configurable Next.js dashboard layout with sidebar, profile, billing, and settings pages",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,189 @@
import type { ReactNode } from 'react';
import type { BillingPageProps } from './types.js';
const statusColors: Record<string, string> = {
active: 'var(--color-success, #16a34a)',
trialing: 'var(--color-warning, #d97706)',
past_due: 'var(--color-destructive, #dc2626)',
canceled: 'var(--color-muted-foreground, #6b7280)',
};
export function BillingPage({
currentPlan = 'Free',
status = 'active',
trialEndsAt,
onManageBilling,
plans = [],
}: BillingPageProps): ReactNode {
return (
<div data-testid="bl-shell-billing-page" style={{ maxWidth: 800 }}>
<h1
style={{
fontSize: 24,
fontWeight: 700,
marginBottom: 24,
color: 'var(--color-foreground, #111827)',
}}
>
Billing
</h1>
{/* Current plan card */}
<div
data-testid="bl-billing-current"
style={{
padding: 24,
borderRadius: 12,
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
marginBottom: 32,
background: 'var(--color-surface, #fff)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<div
style={{
fontSize: 14,
color: 'var(--color-muted-foreground, #6b7280)',
marginBottom: 4,
}}
>
Current Plan
</div>
<div
style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-foreground, #111827)' }}
>
{currentPlan}
</div>
</div>
<span
data-testid="bl-billing-status"
style={{
padding: '4px 12px',
borderRadius: 20,
fontSize: 12,
fontWeight: 600,
color: statusColors[status] || statusColors.active,
background: `color-mix(in srgb, ${statusColors[status] || statusColors.active} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${statusColors[status] || statusColors.active} 30%, transparent)`,
}}
>
{status.replace('_', ' ')}
</span>
</div>
{trialEndsAt && (
<div
data-testid="bl-billing-trial"
style={{ fontSize: 14, color: 'var(--color-warning, #d97706)', marginBottom: 12 }}
>
Trial ends: {trialEndsAt}
</div>
)}
{onManageBilling && (
<button
data-testid="bl-billing-manage"
onClick={onManageBilling}
style={{
padding: '10px 20px',
borderRadius: 8,
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
background: 'var(--color-surface, #fff)',
color: 'var(--color-foreground, #111827)',
}}
>
Manage Billing
</button>
)}
</div>
{/* Plan comparison */}
{plans.length > 0 && (
<div>
<h2
style={{
fontSize: 18,
fontWeight: 600,
marginBottom: 16,
color: 'var(--color-foreground, #111827)',
}}
>
Available Plans
</h2>
<div
data-testid="bl-billing-plans"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.min(plans.length, 3)}, 1fr)`,
gap: 16,
}}
>
{plans.map(plan => (
<div
key={plan.name}
data-testid={`bl-billing-plan-${plan.name.toLowerCase()}`}
style={{
padding: 24,
borderRadius: 12,
border: plan.current
? '2px solid var(--bl-shell-accent, var(--color-primary, #2563eb))'
: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
background: 'var(--color-surface, #fff)',
}}
>
<div style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>{plan.name}</div>
<div
style={{
fontSize: 24,
fontWeight: 700,
marginBottom: 16,
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
}}
>
{plan.price}
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{plan.features.map(f => (
<li
key={f}
style={{
fontSize: 14,
padding: '4px 0',
color: 'var(--color-muted-foreground, #6b7280)',
}}
>
{f}
</li>
))}
</ul>
{plan.current && (
<div
style={{
marginTop: 16,
fontSize: 13,
fontWeight: 600,
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
}}
>
Current Plan
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
import { useState, type ReactNode } from 'react';
import type { DashboardShellProps } from './types.js';
import { Sidebar } from './Sidebar.js';
import { TopBar } from './TopBar.js';
export function DashboardShell({
productName,
logo,
version,
nav,
pathname: externalPathname,
user,
features = {},
onSignOut,
onNavigate,
sidebarFooter,
topBarActions,
children,
}: DashboardShellProps): ReactNode {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Use external pathname or default to '/'
const pathname = externalPathname ?? '/';
return (
<div
data-testid="bl-dashboard-shell"
style={{
display: 'flex',
minHeight: '100vh',
background: 'var(--bl-shell-bg, var(--color-background, #f9fafb))',
}}
>
{/* Sidebar */}
<Sidebar
productName={productName}
logo={logo}
version={version}
nav={nav}
pathname={pathname}
features={features}
onNavigate={onNavigate}
footer={sidebarFooter}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
{/* Main content area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Top bar */}
<TopBar
user={user}
features={features}
onSignOut={onSignOut}
onNavigate={onNavigate}
actions={topBarActions}
/>
{/* Page content */}
<main
data-testid="bl-shell-main"
style={{
flex: 1,
padding: '24px 32px',
overflow: 'auto',
}}
>
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,180 @@
import { useState, type ReactNode } from 'react';
import type { ProfilePageProps } from './types.js';
export function ProfilePage({
user,
onUpdateProfile,
isLoading,
error,
success,
}: ProfilePageProps): ReactNode {
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (onUpdateProfile) onUpdateProfile({ name, email });
};
return (
<div data-testid="bl-shell-profile-page" style={{ maxWidth: 600 }}>
<h1
style={{
fontSize: 24,
fontWeight: 700,
marginBottom: 24,
color: 'var(--color-foreground, #111827)',
}}
>
Profile
</h1>
{error && (
<div data-testid="bl-profile-error" style={alertStyle('var(--color-destructive, #dc2626)')}>
{error}
</div>
)}
{success && (
<div data-testid="bl-profile-success" style={alertStyle('var(--color-success, #16a34a)')}>
{success}
</div>
)}
{/* Avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 32 }}>
<div
data-testid="bl-profile-avatar"
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
fontWeight: 600,
}}
>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name}
style={{ width: 64, height: 64, borderRadius: '50%' }}
/>
) : (
user.name
.split(' ')
.map(w => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
)}
</div>
<div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{user.name}</div>
<div style={{ fontSize: 14, color: 'var(--color-muted-foreground, #6b7280)' }}>
{user.email}
</div>
{user.role && (
<div
style={{
fontSize: 12,
color: 'var(--color-muted-foreground, #6b7280)',
marginTop: 2,
}}
>
Role: {user.role}
</div>
)}
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={labelStyle} htmlFor="bl-profile-name">
Name
</label>
<input
id="bl-profile-name"
data-testid="bl-profile-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle} htmlFor="bl-profile-email">
Email
</label>
<input
id="bl-profile-email"
data-testid="bl-profile-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
style={inputStyle}
/>
</div>
{onUpdateProfile && (
<button
data-testid="bl-profile-submit"
type="submit"
disabled={isLoading}
style={{
padding: '10px 20px',
borderRadius: 8,
border: 'none',
fontSize: 14,
fontWeight: 600,
cursor: isLoading ? 'not-allowed' : 'pointer',
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
color: '#fff',
opacity: isLoading ? 0.6 : 1,
alignSelf: 'flex-start',
}}
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
)}
</form>
</div>
);
}
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 14,
fontWeight: 500,
marginBottom: 6,
color: 'var(--color-foreground, #111827)',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
borderRadius: 8,
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
fontSize: 14,
background: 'var(--color-surface, #fff)',
color: 'var(--color-foreground, #111827)',
boxSizing: 'border-box',
};
function alertStyle(color: string): React.CSSProperties {
return {
padding: '10px 14px',
borderRadius: 8,
marginBottom: 16,
fontSize: 14,
color,
background: `color-mix(in srgb, ${color} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
};
}

View File

@ -0,0 +1,71 @@
import type { ReactNode } from 'react';
import type { SettingsPageProps } from './types.js';
export function SettingsPage({ productName, sections = [] }: SettingsPageProps): ReactNode {
return (
<div data-testid="bl-shell-settings-page" style={{ maxWidth: 700 }}>
<h1
style={{
fontSize: 24,
fontWeight: 700,
marginBottom: 24,
color: 'var(--color-foreground, #111827)',
}}
>
Settings
</h1>
{sections.length === 0 && (
<div
data-testid="bl-settings-empty"
style={{
padding: 32,
textAlign: 'center',
color: 'var(--color-muted-foreground, #6b7280)',
fontSize: 14,
}}
>
No settings configured for {productName}.
</div>
)}
{sections.map((section, i) => (
<div
key={i}
data-testid={`bl-settings-section-${i}`}
style={{
padding: 24,
borderRadius: 12,
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
marginBottom: 16,
background: 'var(--color-surface, #fff)',
}}
>
<h2
style={{
fontSize: 16,
fontWeight: 600,
marginBottom: 4,
color: 'var(--color-foreground, #111827)',
}}
>
{section.title}
</h2>
{section.description && (
<p
style={{
fontSize: 14,
color: 'var(--color-muted-foreground, #6b7280)',
marginBottom: 16,
marginTop: 0,
}}
>
{section.description}
</p>
)}
<div>{section.content}</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,236 @@
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>
);
}

View File

@ -0,0 +1,244 @@
import { useState, type ReactNode } from 'react';
import type { TopBarProps } from './types.js';
export function TopBar({
user,
features = {},
onSignOut,
onNavigate,
actions,
onToggleSidebar,
}: TopBarProps): ReactNode {
const [menuOpen, setMenuOpen] = useState(false);
const handleNav = (href: string) => {
setMenuOpen(false);
if (onNavigate) onNavigate(href);
};
const initials = user
? user.name
.split(' ')
.map(w => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
: '?';
return (
<header
data-testid="bl-shell-topbar"
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
background: 'var(--bl-shell-topbar-bg, var(--color-surface, #fff))',
flexShrink: 0,
}}
>
{/* Left: mobile hamburger */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{onToggleSidebar && (
<button
data-testid="bl-shell-hamburger"
onClick={onToggleSidebar}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 20,
padding: 4,
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
}}
aria-label="Toggle sidebar"
>
</button>
)}
</div>
{/* Right: actions + user menu */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{actions}
{features.notifications && (
<button
data-testid="bl-shell-notifications"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 18,
padding: 4,
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
}}
aria-label="Notifications"
>
🔔
</button>
)}
{user && (
<div style={{ position: 'relative' }}>
<button
data-testid="bl-shell-user-menu-trigger"
onClick={() => setMenuOpen(!menuOpen)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 8,
}}
>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name}
style={{ width: 32, height: 32, borderRadius: '50%' }}
/>
) : (
<div
data-testid="bl-shell-user-avatar"
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
fontWeight: 600,
}}
>
{initials}
</div>
)}
<span
style={{
fontSize: 14,
color: 'var(--color-foreground, #111827)',
}}
>
{user.name}
</span>
<span style={{ fontSize: 10 }}></span>
</button>
{menuOpen && (
<div
data-testid="bl-shell-user-menu"
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 4,
minWidth: 180,
background: 'var(--bl-shell-topbar-bg, var(--color-surface, #fff))',
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
zIndex: 50,
overflow: 'hidden',
}}
>
<div
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
}}
>
<div style={{ fontSize: 14, fontWeight: 600 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-muted-foreground, #6b7280)' }}>
{user.email}
</div>
</div>
{features.profile !== false && (
<a
data-testid="bl-shell-menu-profile"
href="/profile"
onClick={e => {
e.preventDefault();
handleNav('/profile');
}}
style={menuItemStyle}
>
Profile
</a>
)}
{features.billing && (
<a
data-testid="bl-shell-menu-billing"
href="/billing"
onClick={e => {
e.preventDefault();
handleNav('/billing');
}}
style={menuItemStyle}
>
Billing
</a>
)}
{features.settings !== false && (
<a
data-testid="bl-shell-menu-settings"
href="/settings"
onClick={e => {
e.preventDefault();
handleNav('/settings');
}}
style={menuItemStyle}
>
Settings
</a>
)}
{onSignOut && (
<button
data-testid="bl-shell-menu-signout"
onClick={() => {
setMenuOpen(false);
onSignOut();
}}
style={{
...menuItemStyle,
width: '100%',
textAlign: 'left',
borderTop: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
color: 'var(--color-destructive, #dc2626)',
background: 'none',
border: 'none',
borderTopStyle: 'solid',
borderTopWidth: 1,
borderTopColor: 'var(--bl-shell-border, var(--color-border, #e5e7eb))',
}}
>
Sign Out
</button>
)}
</div>
)}
</div>
)}
</div>
</header>
);
}
const menuItemStyle: React.CSSProperties = {
display: 'block',
padding: '10px 16px',
fontSize: 14,
color: 'var(--color-foreground, #111827)',
textDecoration: 'none',
cursor: 'pointer',
};

View File

@ -0,0 +1,376 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { DashboardShell } from '../DashboardShell.js';
import { Sidebar } from '../Sidebar.js';
import { TopBar } from '../TopBar.js';
import { ProfilePage } from '../ProfilePage.js';
import { BillingPage } from '../BillingPage.js';
import { SettingsPage } from '../SettingsPage.js';
import type { NavItem, NavSection, ShellUser } from '../types.js';
const NAV: NavItem[] = [
{ href: '/dashboard', label: 'Dashboard', icon: '◈' },
{ href: '/tasks', label: 'Tasks', icon: '✓' },
{ href: '/settings', label: 'Settings', icon: '⚙' },
];
const USER: ShellUser = {
id: 'u1',
name: 'Alice Smith',
email: 'alice@example.com',
role: 'admin',
};
// ── DashboardShell ───────────────────────────────────────────────────────────
describe('DashboardShell', () => {
beforeEach(() => cleanup());
it('renders sidebar, topbar, and content', () => {
render(
<DashboardShell productName="TestApp" nav={NAV} pathname="/dashboard">
<div>Page content</div>
</DashboardShell>
);
expect(screen.getByTestId('bl-dashboard-shell')).toBeDefined();
expect(screen.getByTestId('bl-shell-sidebar')).toBeDefined();
expect(screen.getByTestId('bl-shell-topbar')).toBeDefined();
expect(screen.getByTestId('bl-shell-main')).toBeDefined();
expect(screen.getByText('Page content')).toBeDefined();
});
it('passes product name to sidebar', () => {
render(
<DashboardShell productName="MyProduct" nav={NAV} pathname="/">
<div />
</DashboardShell>
);
expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('MyProduct');
});
it('passes user to topbar', () => {
render(
<DashboardShell productName="TestApp" nav={NAV} pathname="/" user={USER}>
<div />
</DashboardShell>
);
expect(screen.getByText('Alice Smith')).toBeDefined();
});
it('calls onSignOut when sign out clicked', () => {
const onSignOut = vi.fn();
render(
<DashboardShell
productName="TestApp"
nav={NAV}
pathname="/"
user={USER}
onSignOut={onSignOut}
>
<div />
</DashboardShell>
);
// Open user menu
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
fireEvent.click(screen.getByTestId('bl-shell-menu-signout'));
expect(onSignOut).toHaveBeenCalledOnce();
});
it('toggles sidebar collapse', () => {
render(
<DashboardShell productName="TestApp" nav={NAV} pathname="/dashboard">
<div />
</DashboardShell>
);
const toggle = screen.getByTestId('bl-shell-collapse-toggle');
// Initially expanded — product name shows full text
expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('TestApp');
fireEvent.click(toggle);
// Collapsed — shows first letter
expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('T');
});
});
// ── Sidebar ──────────────────────────────────────────────────────────────────
describe('Sidebar', () => {
beforeEach(() => cleanup());
it('renders nav items', () => {
render(<Sidebar productName="TestApp" nav={NAV} pathname="/dashboard" />);
expect(screen.getByText('Dashboard')).toBeDefined();
expect(screen.getByText('Tasks')).toBeDefined();
});
it('highlights active nav item', () => {
render(<Sidebar productName="TestApp" nav={NAV} pathname="/dashboard" />);
const dashLink = screen.getByTestId('bl-nav-dashboard');
expect(dashLink.style.fontWeight).toBe('600');
});
it('calls onNavigate when item clicked', () => {
const onNavigate = vi.fn();
render(<Sidebar productName="TestApp" nav={NAV} pathname="/" onNavigate={onNavigate} />);
fireEvent.click(screen.getByTestId('bl-nav-tasks'));
expect(onNavigate).toHaveBeenCalledWith('/tasks');
});
it('supports NavSection format', () => {
const sections: NavSection[] = [
{ title: 'Main', items: [{ href: '/home', label: 'Home' }] },
{ title: 'Admin', items: [{ href: '/admin', label: 'Admin Panel' }] },
];
render(<Sidebar productName="TestApp" nav={sections} pathname="/" />);
expect(screen.getByText('Main')).toBeDefined();
expect(screen.getByText('Admin')).toBeDefined();
expect(screen.getByText('Home')).toBeDefined();
expect(screen.getByText('Admin Panel')).toBeDefined();
});
it('hides hidden nav items', () => {
const items: NavItem[] = [
{ href: '/visible', label: 'Visible' },
{ href: '/hidden', label: 'Hidden', hidden: true },
];
render(<Sidebar productName="TestApp" nav={items} pathname="/" />);
expect(screen.getByText('Visible')).toBeDefined();
expect(screen.queryByText('Hidden')).toBeNull();
});
it('shows badge on nav item', () => {
const items: NavItem[] = [{ href: '/inbox', label: 'Inbox', badge: 5 }];
render(<Sidebar productName="TestApp" nav={items} pathname="/" />);
expect(screen.getByText('5')).toBeDefined();
});
it('renders version in footer', () => {
render(<Sidebar productName="TestApp" nav={NAV} pathname="/" version="1.2.3" />);
expect(screen.getByTestId('bl-shell-sidebar-footer').textContent).toContain('v1.2.3');
});
it('renders custom footer', () => {
render(<Sidebar productName="TestApp" nav={NAV} pathname="/" footer={<span>Custom</span>} />);
expect(screen.getByText('Custom')).toBeDefined();
});
it('renders logo instead of product name', () => {
render(
<Sidebar
productName="TestApp"
nav={NAV}
pathname="/"
logo={<span data-testid="logo">Logo</span>}
/>
);
expect(screen.getByTestId('logo')).toBeDefined();
});
it('auto-adds settings link when not present', () => {
const items: NavItem[] = [{ href: '/dashboard', label: 'Dashboard' }];
render(
<Sidebar productName="TestApp" nav={items} pathname="/" features={{ settings: true }} />
);
expect(screen.getByTestId('bl-nav-settings')).toBeDefined();
});
it('does not duplicate settings link when already present', () => {
render(<Sidebar productName="TestApp" nav={NAV} pathname="/" />);
const settingsLinks = screen.getAllByText('Settings');
expect(settingsLinks.length).toBe(1);
});
});
// ── TopBar ───────────────────────────────────────────────────────────────────
describe('TopBar', () => {
beforeEach(() => cleanup());
it('renders user name', () => {
render(<TopBar user={USER} />);
expect(screen.getByText('Alice Smith')).toBeDefined();
});
it('renders initials avatar when no avatarUrl', () => {
render(<TopBar user={USER} />);
expect(screen.getByTestId('bl-shell-user-avatar').textContent).toBe('AS');
});
it('opens and closes user menu', () => {
render(<TopBar user={USER} />);
expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull();
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
expect(screen.getByTestId('bl-shell-user-menu')).toBeDefined();
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull();
});
it('shows profile link in menu by default', () => {
render(<TopBar user={USER} />);
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
expect(screen.getByTestId('bl-shell-menu-profile')).toBeDefined();
});
it('shows billing link when feature enabled', () => {
render(<TopBar user={USER} features={{ billing: true }} />);
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
expect(screen.getByTestId('bl-shell-menu-billing')).toBeDefined();
});
it('hides billing link when feature disabled', () => {
render(<TopBar user={USER} features={{ billing: false }} />);
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
expect(screen.queryByTestId('bl-shell-menu-billing')).toBeNull();
});
it('shows notifications bell when enabled', () => {
render(<TopBar user={USER} features={{ notifications: true }} />);
expect(screen.getByTestId('bl-shell-notifications')).toBeDefined();
});
it('calls onSignOut', () => {
const onSignOut = vi.fn();
render(<TopBar user={USER} onSignOut={onSignOut} />);
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
fireEvent.click(screen.getByTestId('bl-shell-menu-signout'));
expect(onSignOut).toHaveBeenCalledOnce();
});
it('calls onNavigate for menu items', () => {
const onNavigate = vi.fn();
render(<TopBar user={USER} onNavigate={onNavigate} />);
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
fireEvent.click(screen.getByTestId('bl-shell-menu-profile'));
expect(onNavigate).toHaveBeenCalledWith('/profile');
});
it('renders custom actions', () => {
render(<TopBar user={USER} actions={<button data-testid="custom-action">Action</button>} />);
expect(screen.getByTestId('custom-action')).toBeDefined();
});
it('shows hamburger when onToggleSidebar provided', () => {
const toggle = vi.fn();
render(<TopBar user={USER} onToggleSidebar={toggle} />);
fireEvent.click(screen.getByTestId('bl-shell-hamburger'));
expect(toggle).toHaveBeenCalledOnce();
});
});
// ── ProfilePage ──────────────────────────────────────────────────────────────
describe('ProfilePage', () => {
beforeEach(() => cleanup());
it('renders user info', () => {
render(<ProfilePage user={USER} />);
expect(screen.getByTestId('bl-shell-profile-page')).toBeDefined();
expect(screen.getByTestId('bl-profile-avatar')).toBeDefined();
});
it('pre-fills form fields', () => {
render(<ProfilePage user={USER} />);
const nameInput = screen.getByTestId('bl-profile-name') as unknown as { value: string };
const emailInput = screen.getByTestId('bl-profile-email') as unknown as { value: string };
expect(nameInput.value).toBe('Alice Smith');
expect(emailInput.value).toBe('alice@example.com');
});
it('calls onUpdateProfile with form data', () => {
const onUpdate = vi.fn();
render(<ProfilePage user={USER} onUpdateProfile={onUpdate} />);
fireEvent.change(screen.getByTestId('bl-profile-name'), { target: { value: 'Bob Jones' } });
fireEvent.submit(screen.getByTestId('bl-profile-submit').closest('form')!);
expect(onUpdate).toHaveBeenCalledWith({ name: 'Bob Jones', email: 'alice@example.com' });
});
it('shows loading state', () => {
render(<ProfilePage user={USER} onUpdateProfile={vi.fn()} isLoading />);
expect(screen.getByText('Saving...')).toBeDefined();
});
it('shows error and success messages', () => {
const { rerender } = render(<ProfilePage user={USER} error="Failed" />);
expect(screen.getByTestId('bl-profile-error')).toBeDefined();
rerender(<ProfilePage user={USER} success="Saved" />);
expect(screen.getByTestId('bl-profile-success')).toBeDefined();
});
it('shows role when present', () => {
render(<ProfilePage user={USER} />);
expect(screen.getByText('Role: admin')).toBeDefined();
});
});
// ── BillingPage ──────────────────────────────────────────────────────────────
describe('BillingPage', () => {
beforeEach(() => cleanup());
it('renders current plan', () => {
render(<BillingPage currentPlan="Pro" status="active" />);
expect(screen.getByTestId('bl-shell-billing-page')).toBeDefined();
expect(screen.getByText('Pro')).toBeDefined();
});
it('shows status badge', () => {
render(<BillingPage currentPlan="Free" status="trialing" />);
expect(screen.getByTestId('bl-billing-status').textContent).toBe('trialing');
});
it('shows trial end date', () => {
render(<BillingPage trialEndsAt="2026-04-01" />);
expect(screen.getByTestId('bl-billing-trial').textContent).toContain('2026-04-01');
});
it('calls onManageBilling', () => {
const onManage = vi.fn();
render(<BillingPage onManageBilling={onManage} />);
fireEvent.click(screen.getByTestId('bl-billing-manage'));
expect(onManage).toHaveBeenCalledOnce();
});
it('renders plan comparison grid', () => {
const plans = [
{ name: 'Free', price: '$0/mo', features: ['Basic features'], current: true },
{ name: 'Pro', price: '$9/mo', features: ['All features', 'Priority support'] },
];
render(<BillingPage plans={plans} />);
expect(screen.getByTestId('bl-billing-plans')).toBeDefined();
expect(screen.getByTestId('bl-billing-plan-free')).toBeDefined();
expect(screen.getByTestId('bl-billing-plan-pro')).toBeDefined();
expect(screen.getByText('$9/mo')).toBeDefined();
});
it('defaults to Free plan when not specified', () => {
render(<BillingPage />);
expect(screen.getByText('Free')).toBeDefined();
});
});
// ── SettingsPage ─────────────────────────────────────────────────────────────
describe('SettingsPage', () => {
beforeEach(() => cleanup());
it('renders empty state when no sections', () => {
render(<SettingsPage productName="TestApp" />);
expect(screen.getByTestId('bl-shell-settings-page')).toBeDefined();
expect(screen.getByTestId('bl-settings-empty')).toBeDefined();
expect(screen.getByText('No settings configured for TestApp.')).toBeDefined();
});
it('renders sections', () => {
const sections = [
{ title: 'Notifications', description: 'Manage alerts', content: <div>Toggle</div> },
{ title: 'Theme', content: <div>Dark mode</div> },
];
render(<SettingsPage productName="TestApp" sections={sections} />);
expect(screen.getByTestId('bl-settings-section-0')).toBeDefined();
expect(screen.getByTestId('bl-settings-section-1')).toBeDefined();
expect(screen.getByText('Notifications')).toBeDefined();
expect(screen.getByText('Manage alerts')).toBeDefined();
expect(screen.getByText('Toggle')).toBeDefined();
expect(screen.getByText('Dark mode')).toBeDefined();
});
});

View File

@ -0,0 +1,30 @@
/**
* @bytelyst/dashboard-shell
*
* Configurable Next.js dashboard layout with sidebar, top bar,
* and built-in pages for profile, billing, and settings.
*
* All components read CSS custom properties (--bl-shell-*, --color-*)
* with sensible fallback defaults.
*/
export { DashboardShell } from './DashboardShell.js';
export { Sidebar } from './Sidebar.js';
export { TopBar } from './TopBar.js';
export { ProfilePage } from './ProfilePage.js';
export { BillingPage } from './BillingPage.js';
export { SettingsPage } from './SettingsPage.js';
export type {
DashboardShellProps,
SidebarProps,
TopBarProps,
NavItem,
NavSection,
ShellUser,
ShellFeatures,
ProfilePageProps,
BillingPageProps,
SettingsPageProps,
SettingsSection,
} from './types.js';

View File

@ -0,0 +1,131 @@
import type { ReactNode } from 'react';
// ── Navigation ───────────────────────────────────────────────────────────────
export interface NavItem {
href: string;
label: string;
icon?: ReactNode;
badge?: string | number;
/** Hide this item from nav (useful for feature-flag gating) */
hidden?: boolean;
}
export interface NavSection {
title?: string;
items: NavItem[];
}
// ── User ─────────────────────────────────────────────────────────────────────
export interface ShellUser {
id: string;
name: string;
email: string;
avatarUrl?: string;
role?: string;
}
// ── Features ─────────────────────────────────────────────────────────────────
export interface ShellFeatures {
/** Show profile page link in user menu (default: true) */
profile?: boolean;
/** Show billing page link in user menu (default: false) */
billing?: boolean;
/** Show settings page link in sidebar (default: true) */
settings?: boolean;
/** Show notifications icon in top bar (default: false) */
notifications?: boolean;
/** Show dark/light theme toggle (default: false) */
themeToggle?: boolean;
}
// ── Shell Config ─────────────────────────────────────────────────────────────
export interface DashboardShellProps {
/** Product display name shown in sidebar header */
productName: string;
/** Product logo element (replaces text name if provided) */
logo?: ReactNode;
/** Product version shown in sidebar footer */
version?: string;
/** Navigation items or sections */
nav: NavItem[] | NavSection[];
/** Current pathname for active state (if not using internal detection) */
pathname?: string;
/** Currently logged-in user */
user?: ShellUser;
/** Feature toggles for built-in pages */
features?: ShellFeatures;
/** Called when user clicks Sign Out */
onSignOut?: () => void;
/** Called when a nav item is clicked (for SPA routers) */
onNavigate?: (href: string) => void;
/** Sidebar footer content (replaces default) */
sidebarFooter?: ReactNode;
/** Content to render in the top bar (right side) */
topBarActions?: ReactNode;
/** Dashboard page content */
children: ReactNode;
}
// ── Sidebar Props ────────────────────────────────────────────────────────────
export interface SidebarProps {
productName: string;
logo?: ReactNode;
version?: string;
nav: NavItem[] | NavSection[];
pathname: string;
features?: ShellFeatures;
onNavigate?: (href: string) => void;
footer?: ReactNode;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
// ── Top Bar Props ────────────────────────────────────────────────────────────
export interface TopBarProps {
user?: ShellUser;
features?: ShellFeatures;
onSignOut?: () => void;
onNavigate?: (href: string) => void;
actions?: ReactNode;
onToggleSidebar?: () => void;
}
// ── Built-in Page Props ──────────────────────────────────────────────────────
export interface ProfilePageProps {
user: ShellUser;
onUpdateProfile?: (data: { name: string; email: string }) => void;
isLoading?: boolean;
error?: string;
success?: string;
}
export interface BillingPageProps {
currentPlan?: string;
status?: 'active' | 'trialing' | 'past_due' | 'canceled';
trialEndsAt?: string;
onManageBilling?: () => void;
plans?: Array<{
name: string;
price: string;
features: string[];
current?: boolean;
}>;
}
export interface SettingsPageProps {
productName: string;
sections?: SettingsSection[];
}
export interface SettingsSection {
title: string;
description?: string;
content: ReactNode;
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"],
"exclude": ["dist", "src/**/*.test.*"]
}

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
},
});