From 1fda345d38eae6416c19f2b3587e049622f8f2d3 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 20:54:28 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard-shell):=20add=20@bytelyst/dashbo?= =?UTF-8?q?ard-shell=20package=20(4.3)=20=E2=80=94=2041=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/dashboard-shell/package.json | 36 ++ packages/dashboard-shell/src/BillingPage.tsx | 189 +++++++++ .../dashboard-shell/src/DashboardShell.tsx | 73 ++++ packages/dashboard-shell/src/ProfilePage.tsx | 180 +++++++++ packages/dashboard-shell/src/SettingsPage.tsx | 71 ++++ packages/dashboard-shell/src/Sidebar.tsx | 236 +++++++++++ packages/dashboard-shell/src/TopBar.tsx | 244 ++++++++++++ .../src/__tests__/dashboard-shell.test.tsx | 376 ++++++++++++++++++ packages/dashboard-shell/src/index.ts | 30 ++ packages/dashboard-shell/src/types.ts | 131 ++++++ packages/dashboard-shell/tsconfig.json | 11 + packages/dashboard-shell/vitest.config.ts | 7 + 12 files changed, 1584 insertions(+) create mode 100644 packages/dashboard-shell/package.json create mode 100644 packages/dashboard-shell/src/BillingPage.tsx create mode 100644 packages/dashboard-shell/src/DashboardShell.tsx create mode 100644 packages/dashboard-shell/src/ProfilePage.tsx create mode 100644 packages/dashboard-shell/src/SettingsPage.tsx create mode 100644 packages/dashboard-shell/src/Sidebar.tsx create mode 100644 packages/dashboard-shell/src/TopBar.tsx create mode 100644 packages/dashboard-shell/src/__tests__/dashboard-shell.test.tsx create mode 100644 packages/dashboard-shell/src/index.ts create mode 100644 packages/dashboard-shell/src/types.ts create mode 100644 packages/dashboard-shell/tsconfig.json create mode 100644 packages/dashboard-shell/vitest.config.ts diff --git a/packages/dashboard-shell/package.json b/packages/dashboard-shell/package.json new file mode 100644 index 00000000..8098dfe1 --- /dev/null +++ b/packages/dashboard-shell/package.json @@ -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" + } +} diff --git a/packages/dashboard-shell/src/BillingPage.tsx b/packages/dashboard-shell/src/BillingPage.tsx new file mode 100644 index 00000000..b53c128e --- /dev/null +++ b/packages/dashboard-shell/src/BillingPage.tsx @@ -0,0 +1,189 @@ +import type { ReactNode } from 'react'; +import type { BillingPageProps } from './types.js'; + +const statusColors: Record = { + 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 ( +
+

+ Billing +

