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:
parent
6354711f97
commit
1fda345d38
36
packages/dashboard-shell/package.json
Normal file
36
packages/dashboard-shell/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
189
packages/dashboard-shell/src/BillingPage.tsx
Normal file
189
packages/dashboard-shell/src/BillingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
packages/dashboard-shell/src/DashboardShell.tsx
Normal file
73
packages/dashboard-shell/src/DashboardShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
packages/dashboard-shell/src/ProfilePage.tsx
Normal file
180
packages/dashboard-shell/src/ProfilePage.tsx
Normal 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)`,
|
||||
};
|
||||
}
|
||||
71
packages/dashboard-shell/src/SettingsPage.tsx
Normal file
71
packages/dashboard-shell/src/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
packages/dashboard-shell/src/Sidebar.tsx
Normal file
236
packages/dashboard-shell/src/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
packages/dashboard-shell/src/TopBar.tsx
Normal file
244
packages/dashboard-shell/src/TopBar.tsx
Normal 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',
|
||||
};
|
||||
376
packages/dashboard-shell/src/__tests__/dashboard-shell.test.tsx
Normal file
376
packages/dashboard-shell/src/__tests__/dashboard-shell.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
30
packages/dashboard-shell/src/index.ts
Normal file
30
packages/dashboard-shell/src/index.ts
Normal 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';
|
||||
131
packages/dashboard-shell/src/types.ts
Normal file
131
packages/dashboard-shell/src/types.ts
Normal 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;
|
||||
}
|
||||
11
packages/dashboard-shell/tsconfig.json
Normal file
11
packages/dashboard-shell/tsconfig.json
Normal 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.*"]
|
||||
}
|
||||
7
packages/dashboard-shell/vitest.config.ts
Normal file
7
packages/dashboard-shell/vitest.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user