Restructure ComparisonPage with bleed variant + sticky-left columns

Adopts a single-scroll-container layout for the desktop Comparison page
(motivated by a Figma Make exploration). The page header sits in a
max-width container matching the table's natural width, with flex
spacers either side of the table — when the viewport is wider than
the table, spacers centre it; when a 4th+ package pushes the table
wider than viewport, spacers collapse and the table extends rightward
from the page header's left edge.

- New WizardLayout variant `bleed` — viewport-locked, no inner Container,
  main is the single scroll host, back link routed into children,
  `data-wizard-scroll` marker for descendants.
- ComparisonTable: fixed 300px column widths exposed as
  COMPARISON_TABLE_COL_WIDTH; sticky-left on row-label column across
  every per-section mini-table; tiered hover (surface-subtle base /
  surface-warm recommended column); recommended column carries a
  resting 50%-opacity warm tint; "Not Included" copy replaces em-dash
  for unavailable cells in Optionals/Extras sections; CellIconText
  helper applies lineHeight: 1 so icon+text rows align optically.
- ComparisonColumnCard: uniform pt: 5 (40px); medium badge (26px) with
  star/verified icon; 2px brand-600 border for recommended; provider
  name wraps to 2 lines in a reserved 36px bottom-aligned slot so
  1-line names keep subsequent content on a consistent baseline; Remove
  link always rendered as the same Link element (visibility-hidden when
  not applicable) so CTA+footer align across all cards.
- Mackay test data extended to exercise 2-line wrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:51:41 +10:00
parent 356d22da4c
commit f146bb0f81
6 changed files with 549 additions and 216 deletions

View File

