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:
@@ -67,17 +67,17 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
<Badge
|
||||
color="brand"
|
||||
variant={pkg.isRecommended ? 'filled' : 'soft'}
|
||||
size="small"
|
||||
size="medium"
|
||||
icon={
|
||||
pkg.isRecommended ? (
|
||||
<StarRoundedIcon sx={{ fontSize: 14 }} />
|
||||
<StarRoundedIcon sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
top: -13,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
@@ -109,20 +109,23 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
px: 2.5,
|
||||
py: 2.5,
|
||||
pt: pkg.provider.verified || pkg.isRecommended ? 3 : 2.5,
|
||||
gap: 0.5,
|
||||
pt: 5,
|
||||
pb: 3,
|
||||
gap: 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
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
gap: 0.75,
|
||||
maxWidth: '100%',
|
||||
minHeight: 36, // 2 × (14px label × 1.286 line-height)
|
||||
}}
|
||||
>
|
||||
{pkg.isRecommended && (
|
||||
@@ -131,6 +134,7 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
fontSize: 16,
|
||||
color: 'var(--fa-color-brand-600)',
|
||||
flexShrink: 0,
|
||||
mb: '2px',
|
||||
}}
|
||||
aria-label="Verified provider"
|
||||
/>
|
||||
@@ -139,15 +143,17 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
title={pkg.provider.name}
|
||||
arrow
|
||||
placement="top"
|
||||
disableHoverListener={pkg.provider.name.length < 24}
|
||||
disableHoverListener={pkg.provider.name.length < 50}
|
||||
>
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
@@ -179,18 +185,29 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ width: '100%', my: 1 }} />
|
||||
<Divider sx={{ width: '100%', my: 1.5 }} />
|
||||
|
||||
<Typography variant="h6" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
{/* Price subgroup — tighter internal spacing than the outer gap
|
||||
so the label sits close to the amount it describes. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
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 */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
@@ -200,28 +217,33 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 1.5, px: 4 }}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||
</Button>
|
||||
|
||||
{!pkg.isRecommended && onRemove ? (
|
||||
<Link
|
||||
component="button"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={() => onRemove(pkg.id)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
) : (
|
||||
/* Invisible spacer keeps CTA aligned with cards that show Remove */
|
||||
<Box sx={{ mt: 0.5, visibility: 'hidden' }} aria-hidden>
|
||||
<Typography variant="caption">Remove</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* Always render the same Link element; hide when no Remove action
|
||||
applies (recommended or no handler). Keeps the footer row
|
||||
identical across all cards so CTAs align. */}
|
||||
{(() => {
|
||||
const canRemove = !pkg.isRecommended && !!onRemove;
|
||||
return (
|
||||
<Link
|
||||
component="button"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={canRemove ? () => onRemove!(pkg.id) : undefined}
|
||||
tabIndex={canRemove ? 0 : -1}
|
||||
aria-hidden={!canRemove}
|
||||
sx={{
|
||||
...(!canRemove && { visibility: 'hidden', pointerEvents: 'none' }),
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
@@ -63,7 +63,55 @@ function formatPrice(amount: number): string {
|
||||
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) {
|
||||
case 'price':
|
||||
return (
|
||||
@@ -79,33 +127,31 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
);
|
||||
case 'complimentary':
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
||||
>
|
||||
Complimentary
|
||||
</Typography>
|
||||
</Box>
|
||||
<CellIconText
|
||||
color="var(--fa-color-feedback-success)"
|
||||
icon={
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
>
|
||||
Complimentary
|
||||
</CellIconText>
|
||||
);
|
||||
case 'included':
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
||||
>
|
||||
Included
|
||||
</Typography>
|
||||
</Box>
|
||||
<CellIconText
|
||||
color="var(--fa-color-feedback-success)"
|
||||
icon={
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
>
|
||||
Included
|
||||
</CellIconText>
|
||||
);
|
||||
case 'poa':
|
||||
return (
|
||||
@@ -115,20 +161,30 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
);
|
||||
case 'unknown':
|
||||
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
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||
>
|
||||
Unknown
|
||||
Not Included
|
||||
</Typography>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
case 'unavailable':
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
|
||||
—
|
||||
@@ -207,6 +263,19 @@ const tableSx = {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -219,10 +288,10 @@ const tableSx = {
|
||||
*/
|
||||
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
|
||||
({ packages, onArrange, onRemove, sx }, ref) => {
|
||||
const colCount = packages.length + 1;
|
||||
const mergedSections = buildMergedSections(packages);
|
||||
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
|
||||
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
|
||||
const colCount = packages.length + 1;
|
||||
const gridCols = `${COMPARISON_TABLE_COL_WIDTH}px repeat(${packages.length}, ${COMPARISON_TABLE_COL_WIDTH}px)`;
|
||||
const recommendedColIdx = packages.findIndex((p) => p.isRecommended);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -232,32 +301,39 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
sx={[
|
||||
{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
overflowX: 'auto',
|
||||
width: COMPARISON_TABLE_COL_WIDTH * colCount,
|
||||
},
|
||||
...(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
|
||||
role="row"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: 2,
|
||||
mb: 4,
|
||||
alignItems: 'stretch',
|
||||
pt: 3, // Room for floating verified badges
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: Z_HEADER_ROW,
|
||||
bgcolor: 'background.default',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
{/* Info card — stretches to match package card height, text at top */}
|
||||
<Card
|
||||
role="columnheader"
|
||||
variant="elevated"
|
||||
padding="default"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
alignSelf: 'stretch',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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.
|
||||
</Typography>
|
||||
</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
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
onArrange={onArrange}
|
||||
onRemove={onRemove}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* ── Section tables (each separate with left accent headings) ── */}
|
||||
{mergedSections.map((section) => (
|
||||
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
||||
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
|
||||
{/* ── Section tables (each separate with left accent headings) ── */}
|
||||
{mergedSections.map((section) => (
|
||||
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
||||
{/* 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>
|
||||
</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
|
||||
key={item.name}
|
||||
role="row"
|
||||
role="cell"
|
||||
className="comparison-cell comparison-cell--label"
|
||||
sx={{
|
||||
gridColumn: `1 / ${colCount + 1}`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'subgrid',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: Z_STICKY_LEFT,
|
||||
bgcolor: 'background.paper',
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
role="cell"
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.info && (
|
||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{'\u00A0'}
|
||||
<Tooltip title={item.info} arrow placement="top">
|
||||
<InfoOutlinedIcon
|
||||
aria-label={`More information about ${item.name}`}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'var(--fa-color-neutral-400)',
|
||||
cursor: 'help',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.info && (
|
||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{'\u00A0'}
|
||||
<Tooltip title={item.info} arrow placement="top">
|
||||
<InfoOutlinedIcon
|
||||
aria-label={`More information about ${item.name}`}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
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
|
||||
key={pkg.id}
|
||||
role="cell"
|
||||
className={
|
||||
'comparison-cell' + (isRecommended ? ' comparison-cell--recommended' : '')
|
||||
}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -351,23 +473,33 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
borderColor: 'divider',
|
||||
borderLeft: '1px solid',
|
||||
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>
|
||||
))}
|
||||
|
||||
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
* Some providers have not provided an itemised pricing breakdown. Their items are
|
||||
shown as "—" above.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
* Some providers have not provided an itemised pricing breakdown. Their items are shown
|
||||
as "—" above.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { ComparisonTable, default } from './ComparisonTable';
|
||||
export { ComparisonTable, COMPARISON_TABLE_COL_WIDTH, default } from './ComparisonTable';
|
||||
export type {
|
||||
ComparisonTableProps,
|
||||
ComparisonPackage,
|
||||
|
||||
@@ -122,7 +122,7 @@ const pkgMackay: ComparisonPackage = {
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 5495.45,
|
||||
provider: {
|
||||
name: 'Mackay Family Funerals',
|
||||
name: 'Mackay Family Funeral Directors & Cremation Services',
|
||||
location: 'Inglewood',
|
||||
logoUrl: DEMO_LOGO,
|
||||
rating: 4.6,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Link } from '../../atoms/Link';
|
||||
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 { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
||||
|
||||
@@ -113,27 +120,147 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
// 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 (
|
||||
<Box ref={ref} sx={sx}>
|
||||
<WizardLayout
|
||||
variant="wide-form"
|
||||
variant={isMobile ? 'wide-form' : 'bleed'}
|
||||
navigation={navigation}
|
||||
showBackLink
|
||||
showBackLink={isMobile}
|
||||
backLabel="Back"
|
||||
onBack={onBack}
|
||||
>
|
||||
{/* Page header with Share/Print actions */}
|
||||
<Box sx={{ mb: { xs: 3, md: 5 } }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{!isMobile && (
|
||||
<>
|
||||
{/* Page header zone — centred, bounded to the table's natural width */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: pageMaxWidth,
|
||||
px: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
|
||||
pt: { xs: 2, md: 3 },
|
||||
pb: { xs: 3, md: 5 },
|
||||
}}
|
||||
>
|
||||
<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 }}>
|
||||
Compare packages
|
||||
</Typography>
|
||||
@@ -142,50 +269,21 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Share + Print */}
|
||||
{(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>
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Desktop: ComparisonTable */}
|
||||
{!isMobile && (
|
||||
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
|
||||
)}
|
||||
|
||||
{/* Mobile: Tab rail + card view */}
|
||||
{isMobile && allPackages.length > 0 && (
|
||||
<>
|
||||
{/* Tab rail — mini cards showing provider + package + price */}
|
||||
<Typography
|
||||
id="comparison-rail-heading"
|
||||
variant="label"
|
||||
component="h2"
|
||||
sx={{ fontWeight: 600, display: 'block', mb: 1.5 }}
|
||||
>
|
||||
Choose a package to view
|
||||
</Typography>
|
||||
<Box
|
||||
ref={railRef}
|
||||
role="tablist"
|
||||
id={tablistId}
|
||||
aria-label="Packages to compare"
|
||||
aria-labelledby="comparison-rail-heading"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
@@ -193,8 +291,7 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
py: 2,
|
||||
px: 2,
|
||||
mx: -2,
|
||||
mt: 1,
|
||||
mb: 3,
|
||||
mb: 1.5,
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
@@ -216,6 +313,54 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
))}
|
||||
</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 && (
|
||||
<Box
|
||||
role="tabpanel"
|
||||
|
||||
@@ -16,7 +16,8 @@ export type WizardLayoutVariant =
|
||||
| 'list-map'
|
||||
| 'list-detail'
|
||||
| 'grid-sidebar'
|
||||
| 'detail-toggles';
|
||||
| 'detail-toggles'
|
||||
| 'bleed';
|
||||
|
||||
/** Props for the WizardLayout template */
|
||||
export interface WizardLayoutProps {
|
||||
@@ -362,6 +363,30 @@ const DetailTogglesLayout: React.FC<{
|
||||
</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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const LAYOUT_MAP: Record<
|
||||
@@ -378,6 +403,7 @@ const LAYOUT_MAP: Record<
|
||||
'list-detail': ListDetailLayout,
|
||||
'grid-sidebar': GridSidebarLayout,
|
||||
'detail-toggles': DetailTogglesLayout,
|
||||
bleed: BleedLayout,
|
||||
};
|
||||
|
||||
/* 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.
|
||||
*
|
||||
* 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.)
|
||||
* - **wide-form**: Wider single column for card grids (coffins, 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)
|
||||
* - **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,
|
||||
* and optional progress stepper + running total bar (shown when props provided).
|
||||
@@ -426,8 +455,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
// list-map + detail-toggles: lock to viewport so panels scroll independently
|
||||
...((variant === 'list-map' || variant === 'detail-toggles') && {
|
||||
// list-map + detail-toggles + bleed: lock to viewport so panels scroll independently
|
||||
...((variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') && {
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
@@ -445,15 +474,19 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Back link — inside children for list-map/detail-toggles/bleed (scrolls with content),
|
||||
above content for other variants */}
|
||||
{showBackLink &&
|
||||
variant !== 'list-map' &&
|
||||
variant !== 'detail-toggles' &&
|
||||
variant !== 'bleed' && (
|
||||
<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
|
||||
@@ -463,7 +496,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
<LayoutComponent
|
||||
secondaryPanel={secondaryPanel}
|
||||
backLink={
|
||||
showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? (
|
||||
showBackLink &&
|
||||
(variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') ? (
|
||||
<Box sx={{ pt: 1.5 }}>
|
||||
<BackLink label={backLabel} onClick={onBack} />
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user