+ + {/* Current plan card */} +
+
+
+
+ Current Plan +
+
+ {currentPlan} +
+
+ + {status.replace('_', ' ')} + +
+ + {trialEndsAt && ( +
+ Trial ends: {trialEndsAt} +
+ )} + + {onManageBilling && ( + + )} +
+ + {/* Plan comparison */} + {plans.length > 0 && ( +
+

+ Available Plans +

+
+ {plans.map(plan => ( +
+
{plan.name}
+
+ {plan.price} +
+
    + {plan.features.map(f => ( +
  • + ✓ {f} +
  • + ))} +
+ {plan.current && ( +
+ Current Plan +
+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/dashboard-shell/src/DashboardShell.tsx b/packages/dashboard-shell/src/DashboardShell.tsx new file mode 100644 index 00000000..d7457da6 --- /dev/null +++ b/packages/dashboard-shell/src/DashboardShell.tsx @@ -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 ( +
+ {/* Sidebar */} + setSidebarCollapsed(!sidebarCollapsed)} + /> + + {/* Main content area */} +
+ {/* Top bar */} + + + {/* Page content */} +
+ {children} +
+
+
+ ); +} diff --git a/packages/dashboard-shell/src/ProfilePage.tsx b/packages/dashboard-shell/src/ProfilePage.tsx new file mode 100644 index 00000000..570bb318 --- /dev/null +++ b/packages/dashboard-shell/src/ProfilePage.tsx @@ -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 ( +
+

+ Profile +

+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Avatar */} +
+
+ {user.avatarUrl ? ( + {user.name} + ) : ( + user.name + .split(' ') + .map(w => w[0]) + .join('') + .toUpperCase() + .slice(0, 2) + )} +
+
+
{user.name}
+
+ {user.email} +
+ {user.role && ( +
+ Role: {user.role} +
+ )} +
+
+ + {/* Form */} +
+
+ + setName(e.target.value)} + required + style={inputStyle} + /> +
+
+ + setEmail(e.target.value)} + required + style={inputStyle} + /> +
+ + {onUpdateProfile && ( + + )} +
+
+ ); +} + +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)`, + }; +} diff --git a/packages/dashboard-shell/src/SettingsPage.tsx b/packages/dashboard-shell/src/SettingsPage.tsx new file mode 100644 index 00000000..2a0f6692 --- /dev/null +++ b/packages/dashboard-shell/src/SettingsPage.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react'; +import type { SettingsPageProps } from './types.js'; + +export function SettingsPage({ productName, sections = [] }: SettingsPageProps): ReactNode { + return ( +
+

+ Settings +

+ + {sections.length === 0 && ( +
+ No settings configured for {productName}. +
+ )} + + {sections.map((section, i) => ( +
+

+ {section.title} +

+ {section.description && ( +

+ {section.description} +

+ )} +
{section.content}
+
+ ))} +
+ ); +} diff --git a/packages/dashboard-shell/src/Sidebar.tsx b/packages/dashboard-shell/src/Sidebar.tsx new file mode 100644 index 00000000..4bc77430 --- /dev/null +++ b/packages/dashboard-shell/src/Sidebar.tsx @@ -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 ( + + {item.icon && ( + + {item.icon} + + )} + {!collapsed && {item.label}} + {!collapsed && item.badge !== undefined && ( + + {item.badge} + + )} + + ); +} + +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 ( + + ); +} diff --git a/packages/dashboard-shell/src/TopBar.tsx b/packages/dashboard-shell/src/TopBar.tsx new file mode 100644 index 00000000..9759315b --- /dev/null +++ b/packages/dashboard-shell/src/TopBar.tsx @@ -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 ( +
+ {/* Left: mobile hamburger */} +
+ {onToggleSidebar && ( + + )} +
+ + {/* Right: actions + user menu */} +
+ {actions} + + {features.notifications && ( + + )} + + {user && ( +
+ + + {menuOpen && ( +
+
+
{user.name}
+
+ {user.email} +
+
+ + {features.profile !== false && ( + { + e.preventDefault(); + handleNav('/profile'); + }} + style={menuItemStyle} + > + Profile + + )} + {features.billing && ( + { + e.preventDefault(); + handleNav('/billing'); + }} + style={menuItemStyle} + > + Billing + + )} + {features.settings !== false && ( + { + e.preventDefault(); + handleNav('/settings'); + }} + style={menuItemStyle} + > + Settings + + )} + + {onSignOut && ( + + )} +
+ )} +
+ )} +
+
+ ); +} + +const menuItemStyle: React.CSSProperties = { + display: 'block', + padding: '10px 16px', + fontSize: 14, + color: 'var(--color-foreground, #111827)', + textDecoration: 'none', + cursor: 'pointer', +}; diff --git a/packages/dashboard-shell/src/__tests__/dashboard-shell.test.tsx b/packages/dashboard-shell/src/__tests__/dashboard-shell.test.tsx new file mode 100644 index 00000000..c07598a6 --- /dev/null +++ b/packages/dashboard-shell/src/__tests__/dashboard-shell.test.tsx @@ -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( + +
Page content
+
+ ); + 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( + +
+ + ); + expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('MyProduct'); + }); + + it('passes user to topbar', () => { + render( + +
+ + ); + expect(screen.getByText('Alice Smith')).toBeDefined(); + }); + + it('calls onSignOut when sign out clicked', () => { + const onSignOut = vi.fn(); + render( + +
+ + ); + // 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( + +
+ + ); + 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(); + expect(screen.getByText('Dashboard')).toBeDefined(); + expect(screen.getByText('Tasks')).toBeDefined(); + }); + + it('highlights active nav item', () => { + render(); + const dashLink = screen.getByTestId('bl-nav-dashboard'); + expect(dashLink.style.fontWeight).toBe('600'); + }); + + it('calls onNavigate when item clicked', () => { + const onNavigate = vi.fn(); + render(); + 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(); + 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(); + 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(); + expect(screen.getByText('5')).toBeDefined(); + }); + + it('renders version in footer', () => { + render(); + expect(screen.getByTestId('bl-shell-sidebar-footer').textContent).toContain('v1.2.3'); + }); + + it('renders custom footer', () => { + render(Custom} />); + expect(screen.getByText('Custom')).toBeDefined(); + }); + + it('renders logo instead of product name', () => { + render( + Logo} + /> + ); + expect(screen.getByTestId('logo')).toBeDefined(); + }); + + it('auto-adds settings link when not present', () => { + const items: NavItem[] = [{ href: '/dashboard', label: 'Dashboard' }]; + render( + + ); + expect(screen.getByTestId('bl-nav-settings')).toBeDefined(); + }); + + it('does not duplicate settings link when already present', () => { + render(); + const settingsLinks = screen.getAllByText('Settings'); + expect(settingsLinks.length).toBe(1); + }); +}); + +// ── TopBar ─────────────────────────────────────────────────────────────────── + +describe('TopBar', () => { + beforeEach(() => cleanup()); + + it('renders user name', () => { + render(); + expect(screen.getByText('Alice Smith')).toBeDefined(); + }); + + it('renders initials avatar when no avatarUrl', () => { + render(); + expect(screen.getByTestId('bl-shell-user-avatar').textContent).toBe('AS'); + }); + + it('opens and closes user menu', () => { + render(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + expect(screen.queryByTestId('bl-shell-menu-billing')).toBeNull(); + }); + + it('shows notifications bell when enabled', () => { + render(); + expect(screen.getByTestId('bl-shell-notifications')).toBeDefined(); + }); + + it('calls onSignOut', () => { + const onSignOut = vi.fn(); + render(); + 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(); + 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(Action} />); + expect(screen.getByTestId('custom-action')).toBeDefined(); + }); + + it('shows hamburger when onToggleSidebar provided', () => { + const toggle = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('bl-shell-hamburger')); + expect(toggle).toHaveBeenCalledOnce(); + }); +}); + +// ── ProfilePage ────────────────────────────────────────────────────────────── + +describe('ProfilePage', () => { + beforeEach(() => cleanup()); + + it('renders user info', () => { + render(); + expect(screen.getByTestId('bl-shell-profile-page')).toBeDefined(); + expect(screen.getByTestId('bl-profile-avatar')).toBeDefined(); + }); + + it('pre-fills form fields', () => { + render(); + 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(); + 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(); + expect(screen.getByText('Saving...')).toBeDefined(); + }); + + it('shows error and success messages', () => { + const { rerender } = render(); + expect(screen.getByTestId('bl-profile-error')).toBeDefined(); + + rerender(); + expect(screen.getByTestId('bl-profile-success')).toBeDefined(); + }); + + it('shows role when present', () => { + render(); + expect(screen.getByText('Role: admin')).toBeDefined(); + }); +}); + +// ── BillingPage ────────────────────────────────────────────────────────────── + +describe('BillingPage', () => { + beforeEach(() => cleanup()); + + it('renders current plan', () => { + render(); + expect(screen.getByTestId('bl-shell-billing-page')).toBeDefined(); + expect(screen.getByText('Pro')).toBeDefined(); + }); + + it('shows status badge', () => { + render(); + expect(screen.getByTestId('bl-billing-status').textContent).toBe('trialing'); + }); + + it('shows trial end date', () => { + render(); + expect(screen.getByTestId('bl-billing-trial').textContent).toContain('2026-04-01'); + }); + + it('calls onManageBilling', () => { + const onManage = vi.fn(); + render(); + 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(); + 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(); + expect(screen.getByText('Free')).toBeDefined(); + }); +}); + +// ── SettingsPage ───────────────────────────────────────────────────────────── + +describe('SettingsPage', () => { + beforeEach(() => cleanup()); + + it('renders empty state when no sections', () => { + render(); + 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:
Toggle
}, + { title: 'Theme', content:
Dark mode
}, + ]; + render(); + 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(); + }); +}); diff --git a/packages/dashboard-shell/src/index.ts b/packages/dashboard-shell/src/index.ts new file mode 100644 index 00000000..70bef60c --- /dev/null +++ b/packages/dashboard-shell/src/index.ts @@ -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'; diff --git a/packages/dashboard-shell/src/types.ts b/packages/dashboard-shell/src/types.ts new file mode 100644 index 00000000..14a3e980 --- /dev/null +++ b/packages/dashboard-shell/src/types.ts @@ -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; +} diff --git a/packages/dashboard-shell/tsconfig.json b/packages/dashboard-shell/tsconfig.json new file mode 100644 index 00000000..3128359c --- /dev/null +++ b/packages/dashboard-shell/tsconfig.json @@ -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.*"] +} diff --git a/packages/dashboard-shell/vitest.config.ts b/packages/dashboard-shell/vitest.config.ts new file mode 100644 index 00000000..a9f5456f --- /dev/null +++ b/packages/dashboard-shell/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + }, +});