Files
Parsons/src/components/templates/WizardLayout/WizardLayout.tsx
Richie 49b49e2113 Progress bar layout refinement + step label readability
- StepperBar: stepper centred at 700px max-width, cart hugs right edge
- StepIndicator: bump desktop label fontSize to 0.875rem for readability
- DateTimeStep story: demo progress bar + cart in context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:34:35 +11:00

486 lines
14 KiB
TypeScript

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 wizard page templates */
export type WizardLayoutVariant =
| 'centered-form'
| 'wide-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',
gap: 3,
}}
>
<Box sx={{ flex: 1, maxWidth: 700, mx: 'auto' }}>{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>
);
/** Wide Form: single column maxWidth "lg", for card grids (coffins, etc.) */
const WideFormLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Container
maxWidth="lg"
sx={{
py: { xs: 4, md: 6 },
px: { xs: 2, md: 3 },
flex: 1,
}}
>
{children}
</Container>
);
/** List + Map: 420px fixed scrollable list (left) / flex map (right) — D-B */
const ListMapLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
backLink?: React.ReactNode;
}> = ({ children, secondaryPanel, backLink }) => (
<Box
sx={{
display: 'flex',
flex: 1,
overflow: 'hidden',
}}
>
{/* Left panel — scrollable list with scrollbar visible on hover */}
<Box
sx={{
width: { xs: '100%', md: 420 },
flexShrink: 0,
overflowY: 'auto',
px: { xs: 2, md: 3 },
pt: 0,
pb: 3,
// Thin scrollbar, hidden until hover
scrollbarWidth: 'thin',
scrollbarColor: 'transparent transparent',
'&:hover': {
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
},
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'transparent',
borderRadius: 3,
},
'&:hover::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.25)',
},
}}
>
{backLink}
{children}
</Box>
{/* Right panel — map or placeholder, fills available space */}
<Box
sx={{
display: { xs: 'none', md: 'flex' },
flex: 1,
minHeight: 0,
}}
>
{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).
* Viewport-locked on desktop — both panels scroll independently.
* On mobile, stacks vertically and scrolls as a single page. */
const GridSidebarLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Box
sx={{
display: 'flex',
flex: 1,
overflow: { md: 'hidden' },
flexDirection: { xs: 'column', md: 'row' },
maxWidth: 1200,
mx: 'auto',
width: '100%',
}}
>
{/* Left sidebar — scrollbar visible on hover */}
<Box
component="aside"
sx={{
width: { xs: '100%', md: '28%' },
flexShrink: 0,
overflowY: 'auto',
overflowX: 'hidden',
px: { xs: 2, md: 3 },
py: 3,
scrollbarWidth: 'thin',
scrollbarColor: 'transparent transparent',
'&:hover': {
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
},
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'transparent',
borderRadius: 3,
},
'&:hover::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.25)',
},
}}
>
{children}
</Box>
{/* Right panel — always scrollable */}
<Box
sx={{
flex: 1,
overflowY: { md: 'auto' },
px: { xs: 2, md: 3 },
py: 3,
scrollbarWidth: 'thin',
}}
>
{secondaryPanel}
</Box>
</Box>
);
/** Detail + Toggles: scrollable left (image/desc) / sticky right (info/CTA) */
const DetailTogglesLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
backLink?: React.ReactNode;
}> = ({ children, secondaryPanel, backLink }) => (
<Box
sx={{
display: 'flex',
flex: 1,
overflow: 'hidden',
maxWidth: 1200,
mx: 'auto',
width: '100%',
}}
>
{/* Left panel — scrollable content */}
<Box
sx={{
width: { xs: '100%', md: '58%' },
flexShrink: 0,
overflowY: 'auto',
px: { xs: 2, md: 5 },
pt: 0,
pb: 3,
scrollbarWidth: 'thin',
scrollbarColor: 'transparent transparent',
'&:hover': {
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
},
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'transparent',
borderRadius: 3,
},
'&:hover::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.25)',
},
}}
>
{backLink && <Box sx={{ pt: 1.5 }}>{backLink}</Box>}
{children}
</Box>
{/* Right panel — sticky info */}
<Box
sx={{
display: { xs: 'none', md: 'block' },
width: { md: '42%' },
overflowY: 'auto',
px: 5,
py: 3,
borderLeft: '1px solid',
borderColor: 'divider',
scrollbarWidth: 'thin',
scrollbarColor: 'transparent transparent',
'&:hover': {
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
},
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'transparent',
borderRadius: 3,
},
'&:hover::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.25)',
},
}}
>
{secondaryPanel}
</Box>
</Box>
);
// ─── Variant map ─────────────────────────────────────────────────────────────
const LAYOUT_MAP: Record<
WizardLayoutVariant,
React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
backLink?: React.ReactNode;
}>
> = {
'centered-form': CenteredFormLayout,
'wide-form': WideFormLayout,
'list-map': ListMapLayout,
'list-detail': ListDetailLayout,
'grid-sidebar': GridSidebarLayout,
'detail-toggles': DetailTogglesLayout,
};
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
// ─── 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,
* and optional progress stepper + running total bar (shown when props provided).
*/
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];
return (
<Box
ref={ref}
sx={[
{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
bgcolor: 'background.default',
// list-map + detail-toggles: lock to viewport so panels scroll independently
...((variant === 'list-map' || variant === 'detail-toggles') && {
height: '100vh',
overflow: 'hidden',
}),
...(variant === 'grid-sidebar' && {
height: { xs: 'auto', md: '100vh' },
overflow: { xs: 'visible', md: 'hidden' },
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Navigation */}
{navigation}
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
<StepperBar stepper={progressStepper} total={runningTotal} />
{/* Back link — inside left panel for list-map/detail-toggles, above content for others */}
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (
<Container
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
>
<BackLink label={backLabel} onClick={onBack} />
</Container>
)}
{/* Main content area */}
<Box
component="main"
sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
>
<LayoutComponent
secondaryPanel={secondaryPanel}
backLink={
showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? (
<Box sx={{ pt: 1.5 }}>
<BackLink label={backLabel} onClick={onBack} />
</Box>
) : undefined
}
>
{children}
</LayoutComponent>
</Box>
{/* Sticky help bar */}
{!hideHelpBar && <HelpBar phone={helpPhone} />}
</Box>
);
},
);
WizardLayout.displayName = 'WizardLayout';
export default WizardLayout;