@@ -67,17 +67,17 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
<Badge <Badge
color="brand" color="brand"
variant={pkg.isRecommended ? 'filled' : 'soft'} variant={pkg.isRecommended ? 'filled' : 'soft'}
size="small" size="medium"
icon={ icon={
pkg.isRecommended ? ( pkg.isRecommended ? (
<StarRoundedIcon sx={{ fontSize: 14 }} /> <StarRoundedIcon sx={{ fontSize: 16 }} />
) : ( ) : (
<VerifiedOutlinedIcon sx={{ fontSize: 14 }} /> <VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
) )
} }
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -12, top: -13,
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
zIndex: 1, zIndex: 1,
@@ -109,20 +109,23 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
alignItems: 'center', alignItems: 'center',
textAlign: 'center', textAlign: 'center',
px: 2.5, px: 2.5,
py: 2.5, pt: 5,
pt: pkg.provider.verified || pkg.isRecommended ? 3 : 2.5, pb: 3,
gap: 0.5, gap: 1,
flex: 1, flex: 1,
}} }}
> >
{/* Provider name with optional verified icon (truncated with tooltip) */} {/* Provider name — always reserves space for 2 lines (via minHeight),
content bottom-aligned so single-line names sit flush with the
next item below rather than floating high in the slot. */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'flex-end',
justifyContent: 'center', justifyContent: 'center',
gap: 0.75, gap: 0.75,
maxWidth: '100%', maxWidth: '100%',
minHeight: 36, // 2 × (14px label × 1.286 line-height)
}} }}
> >
{pkg.isRecommended && ( {pkg.isRecommended && (
@@ -131,6 +134,7 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
fontSize: 16, fontSize: 16,
color: 'var(--fa-color-brand-600)', color: 'var(--fa-color-brand-600)',
flexShrink: 0, flexShrink: 0,
mb: '2px',
}} }}
aria-label="Verified provider" aria-label="Verified provider"
/> />
@@ -139,15 +143,17 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
title={pkg.provider.name} title={pkg.provider.name}
arrow arrow
placement="top" placement="top"
disableHoverListener={pkg.provider.name.length < 24} disableHoverListener={pkg.provider.name.length < 50}
> >
<Typography <Typography
variant="label" variant="label"
sx={{ sx={{
fontWeight: 600, fontWeight: 600,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0, minWidth: 0,
}} }}
> >
@@ -179,18 +185,29 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
</Typography> </Typography>
)} )}
<Divider sx={{ width: '100%', my: 1 }} /> <Divider sx={{ width: '100%', my: 1.5 }} />
<Typography variant="h6" component="p"> <Typography variant="h6" component="p">
{pkg.name} {pkg.name}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}> {/* Price subgroup — tighter internal spacing than the outer gap
Total package price so the label sits close to the amount it describes. */}
</Typography> <Box
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}> sx={{
{formatPrice(pkg.price)} display: 'flex',
</Typography> flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
</Box>
{/* Spacer pushes CTA to bottom across all cards */} {/* Spacer pushes CTA to bottom across all cards */}
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
@@ -200,28 +217,33 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
color={pkg.provider.verified ? 'primary' : 'secondary'} color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium" size="medium"
onClick={() => onArrange(pkg.id)} onClick={() => onArrange(pkg.id)}
sx={{ mt: 1.5, px: 4 }} sx={{ px: 4 }}
> >
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'} {pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button> </Button>
{!pkg.isRecommended && onRemove ? ( {/* Always render the same Link element; hide when no Remove action
<Link applies (recommended or no handler). Keeps the footer row
component="button" identical across all cards so CTAs align. */}
variant="caption" {(() => {
color="text.secondary" const canRemove = !pkg.isRecommended && !!onRemove;
underline="hover" return (
onClick={() => onRemove(pkg.id)} <Link
sx={{ mt: 0.5 }} component="button"
> variant="caption"
Remove color="text.secondary"
</Link> underline="hover"
) : ( onClick={canRemove ? () => onRemove!(pkg.id) : undefined}
/* Invisible spacer keeps CTA aligned with cards that show Remove */ tabIndex={canRemove ? 0 : -1}
<Box sx={{ mt: 0.5, visibility: 'hidden' }} aria-hidden> aria-hidden={!canRemove}
<Typography variant="caption">Remove</Typography> sx={{
</Box> ...(!canRemove && { visibility: 'hidden', pointerEvents: 'none' }),
)} }}
>
Remove
</Link>
);
})()}
</Box> </Box>
</Card> </Card>
</Box> </Box>

View File

@@ -63,7 +63,55 @@ function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`; return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
} }
function CellValue({ value }: { value: ComparisonCellValue }) { /**
* Inline icon + label wrapper with optically aligned centres.
*
* body2's line-height adds vertical padding above/below the glyphs. Flex
* centring then aligns geometric centres, which puts the icon slightly
* above the text's visual centre. Setting `lineHeight: 1` on the row
* collapses the text line-box to the font size so geometric and visual
* centres match.
*/
function CellIconText({
icon,
iconPosition = 'leading',
color,
children,
}: {
icon: React.ReactNode;
iconPosition?: 'leading' | 'trailing';
color: string;
children: React.ReactNode;
}) {
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
lineHeight: 1,
}}
>
{iconPosition === 'leading' && icon}
<Typography variant="body2" sx={{ color, fontWeight: 500, lineHeight: 1 }} component="span">
{children}
</Typography>
{iconPosition === 'trailing' && icon}
</Box>
);
}
/** Sections where a missing item is better expressed as "Not Included"
* than a bare em-dash — these are opt-in items, so absence is meaningful. */
const OPTIONAL_SECTION_HEADINGS = new Set(['Optionals', 'Extras']);
function CellValue({
value,
sectionHeading,
}: {
value: ComparisonCellValue;
sectionHeading: string;
}) {
switch (value.type) { switch (value.type) {
case 'price': case 'price':
return ( return (
@@ -79,33 +127,31 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
); );
case 'complimentary': case 'complimentary':
return ( return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}> <CellIconText
<CheckCircleOutlineIcon color="var(--fa-color-feedback-success)"
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }} icon={
aria-hidden <CheckCircleOutlineIcon
/> sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
<Typography aria-hidden
variant="body2" />
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }} }
> >
Complimentary Complimentary
</Typography> </CellIconText>
</Box>
); );
case 'included': case 'included':
return ( return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}> <CellIconText
<CheckCircleOutlineIcon color="var(--fa-color-feedback-success)"
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }} icon={
aria-hidden <CheckCircleOutlineIcon
/> sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
<Typography aria-hidden
variant="body2" />
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }} }
> >
Included Included
</Typography> </CellIconText>
</Box>
); );
case 'poa': case 'poa':
return ( return (
@@ -115,20 +161,30 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
); );
case 'unknown': case 'unknown':
return ( return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}> <CellIconText
color="var(--fa-color-neutral-500)"
iconPosition="trailing"
icon={
<InfoOutlinedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
aria-hidden
/>
}
>
Unknown
</CellIconText>
);
case 'unavailable':
if (OPTIONAL_SECTION_HEADINGS.has(sectionHeading)) {
return (
<Typography <Typography
variant="body2" variant="body2"
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }} sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
> >
Unknown Not Included
</Typography> </Typography>
<InfoOutlinedIcon );
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }} }
aria-hidden
/>
</Box>
);
case 'unavailable':
return ( return (
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}> <Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
@@ -207,6 +263,19 @@ const tableSx = {
bgcolor: 'background.paper', bgcolor: 'background.paper',
}; };
/**
* Fixed column width for both the row-label column and each package column.
* Natural table width = COMPARISON_TABLE_COL_WIDTH × (packages.length + 1).
* Exposed so ComparisonPage can size its width-matching page header container
* to align left edges with the table on horizontal overflow.
*/
export const COMPARISON_TABLE_COL_WIDTH = 300;
/** z-index scale for sticky layers inside the table. */
const Z_HEADER_ROW = 30;
const Z_STICKY_LEFT = 20;
const Z_STICKY_LEFT_SECTION = 25; // section heading left cell above body cells
// ─── Component ────────────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────────────
/** /**
@@ -219,10 +288,10 @@ const tableSx = {
*/ */
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>( export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
({ packages, onArrange, onRemove, sx }, ref) => { ({ packages, onArrange, onRemove, sx }, ref) => {
const colCount = packages.length + 1;
const mergedSections = buildMergedSections(packages); const mergedSections = buildMergedSections(packages);
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`; const colCount = packages.length + 1;
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600; const gridCols = `${COMPARISON_TABLE_COL_WIDTH}px repeat(${packages.length}, ${COMPARISON_TABLE_COL_WIDTH}px)`;
const recommendedColIdx = packages.findIndex((p) => p.isRecommended);
return ( return (
<Box <Box
@@ -232,32 +301,39 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
sx={[ sx={[
{ {
display: { xs: 'none', md: 'block' }, display: { xs: 'none', md: 'block' },
overflowX: 'auto', width: COMPARISON_TABLE_COL_WIDTH * colCount,
}, },
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
> >
<Box sx={{ minWidth: minW }}> {/* ── Package header cards ── */}
{/* ── Package header cards ── */} <Box
role="row"
sx={{
display: 'grid',
gridTemplateColumns: gridCols,
mb: 4,
alignItems: 'stretch',
pt: 3, // Room for floating verified badges
}}
>
{/* Info card — sticky-left, matches the row-label column below */}
<Box <Box
role="row"
sx={{ sx={{
display: 'grid', position: 'sticky',
gridTemplateColumns: gridCols, left: 0,
gap: 2, zIndex: Z_HEADER_ROW,
mb: 4, bgcolor: 'background.default',
alignItems: 'stretch', px: 2,
pt: 3, // Room for floating verified badges
}} }}
> >
{/* Info card — stretches to match package card height, text at top */}
<Card <Card
role="columnheader" role="columnheader"
variant="elevated" variant="elevated"
padding="default" padding="default"
sx={{ sx={{
bgcolor: 'var(--fa-color-surface-subtle)', bgcolor: 'var(--fa-color-surface-subtle)',
alignSelf: 'stretch', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'flex-start', justifyContent: 'flex-start',
@@ -276,71 +352,117 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
Review and compare features side-by-side to find the right fit. Review and compare features side-by-side to find the right fit.
</Typography> </Typography>
</Card> </Card>
</Box>
{/* Package column header cards */} {packages.map((pkg) => (
{packages.map((pkg) => ( <Box key={pkg.id} sx={{ px: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<ComparisonColumnCard <ComparisonColumnCard
key={pkg.id}
pkg={pkg} pkg={pkg}
onArrange={onArrange} onArrange={onArrange}
onRemove={onRemove} onRemove={onRemove}
sx={{ flex: 1 }}
/> />
))} </Box>
</Box> ))}
</Box>
{/* ── Section tables (each separate with left accent headings) ── */} {/* ── Section tables (each separate with left accent headings) ── */}
{mergedSections.map((section) => ( {mergedSections.map((section) => (
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}> <Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}> {/* Section heading row — left cell sticky so label stays visible on horizontal scroll */}
<Box
role="row"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
}}
>
<Box
sx={{
position: 'sticky',
left: 0,
zIndex: Z_STICKY_LEFT_SECTION,
gridColumn: '1 / 2',
}}
>
<SectionHeading>{section.heading}</SectionHeading> <SectionHeading>{section.heading}</SectionHeading>
</Box> </Box>
{/* Background continuation for the remaining columns so they
share the heading's surface-subtle wash. */}
<Box
aria-hidden
sx={{
gridColumn: `2 / ${colCount + 1}`,
bgcolor: 'var(--fa-color-surface-subtle)',
}}
/>
</Box>
{section.items.map((item) => ( {section.items.map((item) => (
<Box
key={item.name}
role="row"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
// Tiered hover: base cells go to surface-subtle, recommended
// column cells inherit a warmer surface-warm tint on row hover.
'&:hover .comparison-cell': {
bgcolor: 'var(--fa-color-surface-subtle)',
},
'&:hover .comparison-cell--recommended': {
bgcolor: 'var(--fa-color-surface-warm)',
},
}}
>
{/* Row-label cell — sticky-left */}
<Box <Box
key={item.name} role="cell"
role="row" className="comparison-cell comparison-cell--label"
sx={{ sx={{
gridColumn: `1 / ${colCount + 1}`, position: 'sticky',
display: 'grid', left: 0,
gridTemplateColumns: 'subgrid', zIndex: Z_STICKY_LEFT,
bgcolor: 'background.paper',
px: 3,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
transition: 'background-color 0.15s ease', transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
}} }}
> >
<Box <Typography variant="body2" color="text.secondary" component="span">
role="cell" {item.name}
sx={{ </Typography>
px: 3, {item.info && (
py: 2, <Box component="span" sx={{ whiteSpace: 'nowrap' }}>
borderTop: '1px solid', {'\u00A0'}
borderColor: 'divider', <Tooltip title={item.info} arrow placement="top">
}} <InfoOutlinedIcon
> aria-label={`More information about ${item.name}`}
<Typography variant="body2" color="text.secondary" component="span"> sx={{
{item.name} fontSize: 14,
</Typography> color: 'var(--fa-color-neutral-400)',
{item.info && ( cursor: 'help',
<Box component="span" sx={{ whiteSpace: 'nowrap' }}> verticalAlign: 'middle',
{'\u00A0'} }}
<Tooltip title={item.info} arrow placement="top"> />
<InfoOutlinedIcon </Tooltip>
aria-label={`More information about ${item.name}`} </Box>
sx={{ )}
fontSize: 14, </Box>
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
verticalAlign: 'middle',
}}
/>
</Tooltip>
</Box>
)}
</Box>
{packages.map((pkg) => ( {packages.map((pkg, idx) => {
const isRecommended = idx === recommendedColIdx;
return (
<Box <Box
key={pkg.id} key={pkg.id}
role="cell" role="cell"
className={
'comparison-cell' + (isRecommended ? ' comparison-cell--recommended' : '')
}
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -351,23 +473,33 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
borderColor: 'divider', borderColor: 'divider',
borderLeft: '1px solid', borderLeft: '1px solid',
borderLeftColor: 'divider', borderLeftColor: 'divider',
transition: 'background-color 0.15s ease',
// Resting tint for the recommended column so it reads
// as the default column even without hover.
...(isRecommended && {
bgcolor:
'color-mix(in srgb, var(--fa-color-surface-warm) 50%, transparent)',
}),
}} }}
> >
<CellValue value={lookupValue(pkg, section.heading, item.name)} /> <CellValue
value={lookupValue(pkg, section.heading, item.name)}
sectionHeading={section.heading}
/>
</Box> </Box>
))} );
</Box> })}
))} </Box>
</Box> ))}
))} </Box>
))}
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && ( {packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}> <Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
* Some providers have not provided an itemised pricing breakdown. Their items are * Some providers have not provided an itemised pricing breakdown. Their items are shown
shown as "—" above. as "—" above.
</Typography> </Typography>
)} )}
</Box>
</Box> </Box>
); );
}, },

