Files
Parsons/src/components/organisms/ComparisonTable/ComparisonTable.tsx
Richie e89ac360e8 Extract ComparisonColumnCard + ComparisonTabCard molecules, refine comparison UI
- New molecule: ComparisonColumnCard — desktop column header card extracted
  from ComparisonTable (~150 lines removed from organism)
- New molecule: ComparisonTabCard — mobile tab rail card extracted from
  ComparisonPage (shared by V1 and V2)
- CellValue "unknown" restyled: icon+text in neutral grey (was Badge),
  InfoOutlinedIcon on right at 14px matching item info icons
- Unverified provider story data: all items set to unknown across all
  story files (no dashes in essentials)
- Mobile tab rail: recommended badge (replaces star), package price,
  shadow/glow, center-on-select scroll, overflow clipping fixed
- ComparisonPackageCard: added shadow, reduced CTA button to medium
- ComparisonTable first column: inline info icon pattern (non-breaking
  space + nowrap span) prevents icon orphaning on line wrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:10:17 +10:00

378 lines
12 KiB
TypeScript

import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Card } from '../../atoms/Card';
import { ComparisonColumnCard } from '../../molecules/ComparisonColumnCard';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Cell value types for the comparison table */
export type ComparisonCellValue =
| { type: 'price'; amount: number }
| { type: 'allowance'; amount: number }
| { type: 'complimentary' }
| { type: 'included' }
| { type: 'poa' }
| { type: 'unknown' }
| { type: 'unavailable' };
export interface ComparisonLineItem {
name: string;
info?: string;
value: ComparisonCellValue;
}
export interface ComparisonSection {
heading: string;
items: ComparisonLineItem[];
}
export interface ComparisonProvider {
name: string;
location: string;
logoUrl?: string;
rating?: number;
reviewCount?: number;
verified: boolean;
}
export interface ComparisonPackage {
id: string;
name: string;
price: number;
provider: ComparisonProvider;
sections: ComparisonSection[];
isRecommended?: boolean;
itemizedAvailable?: boolean;
}
export interface ComparisonTableProps {
packages: ComparisonPackage[];
onArrange: (packageId: string) => void;
onRemove: (packageId: string) => void;
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
function CellValue({ value }: { value: ComparisonCellValue }) {
switch (value.type) {
case 'price':
return (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
{formatPrice(value.amount)}
</Typography>
);
case 'allowance':
return (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
{formatPrice(value.amount)}*
</Typography>
);
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>
);
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>
);
case 'poa':
return (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Price On Application
</Typography>
);
case 'unknown':
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
>
Unknown
</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)' }}>
</Typography>
);
}
}
function buildMergedSections(
packages: ComparisonPackage[],
): { heading: string; items: { name: string; info?: string }[] }[] {
const sectionMap = new Map<string, { name: string; info?: string }[]>();
const sectionOrder: string[] = [];
for (const pkg of packages) {
if (pkg.itemizedAvailable === false) continue;
for (const section of pkg.sections) {
if (!sectionMap.has(section.heading)) {
sectionMap.set(section.heading, []);
sectionOrder.push(section.heading);
}
const existing = sectionMap.get(section.heading)!;
for (const item of section.items) {
if (!existing.some((e) => e.name === item.name)) {
existing.push({ name: item.name, info: item.info });
}
}
}
}
return sectionOrder.map((heading) => ({
heading,
items: sectionMap.get(heading) ?? [],
}));
}
function lookupValue(
pkg: ComparisonPackage,
sectionHeading: string,
itemName: string,
): ComparisonCellValue {
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
const section = pkg.sections.find((s) => s.heading === sectionHeading);
if (!section) return { type: 'unavailable' };
const item = section.items.find((i) => i.name === itemName);
if (!item) return { type: 'unavailable' };
return item.value;
}
/** Section heading with left accent border */
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<Box
sx={{
bgcolor: 'var(--fa-color-surface-subtle)',
px: 3,
py: 2.5,
borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)',
}}
>
<Typography variant="h6" component="h3">
{children}
</Typography>
</Box>
);
}
/** Reusable bordered table wrapper */
const tableSx = {
display: 'grid',
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
};
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Side-by-side package comparison table for the FA design system.
*
* Info card in top-left column, floating verified badges above cards,
* section tables with left accent borders, no reviews table (rating in cards).
*
* Desktop only — ComparisonPage handles the mobile card view.
*/
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;
return (
<Box
ref={ref}
role="table"
aria-label="Package comparison"
sx={[
{
display: { xs: 'none', md: 'block' },
overflowX: 'auto',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Box sx={{ minWidth: minW }}>
{/* ── Package header cards ── */}
<Box
role="row"
sx={{
display: 'grid',
gridTemplateColumns: gridCols,
gap: 2,
mb: 4,
alignItems: 'stretch',
pt: 3, // Room for floating verified badges
}}
>
{/* 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',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
border: 'none',
boxShadow: 'none',
}}
>
<Typography variant="label" sx={{ fontWeight: 700, display: 'block', mb: 1 }}>
Package Comparison
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ lineHeight: 1.5, display: 'block' }}
>
Review and compare features side-by-side to find the right fit.
</Typography>
</Card>
{/* Package column header cards */}
{packages.map((pkg) => (
<ComparisonColumnCard
key={pkg.id}
pkg={pkg}
onArrange={onArrange}
onRemove={onRemove}
/>
))}
</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}` }}>
<SectionHeading>{section.heading}</SectionHeading>
</Box>
{section.items.map((item) => (
<Box
key={item.name}
role="row"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
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>
{packages.map((pkg) => (
<Box
key={pkg.id}
role="cell"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
px: 2,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
borderLeft: '1px solid',
borderLeftColor: 'divider',
}}
>
<CellValue value={lookupValue(pkg, section.heading, item.name)} />
</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>
</Box>
);
},
);
ComparisonTable.displayName = 'ComparisonTable';
export default ComparisonTable;