Add WizardLayout template with 5 layout variants

- centered-form: single column ~600px for form steps (intro, auth, etc.)
- list-map: 40/60 split for provider search (card list + map)
- list-detail: 40/60 master-detail for package selection
- grid-sidebar: 25/75 filter sidebar + card grid (coffins)
- detail-toggles: 50/50 hero image + product info (venue/coffin details)

Common elements: nav slot, sticky help bar, optional back link,
optional progress stepper + running total (grid-sidebar, detail-toggles).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:15:41 +11:00
parent ac17b12ad8
commit 43f0360252
3 changed files with 763 additions and 0 deletions

View File

@@ -0,0 +1,424 @@
import type { Meta, StoryObj } from '@storybook/react';
import { WizardLayout } from './WizardLayout';
import Box from '@mui/material/Box';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
import { Navigation } from '../../organisms/Navigation';
import { StepIndicator } from '../../molecules/StepIndicator';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src="/brandlogo/logo-short.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
const nav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const stepper = (
<StepIndicator
steps={[
{ label: 'Details' },
{ label: 'Service' },
{ label: 'Venue' },
{ label: 'Coffin' },
{ label: 'Extras' },
]}
currentStep={2}
/>
);
const runningTotal = (
<Box
sx={{
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
px: 2,
py: 0.75,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Typography variant="body2" color="text.secondary">
Your plan
</Typography>
<Typography variant="h6" color="primary.main">
$3,600
</Typography>
</Box>
);
const PlaceholderCard: React.FC<{ title: string; height?: number }> = ({ title, height = 100 }) => (
<Box
sx={{
p: 3,
mb: 2,
bgcolor: 'background.paper',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
minHeight: height,
}}
>
<Typography variant="h6">{title}</Typography>
<Typography variant="body2" color="text.secondary">
Placeholder content for layout demonstration.
</Typography>
</Box>
);
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof WizardLayout> = {
title: 'Templates/WizardLayout',
component: WizardLayout,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
argTypes: {
variant: {
control: 'select',
options: ['centered-form', 'list-map', 'list-detail', 'grid-sidebar', 'detail-toggles'],
description: 'Layout variant',
table: { defaultValue: { summary: 'centered-form' } },
},
showBackLink: { control: 'boolean' },
hideHelpBar: { control: 'boolean' },
backLabel: { control: 'text' },
helpPhone: { control: 'text' },
},
};
export default meta;
type Story = StoryObj<typeof WizardLayout>;
// ─── Centered Form ──────────────────────────────────────────────────────────
/** Step 1 (Intro) style — single centered column, no back link, no progress stepper */
export const CenteredForm: Story = {
args: {
variant: 'centered-form',
navigation: nav,
},
render: (args) => (
<WizardLayout {...args}>
<Typography variant="h4" sx={{ mb: 1 }}>
Let&apos;s get started
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
We&apos;ll guide you through arranging a funeral, step by step.
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="label" sx={{ mb: 1, display: 'block' }}>
Who is this funeral being arranged for?
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="soft" color="secondary" size="large" fullWidth>
Myself
</Button>
<Button variant="soft" color="secondary" size="large" fullWidth>
Someone else
</Button>
</Box>
</Box>
<Divider sx={{ my: 4 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="large">
Continue
</Button>
</Box>
</WizardLayout>
),
};
// ─── Centered Form with Back ────────────────────────────────────────────────
/** Form step with back link (e.g. date/time, payment) */
export const CenteredFormWithBack: Story = {
args: {
variant: 'centered-form',
navigation: nav,
showBackLink: true,
backLabel: 'Back',
},
render: (args) => (
<WizardLayout {...args}>
<Typography variant="h4" sx={{ mb: 1 }}>
Date and time
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
When would you like the service to take place?
</Typography>
<PlaceholderCard title="Date picker" height={200} />
<PlaceholderCard title="Time selector" />
<Divider sx={{ my: 4 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="large">
Continue
</Button>
</Box>
</WizardLayout>
),
};
// ─── List + Map ──────────────────────────────────────────────────────────────
/** Provider search — scrollable card list with map panel */
export const ListMap: Story = {
args: {
variant: 'list-map',
navigation: nav,
showBackLink: true,
},
render: (args) => (
<WizardLayout
{...args}
secondaryPanel={
<Box
sx={{
bgcolor: 'var(--fa-color-sage-200)',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="body1" color="text.secondary">
Map placeholder
</Typography>
</Box>
}
>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Choose a funeral provider
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Explore providers near you
</Typography>
<PlaceholderCard title="Search bar + filters" height={48} />
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
Showing results from 5 providers
</Typography>
{Array.from({ length: 4 }).map((_, i) => (
<PlaceholderCard key={i} title={`Provider ${i + 1}`} />
))}
</WizardLayout>
),
};
// ─── List + Detail ───────────────────────────────────────────────────────────
/** Package selection — list left, detail panel right */
export const ListDetail: Story = {
args: {
variant: 'list-detail',
navigation: nav,
showBackLink: true,
},
render: (args) => (
<WizardLayout
{...args}
secondaryPanel={
<Box>
<Typography variant="h5" sx={{ mb: 1 }}>
Everyday Funeral Package
</Typography>
<Typography variant="h4" color="primary.main" sx={{ mb: 3 }}>
$2,700
</Typography>
<Button variant="contained" size="large" fullWidth sx={{ mb: 3 }}>
Select Package
</Button>
<Divider sx={{ mb: 2 }} />
{['Essentials', 'Complimentary Items', 'Extras'].map((section) => (
<Box key={section} sx={{ mb: 3 }}>
<Typography variant="label" sx={{ mb: 1, display: 'block' }}>
{section}
</Typography>
{Array.from({ length: 3 }).map((_, i) => (
<PlaceholderCard key={i} title={`Item ${i + 1}`} height={40} />
))}
</Box>
))}
</Box>
}
>
<PlaceholderCard title="Provider card (compact)" height={80} />
<Typography variant="h6" sx={{ mb: 2 }}>
Packages
</Typography>
{Array.from({ length: 4 }).map((_, i) => (
<PlaceholderCard key={i} title={`Package ${i + 1}$${900 * (i + 1)}`} />
))}
</WizardLayout>
),
};
// ─── Grid + Sidebar ──────────────────────────────────────────────────────────
/** Coffin selection — filter sidebar + responsive card grid */
export const GridSidebar: Story = {
args: {
variant: 'grid-sidebar',
navigation: nav,
showBackLink: true,
progressStepper: stepper,
runningTotal: runningTotal,
},
render: (args) => (
<WizardLayout
{...args}
secondaryPanel={
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Coffins
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Browse our selection of bespoke designer coffins.
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
gap: 2,
}}
>
{Array.from({ length: 6 }).map((_, i) => (
<PlaceholderCard key={i} title={`Coffin ${i + 1}`} height={160} />
))}
</Box>
</Box>
}
>
<Typography variant="h6" sx={{ mb: 2 }}>
Categories
</Typography>
{['Materials', 'Colour', 'Environmental', 'Religious'].map((cat) => (
<PlaceholderCard key={cat} title={cat} height={40} />
))}
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
Price
</Typography>
<PlaceholderCard title="Price range slider" height={48} />
</WizardLayout>
),
};
// ─── Detail + Toggles ───────────────────────────────────────────────────────
/** Venue/coffin detail — hero image + product info */
export const DetailToggles: Story = {
args: {
variant: 'detail-toggles',
navigation: nav,
showBackLink: true,
progressStepper: stepper,
runningTotal: runningTotal,
},
render: (args) => (
<WizardLayout
{...args}
secondaryPanel={
<Box>
<Typography variant="h4" sx={{ mb: 0.5 }}>
West Chapel
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Wentworth, NSW
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Capacity: 120 guests
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
A beautiful heritage chapel set in peaceful gardens, offering a serene setting for
farewell services.
</Typography>
<Typography variant="h5" color="primary.main" sx={{ mb: 2 }}>
$900
</Typography>
<Button variant="contained" size="large" fullWidth>
Add to package
</Button>
</Box>
}
>
<Box
sx={{
bgcolor: 'var(--fa-color-sage-200)',
borderRadius: 2,
height: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 2,
}}
>
<Typography variant="body1" color="text.secondary">
Venue image placeholder
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{Array.from({ length: 5 }).map((_, i) => (
<Box
key={i}
sx={{
bgcolor: 'var(--fa-color-sage-200)',
borderRadius: 1,
width: 60,
height: 48,
}}
/>
))}
</Box>
</WizardLayout>
),
};
// ─── No Navigation ──────────────────────────────────────────────────────────
/** Minimal — no nav, no help bar (for embedded use) */
export const Minimal: Story = {
args: {
variant: 'centered-form',
hideHelpBar: true,
},
render: (args) => (
<WizardLayout {...args}>
<Typography variant="h4" sx={{ mb: 1 }}>
Embedded form
</Typography>
<Typography variant="body1" color="text.secondary">
No navigation or help bar for iframe or modal contexts.
</Typography>
</WizardLayout>
),
};

View File

@@ -0,0 +1,337 @@
import React from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles';
import { Link } from '../../atoms/Link';
import { Typography } from '../../atoms/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Layout variant matching the 5 wizard page templates */
export type WizardLayoutVariant =
| 'centered-form'
| 'list-map'
| 'list-detail'
| 'grid-sidebar'
| 'detail-toggles';
/** Props for the WizardLayout template */
export interface WizardLayoutProps {
/** Which layout variant to render */
variant: WizardLayoutVariant;
/** Navigation bar — rendered at the top of the page */
navigation?: React.ReactNode;
/** Optional progress stepper — shown below nav on grid-sidebar and detail-toggles variants */
progressStepper?: React.ReactNode;
/** Optional running total widget — shown in a bar below nav (grid-sidebar, detail-toggles) */
runningTotal?: React.ReactNode;
/** Show a back link above the content area */
showBackLink?: boolean;
/** Label for the back link */
backLabel?: string;
/** Click handler for the back link */
onBack?: () => void;
/** Help bar phone number */
helpPhone?: string;
/** Hide the sticky help bar */
hideHelpBar?: boolean;
// ─── Slot content ───
/** Main content — for centered-form this is the form; for split layouts this is the primary (left) panel */
children: React.ReactNode;
/** Secondary panel content — right panel for split layouts (map, detail, grid) */
secondaryPanel?: React.ReactNode;
/** MUI sx prop for the root container */
sx?: SxProps<Theme>;
}
// ─── Help bar ────────────────────────────────────────────────────────────────
const HelpBar: React.FC<{ phone: string }> = ({ phone }) => (
<Box
component="footer"
sx={{
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
py: 1.5,
px: { xs: 2, md: 4 },
textAlign: 'center',
}}
>
<Typography variant="body2" color="text.secondary" component="span">
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
Need help? Call us on{' '}
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
{phone}
</Link>
</Typography>
</Box>
);
// ─── Back link ───────────────────────────────────────────────────────────────
const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => (
<Link
component="button"
onClick={onClick}
underline="hover"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'text.secondary',
fontSize: '0.875rem',
fontWeight: 500,
mb: 2,
'&:hover': { color: 'text.primary' },
}}
>
<ArrowBackIcon sx={{ fontSize: 18 }} />
{label}
</Link>
);
// ─── Stepper + total bar ─────────────────────────────────────────────────────
const StepperBar: React.FC<{
stepper?: React.ReactNode;
total?: React.ReactNode;
}> = ({ stepper, total }) => {
if (!stepper && !total) return null;
return (
<Box
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
px: { xs: 2, md: 4 },
py: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box sx={{ flex: 1 }}>{stepper}</Box>
{total && <Box sx={{ flexShrink: 0 }}>{total}</Box>}
</Box>
);
};
// ─── Layout variants ─────────────────────────────────────────────────────────
/** Centered Form: single column ~600px, heading + fields + CTA */
const CenteredFormLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Container
maxWidth="sm"
sx={{
py: { xs: 6, md: 10 },
px: { xs: 4, md: 3 },
flex: 1,
}}
>
{children}
</Container>
);
/** List + Map: ~40% scrollable list (left) / ~60% map (right) */
const ListMapLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Box
sx={{
display: 'flex',
flex: 1,
overflow: 'hidden',
}}
>
<Box
sx={{
width: { xs: '100%', md: '40%' },
overflowY: 'auto',
px: { xs: 2, md: 3 },
py: 3,
}}
>
{children}
</Box>
<Box
sx={{
display: { xs: 'none', md: 'block' },
width: '60%',
position: 'relative',
}}
>
{secondaryPanel}
</Box>
</Box>
);
/** List + Detail: ~40% selection list (left) / ~60% detail panel (right) */
const ListDetailLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Container maxWidth="lg" sx={{ flex: 1, py: 3 }}>
<Box
sx={{
display: 'flex',
gap: { xs: 0, md: 4 },
flexDirection: { xs: 'column', md: 'row' },
}}
>
<Box sx={{ width: { xs: '100%', md: '40%' } }}>{children}</Box>
<Box sx={{ width: { xs: '100%', md: '60%' } }}>{secondaryPanel}</Box>
</Box>
</Container>
);
/** Grid + Sidebar: ~25% filter sidebar (left) / ~75% card grid (right) */
const GridSidebarLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Container maxWidth="lg" sx={{ flex: 1, py: 3 }}>
<Box
sx={{
display: 'flex',
gap: { xs: 0, md: 3 },
flexDirection: { xs: 'column', md: 'row' },
}}
>
<Box
component="aside"
sx={{
width: { xs: '100%', md: '25%' },
flexShrink: 0,
}}
>
{children}
</Box>
<Box sx={{ flex: 1 }}>{secondaryPanel}</Box>
</Box>
</Container>
);
/** Detail + Toggles: two-column hero (image left / info right), full-width section below */
const DetailTogglesLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Container maxWidth="lg" sx={{ flex: 1, py: 3 }}>
<Box
sx={{
display: 'flex',
gap: { xs: 0, md: 4 },
flexDirection: { xs: 'column', md: 'row' },
mb: 4,
}}
>
<Box sx={{ width: { xs: '100%', md: '50%' } }}>{children}</Box>
<Box sx={{ width: { xs: '100%', md: '50%' } }}>{secondaryPanel}</Box>
</Box>
</Container>
);
// ─── Variant map ─────────────────────────────────────────────────────────────
const LAYOUT_MAP: Record<
WizardLayoutVariant,
React.FC<{ children: React.ReactNode; secondaryPanel?: React.ReactNode }>
> = {
'centered-form': CenteredFormLayout,
'list-map': ListMapLayout,
'list-detail': ListDetailLayout,
'grid-sidebar': GridSidebarLayout,
'detail-toggles': DetailTogglesLayout,
};
/** Variants that show the stepper/total bar */
const STEPPER_VARIANTS: WizardLayoutVariant[] = ['grid-sidebar', 'detail-toggles'];
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Page-level layout template for the FA arrangement wizard.
*
* Provides 5 layout variants matching the wizard page templates:
* - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.)
* - **list-map**: Split view with scrollable card list and map panel (providers)
* - **list-detail**: Master-detail split for selection + detail (packages, preview)
* - **grid-sidebar**: Filter sidebar + card grid (coffins)
* - **detail-toggles**: Hero image + info column (venue, coffin details)
*
* All variants share: navigation slot, optional back link, sticky help bar.
* Grid-sidebar and detail-toggles add: progress stepper, running total widget.
*/
export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
(
{
variant,
navigation,
progressStepper,
runningTotal,
showBackLink = false,
backLabel = 'Back',
onBack,
helpPhone = '1800 987 888',
hideHelpBar = false,
children,
secondaryPanel,
sx,
},
ref,
) => {
const LayoutComponent = LAYOUT_MAP[variant];
const showStepper = STEPPER_VARIANTS.includes(variant);
return (
<Box
ref={ref}
sx={[
{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
bgcolor: 'background.default',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Navigation */}
{navigation}
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
{showStepper && <StepperBar stepper={progressStepper} total={runningTotal} />}
{/* Back link — inside a container for consistent alignment */}
{showBackLink && (
<Container
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
>
<BackLink label={backLabel} onClick={onBack} />
</Container>
)}
{/* Main content area */}
<LayoutComponent secondaryPanel={secondaryPanel}>{children}</LayoutComponent>
{/* Sticky help bar */}
{!hideHelpBar && <HelpBar phone={helpPhone} />}
</Box>
);
},
);
WizardLayout.displayName = 'WizardLayout';
export default WizardLayout;

View File

@@ -0,0 +1,2 @@
export { default } from './WizardLayout';
export * from './WizardLayout';