View File

@@ -1,4 +1,4 @@
export { ComparisonTable, default } from './ComparisonTable'; export { ComparisonTable, COMPARISON_TABLE_COL_WIDTH, default } from './ComparisonTable';
export type { export type {
ComparisonTableProps, ComparisonTableProps,
ComparisonPackage, ComparisonPackage,

View File

@@ -122,7 +122,7 @@ const pkgMackay: ComparisonPackage = {
name: 'Everyday Funeral Package', name: 'Everyday Funeral Package',
price: 5495.45, price: 5495.45,
provider: { provider: {
name: 'Mackay Family Funerals', name: 'Mackay Family Funeral Directors & Cremation Services',
location: 'Inglewood', location: 'Inglewood',
logoUrl: DEMO_LOGO, logoUrl: DEMO_LOGO,
rating: 4.6, rating: 4.6,

View File

@@ -1,14 +1,21 @@
import React, { useId, useState, useRef, useCallback } from 'react'; import React, { useId, useState, useRef, useCallback } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined'; import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined'; import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { Link } from '../../atoms/Link';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable'; import {
ComparisonTable,
COMPARISON_TABLE_COL_WIDTH,
type ComparisonPackage,
} from '../../organisms/ComparisonTable';
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard'; import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard'; import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
@@ -113,27 +120,147 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Natural table width = (row-label col) + (pkg col × n), matches page header maxWidth.
// Page header container reaches this same width so the table's left edge aligns
// with the page header's left edge when the table overflows horizontally.
const tableNaturalWidth = COMPARISON_TABLE_COL_WIDTH * (allPackages.length + 1);
const pageMaxWidth = COMPARISON_TABLE_COL_WIDTH * 4; // fits 3-package case flush
// Matching horizontal padding between the page header container and the
// table-zone spacers keeps inner-content left edges aligned on all viewports.
const edgePadding = { xs: 16, md: 24 };
return ( return (
<Box ref={ref} sx={sx}> <Box ref={ref} sx={sx}>
<WizardLayout <WizardLayout
variant="wide-form" variant={isMobile ? 'wide-form' : 'bleed'}
navigation={navigation} navigation={navigation}
showBackLink showBackLink={isMobile}
backLabel="Back" backLabel="Back"
onBack={onBack} onBack={onBack}
> >
{/* Page header with Share/Print actions */} {!isMobile && (
<Box sx={{ mb: { xs: 3, md: 5 } }}> <>
<Box {/* Page header zone — centred, bounded to the table's natural width */}
sx={{ <Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
display: 'flex', <Box
alignItems: 'flex-start', sx={{
justifyContent: 'space-between', width: '100%',
gap: 2, maxWidth: pageMaxWidth,
flexWrap: 'wrap', px: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
}} pt: { xs: 2, md: 3 },
> pb: { xs: 3, md: 5 },
<Box> }}
>
<Link
component="button"
onClick={onBack}
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 }} />
Back
</Link>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages
</Typography>
<Typography variant="body1" color="text.secondary" aria-live="polite">
{subtitle}
</Typography>
</Box>
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
</Box>
<Divider />
{/* Table zone — width-matching spacers centre the table when room
allows, collapse to the minimum when table is wider than
viewport so overflow extends rightward from the page's
content column. */}
<Box
sx={{
display: 'flex',
width: 'max-content',
minWidth: '100%',
py: { xs: 3, md: 5 },
}}
>
<Box
aria-hidden
sx={{
flex: 1,
minWidth: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
}}
/>
<Box sx={{ flexShrink: 0, width: tableNaturalWidth }}>
<ComparisonTable
packages={allPackages}
onArrange={onArrange}
onRemove={onRemove}
/>
</Box>
<Box
aria-hidden
sx={{
flex: 1,
minWidth: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
}}
/>
</Box>
</>
)}
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
<Box sx={{ mb: 3 }}>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}> <Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages Compare packages
</Typography> </Typography>
@@ -142,50 +269,21 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
</Typography> </Typography>
</Box> </Box>
{/* Share + Print */} <Divider sx={{ mb: 3 }} />
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
{/* Desktop: ComparisonTable */} <Typography
{!isMobile && ( id="comparison-rail-heading"
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} /> variant="label"
)} component="h2"
sx={{ fontWeight: 600, display: 'block', mb: 1.5 }}
{/* Mobile: Tab rail + card view */} >
{isMobile && allPackages.length > 0 && ( Choose a package to view
<> </Typography>
{/* Tab rail — mini cards showing provider + package + price */}
<Box <Box
ref={railRef} ref={railRef}
role="tablist" role="tablist"
id={tablistId} id={tablistId}
aria-label="Packages to compare" aria-labelledby="comparison-rail-heading"
sx={{ sx={{
display: 'flex', display: 'flex',
gap: 1.5, gap: 1.5,
@@ -193,8 +291,7 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
py: 2, py: 2,
px: 2, px: 2,
mx: -2, mx: -2,
mt: 1, mb: 1.5,
mb: 3,
scrollbarWidth: 'none', scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' }, '&::-webkit-scrollbar': { display: 'none' },
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
@@ -216,6 +313,54 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
))} ))}
</Box> </Box>
{/* Dot indicator — position + count. Purely visual supplement;
the tab rail above is the accessible navigation, so dots
are aria-hidden and skipped by keyboard tab-order. */}
<Box
aria-hidden="true"
sx={{
display: 'flex',
justifyContent: 'center',
gap: 0.5,
mb: 3,
}}
>
{allPackages.map((_, idx) => {
const isActive = idx === activeTabIdx;
return (
<Box
key={idx}
component="button"
type="button"
tabIndex={-1}
onClick={() => handleTabClick(idx)}
sx={{
appearance: 'none',
border: 0,
background: 'transparent',
cursor: 'pointer',
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& > span': {
display: 'block',
width: isActive ? 24 : 8,
height: 8,
borderRadius: 4,
bgcolor: isActive
? 'var(--fa-color-brand-600)'
: 'var(--fa-color-neutral-300)',
transition: 'width 0.2s ease, background-color 0.2s ease',
},
}}
>
<span />
</Box>
);
})}
</Box>
{activePackage && ( {activePackage && (
<Box <Box
role="tabpanel" role="tabpanel"

View File

@@ -16,7 +16,8 @@ export type WizardLayoutVariant =
| 'list-map' | 'list-map'
| 'list-detail' | 'list-detail'
| 'grid-sidebar' | 'grid-sidebar'
| 'detail-toggles'; | 'detail-toggles'
| 'bleed';
/** Props for the WizardLayout template */ /** Props for the WizardLayout template */
export interface WizardLayoutProps { export interface WizardLayoutProps {
@@ -362,6 +363,30 @@ const DetailTogglesLayout: React.FC<{
</Box> </Box>
); );
/** Bleed: full-width scroll host. Main becomes the single scroll container
* (both axes). No inner Container — children are full-bleed. Back link is
* passed into children so it scrolls with the page content. Used by pages
* that own their own width + alignment logic (e.g. ComparisonPage). */
const BleedLayout: React.FC<{
children: React.ReactNode;
backLink?: React.ReactNode;
}> = ({ children, backLink }) => (
<Box
id="wizard-scroll"
data-wizard-scroll
sx={{
flex: 1,
minHeight: 0,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{backLink}
{children}
</Box>
);
// ─── Variant map ───────────────────────────────────────────────────────────── // ─── Variant map ─────────────────────────────────────────────────────────────
const LAYOUT_MAP: Record< const LAYOUT_MAP: Record<
@@ -378,6 +403,7 @@ const LAYOUT_MAP: Record<
'list-detail': ListDetailLayout, 'list-detail': ListDetailLayout,
'grid-sidebar': GridSidebarLayout, 'grid-sidebar': GridSidebarLayout,
'detail-toggles': DetailTogglesLayout, 'detail-toggles': DetailTogglesLayout,
bleed: BleedLayout,
}; };
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */ /* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
@@ -387,12 +413,15 @@ const LAYOUT_MAP: Record<
/** /**
* Page-level layout template for the FA arrangement wizard. * Page-level layout template for the FA arrangement wizard.
* *
* Provides 5 layout variants matching the wizard page templates: * Provides 6 layout variants matching the wizard page templates:
* - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.) * - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.)
* - **wide-form**: Wider single column for card grids (coffins, etc.)
* - **list-map**: Split view with scrollable card list and map panel (providers) * - **list-map**: Split view with scrollable card list and map panel (providers)
* - **list-detail**: Master-detail split for selection + detail (packages, preview) * - **list-detail**: Master-detail split for selection + detail (packages, preview)
* - **grid-sidebar**: Filter sidebar + card grid (coffins) * - **grid-sidebar**: Filter sidebar + card grid (coffins)
* - **detail-toggles**: Hero image + info column (venue, coffin details) * - **detail-toggles**: Hero image + info column (venue, coffin details)
* - **bleed**: Viewport-locked, full-width scroll host with no inner container —
* the page owns its own alignment (comparison page)
* *
* All variants share: navigation slot, optional back link, sticky help bar, * All variants share: navigation slot, optional back link, sticky help bar,
* and optional progress stepper + running total bar (shown when props provided). * and optional progress stepper + running total bar (shown when props provided).
@@ -426,8 +455,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
flexDirection: 'column', flexDirection: 'column',
minHeight: '100vh', minHeight: '100vh',
bgcolor: 'background.default', bgcolor: 'background.default',
// list-map + detail-toggles: lock to viewport so panels scroll independently // list-map + detail-toggles + bleed: lock to viewport so panels scroll independently
...((variant === 'list-map' || variant === 'detail-toggles') && { ...((variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') && {
height: '100vh', height: '100vh',
overflow: 'hidden', overflow: 'hidden',
}), }),
@@ -445,15 +474,19 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */} {/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
<StepperBar stepper={progressStepper} total={runningTotal} /> <StepperBar stepper={progressStepper} total={runningTotal} />
{/* Back link — inside left panel for list-map/detail-toggles, above content for others */} {/* Back link — inside children for list-map/detail-toggles/bleed (scrolls with content),
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && ( above content for other variants */}
<Container {showBackLink &&
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'} variant !== 'list-map' &&
sx={{ pt: 2, px: { xs: 4, md: 3 } }} variant !== 'detail-toggles' &&
> variant !== 'bleed' && (
<BackLink label={backLabel} onClick={onBack} /> <Container
</Container> maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
)} sx={{ pt: 2, px: { xs: 4, md: 3 } }}
>
<BackLink label={backLabel} onClick={onBack} />
</Container>
)}
{/* Main content area */} {/* Main content area */}
<Box <Box
@@ -463,7 +496,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
<LayoutComponent <LayoutComponent
secondaryPanel={secondaryPanel} secondaryPanel={secondaryPanel}
backLink={ backLink={
showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? ( showBackLink &&
(variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') ? (
<Box sx={{ pt: 1.5 }}> <Box sx={{ pt: 1.5 }}>
<BackLink label={backLabel} onClick={onBack} /> <BackLink label={backLabel} onClick={onBack} />
</Box> </Box>