From f146bb0f8140ca3a2d19a78721d7d7befdbe23dc Mon Sep 17 00:00:00 2001 From: Richie Date: Fri, 17 Apr 2026 15:51:41 +1000 Subject: [PATCH] Restructure ComparisonPage with bleed variant + sticky-left columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ComparisonColumnCard.tsx | 94 +++-- .../ComparisonTable/ComparisonTable.tsx | 348 ++++++++++++------ .../organisms/ComparisonTable/index.ts | 2 +- .../ComparisonPage/ComparisonPage.stories.tsx | 2 +- .../pages/ComparisonPage/ComparisonPage.tsx | 257 ++++++++++--- .../templates/WizardLayout/WizardLayout.tsx | 62 +++- 6 files changed, 549 insertions(+), 216 deletions(-) diff --git a/src/components/molecules/ComparisonColumnCard/ComparisonColumnCard.tsx b/src/components/molecules/ComparisonColumnCard/ComparisonColumnCard.tsx index 22ec8d7..5b2597c 100644 --- a/src/components/molecules/ComparisonColumnCard/ComparisonColumnCard.tsx +++ b/src/components/molecules/ComparisonColumnCard/ComparisonColumnCard.tsx @@ -67,17 +67,17 @@ export const ComparisonColumnCard = React.forwardRef + ) : ( - + ) } sx={{ position: 'absolute', - top: -12, + top: -13, left: '50%', transform: 'translateX(-50%)', zIndex: 1, @@ -109,20 +109,23 @@ export const ComparisonColumnCard = React.forwardRef - {/* 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. */} {pkg.isRecommended && ( @@ -131,6 +134,7 @@ export const ComparisonColumnCard = React.forwardRef @@ -139,15 +143,17 @@ export const ComparisonColumnCard = React.forwardRef @@ -179,18 +185,29 @@ export const ComparisonColumnCard = React.forwardRef )} - + {pkg.name} - - Total package price - - - {formatPrice(pkg.price)} - + {/* Price subgroup — tighter internal spacing than the outer gap + so the label sits close to the amount it describes. */} + + + Total package price + + + {formatPrice(pkg.price)} + + {/* Spacer pushes CTA to bottom across all cards */} @@ -200,28 +217,33 @@ export const ComparisonColumnCard = React.forwardRef onArrange(pkg.id)} - sx={{ mt: 1.5, px: 4 }} + sx={{ px: 4 }} > {pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'} - {!pkg.isRecommended && onRemove ? ( - onRemove(pkg.id)} - sx={{ mt: 0.5 }} - > - Remove - - ) : ( - /* Invisible spacer keeps CTA aligned with cards that show Remove */ - - Remove - - )} + {/* 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 ( + onRemove!(pkg.id) : undefined} + tabIndex={canRemove ? 0 : -1} + aria-hidden={!canRemove} + sx={{ + ...(!canRemove && { visibility: 'hidden', pointerEvents: 'none' }), + }} + > + Remove + + ); + })()} diff --git a/src/components/organisms/ComparisonTable/ComparisonTable.tsx b/src/components/organisms/ComparisonTable/ComparisonTable.tsx index 930fe37..658022d 100644 --- a/src/components/organisms/ComparisonTable/ComparisonTable.tsx +++ b/src/components/organisms/ComparisonTable/ComparisonTable.tsx @@ -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 ( + + {iconPosition === 'leading' && icon} + + {children} + + {iconPosition === 'trailing' && icon} + + ); +} + +/** 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 ( - - - - Complimentary - - + + } + > + Complimentary + ); case 'included': return ( - - - - Included - - + + } + > + Included + ); case 'poa': return ( @@ -115,20 +161,30 @@ function CellValue({ value }: { value: ComparisonCellValue }) { ); case 'unknown': return ( - + + } + > + Unknown + + ); + case 'unavailable': + if (OPTIONAL_SECTION_HEADINGS.has(sectionHeading)) { + return ( - Unknown + Not Included - - - ); - case 'unavailable': + ); + } return ( — @@ -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( ({ 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 ( - - {/* ── Package header cards ── */} + {/* ── Package header cards ── */} + + {/* Info card — sticky-left, matches the row-label column below */} - {/* Info card — stretches to match package card height, text at top */} + - {/* Package column header cards */} - {packages.map((pkg) => ( + {packages.map((pkg) => ( + - ))} - + + ))} + - {/* ── Section tables (each separate with left accent headings) ── */} - {mergedSections.map((section) => ( - - + {/* ── Section tables (each separate with left accent headings) ── */} + {mergedSections.map((section) => ( + + {/* Section heading row — left cell sticky so label stays visible on horizontal scroll */} + + {section.heading} + {/* Background continuation for the remaining columns so they + share the heading's surface-subtle wash. */} + + - {section.items.map((item) => ( + {section.items.map((item) => ( + + {/* Row-label cell — sticky-left */} - - - {item.name} - - {item.info && ( - - {'\u00A0'} - - - - - )} - + + {item.name} + + {item.info && ( + + {'\u00A0'} + + + + + )} + - {packages.map((pkg) => ( + {packages.map((pkg, idx) => { + const isRecommended = idx === recommendedColIdx; + return ( - + - ))} - - ))} - - ))} + ); + })} + + ))} + + ))} - {packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && ( - - * Some providers have not provided an itemised pricing breakdown. Their items are - shown as "—" above. - - )} - + {packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && ( + + * Some providers have not provided an itemised pricing breakdown. Their items are shown + as "—" above. + + )} ); }, diff --git a/src/components/organisms/ComparisonTable/index.ts b/src/components/organisms/ComparisonTable/index.ts index f73c3ca..b972ba6 100644 --- a/src/components/organisms/ComparisonTable/index.ts +++ b/src/components/organisms/ComparisonTable/index.ts @@ -1,4 +1,4 @@ -export { ComparisonTable, default } from './ComparisonTable'; +export { ComparisonTable, COMPARISON_TABLE_COL_WIDTH, default } from './ComparisonTable'; export type { ComparisonTableProps, ComparisonPackage, diff --git a/src/components/pages/ComparisonPage/ComparisonPage.stories.tsx b/src/components/pages/ComparisonPage/ComparisonPage.stories.tsx index 0a6f8c1..78714e2 100644 --- a/src/components/pages/ComparisonPage/ComparisonPage.stories.tsx +++ b/src/components/pages/ComparisonPage/ComparisonPage.stories.tsx @@ -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, diff --git a/src/components/pages/ComparisonPage/ComparisonPage.tsx b/src/components/pages/ComparisonPage/ComparisonPage.tsx index 2183626..b72854a 100644 --- a/src/components/pages/ComparisonPage/ComparisonPage.tsx +++ b/src/components/pages/ComparisonPage/ComparisonPage.tsx @@ -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 - {/* Page header with Share/Print actions */} - - - + {!isMobile && ( + <> + {/* Page header zone — centred, bounded to the table's natural width */} + + + + + Back + + + + + Compare packages + + + {subtitle} + + + + {(onShare || onPrint) && ( + + {onShare && ( + + )} + {onPrint && ( + + )} + + )} + + + + + + + {/* 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. */} + + + + + + + + + )} + + {/* Mobile: Tab rail + card view */} + {isMobile && allPackages.length > 0 && ( + <> + Compare packages @@ -142,50 +269,21 @@ export const ComparisonPage = React.forwardRef - {/* Share + Print */} - {(onShare || onPrint) && ( - - {onShare && ( - - )} - {onPrint && ( - - )} - - )} - - + - {/* Desktop: ComparisonTable */} - {!isMobile && ( - - )} - - {/* Mobile: Tab rail + card view */} - {isMobile && allPackages.length > 0 && ( - <> - {/* Tab rail — mini cards showing provider + package + price */} + + Choose a package to view + + {/* 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. */} + + {activePackage && ( ); +/** 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 }) => ( + + {backLink} + {children} + +); + // ─── 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( 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( {/* Stepper + running total bar (grid-sidebar, detail-toggles only) */} - {/* Back link — inside left panel for list-map/detail-toggles, above content for others */} - {showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && ( - - - - )} + {/* 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' && ( + + + + )} {/* Main content area */} (