Commit 7357a58b by krds-arun

Revamp frontend form renderer and Storybook form system.

This updates the frontend to a schema-driven checklist experience with custom field components, responsive tabbed section navigation, mobile-friendly date/time and selection UX, and improved migration alignment for comment/file-linked composite questions.

Made-with: Cursor
parent 30388722
...@@ -20,6 +20,9 @@ ...@@ -20,6 +20,9 @@
# production # production
/build /build
# storybook
storybook-static
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
...@@ -41,4 +44,3 @@ yarn-error.log* ...@@ -41,4 +44,3 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
*storybook.log *storybook.log
storybook-static
import type { StorybookConfig } from '@storybook/nextjs'; import type { StorybookConfig } from '@storybook/nextjs-vite';
const config: StorybookConfig = { const config: StorybookConfig = {
"stories": [ stories: ['../components/**/*.stories.@(ts|tsx)'],
"../stories/**/*.mdx", addons: [
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)" '@chromatic-com/storybook',
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@storybook/addon-docs',
], ],
"addons": [ framework: '@storybook/nextjs-vite',
"@storybook/addon-a11y", staticDirs: ['../public'],
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
"framework": "@storybook/nextjs",
"staticDirs": [
"../public"
]
}; };
export default config; export default config;
import type { Preview } from '@storybook/nextjs' import React from 'react'
import type { Preview } from '@storybook/nextjs-vite'
import '../../maf-uis/maf-ui/css/main.css'
const decorators: Preview['decorators'] = [
(Story) => {
// The MAF theme CSS assumes a fixed header + bottom nav. In Storybook we render only the
// form canvas, so we remove that extra body padding for correct visual alignment.
if (typeof document !== 'undefined') {
document.body.style.paddingTop = '0px'
document.body.style.paddingBottom = '0px'
}
return React.createElement(
'div',
{
style: {
minHeight: '100vh',
background: 'var(--ink)',
padding: '24px',
},
},
React.createElement(Story)
)
},
]
const preview: Preview = { const preview: Preview = {
decorators,
parameters: { parameters: {
backgrounds: {
default: 'dark',
values: [
{
name: 'dark',
value: '#0a0a0c',
},
{
name: 'light',
value: '#f8f9fa',
},
],
},
controls: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,
date: /Date$/i, date: /Date$/i,
}, },
}, },
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo'
}
}, },
}; };
......
@import "tailwindcss"; @import "tailwindcss";
/* Load original MAF-UIS CSS selectors so class-based markup matches pixel-for-pixel. */
@import "../../maf-uis/maf-ui/css/main.css";
:root { :root {
/* Brand */ --background: #0c0b09;
--maroon: #8b1538; --foreground: #f4f0e8;
--maroon-deep: #6b0f2a; --muted: #9c958a;
--maroon-bright: #a91d47; --accent: #c4a574;
--gold: #c9a961; --glow: rgba(196, 165, 116, 0.15);
--gold-light: #dfc07a;
--gold-pale: rgba(201, 169, 97, 0.12);
--gold-border: rgba(201, 169, 97, 0.2);
--gold-border-strong: rgba(201, 169, 97, 0.45);
/* Neutrals — professional contrast */
--ink: #0a0a0c;
--ink-2: #121214;
--ink-3: #1a1a1d;
--ink-4: #27272a;
--text-1: #fafafa;
--text-2: #a1a1aa;
--text-3: #71717a;
--white: rgba(255, 255, 255, 0.06);
--white-2: rgba(255, 255, 255, 0.1);
/* Surfaces (semantic) */
--surface-base: var(--ink);
--surface-raised: var(--ink-2);
--surface-overlay: var(--ink-3);
--border-subtle: var(--gold-border);
--border-strong: var(--gold-border-strong);
/* Status */
--status-new: #3b82f6;
--status-incomplete: #eab308;
--status-inprogress: #3b82f6;
--status-completed: #22c55e;
--status-published: #22c55e;
--status-active: #22c55e;
--status-blocked: #ef4444;
--status-draft: #94a3b8;
--notif-info: #3b82f6;
--notif-success: #22c55e;
--notif-warning: #f59e0b;
--notif-action: #8b1538;
/* Typography — MAF theme: professional scale & fonts */
--font-display: "Cormorant Garamond", Georgia, serif;
--font-sans: "Syne", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "SF Mono", Consolas, monospace;
/* Aliases used for Tailwind @theme token mapping (avoid self-references) */
--maf-font-sans: var(--font-sans);
--maf-font-mono: var(--font-mono);
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.9375rem; /* 15px */
--text-md: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.05em;
--tracking-wider: 0.08em;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Spacing scale (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Shadows */
--shadow-maroon: 0 8px 32px rgba(139, 21, 56, 0.4);
--shadow-deep: 0 24px 64px rgba(0, 0, 0, 0.6);
--shadow-focus: 0 0 0 3px rgba(201, 169, 97, 0.3);
--shadow-toast: 0 10px 40px rgba(0, 0, 0, 0.5);
/* Motion */
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--t1: 0.12s;
--t2: 0.22s;
--t3: 0.35s;
/* Layout */
--header-height: 56px;
--sidebar-width: 240px;
--sidebar-width-collapsed: 72px;
--bottom-nav-height: 64px;
--compact-footer-height: 48px;
--notif-panel-width: 380px;
--notif-panel-max-height: 85vh;
--content-max-width: 1280px;
/* Radius */
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-full: 9999px;
/* Touch targets */
--touch-min: 44px;
} }
/* Light theme overrides (triggered by `data-theme="light"`) */ html {
html[data-theme="light"] { color-scheme: dark;
--ink: #f8f9fa;
--ink-2: #ffffff;
--ink-3: #f1f3f5;
--ink-4: #e9ecef;
--text-1: #1a1d21;
--text-2: #495057;
--text-3: #868e96;
--white: rgba(0, 0, 0, 0.04);
--white-2: rgba(0, 0, 0, 0.08);
--surface-base: #f8f9fa;
--surface-raised: #ffffff;
--surface-overlay: #ffffff;
--border-subtle: rgba(139, 21, 56, 0.15);
--border-strong: rgba(139, 21, 56, 0.35);
--gold-border: rgba(201, 169, 97, 0.35);
--gold-border-strong: rgba(201, 169, 97, 0.55);
--gold-pale: rgba(201, 169, 97, 0.12);
--shadow-deep: 0 24px 64px rgba(0, 0, 0, 0.08);
--shadow-toast: 0 10px 40px rgba(0, 0, 0, 0.12);
} }
@theme inline { body {
--font-sans: var(--maf-font-sans); background: var(--background);
--font-mono: var(--maf-font-mono); color: var(--foreground);
font-feature-settings: "ss01" on, "cv11" on;
/* Semantic colors */
--color-ink: var(--ink);
--color-surface-base: var(--surface-base);
--color-surface-raised: var(--surface-raised);
--color-surface-overlay: var(--surface-overlay);
--color-text-1: var(--text-1);
--color-text-2: var(--text-2);
--color-text-3: var(--text-3);
--color-gold: var(--gold);
--color-gold-border: var(--gold-border);
--color-gold-border-strong: var(--gold-border-strong);
--color-gold-pale: var(--gold-pale);
--color-maroon: var(--maroon);
--color-maroon-bright: var(--maroon-bright);
--color-border-subtle: var(--border-subtle);
--color-border-strong: var(--border-strong);
--color-status-new: var(--status-new);
--color-status-incomplete: var(--status-incomplete);
--color-status-inprogress: var(--status-inprogress);
--color-status-completed: var(--status-completed);
--color-status-active: var(--status-active);
--color-status-blocked: var(--status-blocked);
--color-status-draft: var(--status-draft);
} }
html { .font-display {
scroll-behavior: smooth; font-family: var(--font-display), ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
overflow-x: hidden;
} }
@media (prefers-reduced-motion: reduce) { @keyframes drift {
html { 0%,
scroll-behavior: auto; 100% {
opacity: 0.4;
transform: scale(1) translate(0, 0);
} }
*, *::before, *::after { 50% {
animation-duration: 0.01ms !important; opacity: 0.65;
animation-iteration-count: 1 !important; transform: scale(1.05) translate(2%, -1%);
transition-duration: 0.01ms !important;
} }
} }
/* Basic MAF base (trimmed from MAF-UIS base.css) */ @keyframes fade-up {
*, from {
*::before, opacity: 0;
*::after { transform: translateY(12px);
margin: 0; }
padding: 0; to {
box-sizing: border-box; opacity: 1;
} transform: translateY(0);
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: 500;
line-height: var(--leading-normal);
letter-spacing: 0.01em;
background: var(--ink);
color: var(--text-1);
min-height: 100vh;
padding-top: var(--header-height);
padding-bottom: var(--bottom-nav-height);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
:root[data-theme="light"] {
/* keep for older CSS selectors if needed later */
} }
*:focus-visible { .animate-drift {
outline: 2px solid var(--gold); animation: drift 18s ease-in-out infinite;
outline-offset: 2px;
box-shadow: none;
} }
a:focus-visible, .animate-fade-up {
button:focus-visible { animation: fade-up 0.9s ease-out both;
box-shadow: var(--shadow-focus);
} }
/* Grain overlay (from MAF-UIS) */ .animate-fade-up-delay {
html[data-theme="light"] body::before { animation: fade-up 0.9s ease-out 0.15s both;
opacity: 0.15;
} }
body::before { .animate-fade-up-delay-2 {
content: ''; animation: fade-up 0.9s ease-out 0.3s both;
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
opacity: 0.4;
} }
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafBadge from '@/src/maf-ui/components/MafBadge';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function IncidentsPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="incidents" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<div className="flex items-start justify-between gap-4">
<div>
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Incidents
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Placeholder list view that confirms cards/buttons/badges styling on the shell.
</p>
</div>
<MafBadge tone="warning">3 pending</MafBadge>
</div>
<div className="mt-6 space-y-3">
<div className="rounded-[var(--r-md)] border border-gold-border bg-[var(--ink-2)] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-text-1 font-semibold">Incident #INC-2024-001</div>
<div className="text-text-3 text-xs uppercase tracking-[0.06em] mt-1">
Open · Requires review
</div>
</div>
<MafBadge tone="info">1 day</MafBadge>
</div>
<div className="mt-3 flex gap-3 flex-wrap">
<MafButton variant="primary" size="small" onClick={() => {}}>
Review
</MafButton>
<MafButton variant="ghost" size="small" onClick={() => {}}>
Assign
</MafButton>
</div>
</div>
<div className="rounded-[var(--r-md)] border border-gold-border bg-[var(--ink-2)] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-text-1 font-semibold">Incident #INC-2024-014</div>
<div className="text-text-3 text-xs uppercase tracking-[0.06em] mt-1">
Open · Pending approval
</div>
</div>
<MafBadge tone="draft">Draft</MafBadge>
</div>
<div className="mt-3 flex gap-3 flex-wrap">
<MafButton variant="primary" size="small" onClick={() => {}}>
Approve
</MafButton>
<MafButton variant="ghost" size="small" onClick={() => {}}>
View
</MafButton>
</div>
</div>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Cormorant_Garamond, JetBrains_Mono, Syne } from "next/font/google"; import { Manrope, Syne } from "next/font/google";
import "./globals.css";
const cormorant = Cormorant_Garamond({ import "./globals.css";
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-maf-display",
});
const syne = Syne({ const syne = Syne({
variable: "--font-display",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-maf-sans",
}); });
const jetbrainsMono = JetBrains_Mono({ const manrope = Manrope({
variable: "--font-sans",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-maf-mono",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "MAF Revamp", title: "MAF Revamp",
description: "MAF-UIS styled frontend", description: "Welcome to MAF Revamp",
}; };
export default function RootLayout({ export default function RootLayout({
...@@ -31,12 +24,12 @@ export default function RootLayout({ ...@@ -31,12 +24,12 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html <html lang="en">
lang="en" <body
data-theme="dark" className={`${manrope.className} ${syne.variable} min-h-screen antialiased`}
className={`${cormorant.variable} ${syne.variable} ${jetbrainsMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> {children}
</body>
</html> </html>
); );
} }
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafButton from '@/src/maf-ui/components/MafButton';
import MafBadge from '@/src/maf-ui/components/MafBadge';
import MafTabs from '@/src/maf-ui/components/MafTabs';
import { MafCard } from '@/src/maf-ui/components/MafCard';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function ModulesPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="modules" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<div className="flex items-start justify-between gap-4">
<div>
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Modules
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Component kit + shell validation on a real Next route.
</p>
</div>
<MafBadge tone="info">New</MafBadge>
</div>
<div className="mt-6">
<MafTabs
defaultActiveKey="overview"
items={[
{
key: 'overview',
label: 'Overview',
panel: (
<div className="text-text-2 text-sm leading-[var(--leading-relaxed)]">
The Modules page uses <b>MAF shell</b> + <b>MAF tabs</b> + <b>MAF buttons</b>.
<div className="mt-4 flex flex-wrap gap-3">
<MafButton variant="primary" onClick={() => {}}>
Primary action
</MafButton>
<MafButton variant="ghost" onClick={() => {}}>
Secondary
</MafButton>
</div>
</div>
),
},
{
key: 'rules',
label: 'Rules',
panel: <div className="text-text-2 text-sm">Rules configuration placeholder.</div>,
},
{
key: 'approvals',
label: 'Approvals',
panel: (
<div className="text-text-2 text-sm">
Approval workflow placeholder. Use <MafBadge tone="success">Ready</MafBadge> to represent status.
</div>
),
},
]}
/>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafBadge from '@/src/maf-ui/components/MafBadge';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
import MafNotificationCenter from '@/src/maf-ui/shell/MafNotificationCenter';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function NotificationCenterPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell
user={user}
activeView="notification-center"
onNavigate={(next) => router.push(viewToPath(next))}
>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<MafNotificationCenter
open
variant="page"
unreadCount={0}
onUnreadCountChange={() => {}}
onClose={() => {}}
onNavigate={(link) => {
const view = (() => {
switch (link) {
case '#incidents':
return 'incidents';
case '#inspection':
return 'modules';
case '#admin':
return 'modules';
case '#profile':
return 'profile';
case '#settings':
return 'settings';
default:
return 'modules';
}
})();
router.push(viewToPath(view));
}}
/>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import MafGatewayLanding from '@/src/maf-ui/landing/MafGatewayLanding';
import useAuth from '@/src/maf-ui/auth/useAuth';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
export default function Home() { export default function Home() {
const router = useRouter(); return (
const { status, user, login } = useAuth(); <main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 py-20">
<div
useEffect(() => { className="pointer-events-none absolute inset-0 animate-drift"
if (status === 'authed' && user) { aria-hidden
router.replace('/modules'); style={{
} background: `
}, [status, user, router]); radial-gradient(ellipse 80% 50% at 50% -20%, var(--glow), transparent),
radial-gradient(ellipse 60% 40% at 100% 50%, rgba(90, 74, 58, 0.2), transparent),
if (status === 'authed') { radial-gradient(ellipse 50% 35% at 0% 80%, rgba(60, 52, 44, 0.35), transparent)
return null; `,
} }}
/>
const handleLogin = (u: MafUser) => { <div
login({ className="pointer-events-none absolute inset-0 opacity-[0.03]"
email: `${u.name}@company.com`, aria-hidden
accountType: u.role === 'Admin' ? 'employee' : 'contractor', style={{
}); backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
router.replace('/modules'); }}
}; />
return <MafGatewayLanding onLoginSuccess={handleLogin} />; <div className="relative z-10 mx-auto max-w-lg text-center">
<p className="animate-fade-up text-xs font-medium uppercase tracking-[0.35em] text-[var(--muted)]">
MAF Revamp
</p>
<h1 className="font-display animate-fade-up-delay mt-6 text-5xl font-semibold leading-[1.05] tracking-tight sm:text-6xl">
Welcome
</h1>
<p className="animate-fade-up-delay-2 mt-6 text-lg leading-relaxed text-[var(--muted)]">
You are on a clean frontend shell. Build screens and flows here when you are ready.
</p>
<p className="animate-fade-up-delay-2 mt-10 font-mono text-xs text-[var(--muted)]/70">
pnpm dev
</p>
</div>
</main>
);
} }
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function PermitPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="permit" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Permit
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Placeholder route to confirm shell layout across navigation.
</p>
<div className="mt-6">
<MafButton variant="primary" onClick={() => {}}>
Create permit
</MafButton>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function ProfilePage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="profile" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Profile
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Placeholder route to validate header + user menu + shell spacing.
</p>
<div className="mt-6">
<MafButton variant="primary" onClick={() => {}}>
Edit profile
</MafButton>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
import MafBadge from '@/src/maf-ui/components/MafBadge';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function SettingsPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="settings" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<div className="flex items-start justify-between gap-4">
<div>
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Settings
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Placeholder page for settings UI and navigation validation.
</p>
</div>
<MafBadge tone="draft">Demo</MafBadge>
</div>
<div className="mt-6 flex gap-3 flex-wrap">
<MafButton variant="primary" onClick={() => {}}>
Save changes
</MafButton>
<MafButton variant="ghost" onClick={() => {}}>
Reset
</MafButton>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { fn } from 'storybook/test'
import type { FormDefinitionSchema } from '../types'
import { FormRenderer } from './FormRenderer'
const sampleSchema: FormDefinitionSchema = {
meta: {
formCode: 'MAF_OP_TECH_001',
sourceId: 'webform.webform.sample',
title: 'Operational + Technical Checklist',
categoryIds: ['operational_checklist', 'technical_checklist'],
version: 1,
},
layout: [
{ groupCode: 'operational', label: 'Operational Checklist', order: 1 },
{ groupCode: 'technical', label: 'Technical Checklist', order: 2 },
],
questions: [
{
questionCode: 'op_general',
groupCode: 'operational',
label: 'General observation',
inputType: 'text',
required: true,
questionOrder: 1,
validation: {},
},
{
questionCode: 'op_radio_choice',
groupCode: 'operational',
label: 'A separate laundry basket available to collect the dirty napkins for the laundry, tagged as unclean?',
inputType: 'radio',
options: ['Yes', 'No', 'NA'],
required: true,
questionOrder: 2,
validation: {
supportsComment: true,
},
},
{
questionCode: 'op_comment_enabled',
groupCode: 'operational',
label: 'Are there any arrangements made for the waste movement outside the food store to the main waste storage room with controls available?',
inputType: 'select',
options: ['No', 'Yes'],
required: false,
questionOrder: 3,
validation: {
supportsComment: false,
},
},
{
questionCode: 'op_due_date',
groupCode: 'operational',
label: 'Next inspection due date',
inputType: 'date',
required: true,
questionOrder: 4,
validation: {},
},
{
questionCode: 'op_linked_file_enabled',
groupCode: 'operational',
label: 'Upload evidence (optional)',
inputType: 'checkbox',
required: false,
questionOrder: 5,
validation: {
hasLinkedFile: true,
},
},
{
questionCode: 'tech_confirm',
groupCode: 'technical',
label: 'I confirm technical checklist completion',
inputType: 'checkbox',
required: true,
questionOrder: 1,
validation: {},
},
{
questionCode: 'tech_issues',
groupCode: 'technical',
label: 'Select observed issue types',
inputType: 'multiselect',
options: ['Mechanical', 'Electrical', 'Safety', 'Housekeeping'],
required: true,
questionOrder: 2,
validation: {
allowsMultiple: true,
},
},
{
questionCode: 'tech_notes',
groupCode: 'technical',
label: 'Technical notes',
inputType: 'textarea',
required: false,
questionOrder: 3,
validation: {},
},
],
hiddenFields: [],
validation: {
requiredQuestions: [
'op_general',
'op_radio_choice',
'op_due_date',
'tech_confirm',
'tech_issues',
],
},
}
const meta = {
title: 'Forms/FormRenderer',
component: FormRenderer,
parameters: {
layout: 'fullscreen',
},
args: {
onSubmit: fn(),
onTitleAction: fn(),
schema: sampleSchema,
},
} satisfies Meta<typeof FormRenderer>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
import React from 'react'
import type { FormDefinitionSchema, FormQuestion } from '../types'
import { FieldsetTabs } from '../fields/FieldsetTabs'
import { FormGroupSection } from '../fields/FormGroupSection'
import { FormActions } from '../fields/FormActions'
import { FormTitleCard } from '../fields/FormTitleCard'
import { InlineFieldError } from '../fields/InlineFieldError'
import { QuestionRenderer } from '../fields/QuestionRenderer'
type FormValueMap = Record<string, unknown>
export type FormRendererProps = {
schema: FormDefinitionSchema
onSubmit?: (payload: { values: FormValueMap }) => void
onTitleAction?: () => void
}
const REQUIRED_ERROR = 'This field is required.'
const sortByOrder = (a: FormQuestion, b: FormQuestion) =>
(a.questionOrder ?? 0) - (b.questionOrder ?? 0)
const getRequiredError = (q: FormQuestion, value: unknown) => {
const inputType = String(q.inputType)
if (inputType === 'checkbox') {
return value === true ? undefined : REQUIRED_ERROR
}
if (inputType === 'file') {
if (Array.isArray(value) && value.length > 0) return undefined
return 'Please upload a file.'
}
if (inputType === 'multiselect' || q.validation?.allowsMultiple === true) {
if (Array.isArray(value) && value.length > 0) return undefined
return REQUIRED_ERROR
}
if (typeof value === 'string') {
return value.trim().length > 0 ? undefined : REQUIRED_ERROR
}
return value ? undefined : REQUIRED_ERROR
}
export function FormRenderer({ schema, onSubmit, onTitleAction }: FormRendererProps) {
// Defensive guard for Storybook / mis-wired stories. Prevents hard crashes.
if (!schema || !Array.isArray(schema.questions) || !Array.isArray(schema.layout)) {
return (
<div
className="empty-state"
style={{
padding: '2rem',
maxWidth: 640,
margin: '0 auto',
}}
>
<div className="empty-state-heading">FormRenderer schema missing</div>
<p>Please provide a valid `schema` prop (from `definition_schema`).</p>
</div>
)
}
const [values, setValues] = React.useState<FormValueMap>({})
const [errors, setErrors] = React.useState<Record<string, string>>({})
const [submitAttempted, setSubmitAttempted] = React.useState(false)
const [activeGroupCode, setActiveGroupCode] = React.useState<string>('')
const [query, setQuery] = React.useState('')
const setValue = React.useCallback((key: string, next: unknown) => {
setValues((prev) => ({ ...prev, [key]: next }))
}, [])
const questionsByGroup = React.useMemo(() => {
const map = new Map<string, FormQuestion[]>()
for (const q of schema.questions) {
const groupCode = q.groupCode ?? '__ungrouped__'
map.set(groupCode, [...(map.get(groupCode) ?? []), q])
}
for (const [, list] of map) {
list.sort(sortByOrder)
}
return map
}, [schema.questions])
const layoutSorted = React.useMemo(() => {
const copy = [...(schema.layout ?? [])]
copy.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return copy
}, [schema.layout])
const groupCodesFromLayout = new Set(layoutSorted.map((g) => g.groupCode))
React.useEffect(() => {
if (!layoutSorted.length) return
if (!activeGroupCode || !layoutSorted.some((g) => g.groupCode === activeGroupCode)) {
setActiveGroupCode(layoutSorted[0].groupCode)
}
}, [layoutSorted, activeGroupCode])
const onReset = () => {
setValues({})
setErrors({})
setSubmitAttempted(false)
}
const onFormSubmit = (e: React.FormEvent) => {
e.preventDefault()
const nextErrors: Record<string, string> = {}
for (const q of schema.questions) {
if (!q.required) continue
const err = getRequiredError(q, values[q.questionCode])
if (err) nextErrors[q.questionCode] = err
}
setErrors(nextErrors)
setSubmitAttempted(true)
if (Object.keys(nextErrors).length === 0) {
onSubmit?.({ values })
}
}
const errorSummaryCount = submitAttempted ? Object.keys(errors).length : 0
const isAnswered = React.useCallback((q: FormQuestion, value: unknown) => {
const inputType = String(q.inputType)
if (inputType === 'checkbox') return value === true
if (inputType === 'file') return Array.isArray(value) && value.length > 0
if (inputType === 'multiselect' || q.validation?.allowsMultiple === true) {
return Array.isArray(value) && value.length > 0
}
if (typeof value === 'string') return value.trim().length > 0
return Boolean(value)
}, [])
const tabs = React.useMemo(
() =>
layoutSorted.map((g) => ({
key: g.groupCode,
label: g.label,
})),
[layoutSorted]
)
const activeGroupQuestions = React.useMemo(() => {
const fallback = layoutSorted[0]?.groupCode ?? ''
const code = activeGroupCode || fallback
return questionsByGroup.get(code) ?? []
}, [layoutSorted, activeGroupCode, questionsByGroup])
const activeGroupQuestionsFiltered = React.useMemo(() => {
const term = query.trim().toLowerCase()
if (!term) return activeGroupQuestions
return activeGroupQuestions.filter((q) => q.label.toLowerCase().includes(term))
}, [query, activeGroupQuestions])
const activeIndex = React.useMemo(() => {
const code = activeGroupCode || layoutSorted[0]?.groupCode
return layoutSorted.findIndex((g) => g.groupCode === code)
}, [layoutSorted, activeGroupCode])
const goPrev = () => {
if (activeIndex <= 0) return
setQuery('')
setActiveGroupCode(layoutSorted[activeIndex - 1].groupCode)
}
const goNext = () => {
if (activeIndex < 0 || activeIndex >= layoutSorted.length - 1) return
setQuery('')
setActiveGroupCode(layoutSorted[activeIndex + 1].groupCode)
}
return (
<form onSubmit={onFormSubmit} className="maf-form-shell">
<FormTitleCard
title={String(schema.meta.title || 'Checklist')}
actionLabel="Action Plan"
onAction={onTitleAction}
/>
{layoutSorted.length > 0 ? (
<FieldsetTabs
tabs={tabs}
activeKey={activeGroupCode || layoutSorted[0].groupCode}
onChange={(next) => {
setQuery('')
setActiveGroupCode(next)
}}
/>
) : null}
<div className="maf-form-tools">
<input
type="text"
className="form-input maf-form-search"
placeholder="Filter questions in current section..."
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Filter questions in current section"
/>
<div className="maf-form-tools-meta">
Showing {activeGroupQuestionsFiltered.length}/{activeGroupQuestions.length}
</div>
</div>
{errorSummaryCount > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<InlineFieldError error={`Please fix ${errorSummaryCount} required field(s).`} />
</div>
) : null}
{layoutSorted.map((g, idx) => {
if ((activeGroupCode || layoutSorted[0].groupCode) !== g.groupCode) return null
return (
<FormGroupSection
key={g.groupCode}
title={g.label}
sectionIndex={idx + 1}
totalSections={layoutSorted.length}
>
{activeGroupQuestionsFiltered.length === 0 ? (
<div className="maf-empty-filter">No questions match your filter.</div>
) : null}
{activeGroupQuestionsFiltered.map((q) => (
<QuestionRenderer
key={q.questionCode}
question={q}
values={values}
error={submitAttempted ? errors[q.questionCode] : undefined}
setValue={setValue}
/>
))}
</FormGroupSection>
)
})}
{!Array.from(questionsByGroup.keys()).every((groupCode) => groupCodesFromLayout.has(groupCode)) ? (
Array.from(questionsByGroup.entries())
.filter(([groupCode]) => !groupCodesFromLayout.has(groupCode))
.map(([groupCode, groupQuestions]) => (
<FormGroupSection key={groupCode} title="Other">
{groupQuestions.map((q) => (
<QuestionRenderer
key={q.questionCode}
question={q}
values={values}
error={submitAttempted ? errors[q.questionCode] : undefined}
setValue={setValue}
/>
))}
</FormGroupSection>
))
) : null}
{layoutSorted.length > 1 ? (
<div className="maf-section-nav">
<button type="button" className="btn-ghost" onClick={goPrev} disabled={activeIndex <= 0}>
Previous Section
</button>
<button
type="button"
className="btn-ghost"
onClick={goNext}
disabled={activeIndex < 0 || activeIndex >= layoutSorted.length - 1}
>
Next Section
</button>
</div>
) : null}
<FormActions onReset={onReset} disabled={false} />
</form>
)
}
import type { ReactNode } from 'react'
import './form-controls.css'
export type CheckboxFieldProps = {
id: string
checked?: boolean
label: ReactNode
disabled?: boolean
onChange: (next: boolean) => void
}
export function CheckboxField({ id, checked, label, disabled, onChange }: CheckboxFieldProps) {
return (
<label className="maf-choice" data-checked={Boolean(checked)} htmlFor={id}>
<input
className="maf-choice-input"
id={id}
type="checkbox"
checked={Boolean(checked)}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="maf-checkbox-ui" aria-hidden="true" />
<span className="maf-choice-text">{label}</span>
</label>
)
}
import React from 'react'
export type DatePickerFieldProps = {
id: string
value?: string
disabled?: boolean
min?: string
max?: string
withTime?: boolean
onChange: (next: string) => void
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const pad = (n: number) => String(n).padStart(2, '0')
const toDatePart = (d: Date) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const parseDateInput = (value?: string) => {
if (!value) return null
const datePart = value.includes('T') ? value.split('T')[0] : value
const [yy, mm, dd] = datePart.split('-').map(Number)
if (!yy || !mm || !dd) return null
return new Date(yy, mm - 1, dd)
}
const parseTimeInput = (value?: string) => {
if (!value || !value.includes('T')) return '00:00'
const time = value.split('T')[1]?.slice(0, 5)
return time && /^\d{2}:\d{2}$/.test(time) ? time : '00:00'
}
const formatDisplay = (value?: string, withTime?: boolean) => {
const parsed = parseDateInput(value)
if (!parsed) return ''
const base = `${MONTHS[parsed.getMonth()]} ${parsed.getDate()}, ${parsed.getFullYear()}`
if (!withTime) return base
return `${base} ${parseTimeInput(value)}`
}
export function DatePickerField({
id,
value,
disabled,
min,
max,
withTime = false,
onChange,
}: DatePickerFieldProps) {
const rootRef = React.useRef<HTMLDivElement | null>(null)
const [open, setOpen] = React.useState(false)
const [hasTouchedTime, setHasTouchedTime] = React.useState(false)
const selected = React.useMemo(() => parseDateInput(value), [value])
const [viewMonth, setViewMonth] = React.useState<Date>(
selected ? new Date(selected.getFullYear(), selected.getMonth(), 1) : new Date()
)
const [draftDate, setDraftDate] = React.useState<string>(value?.split('T')[0] ?? '')
const [draftTime, setDraftTime] = React.useState<string>(parseTimeInput(value))
React.useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current) return
if (!rootRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [])
const openPanel = () => {
if (disabled) return
const parsed = parseDateInput(value)
const base = parsed ?? new Date()
setViewMonth(new Date(base.getFullYear(), base.getMonth(), 1))
setDraftDate(parsed ? toDatePart(parsed) : toDatePart(base))
setDraftTime(parseTimeInput(value))
setHasTouchedTime(false)
setOpen(true)
}
const moveMonth = (delta: number) => {
setViewMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1))
}
const buildGrid = () => {
const first = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1)
const mondayIndex = (first.getDay() + 6) % 7
const start = new Date(first)
start.setDate(first.getDate() - mondayIndex)
return Array.from({ length: 42 }).map((_, i) => {
const d = new Date(start)
d.setDate(start.getDate() + i)
return d
})
}
const todayPart = toDatePart(new Date())
const selectedPart = draftDate || (selected ? toDatePart(selected) : '')
const minPart = min ?? ''
const maxPart = max ?? ''
const display = formatDisplay(value, withTime)
const draftDateObj = parseDateInput(draftDate || undefined)
const selectedLabel = draftDateObj
? `${MONTHS[draftDateObj.getMonth()]} ${draftDateObj.getDate()}, ${draftDateObj.getFullYear()}`
: 'No date selected'
const closePanel = () => {
setOpen(false)
}
return (
<div className="maf-datepicker" ref={rootRef}>
<button
id={id}
type="button"
className="maf-datepicker-input"
onClick={openPanel}
disabled={disabled}
>
<span>{display || (withTime ? 'Select date and time' : 'Select date')}</span>
<span className="maf-datepicker-input-icon" aria-hidden>
📅
</span>
</button>
{open ? (
<div className="maf-datepicker-panel">
<div className="maf-datepicker-head">
<div className="maf-datepicker-nav">
<button type="button" className="maf-datepicker-nav-btn" onClick={() => moveMonth(-12)}>
«
</button>
<button type="button" className="maf-datepicker-nav-btn" onClick={() => moveMonth(-1)}>
</button>
</div>
<div className="maf-datepicker-title">
{MONTHS[viewMonth.getMonth()]} {viewMonth.getFullYear()}
</div>
<div className="maf-datepicker-nav">
<button type="button" className="maf-datepicker-nav-btn" onClick={() => moveMonth(1)}>
</button>
<button type="button" className="maf-datepicker-nav-btn" onClick={() => moveMonth(12)}>
»
</button>
</div>
</div>
<div className="maf-datepicker-summary">
<span className="maf-datepicker-summary-label">Selected</span>
<span className="maf-datepicker-summary-value">{selectedLabel}</span>
<button
type="button"
className="maf-datepicker-today-btn"
onClick={() => {
const today = new Date()
const dayPart = toDatePart(today)
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1))
setDraftDate(dayPart)
onChange(withTime ? `${dayPart}T${draftTime || '00:00'}` : dayPart)
if (!withTime) closePanel()
}}
>
Today
</button>
</div>
<div className="maf-datepicker-weekdays">
{WEEKDAYS.map((w) => (
<div key={w} className="maf-datepicker-weekday">
{w}
</div>
))}
</div>
<div className="maf-datepicker-grid">
{buildGrid().map((day) => {
const dayPart = toDatePart(day)
const inMonth = day.getMonth() === viewMonth.getMonth()
const isSelected = selectedPart === dayPart
const isToday = todayPart === dayPart
const blocked = Boolean((minPart && dayPart < minPart) || (maxPart && dayPart > maxPart))
return (
<button
key={dayPart}
type="button"
className="maf-datepicker-day"
data-in-month={inMonth}
data-selected={isSelected}
data-today={isToday}
disabled={blocked}
onClick={() => {
setDraftDate(dayPart)
onChange(withTime ? `${dayPart}T${draftTime || '00:00'}` : dayPart)
closePanel()
}}
>
{day.getDate()}
</button>
)
})}
</div>
{withTime ? (
<div className="maf-datepicker-time-row">
<label htmlFor={`${id}__time`} className="maf-datepicker-time-label">
Time
</label>
<input
id={`${id}__time`}
type="time"
className="form-input maf-datepicker-time-input"
value={draftTime}
onChange={(e) => {
const next = e.target.value
setDraftTime(next)
setHasTouchedTime(true)
const datePart = draftDate || toDatePart(new Date())
onChange(`${datePart}T${next || '00:00'}`)
closePanel()
}}
onBlur={() => {
if (open) closePanel()
}}
/>
</div>
) : null}
{withTime && hasTouchedTime ? (
<div className="maf-datepicker-hint">Date and time selected</div>
) : null}
</div>
) : null}
</div>
)
}
export type FieldsetTabItem = {
key: string
label: string
}
export type FieldsetTabsProps = {
tabs: FieldsetTabItem[]
activeKey: string
onChange: (key: string) => void
}
export function FieldsetTabs({ tabs, activeKey, onChange }: FieldsetTabsProps) {
return (
<div className="maf-fieldset-tabs-block">
<label className="maf-fieldset-select-wrap">
<span className="maf-fieldset-select-label">Jump to section</span>
<select
className="maf-fieldset-select"
value={activeKey}
onChange={(e) => onChange(e.target.value)}
aria-label="Jump to checklist section"
>
{tabs.map((tab) => (
<option key={tab.key} value={tab.key}>
{tab.label}
</option>
))}
</select>
</label>
<div className="maf-fieldset-tabs" role="tablist" aria-label="Checklist groups">
{tabs.map((tab) => {
const active = tab.key === activeKey
return (
<button
key={tab.key}
type="button"
className="maf-fieldset-tab"
data-active={active}
role="tab"
aria-selected={active}
onClick={() => onChange(tab.key)}
>
<span className="maf-fieldset-tab-label">{tab.label}</span>
</button>
)
})}
</div>
</div>
)
}
export type FormActionsProps = {
onReset: () => void
submitLabel?: string
resetLabel?: string
disabled?: boolean
}
export function FormActions({
onReset,
submitLabel = 'Submit',
resetLabel = 'Reset',
disabled,
}: FormActionsProps) {
return (
<div className="form-actions">
<button className="btn-primary" type="submit" disabled={disabled}>
{submitLabel}
</button>
<button className="btn-ghost" type="button" onClick={onReset} disabled={disabled}>
{resetLabel}
</button>
</div>
)
}
import React, { type ReactNode } from 'react'
import { ToggleField } from './ToggleField'
export type FormGroupSectionProps = {
title: string
sectionIndex?: number
totalSections?: number
children: ReactNode
}
export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, children }: FormGroupSectionProps) {
const [expanded, setExpanded] = React.useState(true)
return (
<section className="form-section">
<div className="maf-checklist-section-head">
<div className="maf-checklist-section-left">
<span className="maf-checklist-section-count">
{sectionIndex}/{totalSections}
</span>
<span className="maf-checklist-section-title">{title}</span>
</div>
<div className="maf-checklist-section-right">
<div className="maf-checklist-legend">
<span>Yes</span>
<span>No</span>
<span>NA</span>
</div>
<div className="maf-checklist-legend-dots" aria-hidden="true">
<span className="maf-rating-dot maf-rating-dot-yes" />
<span className="maf-rating-dot maf-rating-dot-no" />
<span className="maf-rating-dot maf-rating-dot-na" />
</div>
<ToggleField
id={`group-toggle-${title.replace(/\s+/g, '-').toLowerCase()}`}
checked={expanded}
onChange={(next) => setExpanded(next)}
onLabel="Hide"
offLabel="Show"
/>
</div>
</div>
{expanded ? children : null}
</section>
)
}
export type FormTitleCardProps = {
title: string
actionLabel?: string
onAction?: () => void
}
export function FormTitleCard({
title,
actionLabel = 'Action Plan',
onAction,
}: FormTitleCardProps) {
return (
<div className="maf-title-card">
<div className="maf-title-card-left">
<div className="maf-title-card-kicker">Audit Report</div>
<div className="maf-title-card-title">{title}</div>
</div>
<div className="maf-title-card-right">
<button type="button" className="maf-title-card-btn" onClick={onAction}>
{actionLabel}
</button>
</div>
</div>
)
}
export function InlineFieldError({ error }: { error?: string }) {
if (!error) return null
return <div className="form-error visible">{error}</div>
}
import React from 'react'
import './form-controls.css'
export type LinkedFileFieldProps = {
id: string
files: File[]
onChange: (next: File[]) => void
disabled?: boolean
multiple?: boolean
accept?: string
hint?: string
}
export function LinkedFileField({
id,
files,
onChange,
disabled,
multiple = true,
accept,
hint = 'Click to upload supporting files',
}: LinkedFileFieldProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null)
const [isDragging, setIsDragging] = React.useState(false)
const openFileDialog = () => {
if (disabled) return
inputRef.current?.click()
}
const applyFiles = (nextList: FileList | null | undefined) => {
if (!nextList) return
const next = Array.from(nextList)
onChange(multiple ? next : next.slice(0, 1))
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
applyFiles(e.dataTransfer?.files)
}
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onRemoveAt = (idx: number) => {
const next = files.filter((_, i) => i !== idx)
onChange(next)
}
return (
<div>
<div
className="upload-placeholder"
role="button"
tabIndex={0}
aria-disabled={disabled}
onClick={openFileDialog}
onKeyDown={(e) => {
if (disabled) return
if (e.key === 'Enter' || e.key === ' ') openFileDialog()
}}
onDrop={onDrop}
onDragOver={onDragOver}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
data-dragging={isDragging}
>
<div style={{ fontWeight: 700, color: 'var(--text-1)', fontSize: 'var(--text-base)' }}>
{multiple ? 'Upload files' : 'Upload file'}
</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-3)' }}>{hint}</div>
</div>
<input
ref={inputRef}
id={id}
type="file"
accept={accept}
multiple={multiple}
className="visually-hidden"
disabled={disabled}
onChange={(e) => applyFiles(e.target.files)}
/>
{files.length > 0 ? (
<div className="maf-file-list" aria-label="Selected files">
{files.map((f, idx) => (
<div key={`${f.name}-${idx}`} className="maf-file-item">
<div className="maf-file-name" title={f.name}>
{f.name}
</div>
<button
type="button"
className="maf-icon-btn"
aria-label={`Remove ${f.name}`}
onClick={() => onRemoveAt(idx)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
) : null}
</div>
)
}
export type OptionalCommentFieldProps = {
id: string
value?: string
onChange: (next: string) => void
placeholder?: string
}
export function OptionalCommentField({
id,
value,
onChange,
placeholder = 'Add an optional comment',
}: OptionalCommentFieldProps) {
return (
<div>
<label htmlFor={id} className="form-label">
Comment (optional)
</label>
<textarea
id={id}
className="form-input"
value={value ?? ''}
placeholder={placeholder}
rows={4}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
}
import './form-controls.css'
export type RadioGroupFieldProps = {
id: string
name: string
value?: string
options: string[]
disabled?: boolean
variant?: 'default' | 'rating'
onChange: (next: string) => void
}
const getRatingClass = (opt: string) => {
const key = opt.trim().toLowerCase()
if (key === 'yes') return 'maf-rating-dot-yes'
if (key === 'no') return 'maf-rating-dot-no'
if (key === 'na' || key === 'n/a') return 'maf-rating-dot-na'
return 'maf-rating-dot-default'
}
export function RadioGroupField({
id,
name,
value,
options,
disabled,
variant = 'default',
onChange,
}: RadioGroupFieldProps) {
if (variant === 'rating') {
return (
<div className="maf-rating-group" role="radiogroup" aria-label={id}>
{options.map((opt) => {
const checked = value === opt
return (
<button
key={opt}
type="button"
className={`maf-rating-dot ${getRatingClass(opt)}`}
data-checked={checked}
aria-label={opt}
disabled={disabled}
onClick={() => onChange(opt)}
/>
)
})}
</div>
)
}
return (
<div className="maf-choice-group" role="radiogroup" aria-label={id}>
{options.map((opt) => {
const checked = value === opt
return (
<label key={opt} className="maf-choice" data-checked={checked}>
<input
className="maf-choice-input"
type="radio"
name={name}
value={opt}
checked={checked}
disabled={disabled}
onChange={() => onChange(opt)}
/>
<span className="maf-radio-ui" aria-hidden="true" />
<span className="maf-choice-text">{opt}</span>
</label>
)
})}
</div>
)
}
import React from 'react'
export type SelectFieldProps = {
id: string
value?: string | string[]
options: string[]
disabled?: boolean
placeholder?: string
multiple?: boolean
onChange: (next: string | string[]) => void
}
export function SelectField({
id,
value,
options,
disabled,
placeholder,
multiple = false,
onChange,
}: SelectFieldProps) {
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState('')
const rootRef = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!multiple) return
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current) return
if (!rootRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [multiple])
const normalizedValue = multiple
? Array.isArray(value)
? value
: []
: typeof value === 'string'
? value
: ''
if (multiple) {
const selected = normalizedValue as string[]
const onSelectOption = (opt: string) => {
if (selected.includes(opt)) {
setOpen(false)
setQuery('')
return
}
onChange([...selected, opt])
setOpen(false)
setQuery('')
}
const filteredOptions = options.filter((opt) =>
opt.toLowerCase().includes(query.trim().toLowerCase())
)
return (
<div className="maf-multiselect" ref={rootRef}>
<button
type="button"
className="maf-multiselect-control"
onClick={() => setOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
>
<div className="maf-multiselect-chips">
{selected.length === 0 ? (
<span className="maf-multiselect-placeholder">{placeholder ?? 'Select options'}</span>
) : (
selected.map((item) => (
<span className="maf-multiselect-chip" key={item}>
<span className="maf-multiselect-chip-text">{item}</span>
<button
type="button"
className="maf-multiselect-chip-remove"
aria-label={`Remove ${item}`}
onClick={(e) => {
e.stopPropagation()
onChange(selected.filter((v) => v !== item))
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onChange(selected.filter((v) => v !== item))
}
}}
>
×
</button>
</span>
))
)}
</div>
<span className="maf-multiselect-chevron" aria-hidden>
</span>
</button>
{open ? (
<div className="maf-multiselect-menu" role="listbox" aria-multiselectable="true">
<input
className="maf-multiselect-search"
type="text"
value={query}
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
<div className="maf-multiselect-options">
{filteredOptions.length === 0 ? (
<div className="maf-multiselect-empty">No options found</div>
) : (
filteredOptions.map((opt) => {
const checked = selected.includes(opt)
return (
<button
key={opt}
type="button"
className="maf-multiselect-option"
data-checked={checked}
onClick={() => onSelectOption(opt)}
>
<span>{opt}</span>
</button>
)
})
)}
</div>
</div>
) : null}
</div>
)
}
return (
<div className="maf-select-wrap">
<select
id={id}
className="form-input form-select maf-select"
value={normalizedValue}
disabled={disabled}
onChange={(e) => {
onChange(e.target.value)
}}
>
{placeholder ? <option value="">{placeholder}</option> : null}
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<span className="maf-select-chevron" aria-hidden>
</span>
</div>
)
}
export type TextInputFieldProps = {
id: string
value?: string
disabled?: boolean
placeholder?: string
onChange: (next: string) => void
}
export function TextInputField({
id,
value,
disabled,
placeholder,
onChange,
}: TextInputFieldProps) {
return (
<input
id={id}
className="form-input"
type="text"
value={value ?? ''}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
)
}
export type TextareaFieldProps = {
id: string
value?: string
disabled?: boolean
placeholder?: string
rows?: number
onChange: (next: string) => void
}
export function TextareaField({
id,
value,
disabled,
placeholder,
rows = 4,
onChange,
}: TextareaFieldProps) {
return (
<textarea
id={id}
className="form-input"
value={value ?? ''}
disabled={disabled}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange(e.target.value)}
/>
)
}
export type ToggleFieldProps = {
id: string
checked: boolean
onChange: (next: boolean) => void
onLabel?: string
offLabel?: string
disabled?: boolean
}
export function ToggleField({
id,
checked,
onChange,
onLabel = 'Show',
offLabel = 'Hide',
disabled,
}: ToggleFieldProps) {
return (
<label className="maf-toggle-wrap" htmlFor={id}>
<input
id={id}
className="maf-toggle-input"
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="maf-toggle-pill" data-checked={checked}>
<span className="maf-toggle-text">{checked ? onLabel : offLabel}</span>
</span>
</label>
)
}
import React from 'react'
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { CheckboxField } from './CheckboxField'
import { DatePickerField } from './DatePickerField'
import { LinkedFileField } from './LinkedFileField'
import { OptionalCommentField } from './OptionalCommentField'
import { RadioGroupField } from './RadioGroupField'
import { SelectField } from './SelectField'
import { TextInputField } from './TextInputField'
import { TextareaField } from './TextareaField'
import { ToggleField } from './ToggleField'
const meta = {
title: 'Forms/Fields',
} satisfies Meta
export default meta
type Story = StoryObj
type RadioStoryArgs = { variant?: 'default' | 'rating' }
type DatePickerStoryArgs = { withTime?: boolean }
export const TextInput: Story = {
render: () => {
const [value, setValue] = React.useState('')
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-text">
Text input *
</label>
<TextInputField id="field-text" value={value} onChange={setValue} placeholder="Type here…" />
</div>
</div>
)
},
}
export const Textarea: Story = {
render: () => {
const [value, setValue] = React.useState('Initial note')
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-textarea">
Textarea
</label>
<TextareaField id="field-textarea" value={value} onChange={setValue} rows={5} />
</div>
</div>
)
},
}
export const RadioGroup: Story = {
render: (args: RadioStoryArgs) => {
const isRating = args.variant === 'rating'
const [value, setValue] = React.useState<string | undefined>(isRating ? 'Yes' : undefined)
const options = isRating ? ['Yes', 'No', 'NA'] : ['Pass', 'Fail']
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label">{isRating ? 'Rating (Yes / No / NA)' : 'Radio group'}</label>
{isRating ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }}>
<span style={{ fontSize: '0.82rem', color: 'var(--text-2)', fontWeight: 600 }}>Yes</span>
<span style={{ fontSize: '0.82rem', color: 'var(--text-2)', fontWeight: 600 }}>No</span>
<span style={{ fontSize: '0.82rem', color: 'var(--text-2)', fontWeight: 600 }}>NA</span>
</div>
) : null}
<RadioGroupField
key={String(args.variant)}
id={isRating ? 'field-rating' : 'field-radio'}
name={isRating ? 'field-rating' : 'field-radio'}
value={value}
options={options}
variant={args.variant}
onChange={setValue}
/>
</div>
</div>
)
},
args: {
variant: 'default',
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'rating'],
},
},
}
export const Select: Story = {
render: () => {
const [value, setValue] = React.useState<string | undefined>('No')
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-select">
Select
</label>
<SelectField
id="field-select"
value={value}
options={['No', 'Yes']}
placeholder="Select…"
onChange={(next) => setValue(Array.isArray(next) ? next[0] : next)}
/>
</div>
</div>
)
},
}
export const MultiSelect: Story = {
render: () => {
const [value, setValue] = React.useState<string[]>(['No'])
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-multi-select">
Multi Select
</label>
<SelectField
id="field-multi-select"
value={value}
options={['No', 'Yes', 'N/A']}
multiple
onChange={(next) => setValue(Array.isArray(next) ? next : [])}
/>
</div>
</div>
)
},
}
export const DatePicker: Story = {
render: (args: DatePickerStoryArgs) => {
const [value, setValue] = React.useState(args.withTime ? '2026-03-23T14:30' : '2026-03-23')
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-datepicker">
{args.withTime ? 'Date Time Picker' : 'Date Picker'}
</label>
<DatePickerField
id="field-datepicker"
value={value}
min="2020-01-01"
max="2035-12-31"
withTime={Boolean(args.withTime)}
onChange={setValue}
/>
</div>
</div>
)
},
args: {
withTime: false,
},
argTypes: {
withTime: {
control: 'boolean',
},
},
}
export const Checkbox: Story = {
render: () => {
const [checked, setChecked] = React.useState(false)
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<CheckboxField id="field-checkbox" checked={checked} label="Confirm checkbox" onChange={setChecked} />
</div>
</div>
)
},
}
export const ToggleButton: Story = {
render: () => {
const [checked, setChecked] = React.useState(false)
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-toggle">
Toggle Button
</label>
<ToggleField id="field-toggle" checked={checked} onChange={setChecked} onLabel="Show" offLabel="Hide" />
</div>
</div>
)
},
}
export const OptionalComment: Story = {
render: () => {
const [value, setValue] = React.useState('')
return (
<div style={{ maxWidth: 520 }}>
<OptionalCommentField id="field-comment" value={value} onChange={setValue} />
</div>
)
},
}
export const LinkedFile: Story = {
render: () => {
const [files, setFiles] = React.useState<File[]>([])
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<div className="form-label" style={{ marginBottom: '0.35rem' }}>
Linked file
</div>
<LinkedFileField id="field-file" files={files} onChange={setFiles} multiple accept="*/*" />
</div>
</div>
)
},
}
export type FormTypeEnum = 'audit' | 'inspection' | 'checklist'
export type FormInputType =
| 'text'
| 'textarea'
| 'radio'
| 'select'
| 'multiselect'
| 'date'
| 'datetime'
| 'datetime-local'
| 'checkbox'
| 'file'
| 'hidden'
| string
export type FormQuestionValidation = {
supportsComment?: boolean
hasLinkedFile?: boolean
allowsMultiple?: boolean
// Keep room for future flags without breaking strict typing.
[key: string]: unknown
}
export type FormQuestion = {
questionCode: string
groupCode?: string
label: string
inputType: FormInputType
required?: boolean
questionOrder?: number
options?: string[]
validation?: FormQuestionValidation
}
export type FormGroupLayout = {
groupCode: string
label: string
order?: number
}
export type FormDefinitionSchema = {
meta: {
formCode: string
sourceId?: string
title: string
categoryIds?: string[]
version?: number
[key: string]: unknown
}
layout: FormGroupLayout[]
questions: FormQuestion[]
hiddenFields?: unknown[]
validation?: {
requiredQuestions?: string[]
[key: string]: unknown
}
}
...@@ -25,9 +25,15 @@ ...@@ -25,9 +25,15 @@
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"storybook": "^10.3.1", "storybook": "^10.3.1",
"@storybook/nextjs": "^10.3.1", "@storybook/nextjs-vite": "^10.3.1",
"@chromatic-com/storybook": "^5.0.2",
"@storybook/addon-vitest": "^10.3.1",
"@storybook/addon-a11y": "^10.3.1", "@storybook/addon-a11y": "^10.3.1",
"@storybook/addon-docs": "^10.3.1", "@storybook/addon-docs": "^10.3.1",
"@storybook/addon-onboarding": "^10.3.1" "vite": "^8.0.1",
"vitest": "^4.1.0",
"playwright": "^1.58.2",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0"
} }
} }
This source diff could not be displayed because it is too large. You can view the blob instead.
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
// MAF-styled login dialog (mock auth).
'use client';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
export default function MafLoginModal({
open,
defaultAccountType = 'employee',
onClose,
onSuccess,
}: {
open: boolean;
defaultAccountType?: 'employee' | 'contractor';
onClose?: () => void;
onSuccess: (user: MafUser) => void;
}) {
const [accountType, setAccountType] = useState<'employee' | 'contractor'>(defaultAccountType);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const emailRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setAccountType(defaultAccountType);
setError(null);
setLoading(false);
// focus first field for accessibility.
setTimeout(() => emailRef.current?.focus(), 0);
}, [open, defaultAccountType]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose?.();
};
document.addEventListener('keydown', onKeyDown);
// Prevent background scroll.
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = prev;
};
}, [open, onClose]);
const tabDefs = useMemo(
() => [
{ key: 'employee' as const, label: 'MAF Employee' },
{ key: 'contractor' as const, label: 'Contractor / Supplier' },
],
[]
);
if (!open) return null;
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!email.trim()) {
setError('Please enter your email.');
return;
}
if (!password.trim()) {
setError('Password is required.');
return;
}
setLoading(true);
setError(null);
// Mock latency to mimic UX.
await new Promise((r) => setTimeout(r, 600));
const base = (email.split('@')[0] || email).replace(/[^a-zA-Z0-9]/g, '');
const initials = (base.slice(0, 2).toUpperCase() || 'MAF').padEnd(2, 'F');
const user: MafUser = {
initials,
name: email.includes('@') ? email.split('@')[0] : email,
role: accountType === 'employee' ? 'Admin' : 'Contractor',
};
setLoading(false);
onSuccess(user);
onClose?.();
};
return (
<div
className="fixed inset-0 z-[2000] bg-black/60 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Login"
>
<div className="absolute inset-0" onClick={onClose} aria-hidden="true" />
<MafCard className="relative w-full max-w-[560px] p-5 md:p-7">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2
className="text-[1.65rem] leading-tight font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Welcome back
</h2>
<p className="text-text-2 text-sm mt-1 font-medium">Choose your account type to continue</p>
</div>
<button
type="button"
className="w-10 h-10 rounded-[var(--r-sm)] border border-gold-border bg-white/0 hover:bg-white/5 text-text-2"
aria-label="Close login dialog"
onClick={onClose}
>
×
</button>
</div>
<div className="mb-4 flex gap-3" role="tablist" aria-label="Login type">
{tabDefs.map((t) => {
const active = t.key === accountType;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
className={[
'px-4 py-2 rounded-[var(--r-sm)] text-sm font-semibold border transition-colors',
active ? 'bg-gold-pale border-gold-border-strong text-gold' : 'bg-surface-raised border-gold-border text-text-2 hover:text-text-1',
].join(' ')}
onClick={() => setAccountType(t.key)}
>
{t.label}
</button>
);
})}
</div>
<form onSubmit={submit} className="space-y-4" aria-label="Sign in form">
<div className="space-y-2">
<label className="block text-text-3 text-xs uppercase tracking-[0.08em] font-semibold" htmlFor="loginEmail">
Company Email
</label>
<input
ref={emailRef}
id="loginEmail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
autoComplete="email"
className="w-full h-[44px] px-3 rounded-[var(--r-sm)] border border-gold-border bg-surface-raised text-text-1 outline-none focus:border-gold-border-strong"
/>
</div>
<div className="space-y-2">
<label className="block text-text-3 text-xs uppercase tracking-[0.08em] font-semibold" htmlFor="loginPassword">
Password
</label>
<input
id="loginPassword"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
autoComplete="current-password"
className="w-full h-[44px] px-3 rounded-[var(--r-sm)] border border-gold-border bg-surface-raised text-text-1 outline-none focus:border-gold-border-strong"
/>
</div>
{error && (
<div className="text-red-400 text-sm font-semibold" role="alert">
{error}
</div>
)}
<div className="flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-text-2 text-sm select-none">
<input type="checkbox" className="accent-[#C9A961]" />
Remember me
</label>
<button
type="button"
className="text-gold text-sm font-semibold hover:opacity-90"
onClick={() => setError('Password reset is mocked in this demo.')}
>
Forgot password?
</button>
</div>
<div className="pt-2">
<MafButton variant="primary" size="medium" loading={loading} disabled={loading} className="w-full">
{loading ? 'Signing in...' : 'Log In'}
</MafButton>
</div>
<div className="text-text-2 text-sm pt-1">
Need help accessing your account?{' '}
<button type="button" className="text-gold font-semibold hover:opacity-90" onClick={() => setError('Support is mocked.')}>
Contact support →
</button>
</div>
</form>
</MafCard>
</div>
);
}
'use client';
import { ReactNode } from 'react';
import useAuth from '@/src/maf-ui/auth/useAuth';
import MafLoginModal from '@/src/maf-ui/auth/MafLoginModal';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
export default function MafRequireAuth({
render,
fallbackAccountType,
}: {
render: (user: MafUser) => ReactNode;
fallbackAccountType?: 'employee' | 'contractor';
}) {
const { status, user, login } = useAuth();
if (status === 'loading') {
// Keep layout stable while auth state initializes.
return null;
}
if (!user) {
return (
<MafLoginModal
open
defaultAccountType={fallbackAccountType ?? 'employee'}
onClose={() => {}}
onSuccess={(u) => {
// Ensure storage is set via hook login (we only need to route after).
login({
email: `${u.name}@company.com`,
accountType: u.role === 'Admin' ? 'employee' : 'contractor',
});
}}
/>
);
}
return <>{render(user)}</>;
}
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
export type AuthStatus = 'loading' | 'guest' | 'authed';
const STORAGE_USER_KEY = 'maf_user';
function safeParseUser(raw: string | null): MafUser | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as MafUser;
if (!parsed?.initials || !parsed?.name || !parsed?.role) return null;
return parsed;
} catch {
return null;
}
}
function makeInitials(email: string) {
const base = (email.split('@')[0] || email).replace(/[^a-zA-Z0-9]/g, '');
const a = base.slice(0, 2).toUpperCase();
return a.length ? a : 'MAF';
}
export default function useAuth() {
const [status, setStatus] = useState<AuthStatus>('loading');
const [user, setUser] = useState<MafUser | null>(null);
useEffect(() => {
const u = safeParseUser(localStorage.getItem(STORAGE_USER_KEY));
if (u) {
setUser(u);
setStatus('authed');
} else {
setUser(null);
setStatus('guest');
}
}, []);
const logout = useCallback(() => {
try {
localStorage.removeItem(STORAGE_USER_KEY);
} catch {
// ignore
}
setUser(null);
setStatus('guest');
}, []);
const login = useCallback((params: { email: string; accountType: 'employee' | 'contractor' }) => {
const email = params.email.trim();
const initials = makeInitials(email);
const nextUser: MafUser = {
initials,
name: email.includes('@') ? email.split('@')[0] : email,
role: params.accountType === 'employee' ? 'Admin' : 'Contractor',
};
try {
localStorage.setItem(STORAGE_USER_KEY, JSON.stringify(nextUser));
} catch {
// ignore
}
setUser(nextUser);
setStatus('authed');
}, []);
const api = useMemo(
() => ({
status,
user,
authed: status === 'authed',
guest: status === 'guest',
login,
logout,
}),
[status, user, login, logout]
);
return api;
}
import { HTMLAttributes } from 'react';
export type MafBadgeTone = 'info' | 'success' | 'warning' | 'action' | 'draft';
export type MafBadgeProps = {
tone?: MafBadgeTone;
} & HTMLAttributes<HTMLSpanElement>;
function toneClasses(tone: MafBadgeTone) {
switch (tone) {
case 'success':
return 'bg-[rgba(34,197,94,0.12)] border border-[rgba(34,197,94,0.25)] text-[#22c55e]';
case 'warning':
return 'bg-[rgba(245,158,11,0.12)] border border-[rgba(245,158,11,0.25)] text-[#f59e0b]';
case 'action':
return 'bg-[rgba(139,21,56,0.18)] border border-[rgba(201,169,97,0.25)] text-[#dfc07a]';
case 'draft':
return 'bg-[rgba(148,163,184,0.12)] border border-[rgba(148,163,184,0.25)] text-[#94a3b8]';
case 'info':
default:
return 'bg-[rgba(59,130,246,0.12)] border border-[rgba(59,130,246,0.25)] text-[#3b82f6]';
}
}
export default function MafBadge({ tone = 'info', className, ...rest }: MafBadgeProps) {
return (
<span
className={[
'inline-flex items-center px-3 min-h-[28px] rounded-[var(--r-sm)] text-xs font-semibold border',
'tracking-[0.03em]',
toneClasses(tone),
className ?? '',
].join(' ')}
{...rest}
/>
);
}
import { ButtonHTMLAttributes, ReactNode } from 'react';
export type MafButtonVariant = 'primary' | 'secondary' | 'ghost';
export type MafButtonSize = 'small' | 'medium' | 'large';
export type MafButtonProps = {
variant?: MafButtonVariant;
size?: MafButtonSize;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
loading?: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement>;
function sizeClasses(size: MafButtonSize) {
switch (size) {
case 'small':
return 'min-h-[44px] px-4 text-sm';
case 'large':
return 'min-h-[44px] px-6 text-base';
case 'medium':
default:
return 'min-h-[44px] px-5 text-sm';
}
}
function variantClasses(variant: MafButtonVariant) {
switch (variant) {
case 'primary':
return 'bg-gradient-to-r from-maroon to-maroon-bright text-white border-none shadow-[var(--shadow-maroon)]';
case 'ghost':
return 'bg-transparent text-gold border border-gold-border hover:bg-gold-pale hover:border-gold-border-strong';
case 'secondary':
default:
return 'bg-surface-raised text-text-1 border border-gold-border hover:bg-surface-overlay hover:border-gold-border-strong';
}
}
export default function MafButton({
variant = 'primary',
size = 'medium',
leftIcon,
rightIcon,
loading,
disabled,
className,
children,
...rest
}: MafButtonProps) {
const isDisabled = disabled || loading;
return (
<button
type="button"
disabled={isDisabled}
className={[
'inline-flex items-center justify-center gap-2 rounded-[var(--r-sm)] font-semibold transition-colors',
sizeClasses(size),
variantClasses(variant),
isDisabled ? 'opacity-80 cursor-not-allowed' : 'cursor-pointer',
className ?? '',
].join(' ')}
{...rest}
>
{loading ? (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full border-2 border-white/40 border-t-white animate-spin" aria-label="Loading" />
) : (
<>
{leftIcon}
<span>{children}</span>
{rightIcon}
</>
)}
</button>
);
}
import { HTMLAttributes, ReactNode } from 'react';
export type MafSurfaceProps = {
as?: 'div' | 'section' | 'article';
children: ReactNode;
} & HTMLAttributes<HTMLDivElement>;
export function MafSurface({ as = 'div', children, className, ...rest }: MafSurfaceProps) {
// Use `any` for the polymorphic element to avoid `JSX.IntrinsicElements` typing issues.
const Component = as as any;
return (
<Component
{...rest}
className={[
'rounded-[var(--r-md)] border border-gold-border bg-surface-raised text-text-1',
className ?? '',
].join(' ')}
>
{children}
</Component>
);
}
export function MafCard({
children,
className,
...rest
}: {
children: ReactNode;
className?: string;
} & HTMLAttributes<HTMLDivElement>) {
return (
<MafSurface className={className ?? ''} {...rest}>
{children}
</MafSurface>
);
}
'use client';
import { ReactNode, useMemo, useState } from 'react';
export type MafTabItem = {
key: string;
label: string;
panel: ReactNode;
};
export default function MafTabs({
items,
defaultActiveKey,
}: {
items: MafTabItem[];
defaultActiveKey?: string;
}) {
const initial = useMemo(() => {
if (defaultActiveKey && items.some((i) => i.key === defaultActiveKey)) return defaultActiveKey;
return items[0]?.key;
}, [defaultActiveKey, items]);
const [activeKey, setActiveKey] = useState<string | undefined>(initial);
return (
<div className="w-full">
<div className="flex gap-[4px] overflow-x-auto pb-1">
{items.map((it) => {
const active = it.key === activeKey;
return (
<button
key={it.key}
type="button"
className={[
'flex-shrink-0 px-4 py-2 rounded-[var(--r-sm)] border transition-colors text-xs font-semibold',
active ? 'bg-gold-pale border-gold-border-strong text-gold' : 'bg-surface-raised border-gold-border text-text-2 hover:text-text-1 hover:border-gold-border-strong',
].join(' ')}
aria-pressed={active}
onClick={() => setActiveKey(it.key)}
>
{it.label}
</button>
);
})}
</div>
<div className="mt-4">{items.find((i) => i.key === activeKey)?.panel}</div>
</div>
);
}
// MAF-UIS app shell: fixed header, responsive sidebar/drawer, and mobile bottom nav.
'use client';
import { ReactNode, useMemo, useState } from 'react';
import MafBottomNav, { MafBottomNavItem } from './MafBottomNav';
import MafDrawer, { MafDrawerItem } from './MafDrawer';
import MafHeader from './MafHeader';
import MafNotificationCenter from './MafNotificationCenter';
import MafSidebar, { MafNavItem } from './MafSidebar';
import MafUserMenu, { MafUser } from './MafUserMenu';
export type MafAppShellProps = {
children: ReactNode;
user: MafUser;
activeView?: string;
onNavigate?: (view: string) => void;
};
function Icon({ children }: { children: ReactNode }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
}
export default function MafAppShell({ children, user, activeView = 'welcome', onNavigate }: MafAppShellProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const [notifUnreadCount, setNotifUnreadCount] = useState(0);
const linkToView = (link: string) => {
// Map notification anchors used by MAF-UIS into our Next route "view" strings.
switch (link) {
case '#incidents':
return 'incidents';
case '#inspection':
return 'modules';
case '#admin':
return 'modules';
case '#profile':
return 'profile';
case '#settings':
return 'settings';
case '#notification-center':
return 'notification-center';
default:
return 'modules';
}
};
const navItems: MafNavItem[] = useMemo(
() => [
{
view: 'welcome',
label: 'Home',
icon: (
<Icon>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</Icon>
),
},
{
view: 'modules',
label: 'Modules',
icon: (
<Icon>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</Icon>
),
},
{
view: 'incidents',
label: 'Incidents',
icon: (
<Icon>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</Icon>
),
},
{
view: 'permit',
label: 'Permit',
icon: (
<Icon>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</Icon>
),
},
{
view: 'settings',
label: 'Settings',
icon: (
<Icon>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</Icon>
),
},
],
[]
);
const drawerItems: MafDrawerItem[] = useMemo(
() =>
navItems.map((x) => ({
view: x.view,
label: x.label,
icon: x.icon,
})),
[navItems]
);
const bottomItems: MafBottomNavItem[] = useMemo(
() => [
{
view: 'welcome',
label: 'Home',
icon: (
<Icon>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</Icon>
),
},
{
view: 'modules',
label: 'Modules',
icon: (
<Icon>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</Icon>
),
},
{
view: 'incidents',
label: 'Incidents',
icon: (
<Icon>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</Icon>
),
},
{
view: 'notification-center',
label: 'Alerts',
icon: (
<Icon>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</Icon>
),
},
{
view: 'profile',
label: 'Profile',
icon: (
<Icon>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</Icon>
),
},
],
[]
);
return (
<div className="min-h-screen">
<MafSidebar items={navItems} activeView={activeView} onNavigate={onNavigate} />
<MafHeader
onOpenDrawer={() => setDrawerOpen(true)}
drawerOpen={drawerOpen}
user={user}
activeView={activeView}
onNavigate={onNavigate}
notifOpen={notifOpen}
notifUnreadCount={notifUnreadCount}
onToggleNotifications={() => setNotifOpen((v) => !v)}
/>
<MafDrawer
open={drawerOpen}
items={drawerItems}
activeView={activeView}
onClose={() => setDrawerOpen(false)}
onNavigate={onNavigate}
/>
<main
id="main-content"
tabIndex={-1}
className="relative flex-1 md:pl-[var(--sidebar-width)]"
>
{children}
</main>
<MafBottomNav items={bottomItems} activeView={activeView} onNavigate={onNavigate} />
<MafNotificationCenter
open={notifOpen}
onClose={() => setNotifOpen(false)}
unreadCount={notifUnreadCount}
onUnreadCountChange={setNotifUnreadCount}
onNavigate={(link) => {
const view = linkToView(link);
onNavigate?.(view);
}}
variant="dropdown"
/>
</div>
);
}
// Mobile bottom navigation.
'use client';
import { ReactNode } from 'react';
export type MafBottomNavItem = {
view: string;
label: string;
icon?: ReactNode;
};
export default function MafBottomNav({
items,
activeView,
onNavigate,
}: {
items: MafBottomNavItem[];
activeView?: string;
onNavigate?: (view: string) => void;
}) {
return (
<nav
className="md:hidden fixed bottom-0 left-0 right-0 z-[998] h-[var(--bottom-nav-height)] bg-surface-raised border-t border-gold-border"
aria-label="Primary actions"
>
<div className="h-full flex items-stretch justify-around px-2">
{items.map((it) => {
const active = activeView && it.view === activeView;
return (
<a
key={it.view}
href="#"
className={[
'flex flex-col items-center justify-center flex-1 gap-1 rounded-[var(--r-sm)] transition-colors',
active ? 'text-maroon bg-gold-pale/40' : 'text-text-2 hover:text-text-1',
].join(' ')}
aria-current={active ? 'page' : undefined}
onClick={(e) => {
e.preventDefault();
onNavigate?.(it.view);
}}
>
<div className="w-6 h-6 text-gold">{it.icon}</div>
<span className="text-[11px] font-semibold">{it.label}</span>
</a>
);
})}
</div>
</nav>
);
}
// Mobile right drawer navigation.
'use client';
import { ReactNode, useEffect, useRef } from 'react';
export type MafDrawerItem = {
view: string;
label: string;
icon?: ReactNode;
};
export default function MafDrawer({
open,
items,
onClose,
activeView,
onNavigate,
}: {
open: boolean;
items: MafDrawerItem[];
onClose: () => void;
activeView?: string;
onNavigate?: (view: string) => void;
}) {
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
// Move focus to the close control for keyboard users.
closeBtnRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
return (
<>
<div
className={[
'fixed inset-0 z-[1000] bg-black/0 transition-opacity',
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none',
].join(' ')}
id="mafDrawerBackdrop"
aria-hidden="true"
onClick={onClose}
/>
<aside
id="mafDrawer"
className={[
'fixed top-0 right-0 bottom-0 z-[1001] border-l border-gold-border bg-surface-raised transition-transform',
open ? 'translate-x-0' : 'translate-x-full',
'w-[min(320px,90vw)]',
].join(' ')}
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
aria-hidden={!open}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gold-border">
<span className="text-text-3 text-xs uppercase tracking-[0.12em] font-semibold">Navigation</span>
<button
type="button"
ref={closeBtnRef}
className="w-10 h-10 flex items-center justify-center rounded-[var(--r-sm)] hover:bg-white/5 transition-colors text-text-2"
aria-label="Close menu"
onClick={onClose}
>
×
</button>
</div>
<nav className="px-3 py-3 flex flex-col gap-1" aria-label="Mobile navigation">
{items.map((it) => {
const active = activeView && it.view === activeView;
return (
<a
key={it.view}
href="#"
className={[
'drawer-item flex items-center gap-3 px-3 py-2 rounded-[var(--r-sm)] text-sm font-semibold transition-colors',
active ? 'bg-gold-pale text-maroon' : 'text-text-2 hover:bg-white/5 hover:text-text-1',
].join(' ')}
aria-current={active ? 'page' : undefined}
onClick={(e) => {
e.preventDefault();
onNavigate?.(it.view);
onClose();
}}
>
<span className="w-9 h-9 rounded-[var(--r-sm)] border border-gold-border bg-white/0 flex items-center justify-center text-gold">
{it.icon}
</span>
{it.label}
</a>
);
})}
</nav>
<div className="px-3 pb-4 pt-2 border-t border-gold-border mt-auto">
<a
href="#"
className="w-full flex items-center justify-center min-h-12 rounded-[var(--r-sm)] border border-gold-border bg-white/0 text-gold hover:bg-white/5 font-semibold text-sm"
onClick={(e) => {
e.preventDefault();
onClose();
}}
>
Support Center
</a>
<a
href="#"
className="w-full flex items-center justify-center min-h-12 rounded-[var(--r-sm)] mt-2 bg-gradient-to-r from-maroon to-maroon-bright text-white font-semibold text-sm hover:opacity-95"
onClick={(e) => {
e.preventDefault();
onClose();
}}
>
Login to Gateway
</a>
</div>
</aside>
</>
);
}
// Fixed header (logo, search, actions).
'use client';
import MafThemeToggle from './MafThemeToggle';
import MafUserMenu, { MafUser } from './MafUserMenu';
import { ReactNode } from 'react';
function IconWrap({ children }: { children: ReactNode }) {
return (
<button
type="button"
className="w-[44px] h-[44px] rounded-[var(--r-sm)] flex items-center justify-center text-text-2 hover:bg-gold-pale hover:text-gold transition-colors"
>
{children}
</button>
);
}
export default function MafHeader({
onOpenDrawer,
drawerOpen,
user,
activeView,
onNavigate,
notifOpen,
notifUnreadCount,
onToggleNotifications,
}: {
onOpenDrawer: () => void;
drawerOpen: boolean;
user: MafUser;
activeView?: string;
onNavigate?: (view: string) => void;
notifOpen?: boolean;
notifUnreadCount?: number;
onToggleNotifications?: () => void;
}) {
return (
<header
className="fixed top-0 left-0 right-0 md:left-[var(--sidebar-width)] z-[1000] h-[var(--header-height)] bg-surface-overlay/90 backdrop-blur-[20px] border-b border-gold-border flex items-center justify-between gap-3 px-3"
style={{
paddingTop: `max(0.35rem, env(safe-area-inset-top))`,
paddingBottom: `max(0.35rem, env(safe-area-inset-bottom))`,
paddingLeft: `max(0.75rem, env(safe-area-inset-left))`,
paddingRight: `max(0.75rem, env(safe-area-inset-right))`,
}}
>
<div className="flex items-center gap-3 min-w-0">
<button
type="button"
className="md:hidden w-[44px] h-[44px] min-w-[44px] min-h-[44px] rounded-[var(--r-sm)] bg-[var(--white)] border border-gold-border flex items-center justify-center text-text-1"
aria-label="Open menu"
aria-expanded={drawerOpen}
aria-controls="mafDrawer"
aria-haspopup="dialog"
onClick={onOpenDrawer}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<a
href="#"
className="flex items-center gap-2 text-text-1 no-underline"
onClick={(e) => {
e.preventDefault();
onNavigate?.('welcome');
}}
>
<div
className="w-9 h-9 rounded-[var(--r-sm)] bg-gradient-to-br from-maroon to-maroon-bright flex items-center justify-center shadow-[var(--shadow-maroon)] relative overflow-hidden"
aria-hidden="true"
>
<span
className="text-white font-bold text-sm"
style={{ fontFamily: 'var(--font-display)' }}
>
M
</span>
</div>
<span className="hidden sm:block text-sm font-bold tracking-wide text-text-1">MAF Gateway</span>
</a>
</div>
<div className="flex-1 hidden lg:flex items-center justify-center px-6 min-w-0">
<div className="flex items-center gap-2 w-full max-w-[520px] bg-[var(--ink-3)]/60 border border-gold-border rounded-[var(--r-sm)] px-3 h-[44px]">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="search"
placeholder="Search modules…"
className="w-full bg-transparent outline-none text-text-1 placeholder:text-text-3 text-sm"
aria-label="Search modules and pages"
/>
</div>
</div>
<div className="flex items-center gap-2 min-w-0">
<MafThemeToggle />
<button
type="button"
className="icon-btn notif-trigger-wrap"
id="notifTrigger"
aria-label="Notifications"
aria-expanded={notifOpen ?? false}
aria-haspopup="true"
title="Notifications"
onClick={() => onToggleNotifications?.()}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
<span className="badge" id="notifBadge" style={{ display: (notifUnreadCount ?? 0) ? '' : 'none' }}>
{(notifUnreadCount ?? 0) > 99 ? '99+' : notifUnreadCount ?? 0}
</span>
</button>
<MafUserMenu
user={user}
onProfile={() => onNavigate?.('profile')}
onSettings={() => onNavigate?.('settings')}
onSignOut={() => {
// Placeholder for sign out flow.
}}
/>
</div>
</header>
);
}
// Desktop sidebar navigation.
'use client';
import { ReactNode } from 'react';
export type MafNavItem = {
view: string;
label: string;
icon?: ReactNode;
};
export default function MafSidebar({
items,
activeView,
onNavigate,
}: {
items: MafNavItem[];
activeView?: string;
onNavigate?: (view: string) => void;
}) {
return (
<nav
className="hidden md:flex fixed top-0 left-0 bottom-0 z-[999] w-[var(--sidebar-width)] bg-surface-raised border-r border-gold-border pt-[var(--header-height)] overflow-y-auto"
aria-label="Main navigation"
>
<div className="w-full px-3 pb-3">
<div className="h-[var(--header-height)] flex items-center">
<div className="text-text-3 text-xs uppercase tracking-[0.08em] font-semibold px-2">
Navigation
</div>
</div>
<div className="flex flex-col gap-1">
{items.map((it) => {
const active = activeView && it.view === activeView;
return (
<a
key={it.view}
href="#"
className={[
'flex items-center gap-3 px-3 py-2 rounded-[var(--r-sm)] text-sm font-semibold transition-colors',
active ? 'bg-gold-pale text-maroon' : 'text-text-2 hover:bg-white/5 hover:text-text-1',
].join(' ')}
aria-current={active ? 'page' : undefined}
onClick={(e) => {
e.preventDefault();
onNavigate?.(it.view);
}}
>
<span className="w-9 h-9 rounded-[var(--r-sm)] border border-gold-border bg-white/0 flex items-center justify-center text-gold">
{it.icon}
</span>
<span className="whitespace-nowrap">{it.label}</span>
</a>
);
})}
</div>
</div>
</nav>
);
}
// MAF theme toggle (dark-first) with persistence via localStorage.
// This component is client-only because it touches `window`/`document`.
'use client';
import { useEffect, useMemo, useState } from 'react';
type MafTheme = 'light' | 'dark';
const STORAGE_KEY = 'maf_theme';
function getStoredTheme(): MafTheme {
try {
const v = localStorage.getItem(STORAGE_KEY);
return v === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
}
export default function MafThemeToggle() {
const [theme, setTheme] = useState<MafTheme>(() => {
if (typeof window === 'undefined') return 'dark';
const v = localStorage.getItem(STORAGE_KEY);
return v === 'light' ? 'light' : 'dark';
});
useEffect(() => {
// Keep html[data-theme] in sync with persisted theme.
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
}, []);
const nextTheme: MafTheme = useMemo(() => (theme === 'light' ? 'dark' : 'light'), [theme]);
return (
<button
type="button"
id="themeToggle"
className="theme-toggle"
aria-label={theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
aria-pressed={theme === 'light'}
onClick={() => {
const t = nextTheme;
setTheme(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
// ignore
}
document.documentElement.setAttribute('data-theme', t);
}}
>
<span className="icon-dark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
<span className="icon-light" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
</span>
</button>
);
}
// User dropdown menu anchored in the header.
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
export type MafUser = {
initials: string;
name: string;
role: string;
};
export type MafUserMenuProps = {
user: MafUser;
onProfile?: () => void;
onSettings?: () => void;
onSignOut?: () => void;
};
export default function MafUserMenu({ user, onProfile, onSettings, onSignOut }: MafUserMenuProps) {
const [open, setOpen] = useState(false);
const id = useId();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const menuId = useMemo(() => `userMenu-${id.replace(/:/g, '')}`, [id]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
};
const onPointerDown = (e: PointerEvent) => {
const el = e.target as Node | null;
if (!el) return;
if (!wrapRef.current) return;
if (!wrapRef.current.contains(el)) {
setOpen(false);
}
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('pointerdown', onPointerDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('pointerdown', onPointerDown);
};
}, [open]);
return (
<div ref={wrapRef} className="relative">
<button
ref={triggerRef}
type="button"
className="flex items-center gap-2 px-2.5 min-h-[44px] rounded-full border border-gold-border bg-white/0 hover:bg-white/5 transition-colors"
aria-label="User menu"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((v) => !v)}
>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-maroon to-maroon-bright flex items-center justify-center text-white text-xs font-bold">
{user.initials}
</div>
<div className="text-left hidden sm:block">
<span className="block text-text-2 text-sm font-semibold leading-4">{user.name}</span>
<span className="block text-text-3 text-[11px] uppercase tracking-[0.08em] font-medium leading-3">
{user.role}
</span>
</div>
</button>
{open && (
<>
<div
className="fixed inset-0 z-[1000]"
aria-hidden="true"
/>
<div
id={menuId}
role="menu"
aria-label="User menu"
className="absolute right-0 mt-2 z-[1001] min-w-[220px] rounded-[var(--r-md)] border border-gold-border bg-surface-raised shadow-[var(--shadow-deep)] py-2"
>
<button
type="button"
role="menuitem"
className="w-full flex items-center gap-3 px-4 py-2 text-text-2 hover:bg-white/5 hover:text-text-1 transition-colors text-sm"
onClick={() => {
setOpen(false);
onProfile?.();
}}
>
<svg viewBox="0 0 24 24" className="w-4 h-4" aria-hidden="true">
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" strokeWidth="2" />
</svg>
Profile
</button>
<button
type="button"
role="menuitem"
className="w-full flex items-center gap-3 px-4 py-2 text-text-2 hover:bg-white/5 hover:text-text-1 transition-colors text-sm"
onClick={() => {
setOpen(false);
onSettings?.();
}}
>
<svg viewBox="0 0 24 24" className="w-4 h-4" aria-hidden="true">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="2" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
Settings
</button>
<div className="h-px bg-gold-border my-1" role="separator" />
<button
type="button"
role="menuitem"
className="w-full flex items-center gap-3 px-4 py-2 text-text-3 hover:bg-white/5 hover:text-red-400 transition-colors text-sm"
onClick={() => {
setOpen(false);
onSignOut?.();
}}
>
<svg viewBox="0 0 24 24" className="w-4 h-4" aria-hidden="true">
<path
d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="16 17 21 12 16 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line x1="21" y1="12" x2="9" y2="12" fill="none" stroke="currentColor" strokeWidth="2" />
</svg>
Sign out
</button>
</div>
</>
)}
</div>
);
}
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafBadge from '@/src/maf-ui/components/MafBadge';
const meta = {
title: 'MAF/Badge',
component: MafBadge,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
args: {
children: 'Badge',
},
} satisfies Meta<typeof MafBadge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Info: Story = { args: { tone: 'info', children: 'Info' } };
export const Success: Story = { args: { tone: 'success', children: 'Success' } };
export const Warning: Story = { args: { tone: 'warning', children: 'Warning' } };
export const Action: Story = { args: { tone: 'action', children: 'Action' } };
export const Draft: Story = { args: { tone: 'draft', children: 'Draft' } };
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafBottomNav, { MafBottomNavItem } from '@/src/maf-ui/shell/MafBottomNav';
const meta = {
title: 'MAF/Shell/BottomNav',
component: MafBottomNav,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
} satisfies Meta<typeof MafBottomNav>;
export default meta;
type Story = StoryObj<typeof meta>;
const icon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
);
const items: MafBottomNavItem[] = [
{ view: 'welcome', label: 'Home', icon },
{ view: 'modules', label: 'Modules', icon },
{ view: 'incidents', label: 'Incidents', icon },
{ view: 'notification-center', label: 'Alerts', icon },
{ view: 'profile', label: 'Profile', icon },
];
export const ActiveIncidents: Story = {
args: {
items,
activeView: 'incidents',
onNavigate: () => {},
},
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { fn } from 'storybook/test';
import { createElement } from 'react';
import MafButton from '@/src/maf-ui/components/MafButton';
const meta = {
title: 'MAF/Button',
component: MafButton,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
args: {
onClick: fn(),
children: 'Button',
disabled: false,
},
} satisfies Meta<typeof MafButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = { args: { variant: 'primary', children: 'Primary' } };
export const Secondary: Story = { args: { variant: 'secondary', children: 'Secondary' } };
export const Ghost: Story = { args: { variant: 'ghost', children: 'Ghost' } };
export const Sizes: Story = {
render: (args) =>
createElement(
'div',
{ className: 'flex flex-col gap-3 items-center' },
createElement(MafButton, { ...args, size: 'small', variant: 'primary' }, 'Small'),
createElement(MafButton, { ...args, size: 'medium', variant: 'primary' }, 'Medium'),
createElement(MafButton, { ...args, size: 'large', variant: 'primary' }, 'Large')
),
args: { onClick: fn() },
};
import './button.css';
export interface ButtonProps {
/** Is this the principal call to action on the page? */
primary?: boolean;
/** What background color to use */
backgroundColor?: string;
/** How large should the button be? */
size?: 'small' | 'medium' | 'large';
/** Button contents */
label: string;
/** Optional click handler */
onClick?: () => void;
}
/** Primary UI component for user interaction */
export const Button = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
return (
<button
type="button"
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
{...props}
>
{label}
<style jsx>{`
button {
background-color: ${backgroundColor};
}
`}</style>
</button>
);
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { MafCard } from '@/src/maf-ui/components/MafCard';
const meta = {
title: 'MAF/Card',
component: MafCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof MafCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'Card',
},
render: () => (
<MafCard className="p-6 md:p-8">
<div className="font-semibold text-text-1">Card</div>
<div className="mt-2 text-text-2 text-sm leading-[var(--leading-relaxed)]">
Default surface styling for content containers.
</div>
</MafCard>
),
};
export const WithBorderOverride: Story = {
args: {
children: 'Custom border',
},
render: () => (
<MafCard className="p-6 md:p-8 border-gold-border-strong">
<div className="font-semibold text-text-1">Custom border</div>
<div className="mt-2 text-text-2 text-sm">Useful for emphasis states.</div>
</MafCard>
),
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { useState } from 'react';
import MafDrawer, { MafDrawerItem } from '@/src/maf-ui/shell/MafDrawer';
const meta = {
title: 'MAF/Shell/Drawer',
component: MafDrawer,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafDrawer>;
export default meta;
type Story = StoryObj<typeof meta>;
const icon = (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
const items: MafDrawerItem[] = [
{ view: 'welcome', label: 'Home', icon },
{ view: 'modules', label: 'Modules', icon },
{ view: 'incidents', label: 'Incidents', icon },
];
export const Open: Story = {
args: {
open: true,
items,
activeView: 'modules',
onClose: () => {},
onNavigate: () => {},
},
render: () => {
const [open, setOpen] = useState(true);
return <MafDrawer open={open} items={items} activeView="modules" onClose={() => setOpen(false)} onNavigate={() => {}} />;
},
};
export const Closed: Story = {
args: {
open: false,
items,
activeView: 'modules',
onClose: () => {},
onNavigate: () => {},
},
render: () => <MafDrawer open={false} items={items} activeView="modules" onClose={() => {}} onNavigate={() => {}} />,
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafTabs from '@/src/maf-ui/components/MafTabs';
import { MafCard } from '@/src/maf-ui/components/MafCard';
const meta = {
title: 'MAF/Tabs (Legacy)',
component: MafTabs,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof MafTabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [],
defaultActiveKey: 'overview',
},
render: () => (
<div className="w-full max-w-[720px] p-6">
<MafCard className="p-4 md:p-6">
<MafTabs
defaultActiveKey="overview"
items={[
{
key: 'overview',
label: 'Overview',
panel: <div className="text-text-2 text-sm">Overview content goes here.</div>,
},
{
key: 'activity',
label: 'Activity',
panel: <div className="text-text-2 text-sm">Activity content goes here.</div>,
},
{
key: 'settings',
label: 'Settings',
panel: <div className="text-text-2 text-sm">Settings content goes here.</div>,
},
]}
/>
</MafCard>
</div>
),
};
import { Button } from './Button';
import './header.css';
type User = {
name: string;
};
export interface HeaderProps {
user?: User;
onLogin?: () => void;
onLogout?: () => void;
onCreateAccount?: () => void;
}
export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
<header>
<div className="storybook-header">
<div>
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<path
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
fill="#FFF"
/>
<path
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
fill="#555AB9"
/>
<path
d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
fill="#91BAF8"
/>
</g>
</svg>
<h1>Acme</h1>
</div>
<div>
{user ? (
<>
<span className="welcome">
Welcome, <b>{user.name}</b>!
</span>
<Button size="small" onClick={onLogout} label="Log out" />
</>
) : (
<>
<Button size="small" onClick={onLogin} label="Log in" />
<Button primary size="small" onClick={onCreateAccount} label="Sign up" />
</>
)}
</div>
</div>
</header>
);
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafLoginModal from '@/src/maf-ui/auth/MafLoginModal';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
const meta = {
title: 'MAF/Auth/LoginModal',
component: MafLoginModal,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafLoginModal>;
export default meta;
type Story = StoryObj<typeof meta>;
const onSuccess = (u: MafUser) => {
// no-op for Storybook
void u;
};
export const EmployeeOpen: Story = {
args: {
open: true,
defaultAccountType: 'employee',
onClose: () => {},
onSuccess,
},
render: () => <MafLoginModal open defaultAccountType="employee" onClose={() => {}} onSuccess={onSuccess} />,
};
export const ContractorOpen: Story = {
args: {
open: true,
defaultAccountType: 'contractor',
onClose: () => {},
onSuccess,
},
render: () => <MafLoginModal open defaultAccountType="contractor" onClose={() => {}} onSuccess={onSuccess} />,
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafNotificationCenter from '@/src/maf-ui/shell/MafNotificationCenter';
import { useEffect } from 'react';
const meta = {
title: 'MAF/NotificationCenter',
component: MafNotificationCenter,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafNotificationCenter>;
export default meta;
type Story = StoryObj<typeof meta>;
function NotifSetup({ readIds }: { readIds: string[] }) {
useEffect(() => {
try {
sessionStorage.setItem('maf_notif_read', JSON.stringify(readIds));
} catch {
// ignore
}
// Ensure we're in a known theme.
document.documentElement.setAttribute('data-theme', 'dark');
}, [readIds]);
return null;
}
export const DropdownWithUnread: Story = {
args: {
open: true,
variant: 'dropdown',
unreadCount: 0,
onUnreadCountChange: () => {},
onClose: () => {},
onNavigate: () => {},
},
render: () => {
// Mark only one notification as read to demonstrate badge count.
const readIds = ['2', '5'];
return (
<>
<NotifSetup readIds={readIds} />
<div className="p-6">
<MafNotificationCenter
open
variant="dropdown"
unreadCount={0}
onUnreadCountChange={() => {}}
onClose={() => {}}
onNavigate={() => {}}
/>
</div>
</>
);
},
};
export const PageVariant: Story = {
args: {
open: true,
variant: 'page',
unreadCount: 0,
onUnreadCountChange: () => {},
onClose: () => {},
onNavigate: () => {},
},
render: () => {
const readIds: string[] = [];
return (
<>
<NotifSetup readIds={readIds} />
<div className="p-6 max-w-[980px] mx-auto">
<MafNotificationCenter
open
variant="page"
unreadCount={0}
onUnreadCountChange={() => {}}
onClose={() => {}}
onNavigate={() => {}}
/>
</div>
</>
);
},
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { useMemo, useState } from 'react';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
const meta = {
title: 'MAF/AppShell',
component: MafAppShell,
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafAppShell>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ShellWelcome: Story = {
args: {
user: {
initials: 'NL',
name: 'Nino Lester Cruz',
role: 'Admin',
},
activeView: 'welcome',
children: null,
},
render: () => {
const user = useMemo(
() => ({
initials: 'NL',
name: 'Nino Lester Cruz',
role: 'Admin',
}),
[]
);
const [view, setView] = useState<
'welcome' | 'modules' | 'incidents' | 'permit' | 'settings' | 'profile' | 'notification-center'
>('welcome');
return (
<MafAppShell user={user} activeView={view} onNavigate={(next) => setView(next as any)}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<section className="rounded-[var(--r-lg)] border border-gold-border bg-surface-raised p-6 md:p-8">
<h1
className="text-[var(--text-2xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1 mb-3"
style={{ fontFamily: 'var(--font-display)' }}
>
Storybook shell validation
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)]">
Theme toggle, sidebar, drawer, and bottom nav should match the MAF-UIS layout.
</p>
<div className="mt-4 flex gap-3 flex-wrap">
<button
type="button"
className="rounded-[var(--r-sm)] border border-gold-border bg-white/0 hover:bg-white/5 px-4 py-2 text-text-1 font-semibold text-sm"
onClick={() => setView('welcome')}
>
Welcome
</button>
<button
type="button"
className="rounded-[var(--r-sm)] border border-gold-border bg-white/0 hover:bg-white/5 px-4 py-2 text-text-1 font-semibold text-sm"
onClick={() => setView('modules')}
>
Modules
</button>
<button
type="button"
className="rounded-[var(--r-sm)] border border-gold-border bg-white/0 hover:bg-white/5 px-4 py-2 text-text-1 font-semibold text-sm"
onClick={() => setView('incidents')}
>
Incidents
</button>
</div>
</section>
</div>
</MafAppShell>
);
},
};
import React from 'react';
import { Header } from './Header';
import './page.css';
type User = {
name: string;
};
export const Page: React.FC = () => {
const [user, setUser] = React.useState<User>();
return (
<article>
<Header
user={user}
onLogin={() => setUser({ name: 'Jane Doe' })}
onLogout={() => setUser(undefined)}
onCreateAccount={() => setUser({ name: 'Jane Doe' })}
/>
<section className="storybook-page">
<h2>Pages in Storybook</h2>
<p>
We recommend building UIs with a{' '}
<a href="https://componentdriven.org" target="_blank" rel="noopener noreferrer">
<strong>component-driven</strong>
</a>{' '}
process starting with atomic components and ending with pages.
</p>
<p>
Render pages with mock data. This makes it easy to build and review page states without
needing to navigate to them in your app. Here are some handy patterns for managing page
data in Storybook:
</p>
<ul>
<li>
Use a higher-level connected component. Storybook helps you compose such data from the
"args" of child component stories
</li>
<li>
Assemble data in the page component from your services. You can mock these services out
using Storybook.
</li>
</ul>
<p>
Get a guided tutorial on component-driven development at{' '}
<a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer">
Storybook tutorials
</a>
. Read more in the{' '}
<a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">
docs
</a>
.
</p>
<div className="tip-wrapper">
<span className="tip">Tip</span> Adjust the width of the canvas with the{' '}
<svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<path
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
id="a"
fill="#999"
/>
</g>
</svg>
Viewports addon in the toolbar
</div>
</section>
</article>
);
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafSidebar, { MafNavItem } from '@/src/maf-ui/shell/MafSidebar';
const meta = {
title: 'MAF/Shell/Sidebar',
component: MafSidebar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafSidebar>;
export default meta;
type Story = StoryObj<typeof meta>;
const icon = (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="10" />
</svg>
);
const items: MafNavItem[] = [
{ view: 'welcome', label: 'Home', icon },
{ view: 'modules', label: 'Modules', icon },
{ view: 'incidents', label: 'Incidents', icon },
{ view: 'permit', label: 'Permit', icon },
{ view: 'settings', label: 'Settings', icon },
{ view: 'profile', label: 'Profile', icon },
];
export const ActiveModules: Story = {
args: {
items,
activeView: 'modules',
onNavigate: () => {},
},
render: () => <MafSidebar items={items} activeView="modules" onNavigate={() => {}} />,
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafTabs from '@/src/maf-ui/components/MafTabs';
import { MafCard } from '@/src/maf-ui/components/MafCard';
const meta = {
title: 'MAF/Tabs',
component: MafTabs,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof MafTabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [],
defaultActiveKey: 'overview',
},
render: () => (
<div className="w-full max-w-[720px] p-6">
<MafCard className="p-4 md:p-6">
<MafTabs
defaultActiveKey="overview"
items={[
{
key: 'overview',
label: 'Overview',
panel: <div className="text-text-2 text-sm">Overview content.</div>,
},
{
key: 'activity',
label: 'Activity',
panel: <div className="text-text-2 text-sm">Activity content.</div>,
},
{
key: 'settings',
label: 'Settings',
panel: <div className="text-text-2 text-sm">Settings content.</div>,
},
]}
/>
</MafCard>
</div>
),
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { useEffect } from 'react';
import MafThemeToggle from '@/src/maf-ui/shell/MafThemeToggle';
const meta = {
title: 'MAF/ThemeToggle',
component: MafThemeToggle,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof MafThemeToggle>;
export default meta;
type Story = StoryObj<typeof meta>;
function ThemeSetup({ theme }: { theme: 'light' | 'dark' }) {
useEffect(() => {
try {
localStorage.setItem('maf_theme', theme);
} catch {
// ignore
}
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return <MafThemeToggle />;
}
export const Dark: Story = {
render: () => <ThemeSetup theme="dark" />,
};
export const Light: Story = {
render: () => <ThemeSetup theme="light" />,
};
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48"><title>Accessibility</title><circle cx="24.334" cy="24" r="24" fill="#A849FF" fill-opacity=".3"/><path fill="#A470D5" fill-rule="evenodd" d="M27.8609 11.585C27.8609 9.59506 26.2497 7.99023 24.2519 7.99023C22.254 7.99023 20.6429 9.65925 20.6429 11.585C20.6429 13.575 22.254 15.1799 24.2519 15.1799C26.2497 15.1799 27.8609 13.575 27.8609 11.585ZM21.8922 22.6473C21.8467 23.9096 21.7901 25.4788 21.5897 26.2771C20.9853 29.0462 17.7348 36.3314 17.3325 37.2275C17.1891 37.4923 17.1077 37.7955 17.1077 38.1178C17.1077 39.1519 17.946 39.9902 18.9802 39.9902C19.6587 39.9902 20.253 39.6293 20.5814 39.0889L20.6429 38.9874L24.2841 31.22C24.2841 31.22 27.5529 37.9214 27.9238 38.6591C28.2948 39.3967 28.8709 39.9902 29.7168 39.9902C30.751 39.9902 31.5893 39.1519 31.5893 38.1178C31.5893 37.7951 31.3639 37.2265 31.3639 37.2265C30.9581 36.3258 27.698 29.0452 27.0938 26.2771C26.8975 25.4948 26.847 23.9722 26.8056 22.7236C26.7927 22.333 26.7806 21.9693 26.7653 21.6634C26.7008 21.214 27.0231 20.8289 27.4097 20.7005L35.3366 18.3253C36.3033 18.0685 36.8834 16.9773 36.6256 16.0144C36.3678 15.0515 35.2722 14.4737 34.3055 14.7305C34.3055 14.7305 26.8619 17.1057 24.2841 17.1057C21.7062 17.1057 14.456 14.7947 14.456 14.7947C13.4893 14.5379 12.3937 14.9873 12.0715 15.9502C11.7493 16.9131 12.3293 18.0044 13.3604 18.3253L21.2873 20.7005C21.674 20.8289 21.9318 21.214 21.9318 21.6634C21.9174 21.9493 21.9053 22.2857 21.8922 22.6473Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment