Initial commit: FA Design System source files

Copy of the Funeral Arranger design system components, theme, tokens,
and Storybook config from the original Parsons project. Pre-upgrade
baseline with React 18, MUI v5, Storybook 8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 13:12:45 +10:00
commit 4cafd84142
2555 changed files with 40558 additions and 0 deletions

View File

@@ -0,0 +1,346 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Badge } from './Badge';
import { Card } from '../Card';
import { Typography } from '../Typography';
import { Button } from '../Button';
import Box from '@mui/material/Box';
import StarIcon from '@mui/icons-material/Star';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import NewReleasesIcon from '@mui/icons-material/NewReleases';
import VerifiedIcon from '@mui/icons-material/Verified';
const meta: Meta<typeof Badge> = {
title: 'Atoms/Badge',
component: Badge,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
color: {
control: 'select',
options: ['default', 'brand', 'success', 'warning', 'error', 'info'],
description: 'Colour intent',
table: { defaultValue: { summary: 'default' } },
},
variant: {
control: 'select',
options: ['soft', 'filled'],
description: 'Visual style variant',
table: { defaultValue: { summary: 'soft' } },
},
size: {
control: 'select',
options: ['small', 'medium', 'large'],
description: 'Size preset',
table: { defaultValue: { summary: 'medium' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Badge>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default badge — soft variant, default colour */
export const Default: Story = {
args: {
children: 'Status',
},
};
// ─── All Colours — Soft ─────────────────────────────────────────────────────
/** Soft variant across all colour options */
export const AllColoursSoft: Story = {
name: 'All Colours — Soft',
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge color="default">Default</Badge>
<Badge color="brand">Brand</Badge>
<Badge color="success">Success</Badge>
<Badge color="warning">Warning</Badge>
<Badge color="error">Error</Badge>
<Badge color="info">Info</Badge>
</div>
),
};
// ─── All Colours — Filled ───────────────────────────────────────────────────
/** Filled variant across all colour options */
export const AllColoursFilled: Story = {
name: 'All Colours — Filled',
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge variant="filled" color="default">
Default
</Badge>
<Badge variant="filled" color="brand">
Brand
</Badge>
<Badge variant="filled" color="success">
Success
</Badge>
<Badge variant="filled" color="warning">
Warning
</Badge>
<Badge variant="filled" color="error">
Error
</Badge>
<Badge variant="filled" color="info">
Info
</Badge>
</div>
),
};
// ─── With Icons ─────────────────────────────────────────────────────────────
/** Badges with leading icons */
export const WithIcons: Story = {
name: 'With Icons',
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge color="brand" icon={<StarIcon />}>
Popular
</Badge>
<Badge color="success" icon={<CheckCircleIcon />}>
Verified
</Badge>
<Badge color="warning" icon={<WarningAmberIcon />}>
Limited
</Badge>
<Badge color="error" icon={<ErrorOutlineIcon />}>
Sold out
</Badge>
<Badge color="info" icon={<InfoOutlinedIcon />}>
New
</Badge>
</div>
),
};
/** Filled badges with icons */
export const WithIconsFilled: Story = {
name: 'With Icons — Filled',
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge variant="filled" color="brand" icon={<StarIcon />}>
Popular
</Badge>
<Badge variant="filled" color="success" icon={<CheckCircleIcon />}>
Included
</Badge>
<Badge variant="filled" color="warning" icon={<WarningAmberIcon />}>
Attention
</Badge>
<Badge variant="filled" color="error" icon={<ErrorOutlineIcon />}>
Unavailable
</Badge>
<Badge variant="filled" color="info" icon={<InfoOutlinedIcon />}>
Updated
</Badge>
</div>
),
};
// ─── Sizes ──────────────────────────────────────────────────────────────────
/** All three sizes side by side */
export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Badge size="small" color="brand" icon={<StarIcon />}>
Small
</Badge>
<Badge size="medium" color="brand" icon={<StarIcon />}>
Medium
</Badge>
<Badge size="large" color="brand" icon={<StarIcon />}>
Large
</Badge>
</div>
),
};
// ─── In Context: Price Card ─────────────────────────────────────────────────
/**
* Badge used inside a PriceCard-style layout.
* Shows how badges label card content in realistic compositions.
*/
export const InPriceCard: Story = {
name: 'In Context — Price Card',
render: () => (
<div style={{ display: 'flex', gap: 24, maxWidth: 750 }}>
<Card sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2 }}>
<Typography variant="overline" color="text.secondary">
Essential
</Typography>
<Badge size="small" color="default">
Standard
</Badge>
</Box>
<Typography variant="display3" color="primary" sx={{ mb: 1 }}>
$3,200
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
A simple, respectful service with chapel ceremony.
</Typography>
<Button fullWidth>Select</Button>
</Card>
<Card sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2 }}>
<Typography variant="overline" color="text.secondary">
Premium
</Typography>
<Badge color="brand" icon={<StarIcon />}>
Most popular
</Badge>
</Box>
<Typography variant="display3" color="primary" sx={{ mb: 1 }}>
$5,800
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Comprehensive service with premium inclusions.
</Typography>
<Button fullWidth>Select</Button>
</Card>
<Card sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2 }}>
<Typography variant="overline" color="text.secondary">
Bespoke
</Typography>
<Badge color="info" icon={<LocalOfferIcon />}>
Best value
</Badge>
</Box>
<Typography variant="display3" color="primary" sx={{ mb: 1 }}>
$8,500
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Fully customised farewell with dedicated coordinator.
</Typography>
<Button fullWidth>Select</Button>
</Card>
</div>
),
};
// ─── In Context: Service Status ─────────────────────────────────────────────
/**
* Badges used as status indicators in a service listing.
*/
export const ServiceStatus: Story = {
name: 'In Context — Service Status',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 500 }}>
{[
{
service: 'Chapel ceremony',
badge: (
<Badge color="success" icon={<CheckCircleIcon />}>
Confirmed
</Badge>
),
},
{
service: 'Floral arrangements',
badge: (
<Badge color="warning" icon={<WarningAmberIcon />}>
Pending
</Badge>
),
},
{
service: 'Catering',
badge: (
<Badge color="error" icon={<ErrorOutlineIcon />}>
Unavailable
</Badge>
),
},
{
service: 'Memorial printing',
badge: (
<Badge color="info" icon={<NewReleasesIcon />}>
New option
</Badge>
),
},
{
service: 'Premium casket',
badge: (
<Badge variant="filled" color="brand" icon={<VerifiedIcon />}>
Included
</Badge>
),
},
].map((item) => (
<Card key={item.service} variant="outlined" padding="compact">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="labelLg">{item.service}</Typography>
{item.badge}
</Box>
</Card>
))}
</div>
),
};
// ─── Complete Matrix ────────────────────────────────────────────────────────
/** Full variant × colour × size matrix for visual QA */
export const CompleteMatrix: Story = {
name: 'Complete Matrix',
render: () => {
const colors = ['default', 'brand', 'success', 'warning', 'error', 'info'] as const;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{(['soft', 'filled'] as const).map((variant) => (
<div key={variant}>
<div
style={{
marginBottom: 8,
fontWeight: 600,
fontSize: 14,
textTransform: 'capitalize',
}}
>
{variant}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(['large', 'medium', 'small'] as const).map((size) => (
<div key={size} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ width: 60, fontSize: 12, color: '#737373' }}>{size}</span>
{colors.map((color) => (
<Badge
key={color}
variant={variant}
color={color}
size={size}
icon={<StarIcon />}
>
{color}
</Badge>
))}
</div>
))}
</div>
</div>
))}
</div>
);
},
};

View File

@@ -0,0 +1,140 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { BoxProps } from '@mui/material/Box';
import type { Theme } from '@mui/material/styles';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Colour intent for the badge */
export type BadgeColor = 'default' | 'brand' | 'success' | 'warning' | 'error' | 'info';
/** Props for the FA Badge component */
export interface BadgeProps extends Omit<BoxProps, 'color'> {
/** Colour intent */
color?: BadgeColor;
/** Visual style: "filled" (solid background) or "soft" (tonal/subtle background) */
variant?: 'filled' | 'soft';
/** Size preset */
size?: 'small' | 'medium' | 'large';
/** Optional leading icon */
icon?: React.ReactNode;
/** Label text */
children: React.ReactNode;
}
// ─── Colour maps ─────────────────────────────────────────────────────────────
const filledColors: Record<BadgeColor, { bg: string; text: string }> = {
default: { bg: 'var(--fa-color-neutral-700)', text: 'var(--fa-color-white)' },
brand: { bg: 'var(--fa-color-interactive-default)', text: 'var(--fa-color-white)' },
success: { bg: 'var(--fa-color-feedback-success)', text: 'var(--fa-color-white)' },
warning: { bg: 'var(--fa-color-feedback-warning)', text: 'var(--fa-color-white)' },
error: { bg: 'var(--fa-color-feedback-error)', text: 'var(--fa-color-white)' },
info: { bg: 'var(--fa-color-feedback-info)', text: 'var(--fa-color-white)' },
};
const softColors: Record<BadgeColor, { bg: string; text: string }> = {
default: { bg: 'var(--fa-color-neutral-200)', text: 'var(--fa-color-neutral-700)' },
brand: { bg: 'var(--fa-color-brand-200)', text: 'var(--fa-color-brand-700)' },
success: {
bg: 'var(--fa-color-feedback-success-subtle)',
text: 'var(--fa-color-feedback-success)',
},
warning: { bg: 'var(--fa-color-feedback-warning-subtle)', text: 'var(--fa-color-text-warning)' },
error: { bg: 'var(--fa-color-feedback-error-subtle)', text: 'var(--fa-color-feedback-error)' },
info: { bg: 'var(--fa-color-feedback-info-subtle)', text: 'var(--fa-color-feedback-info)' },
};
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Status indicator label for the FA design system.
*
* Pill-shaped, display-only badge for communicating status, category,
* or emphasis. Used in PriceCard ("Popular"), ServiceOption ("Included"),
* and other contexts.
*
* Colour options:
* - `default` — neutral grey (general labels)
* - `brand` — warm gold/copper (promoted, featured)
* - `success` — green (confirmed, included, available)
* - `warning` — amber (limited, expiring, attention)
* - `error` — red (sold out, unavailable, urgent)
* - `info` — blue (new, updated, informational)
*
* Variant options:
* - `soft` (default) — tonal background, coloured text. Calmer, preferred for FA.
* - `filled` — solid background, white text. For high-priority emphasis.
*
* **Accessibility**: If a Badge contains only an icon (no text children),
* provide an `aria-label` prop so screen readers can announce the status.
*/
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ color = 'default', variant = 'soft', size = 'medium', icon, children, sx, ...props }, ref) => {
const sizeMap = {
small: {
height: 'var(--fa-badge-height-sm)',
px: 'var(--fa-badge-padding-x-sm)',
fontSize: 'var(--fa-badge-font-size-sm)',
iconSize: 'var(--fa-badge-icon-size-sm)',
},
medium: {
height: 'var(--fa-badge-height-md)',
px: 'var(--fa-badge-padding-x-md)',
fontSize: 'var(--fa-badge-font-size-md)',
iconSize: 'var(--fa-badge-icon-size-md)',
},
large: {
height: 'var(--fa-badge-height-lg)',
px: 'var(--fa-badge-padding-x-lg)',
fontSize: 'var(--fa-badge-font-size-lg)',
iconSize: 'var(--fa-badge-icon-size-lg)',
},
} as const;
const s = sizeMap[size];
return (
<Box
ref={ref}
component="span"
sx={[
(theme: Theme) => {
const colors = variant === 'filled' ? filledColors[color] : softColors[color];
return {
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--fa-badge-icon-gap-default)',
minHeight: s.height,
px: s.px,
borderRadius: 'var(--fa-badge-border-radius-default)',
backgroundColor: colors.bg,
color: colors.text,
fontSize: s.fontSize,
fontWeight: 600,
fontFamily: theme.typography.fontFamily,
lineHeight: 1,
letterSpacing: '0.02em',
whiteSpace: 'nowrap',
userSelect: 'none',
// Icon sizing
'& > .MuiSvgIcon-root, & > svg': {
fontSize: s.iconSize,
flexShrink: 0,
},
};
},
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
>
{icon}
{children}
</Box>
);
},
);
Badge.displayName = 'Badge';
export default Badge;

View File

@@ -0,0 +1,2 @@
export { Badge, type BadgeProps, type BadgeColor } from './Badge';
export { default } from './Badge';

View File

@@ -0,0 +1,429 @@
import { useEffect, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import AddIcon from '@mui/icons-material/Add';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import SearchIcon from '@mui/icons-material/Search';
import CheckIcon from '@mui/icons-material/Check';
const meta: Meta<typeof Button> = {
title: 'Atoms/Button',
component: Button,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/3t6fpT5inh7zzjxQdW8U5p/Design-System---Template?node-id=28-50',
},
},
argTypes: {
variant: {
control: 'select',
options: ['contained', 'soft', 'outlined', 'text'],
description: 'Visual style variant',
table: { defaultValue: { summary: 'contained' } },
},
color: {
control: 'select',
options: ['primary', 'secondary'],
description: 'Colour intent',
table: { defaultValue: { summary: 'primary' } },
},
size: {
control: 'select',
options: ['xs', 'small', 'medium', 'large'],
description: 'Size preset',
table: { defaultValue: { summary: 'medium' } },
},
loading: {
control: 'boolean',
description: 'Show loading spinner and disable interaction',
table: { defaultValue: { summary: 'false' } },
},
underline: {
control: 'boolean',
description: 'Underline decoration for text variant buttons',
table: { defaultValue: { summary: 'false' } },
},
disabled: {
control: 'boolean',
description: 'Disable the button',
table: { defaultValue: { summary: 'false' } },
},
fullWidth: {
control: 'boolean',
description: 'Stretch to full width of parent container',
table: { defaultValue: { summary: 'false' } },
},
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default button appearance — primary contained, medium size */
export const Default: Story = {
args: {
children: 'Get started',
},
};
// ─── Figma Mapping ──────────────────────────────────────────────────────────
/**
* Maps directly to the Figma button component columns:
* - **Primary** → `contained` + `primary` (strong copper fill)
* - **Secondary/Brand** → `soft` + `primary` (warm tonal fill)
* - **Secondary/Grey** → `soft` + `secondary` (neutral tonal fill)
* - **Ghost** → `text` + `primary` (no fill, copper text)
*/
export const FigmaMapping: Story = {
name: 'Figma Mapping',
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button variant="contained">Primary</Button>
<Button variant="soft">Sec / Brand</Button>
<Button variant="soft" color="secondary">
Sec / Grey
</Button>
<Button variant="text">Ghost</Button>
</div>
),
};
// ─── Variants ───────────────────────────────────────────────────────────────
/** All visual variants for primary (brand) colour */
export const VariantsPrimary: Story = {
name: 'Variants — Primary',
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button variant="contained">Contained</Button>
<Button variant="soft">Soft</Button>
<Button variant="outlined">Outlined</Button>
<Button variant="text">Text</Button>
</div>
),
};
/** All visual variants for secondary (neutral grey) colour */
export const VariantsSecondary: Story = {
name: 'Variants — Secondary',
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button variant="contained" color="secondary">
Contained
</Button>
<Button variant="soft" color="secondary">
Soft
</Button>
<Button variant="outlined" color="secondary">
Outlined
</Button>
<Button variant="text" color="secondary">
Text
</Button>
</div>
),
};
// ─── Sizes ──────────────────────────────────────────────────────────────────
/** All four sizes side by side */
export const AllSizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button size="xs">Extra small</Button>
<Button size="small">Small</Button>
<Button size="medium">Medium</Button>
<Button size="large">Large</Button>
</div>
),
};
/** All sizes in soft variant */
export const AllSizesSoft: Story = {
name: 'All Sizes — Soft',
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button variant="soft" size="xs">
Extra small
</Button>
<Button variant="soft" size="small">
Small
</Button>
<Button variant="soft" size="medium">
Medium
</Button>
<Button variant="soft" size="large">
Large
</Button>
</div>
),
};
// ─── With Icons ─────────────────────────────────────────────────────────────
/** Button with a leading (start) icon */
export const WithStartIcon: Story = {
args: {
children: 'Add to package',
startIcon: <AddIcon />,
},
};
/** Button with a trailing (end) icon */
export const WithEndIcon: Story = {
args: {
children: 'Continue',
endIcon: <ArrowForwardIcon />,
},
};
/** Button with both leading and trailing icons */
export const WithBothIcons: Story = {
args: {
children: 'Search',
startIcon: <SearchIcon />,
endIcon: <ArrowForwardIcon />,
},
};
/** Icons across all sizes */
export const IconsAllSizes: Story = {
name: 'Icons — All Sizes',
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button size="xs" startIcon={<AddIcon />}>
Add
</Button>
<Button size="small" startIcon={<AddIcon />}>
Add
</Button>
<Button size="medium" startIcon={<AddIcon />}>
Add
</Button>
<Button size="large" startIcon={<AddIcon />}>
Add
</Button>
</div>
),
};
// ─── States ─────────────────────────────────────────────────────────────────
/** Disabled button */
export const Disabled: Story = {
args: {
children: 'Unavailable',
disabled: true,
},
};
/** Disabled across all variants */
export const DisabledAllVariants: Story = {
name: 'Disabled — All Variants',
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button disabled>Contained</Button>
<Button disabled variant="soft">
Soft
</Button>
<Button disabled variant="outlined">
Outlined
</Button>
<Button disabled variant="text">
Text
</Button>
</div>
),
};
/** Loading state with spinner (spinner appears on the right) */
export const Loading: Story = {
args: {
children: 'Submitting...',
loading: true,
},
};
/** Loading across variants */
export const LoadingAllVariants: Story = {
name: 'Loading — All Variants',
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button loading>Submitting...</Button>
<Button loading variant="soft">
Processing...
</Button>
<Button loading variant="outlined">
Processing...
</Button>
<Button loading variant="text">
Loading...
</Button>
</div>
),
};
// ─── Loading → Success Pattern ──────────────────────────────────────────────
/**
* Demonstrates the recommended loading → success flow for async actions.
*
* The Button itself stays simple — the consumer controls the state
* by toggling `loading`, `children`, and `endIcon`. Click to see the flow.
*/
export const LoadingToSuccess: Story = {
name: 'Loading → Success Pattern',
render: function LoadingSuccessDemo() {
const [status, setStatus] = useState<'idle' | 'loading' | 'success'>('idle');
useEffect(() => {
if (status === 'loading') {
const timer = setTimeout(() => setStatus('success'), 1500);
return () => clearTimeout(timer);
}
if (status === 'success') {
const timer = setTimeout(() => setStatus('idle'), 2000);
return () => clearTimeout(timer);
}
}, [status]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center' }}>
<Button
loading={status === 'loading'}
endIcon={status === 'success' ? <CheckIcon /> : undefined}
color={status === 'success' ? 'success' : 'primary'}
onClick={() => setStatus('loading')}
>
{status === 'idle' && 'Add to package'}
{status === 'loading' && 'Adding...'}
{status === 'success' && 'Added'}
</Button>
<p style={{ fontSize: 12, color: '#737373', margin: 0 }}>
Click to see: idle loading success idle
</p>
</div>
);
},
};
// ─── Text / Underline ───────────────────────────────────────────────────────
/** Text button with underline decoration (link-style) */
export const TextWithUnderline: Story = {
args: {
children: 'Go back',
variant: 'text',
underline: true,
},
};
/** Text buttons with and without underline */
export const TextButtonComparison: Story = {
name: 'Text Buttons — With & Without Underline',
render: () => (
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
<Button variant="text">No underline</Button>
<Button variant="text" underline>
With underline
</Button>
<Button variant="text" color="secondary">
Secondary
</Button>
<Button variant="text" color="secondary" underline>
Secondary underlined
</Button>
</div>
),
};
/** Text button sizes (from the merged Text Button Figma component) */
export const TextButtonSizes: Story = {
name: 'Text Buttons — All Sizes',
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Button variant="text" size="xs">
Extra small
</Button>
<Button variant="text" size="small">
Small
</Button>
<Button variant="text" size="medium">
Medium
</Button>
<Button variant="text" size="large">
Large
</Button>
</div>
),
};
// ─── Full Width ─────────────────────────────────────────────────────────────
/** Full width button (useful in mobile layouts and forms) */
export const FullWidth: Story = {
args: {
children: 'Complete arrangement',
fullWidth: true,
size: 'large',
},
decorators: [
(Story) => (
<div style={{ width: 360 }}>
<Story />
</div>
),
],
};
// ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Long content to test text wrapping and overflow */
export const LongContent: Story = {
args: {
children: 'Add funeral arrangement to your saved packages for comparison',
},
};
// ─── Complete Matrix ────────────────────────────────────────────────────────
/** Full variant x colour matrix for visual QA */
export const CompleteMatrix: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{(['contained', 'soft', 'outlined', 'text'] as const).map((variant) => (
<div key={variant}>
<div
style={{ marginBottom: 8, fontWeight: 600, fontSize: 14, textTransform: 'capitalize' }}
>
{variant}
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<Button variant={variant} color="primary">
Primary
</Button>
<Button variant={variant} color="secondary">
Secondary
</Button>
<Button variant={variant} color="primary" startIcon={<AddIcon />}>
With icon
</Button>
<Button variant={variant} color="primary" disabled>
Disabled
</Button>
<Button variant={variant} color="primary" loading>
Loading...
</Button>
</div>
</div>
))}
</div>
),
};

View File

@@ -0,0 +1,96 @@
import React from 'react';
import MuiButton from '@mui/material/Button';
import type { ButtonProps as MuiButtonProps } from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Button component */
export interface ButtonProps extends MuiButtonProps {
/** Show a loading spinner and disable interaction */
loading?: boolean;
/** Add underline decoration (useful for text variant link-style buttons) */
underline?: boolean;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Primary interactive element for the FA design system.
*
* Wraps MUI Button with FA brand tokens, custom sizes (xs/sm/md/lg),
* loading state, and underline support for text-variant buttons.
*
* Variant mapping from design:
* - `contained` + `primary` — Primary CTA (copper fill)
* - `soft` + `primary` — Secondary/Brand (warm tonal fill)
* - `soft` + `secondary` — Secondary/Grey (neutral tonal fill)
* - `outlined` + `primary` — Outlined brand (copper border)
* - `outlined` + `secondary` — Outlined grey (neutral border)
* - `text` + `primary` — Ghost / text button (copper text)
* - `text` + `secondary` — Ghost secondary (grey text)
*
* **Accessibility**: Icon-only buttons (no visible text) require an
* `aria-label` prop. Without it, screen readers announce nothing.
* Example: `<Button startIcon={<DeleteIcon />} aria-label="Delete item" />`
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
loading = false,
underline = false,
disabled,
children,
variant = 'contained',
size = 'medium',
sx,
...props
},
ref,
) => {
return (
<MuiButton
ref={ref}
variant={variant}
size={size}
disabled={loading || disabled}
aria-busy={loading || undefined}
sx={[
underline &&
variant === 'text' && {
textDecoration: 'underline',
textUnderlineOffset: '3px',
'&:hover': { textDecoration: 'underline' },
},
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
>
{children}
{loading && (
<>
<CircularProgress size={16} color="inherit" thickness={3} aria-hidden sx={{ ml: 1 }} />
<span
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}}
>
Loading
</span>
</>
)}
</MuiButton>
);
},
);
Button.displayName = 'Button';
export default Button;

View File

@@ -0,0 +1,3 @@
export { default } from './Button';
export { Button } from './Button';
export type { ButtonProps } from './Button';

View File

@@ -0,0 +1,493 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Card } from './Card';
import { Typography } from '../Typography';
import { Button } from '../Button';
import Box from '@mui/material/Box';
const meta: Meta<typeof Card> = {
title: 'Atoms/Card',
component: Card,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
variant: {
control: 'select',
options: ['elevated', 'outlined'],
description: 'Visual style variant',
table: { defaultValue: { summary: 'elevated' } },
},
interactive: {
control: 'boolean',
description: 'Adds hover background fill, shadow lift, and pointer cursor',
table: { defaultValue: { summary: 'false' } },
},
selected: {
control: 'boolean',
description: 'Highlights the card as selected — brand border + warm background',
table: { defaultValue: { summary: 'false' } },
},
padding: {
control: 'select',
options: ['default', 'compact', 'none'],
description: 'Padding preset',
table: { defaultValue: { summary: 'default' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Card>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default card — elevated with standard padding */
export const Default: Story = {
args: {
children: (
<>
<Typography variant="h4" gutterBottom>
Funeral package
</Typography>
<Typography variant="body1" color="text.secondary">
A comprehensive service including chapel ceremony, transport, and preparation. Suitable
for families seeking a traditional farewell.
</Typography>
</>
),
},
decorators: [
(Story) => (
<div style={{ width: 400 }}>
<Story />
</div>
),
],
};
// ─── Variants ───────────────────────────────────────────────────────────────
/** Both visual variants side by side */
export const Variants: Story = {
render: () => (
<div style={{ display: 'flex', gap: 24, maxWidth: 800 }}>
<Card variant="elevated" sx={{ flex: 1 }}>
<Typography variant="h5" gutterBottom>
Elevated
</Typography>
<Typography variant="body2" color="text.secondary">
Uses shadow for depth. Default variant for most content cards.
</Typography>
</Card>
<Card variant="outlined" sx={{ flex: 1 }}>
<Typography variant="h5" gutterBottom>
Outlined
</Typography>
<Typography variant="body2" color="text.secondary">
Uses a subtle border. Good for less prominent or grouped content.
</Typography>
</Card>
</div>
),
};
// ─── Interactive ────────────────────────────────────────────────────────────
/** Interactive cards with hover background fill and shadow lift */
export const Interactive: Story = {
render: () => (
<div style={{ display: 'flex', gap: 24, maxWidth: 800 }}>
<Card interactive sx={{ flex: 1 }} tabIndex={0} onClick={() => alert('Card clicked')}>
<Typography variant="h5" gutterBottom>
Elevated + Interactive
</Typography>
<Typography variant="body2" color="text.secondary">
Hover to see the background fill and shadow lift.
</Typography>
</Card>
<Card
variant="outlined"
interactive
sx={{ flex: 1 }}
tabIndex={0}
onClick={() => alert('Card clicked')}
>
<Typography variant="h5" gutterBottom>
Outlined + Interactive
</Typography>
<Typography variant="body2" color="text.secondary">
Outlined cards get a subtle background fill on hover.
</Typography>
</Card>
</div>
),
};
// ─── Selected ───────────────────────────────────────────────────────────────
/** Selected state — brand border + warm background tint */
export const Selected: Story = {
render: () => (
<div style={{ display: 'flex', gap: 24, maxWidth: 800 }}>
<Card variant="outlined" sx={{ flex: 1 }}>
<Typography variant="h5" gutterBottom>
Not selected
</Typography>
<Typography variant="body2" color="text.secondary">
Standard outlined card in its resting state.
</Typography>
</Card>
<Card variant="outlined" selected sx={{ flex: 1 }}>
<Typography variant="h5" gutterBottom>
Selected
</Typography>
<Typography variant="body2" color="text.secondary">
Brand border and warm background tint show this is the active choice.
</Typography>
</Card>
</div>
),
};
// ─── Option Select Pattern ──────────────────────────────────────────────────
/**
* Interactive option selection matching the FA 1.0 "ListItemPurchaseOption" pattern.
* Click a card to select it. Matches the Figma states:
* inactive → hover (bg fill) → active (brand border + warm bg).
*/
export const OptionSelect: Story = {
name: 'Option Select',
render: function OptionSelectDemo() {
const [selectedId, setSelectedId] = useState<string | null>('chapel');
const options = [
{
id: 'chapel',
title: 'Chapel service',
desc: 'Traditional ceremony in our heritage-listed chapel, seating up to 120 guests.',
},
{
id: 'graveside',
title: 'Graveside service',
desc: 'An intimate outdoor farewell at the final resting place.',
},
{
id: 'memorial',
title: 'Memorial service',
desc: 'A celebration of life gathering at a venue of your choosing.',
},
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 500 }}>
<Typography variant="h5" sx={{ mb: 1 }}>
Choose your service type
</Typography>
{options.map((option) => (
<Card
key={option.id}
variant="outlined"
interactive
selected={selectedId === option.id}
padding="compact"
tabIndex={0}
onClick={() => setSelectedId(option.id)}
role="radio"
aria-checked={selectedId === option.id}
>
<Typography variant="labelLg" gutterBottom>
{option.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{option.desc}
</Typography>
</Card>
))}
</div>
);
},
};
// ─── Multi-Select Pattern ───────────────────────────────────────────────────
/**
* Multi-select variant — click to toggle multiple cards.
* Useful for add-on services, package inclusions, etc.
*/
export const MultiSelect: Story = {
name: 'Multi-Select',
render: function MultiSelectDemo() {
const [selected, setSelected] = useState<Set<string>>(new Set(['flowers']));
const addOns = [
{ id: 'flowers', title: 'Floral arrangements', desc: 'Custom flowers for the service' },
{ id: 'catering', title: 'Catering', desc: 'Light refreshments after the service' },
{ id: 'music', title: 'Live musician', desc: 'Solo musician for the ceremony' },
{ id: 'printing', title: 'Memorial printing', desc: 'Order of service booklets' },
];
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div style={{ maxWidth: 500 }}>
<Typography variant="h5" sx={{ mb: 1 }}>
Select add-ons
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Choose as many as you like
</Typography>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{addOns.map((item) => (
<Card
key={item.id}
variant="outlined"
interactive
selected={selected.has(item.id)}
padding="compact"
tabIndex={0}
onClick={() => toggle(item.id)}
role="checkbox"
aria-checked={selected.has(item.id)}
>
<Typography variant="labelLg" gutterBottom>
{item.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{item.desc}
</Typography>
</Card>
))}
</div>
</div>
);
},
};
// ─── On Different Backgrounds ───────────────────────────────────────────────
/**
* Demonstrates how cards adapt to different surface colours.
* Elevated cards stand out via shadow on any surface.
* Outlined cards use borders on white, contrast on grey.
*/
export const OnDifferentBackgrounds: Story = {
name: 'On Different Backgrounds',
render: () => (
<div style={{ display: 'flex', gap: 32, maxWidth: 900 }}>
{/* White background */}
<div
style={{
flex: 1,
padding: 24,
backgroundColor: '#ffffff',
borderRadius: 8,
border: '1px dashed #d4d4d4',
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
On white surface
</Typography>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card variant="elevated">
<Typography variant="labelLg">Elevated</Typography>
<Typography variant="body2" color="text.secondary">
Shadow defines edges
</Typography>
</Card>
<Card variant="outlined">
<Typography variant="labelLg">Outlined</Typography>
<Typography variant="body2" color="text.secondary">
Border defines edges
</Typography>
</Card>
</div>
</div>
{/* Grey background */}
<div
style={{
flex: 1,
padding: 24,
backgroundColor: '#f5f5f5',
borderRadius: 8,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
On grey surface
</Typography>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card variant="elevated">
<Typography variant="labelLg">Elevated</Typography>
<Typography variant="body2" color="text.secondary">
White card + shadow on grey
</Typography>
</Card>
<Card variant="outlined">
<Typography variant="labelLg">Outlined</Typography>
<Typography variant="body2" color="text.secondary">
Contrast + border on grey
</Typography>
</Card>
</div>
</div>
</div>
),
};
// ─── Padding Presets ────────────────────────────────────────────────────────
/** All three padding options */
export const PaddingPresets: Story = {
name: 'Padding Presets',
render: () => (
<div style={{ display: 'flex', gap: 24, maxWidth: 900, alignItems: 'start' }}>
<Card padding="default" sx={{ flex: 1 }}>
<Typography variant="labelLg" gutterBottom>
Default (24px)
</Typography>
<Typography variant="body2" color="text.secondary">
Standard spacing for desktop cards.
</Typography>
</Card>
<Card padding="compact" sx={{ flex: 1 }}>
<Typography variant="labelLg" gutterBottom>
Compact (16px)
</Typography>
<Typography variant="body2" color="text.secondary">
Tighter spacing for mobile or dense layouts.
</Typography>
</Card>
<Card padding="none" sx={{ flex: 1 }}>
<Box sx={{ p: 3 }}>
<Typography variant="labelLg" gutterBottom>
None (manual)
</Typography>
<Typography variant="body2" color="text.secondary">
Full control add your own padding.
</Typography>
</Box>
</Card>
</div>
),
};
// ─── Price Card Preview ─────────────────────────────────────────────────────
/**
* Preview of how Card will be used in the PriceCard molecule.
* Demonstrates realistic content composition with FA typography and brand colours.
*/
export const PriceCardPreview: Story = {
name: 'Price Card Preview',
render: () => (
<div style={{ width: 340 }}>
<Card interactive tabIndex={0}>
<Typography variant="overline" color="text.secondary" gutterBottom>
Essential
</Typography>
<Typography variant="display3" color="primary" sx={{ mb: 1 }}>
$3,200
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
A respectful and simple service with chapel ceremony, transport, and professional
preparation.
</Typography>
<Button fullWidth size="large">
Select this package
</Button>
</Card>
</div>
),
};
// ─── With Image ─────────────────────────────────────────────────────────────
/** Card with full-bleed image using padding="none" */
export const WithImage: Story = {
name: 'With Image (No Padding)',
render: () => (
<div style={{ width: 340 }}>
<Card padding="none">
<Box
sx={{
height: 180,
backgroundColor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="body2" color="text.secondary">
Image placeholder
</Typography>
</Box>
<Box sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>
Parsons Chapel
</Typography>
<Typography variant="body2" color="text.secondary">
Our heritage-listed chapel seats up to 120 guests and features modern audio-visual
facilities.
</Typography>
</Box>
</Card>
</div>
),
};
// ─── Rich Content ───────────────────────────────────────────────────────────
/** Card with rich nested content to verify layout flexibility */
export const RichContent: Story = {
name: 'Rich Content',
render: () => (
<div style={{ width: 400 }}>
<Card>
<Typography variant="overline" color="text.secondary">
Package details
</Typography>
<Typography variant="h4" sx={{ mt: 1, mb: 2 }}>
Premium farewell
</Typography>
<Box
component="ul"
sx={{
pl: 4,
mb: 3,
'& li': { mb: 1 },
}}
>
<li>
<Typography variant="body2">Chapel ceremony (up to 120 guests)</Typography>
</li>
<li>
<Typography variant="body2">Premium timber casket</Typography>
</li>
<li>
<Typography variant="body2">Transport within 50km</Typography>
</li>
<li>
<Typography variant="body2">Professional preparation</Typography>
</li>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button size="large" sx={{ flex: 1 }}>
Select
</Button>
<Button variant="outlined" size="large" sx={{ flex: 1 }}>
Compare
</Button>
</Box>
</Card>
</div>
),
};

View File

@@ -0,0 +1,127 @@
import React from 'react';
import MuiCard from '@mui/material/Card';
import type { CardProps as MuiCardProps } from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Card component */
export interface CardProps extends Omit<MuiCardProps, 'raised' | 'variant'> {
/** Visual style: "elevated" uses shadow, "outlined" uses border */
variant?: 'elevated' | 'outlined';
/** Adds hover background fill, shadow lift, and pointer cursor for clickable cards */
interactive?: boolean;
/** Highlights the card as selected — brand border + warm background tint */
selected?: boolean;
/** Padding preset: "default" (16px mobile / 24px desktop), "compact" (12px mobile / 16px desktop), "none" (no wrapper) */
padding?: 'default' | 'compact' | 'none';
/** Content to render inside the card */
children?: React.ReactNode;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Content container for the FA design system.
*
* Wraps MUI Card with FA brand tokens, two visual variants (elevated/outlined),
* optional hover interactivity, selected state, and padding presets.
*
* Variant mapping from design:
* - `elevated` (default) — shadow.md resting, white background
* - `outlined` — neutral border, no shadow, white background
*
* Use `interactive` for clickable cards (PriceCard, ServiceOption) —
* adds background fill on hover, shadow lift, and cursor pointer.
*
* Use `selected` for option-select patterns — applies brand border
* (warm gold) and warm background tint (brand.50).
*
* Use `padding="none"` when composing with CardMedia or custom layouts
* that need full-bleed content.
*/
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
(
{
variant = 'elevated',
interactive = false,
selected = false,
padding = 'default',
children,
sx,
...props
},
ref,
) => {
// Map FA variant names to MUI Card variant
const muiVariant = variant === 'outlined' ? 'outlined' : undefined;
// Interactive cards need keyboard operability
const handleKeyDown = interactive
? (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.currentTarget.click();
}
}
: undefined;
const interactiveProps = interactive
? { tabIndex: 0 as const, role: 'button' as const, onKeyDown: handleKeyDown }
: {};
return (
<MuiCard
ref={ref}
variant={muiVariant}
elevation={0}
{...interactiveProps}
sx={[
// Selected state: brand border + warm background
// Border width is always 2px (set in theme) — only colour changes here
selected && {
borderColor: 'var(--fa-card-border-selected)',
backgroundColor: 'var(--fa-card-background-selected)',
},
// Interactive: hover fill + shadow lift + pointer
interactive && {
cursor: 'pointer',
'&:hover': {
backgroundColor: selected
? 'var(--fa-card-background-selected)'
: 'var(--fa-card-background-hover)',
boxShadow: variant === 'elevated' ? 'var(--fa-card-shadow-hover)' : undefined,
},
},
// Focus-visible for keyboard accessibility on interactive cards
interactive && {
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
>
{padding !== 'none' ? (
<CardContent
sx={{
p: padding === 'compact' ? { xs: 3, md: 4 } : { xs: 4, md: 6 },
'&:last-child': {
pb: padding === 'compact' ? { xs: 3, md: 4 } : { xs: 4, md: 6 },
},
}}
>
{children}
</CardContent>
) : (
children
)}
</MuiCard>
);
},
);
Card.displayName = 'Card';
export default Card;

View File

@@ -0,0 +1,2 @@
export { Card, type CardProps } from './Card';
export { default } from './Card';

View File

@@ -0,0 +1,144 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Checkbox } from './Checkbox';
import { Typography } from '../Typography';
import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
const meta: Meta<typeof Checkbox> = {
title: 'Atoms/Checkbox',
component: Checkbox,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
checked: {
control: 'boolean',
description: 'Whether the checkbox is checked',
},
disabled: {
control: 'boolean',
description: 'Disable the checkbox',
table: { defaultValue: { summary: 'false' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Checkbox>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default checkbox — unchecked */
export const Default: Story = {
args: {},
};
// ─── States ─────────────────────────────────────────────────────────────────
/** All visual states */
export const States: Story = {
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel control={<Checkbox />} label="Unchecked" />
<FormControlLabel control={<Checkbox defaultChecked />} label="Checked" />
<FormControlLabel control={<Checkbox disabled />} label="Disabled unchecked" />
<FormControlLabel control={<Checkbox disabled defaultChecked />} label="Disabled checked" />
<FormControlLabel control={<Checkbox indeterminate />} label="Indeterminate" />
</Box>
),
};
// ─── Terms Agreement ────────────────────────────────────────────────────────
/**
* Realistic pattern — terms and conditions checkbox in a payment form.
*/
export const TermsAgreement: Story = {
name: 'Terms Agreement',
render: () => {
const TermsDemo = () => {
const [accepted, setAccepted] = useState(false);
return (
<Box sx={{ maxWidth: 420 }}>
<FormControlLabel
control={
<Checkbox checked={accepted} onChange={(e) => setAccepted(e.target.checked)} />
}
label={
<Typography variant="body2">
I agree to the service agreement and privacy policy
</Typography>
}
sx={{ alignItems: 'flex-start', '& .MuiCheckbox-root': { pt: 0.5 } }}
/>
{!accepted && (
<Typography
variant="body2"
sx={{ ml: 4, mt: 0.5, color: 'var(--fa-color-text-brand)' }}
>
You must accept the terms to continue
</Typography>
)}
</Box>
);
};
return <TermsDemo />;
},
};
// ─── Checklist ──────────────────────────────────────────────────────────────
/**
* Checklist pattern — multiple independent options.
*/
export const Checklist: Story = {
render: () => {
const ChecklistDemo = () => {
const [items, setItems] = useState({
dressing: true,
viewing: false,
prayers: false,
announcement: true,
});
const toggle = (key: keyof typeof items) => {
setItems((prev) => ({ ...prev, [key]: !prev[key] }));
};
return (
<Box sx={{ maxWidth: 420 }}>
<Typography variant="h5" sx={{ mb: 2 }}>
Complimentary inclusions
</Typography>
<FormGroup>
<FormControlLabel
control={<Checkbox checked={items.dressing} onChange={() => toggle('dressing')} />}
label="Dressing and preparation"
/>
<FormControlLabel
control={<Checkbox checked={items.viewing} onChange={() => toggle('viewing')} />}
label="Viewing for family and friends"
/>
<FormControlLabel
control={<Checkbox checked={items.prayers} onChange={() => toggle('prayers')} />}
label="Prayers or vigil"
/>
<FormControlLabel
control={
<Checkbox checked={items.announcement} onChange={() => toggle('announcement')} />
}
label="Funeral announcement"
/>
</FormGroup>
</Box>
);
};
return <ChecklistDemo />;
},
};

View File

@@ -0,0 +1,35 @@
import React from 'react';
import MuiCheckbox from '@mui/material/Checkbox';
import type { CheckboxProps as MuiCheckboxProps } from '@mui/material/Checkbox';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Checkbox component */
export type CheckboxProps = MuiCheckboxProps;
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Checkbox for the FA design system.
*
* Multi-select control for independent boolean options. Wraps MUI Checkbox
* with FA brand tokens — warm gold fill when checked, rounded square shape.
*
* Usage:
* - For independent booleans ("I agree to terms", "Include catering")
* - For mutually exclusive options, use Radio instead
* - For binary toggles, use Switch instead
*
* **Accessibility**: Always wrap in `FormControlLabel` with a `label` prop.
* A standalone Checkbox without a visible label is inaccessible — screen
* readers cannot announce what the checkbox controls.
* ```tsx
* <FormControlLabel control={<Checkbox />} label="I agree to the terms" />
* ```
*/
export const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>((props, ref) => {
return <MuiCheckbox ref={ref} {...props} />;
});
Checkbox.displayName = 'Checkbox';
export default Checkbox;

View File

@@ -0,0 +1,2 @@
export { Checkbox, default } from './Checkbox';
export type { CheckboxProps } from './Checkbox';

View File

@@ -0,0 +1,383 @@
import React, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Chip } from './Chip';
import { Card } from '../Card';
import { Typography } from '../Typography';
import Box from '@mui/material/Box';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import FaceIcon from '@mui/icons-material/Face';
import CheckIcon from '@mui/icons-material/Check';
import FilterListIcon from '@mui/icons-material/FilterList';
import ChurchIcon from '@mui/icons-material/Church';
import LocalFloristIcon from '@mui/icons-material/LocalFlorist';
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
import RestaurantIcon from '@mui/icons-material/Restaurant';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
const meta: Meta<typeof Chip> = {
title: 'Atoms/Chip',
component: Chip,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
variant: {
control: 'select',
options: ['filled', 'outlined'],
description: 'Visual style variant',
table: { defaultValue: { summary: 'filled' } },
},
color: {
control: 'select',
options: ['default', 'primary'],
description: 'Colour intent',
table: { defaultValue: { summary: 'default' } },
},
size: {
control: 'select',
options: ['small', 'medium'],
description: 'Size preset',
table: { defaultValue: { summary: 'medium' } },
},
selected: {
control: 'boolean',
description: 'Selected/active state',
table: { defaultValue: { summary: 'false' } },
},
clickable: {
control: 'boolean',
description: 'Whether the chip is clickable',
},
},
};
export default meta;
type Story = StoryObj<typeof Chip>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default chip — filled variant, neutral colour */
export const Default: Story = {
args: {
label: 'Chip label',
},
};
// ─── Variants ───────────────────────────────────────────────────────────────
/** Both visual variants with default and primary colour */
export const Variants: Story = {
render: () => (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip label="Filled default" />
<Chip label="Filled primary" color="primary" />
<Chip variant="outlined" label="Outlined default" />
<Chip variant="outlined" label="Outlined primary" color="primary" />
</Box>
),
};
// ─── Sizes ──────────────────────────────────────────────────────────────────
/** Small and medium sizes side by side */
export const Sizes: Story = {
render: () => (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Chip size="small" label="Small" icon={<LocalOfferIcon />} />
<Chip size="medium" label="Medium" icon={<LocalOfferIcon />} />
<Chip size="small" variant="outlined" label="Small outlined" icon={<LocalOfferIcon />} />
<Chip size="medium" variant="outlined" label="Medium outlined" icon={<LocalOfferIcon />} />
</Box>
),
};
// ─── With Icons ─────────────────────────────────────────────────────────────
/** Chips with leading icons */
export const WithIcons: Story = {
name: 'With Icons',
render: () => (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip icon={<ChurchIcon />} label="Chapel" />
<Chip icon={<LocalFloristIcon />} label="Flowers" color="primary" />
<Chip icon={<DirectionsCarIcon />} label="Transport" variant="outlined" />
<Chip icon={<FaceIcon />} label="Family" variant="outlined" color="primary" />
</Box>
),
};
// ─── Clickable ──────────────────────────────────────────────────────────────
/** Clickable chips respond to click events (for filtering/toggling) */
export const Clickable: Story = {
render: () => (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip label="Clickable default" onClick={() => {}} />
<Chip label="Clickable primary" color="primary" onClick={() => {}} />
<Chip label="Clickable outlined" variant="outlined" onClick={() => {}} />
<Chip
label="Clickable outlined primary"
variant="outlined"
color="primary"
onClick={() => {}}
/>
</Box>
),
};
// ─── Deletable ──────────────────────────────────────────────────────────────
/** Deletable chips show a close icon */
export const Deletable: Story = {
render: () => (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip label="Remove me" onDelete={() => {}} />
<Chip label="Brand deletable" color="primary" onDelete={() => {}} />
<Chip label="Outlined deletable" variant="outlined" onDelete={() => {}} />
<Chip label="With icon" icon={<LocalOfferIcon />} onDelete={() => {}} />
</Box>
),
};
// ─── Selected State ─────────────────────────────────────────────────────────
/** Selected state promotes chip to brand colour */
export const Selected: Story = {
render: () => (
<Box sx={{ display: 'flex', gap: 2, flexDirection: 'column' }}>
<Box>
<Typography variant="label" sx={{ mb: 1 }}>
Filled
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Chip label="Not selected" onClick={() => {}} />
<Chip label="Selected" selected onClick={() => {}} />
</Box>
</Box>
<Box>
<Typography variant="label" sx={{ mb: 1 }}>
Outlined
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Chip variant="outlined" label="Not selected" onClick={() => {}} />
<Chip variant="outlined" label="Selected" selected onClick={() => {}} />
</Box>
</Box>
</Box>
),
};
// ─── Interactive: Filter Chips ──────────────────────────────────────────────
/**
* Toggle filter pattern — commonly used for service category filtering.
* Click chips to toggle selection.
*/
export const FilterChips: Story = {
name: 'Interactive — Filter Chips',
render: () => {
const categories = [
{ label: 'Chapel', icon: <ChurchIcon /> },
{ label: 'Flowers', icon: <LocalFloristIcon /> },
{ label: 'Transport', icon: <DirectionsCarIcon /> },
{ label: 'Catering', icon: <RestaurantIcon /> },
{ label: 'Music', icon: <MusicNoteIcon /> },
{ label: 'Photography', icon: <PhotoCameraIcon /> },
];
const FilterDemo = () => {
const [selected, setSelected] = useState<Set<string>>(new Set(['Chapel', 'Flowers']));
const toggle = (label: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
};
return (
<Box sx={{ maxWidth: 450 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<FilterListIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<Typography variant="label">Filter services</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{categories.map(({ label, icon }) => (
<Chip
key={label}
label={label}
icon={selected.has(label) ? <CheckIcon /> : icon}
selected={selected.has(label)}
onClick={() => toggle(label)}
variant="outlined"
/>
))}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Selected: {selected.size === 0 ? 'None' : Array.from(selected).join(', ')}
</Typography>
</Box>
);
};
return <FilterDemo />;
},
};
// ─── Interactive: Removable Tags ────────────────────────────────────────────
/**
* Removable tag pattern — for selected items that can be dismissed.
* Click the × icon to remove a tag.
*/
export const RemovableTags: Story = {
name: 'Interactive — Removable Tags',
render: () => {
const TagDemo = () => {
const [tags, setTags] = useState([
'White roses',
'Organ music',
'Prayer cards',
'Memorial video',
'Guest book',
]);
const remove = (tag: string) => {
setTags((prev) => prev.filter((t) => t !== tag));
};
return (
<Box sx={{ maxWidth: 450 }}>
<Typography variant="label" sx={{ mb: 1 }}>
Selected additions ({tags.length})
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', minHeight: 32 }}>
{tags.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No items selected
</Typography>
) : (
tags.map((tag) => (
<Chip key={tag} label={tag} color="primary" onDelete={() => remove(tag)} />
))
)}
</Box>
</Box>
);
};
return <TagDemo />;
},
};
// ─── In Context: Service Option ─────────────────────────────────────────────
/**
* Chips used inside a ServiceOption-style card layout,
* showing service tags and category labels.
*/
export const InServiceOption: Story = {
name: 'In Context — Service Option',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 400 }}>
<Card interactive>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 1 }}>
<Typography variant="h5">Chapel Ceremony</Typography>
<Typography variant="display3" color="primary">
$1,200
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Traditional chapel service with celebrant and music of your choosing.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip size="small" icon={<ChurchIcon />} label="Indoor" />
<Chip size="small" icon={<MusicNoteIcon />} label="Music included" />
<Chip size="small" label="60 minutes" />
</Box>
</Card>
<Card interactive>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 1 }}>
<Typography variant="h5">Graveside Service</Typography>
<Typography variant="display3" color="primary">
$900
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Intimate outdoor farewell at the burial site.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip size="small" label="Outdoor" />
<Chip size="small" label="30 minutes" />
<Chip size="small" color="primary" label="Popular" />
</Box>
</Card>
</Box>
),
};
// ─── Complete Matrix ────────────────────────────────────────────────────────
/** Full variant × colour × size × state matrix for visual QA */
export const CompleteMatrix: Story = {
name: 'Complete Matrix',
render: () => {
const variants = ['filled', 'outlined'] as const;
const colors = ['default', 'primary'] as const;
const sizes = ['medium', 'small'] as const;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{variants.map((variant) => (
<Box key={variant}>
<Typography variant="label" sx={{ mb: 1, textTransform: 'capitalize' }}>
{variant}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{sizes.map((size) => (
<Box key={size} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Box sx={{ width: 70, fontSize: 12, color: 'text.secondary' }}>{size}</Box>
{colors.map((color) => (
<React.Fragment key={color}>
<Chip variant={variant} color={color} size={size} label={color} />
<Chip
variant={variant}
color={color}
size={size}
label={`${color} + icon`}
icon={<LocalOfferIcon />}
/>
<Chip
variant={variant}
color={color}
size={size}
label={`${color} delete`}
onDelete={() => {}}
/>
</React.Fragment>
))}
</Box>
))}
</Box>
</Box>
))}
<Box>
<Typography variant="label" sx={{ mb: 1 }}>
Selected state
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Chip selected label="Filled selected" onClick={() => {}} />
<Chip selected variant="outlined" label="Outlined selected" onClick={() => {}} />
<Chip selected label="With icon" icon={<CheckIcon />} onClick={() => {}} />
</Box>
</Box>
</Box>
);
},
};

View File

@@ -0,0 +1,72 @@
import React from 'react';
import MuiChip from '@mui/material/Chip';
import type { ChipProps as MuiChipProps } from '@mui/material/Chip';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Chip component */
export interface ChipProps extends MuiChipProps {
/** Whether the chip is in a selected/active state */
selected?: boolean;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Interactive tag for the FA design system.
*
* Pill-shaped chip for filtering, selection, and dismissible labels.
* Used in ServiceOption (service tags), search filters, arrangement forms,
* and anywhere users interact with categorised content.
*
* Unlike Badge (display-only), Chip is interactive — it can be clicked
* (for toggling/selection) or deleted (for dismissing/removing).
*
* Variant mapping:
* - `filled` (default) — soft tonal background, like Button's soft variant
* - `outlined` — border-only, lighter visual weight
*
* Colour options:
* - `default` — neutral grey (general tags, filters)
* - `primary` — warm brand (selected states, category tags)
*
* Interactive modes:
* - **Clickable** — pass `onClick` or set `clickable` for toggle/filter chips
* - **Deletable** — pass `onDelete` for dismissible tags
* - **Both** — clickable + deletable for full interactive chips
* - **Static** — no onClick/onDelete for display-only tags (prefer Badge for pure status)
*
* Selected state:
* - `selected` prop applies brand styling (filled primary bg or outlined primary border)
*
* **Accessibility**: If a Chip has no visible label text (icon-only) and is
* deletable, provide an `aria-label` so screen readers can announce what
* is being removed.
*/
export const Chip = React.forwardRef<HTMLDivElement, ChipProps>(
({ selected = false, variant = 'filled', color, sx, ...props }, ref) => {
// When selected, promote to primary colour unless explicitly set
const resolvedColor = color ?? (selected ? 'primary' : 'default');
return (
<MuiChip
ref={ref}
variant={variant}
color={resolvedColor}
sx={[
selected &&
variant === 'outlined' && {
borderWidth: 2,
borderColor: 'var(--fa-color-brand-500)',
backgroundColor: 'var(--fa-color-brand-50)',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
/>
);
},
);
Chip.displayName = 'Chip';
export default Chip;

View File

@@ -0,0 +1,2 @@
export { Chip, default } from './Chip';
export type { ChipProps } from './Chip';

View File

@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ClusterMarker } from './ClusterMarker';
const meta: Meta<typeof ClusterMarker> = {
title: 'Atoms/ClusterMarker',
component: ClusterMarker,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof ClusterMarker>;
/** Cluster containing at least one verified provider — promoted palette */
export const MixedOrVerified: Story = {
args: {
count: 5,
hasVerified: true,
},
};
/** Cluster of all-unverified providers — neutral palette */
export const AllUnverified: Story = {
args: {
count: 3,
hasVerified: false,
},
};
/** Small cluster — pair of providers */
export const Pair: Story = {
args: {
count: 2,
hasVerified: true,
},
};
/** Large cluster — double-digit count */
export const LargeCluster: Story = {
args: {
count: 27,
hasVerified: true,
},
};
/** Side-by-side comparison — verified vs unverified at various counts */
export const PaletteGrid: Story = {
render: () => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 6,
p: 4,
}}
>
<ClusterMarker count={2} hasVerified />
<ClusterMarker count={5} hasVerified />
<ClusterMarker count={12} hasVerified />
<ClusterMarker count={99} hasVerified />
<ClusterMarker count={2} />
<ClusterMarker count={5} />
<ClusterMarker count={12} />
<ClusterMarker count={99} />
</Box>
),
};

View File

@@ -0,0 +1,161 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA ClusterMarker atom */
export interface ClusterMarkerProps {
/** Number of providers in this cluster */
count: number;
/** True if any provider in the cluster is verified — drives the promoted palette */
hasVerified?: boolean;
/** Click handler — opens the cluster popup */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const BADGE_SIZE = 36;
// ─── Colour sets — matches MapPin ───────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-700)',
text: 'var(--fa-color-white)',
border: 'var(--fa-color-brand-700)',
nub: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
text: 'var(--fa-color-neutral-800)',
border: 'var(--fa-color-neutral-300)',
nub: 'var(--fa-color-neutral-100)',
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Cluster map marker for the FA design system.
*
* Circular pill with a count, representing N provider pins grouped at the
* same screen location. Sibling to `MapPin` — same palette language (verified
* promoted, unverified neutral), same nub treatment, same shadow.
*
* `hasVerified` drives the palette: if *any* provider in the cluster is
* verified, the cluster adopts the promoted (brand-700) palette. All-unverified
* clusters use the neutral palette.
*
* Designed for use as the `render`-ed output of `@googlemaps/markerclusterer`.
* Pure CSS + SVG — no canvas. role="button" + keyboard + focus ring.
*
* Usage:
* ```tsx
* <ClusterMarker count={5} hasVerified onClick={...} />
* <ClusterMarker count={12} />
* ```
*/
export const ClusterMarker = React.forwardRef<HTMLDivElement, ClusterMarkerProps>(
({ count, hasVerified = false, onClick, sx }, ref) => {
const palette = hasVerified ? colours.verified : colours.unverified;
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
};
const label = `${count} providers in this area`;
return (
<Box
ref={ref}
role="button"
tabIndex={0}
aria-label={label}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
// Fade in on mount — matches MapPin and popups for a consistent
// entry timing across the map.
'@keyframes clusterMarkerIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'clusterMarkerIn 180ms ease-out',
'&:hover': { transform: 'scale(1.08)' },
'&:focus-visible': {
outline: 'none',
'& > .ClusterMarker-badge': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Circular badge */}
<Box
className="ClusterMarker-badge"
sx={{
width: BADGE_SIZE,
height: BADGE_SIZE,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: palette.bg,
border: '1px solid',
borderColor: palette.border,
boxShadow: 'var(--fa-shadow-sm)',
color: palette.text,
fontFamily: 'var(--fa-font-family-body)',
fontSize: 14,
fontWeight: 700,
lineHeight: 1,
}}
>
{count}
</Box>
{/* Nub — same SVG pattern as MapPin for visual continuity */}
<svg
aria-hidden
viewBox="0 0 16 8"
style={{
display: 'block',
width: `calc(2 * ${NUB_SIZE})`,
height: NUB_SIZE,
marginTop: '-1px',
overflow: 'visible',
}}
>
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
<path
d="M 0 0 L 8 8 L 16 0"
fill="none"
stroke={palette.border}
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
</Box>
);
},
);
ClusterMarker.displayName = 'ClusterMarker';
export default ClusterMarker;

View File

@@ -0,0 +1 @@
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';

View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Collapse } from './Collapse';
import Box from '@mui/material/Box';
import { Typography } from '../Typography';
import { Button } from '../Button';
const meta: Meta<typeof Collapse> = {
title: 'Atoms/Collapse',
component: Collapse,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
in: {
control: 'boolean',
description: 'Whether the content is expanded',
},
timeout: {
control: 'number',
description: 'Transition duration in ms (or { enter, exit })',
table: { defaultValue: { summary: '300' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Collapse>;
// ─── Default (controlled via args) ──────────────────────────────────────────
/** Toggle the `in` control to expand/collapse */
export const Default: Story = {
args: {
in: true,
},
render: (args) => (
<Box sx={{ width: 400 }}>
<Collapse {...args}>
<Box
sx={{
p: 3,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body1">
This content is revealed with a smooth slide-down animation.
</Typography>
</Box>
</Collapse>
</Box>
),
};
// ─── Interactive toggle ─────────────────────────────────────────────────────
/** Click the button to toggle progressive disclosure */
export const Interactive: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<Box sx={{ width: 400 }}>
<Button variant="soft" color="secondary" onClick={() => setOpen(!open)} sx={{ mb: 2 }}>
{open ? 'Hide details' : 'Show details'}
</Button>
<Collapse in={open}>
<Box
sx={{
p: 3,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body1" sx={{ mb: 1 }}>
Additional details are revealed here.
</Typography>
<Typography variant="body2" color="text.secondary">
This pattern is used in the wizard for progressive disclosure fields appear after a
previous selection is made.
</Typography>
</Box>
</Collapse>
</Box>
);
},
};
// ─── Wizard field reveal ────────────────────────────────────────────────────
/** Simulates the wizard pattern: selecting an option reveals the next field */
export const WizardFieldReveal: Story = {
render: () => {
const [step, setStep] = useState(0);
return (
<Box sx={{ width: 400 }}>
<Typography variant="label" sx={{ mb: 1, display: 'block' }}>
Who is this funeral being arranged for?
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Button
variant={step >= 1 ? 'contained' : 'soft'}
color={step >= 1 ? 'primary' : 'secondary'}
size="large"
fullWidth
onClick={() => setStep(1)}
>
Myself
</Button>
<Button
variant={step >= 2 ? 'contained' : 'soft'}
color={step >= 2 ? 'primary' : 'secondary'}
size="large"
fullWidth
onClick={() => setStep(2)}
>
Someone else
</Button>
</Box>
<Collapse in={step >= 2}>
<Box sx={{ mt: 1 }}>
<Typography variant="label" sx={{ mb: 1, display: 'block' }}>
Has the person died?
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="soft" color="secondary" size="large" fullWidth>
Yes
</Button>
<Button variant="soft" color="secondary" size="large" fullWidth>
No
</Button>
</Box>
</Box>
</Collapse>
</Box>
);
},
};
// ─── Collapsed ──────────────────────────────────────────────────────────────
/** Content is hidden (collapsed state) */
export const Collapsed: Story = {
args: {
in: false,
},
render: (args) => (
<Box sx={{ width: 400 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
The content below is collapsed:
</Typography>
<Collapse {...args}>
<Box sx={{ p: 3, bgcolor: 'var(--fa-color-brand-50)', borderRadius: 1 }}>
<Typography variant="body1">You should not see this.</Typography>
</Box>
</Collapse>
</Box>
),
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import MuiCollapse from '@mui/material/Collapse';
import type { CollapseProps as MuiCollapseProps } from '@mui/material/Collapse';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Collapse component */
export interface CollapseProps extends MuiCollapseProps {
/** Whether the content is expanded */
in: boolean;
/** Content to reveal/hide */
children: React.ReactNode;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Progressive disclosure wrapper for the FA design system.
*
* Thin wrapper around MUI Collapse with sensible defaults for the
* arrangement wizard's progressive disclosure pattern (fields revealed
* after a selection is made).
*
* Uses a smooth slide-down animation. Unmounts children when collapsed
* to keep the DOM clean and prevent focus on hidden fields.
*
* Usage:
* ```tsx
* <Collapse in={hasSelectedOption}>
* <FormField label="Next question" />
* </Collapse>
* ```
*/
export const Collapse = React.forwardRef<HTMLDivElement, CollapseProps>(
({ children, ...props }, ref) => (
<MuiCollapse ref={ref} unmountOnExit {...props}>
{children}
</MuiCollapse>
),
);
Collapse.displayName = 'Collapse';
export default Collapse;

View File

@@ -0,0 +1,2 @@
export { default } from './Collapse';
export * from './Collapse';

View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { DialogShell } from './DialogShell';
import { Button } from '../Button';
import { Typography } from '../Typography';
import Box from '@mui/material/Box';
const meta: Meta<typeof DialogShell> = {
title: 'Atoms/DialogShell',
component: DialogShell,
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
export default meta;
type Story = StoryObj<typeof DialogShell>;
/** Default dialog with title, body, and footer */
export const Default: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Dialog title"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={() => setOpen(false)}>
Done
</Button>
</Box>
}
>
<Typography variant="body1">
This is the dialog body content. It scrolls when the content exceeds the max height.
</Typography>
</DialogShell>
</>
);
},
};
/** Dialog with a back button */
export const WithBackButton: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Step 2 of 3"
onBack={() => alert('Back')}
backLabel="Back to step 1"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" color="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="contained" onClick={() => setOpen(false)}>
Continue
</Button>
</Box>
}
>
<Typography variant="body1">
Content for the second step of a multi-step dialog.
</Typography>
</DialogShell>
</>
);
},
};
/** Long content that triggers scrollable body */
export const LongContent: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Scrollable content"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={() => setOpen(false)}>
Done
</Button>
</Box>
}
>
{Array.from({ length: 12 }, (_, i) => (
<Typography key={i} variant="body1" sx={{ mb: 2 }}>
Paragraph {i + 1}: This is sample content to demonstrate the scrollable body area.
When the content exceeds the dialog&apos;s max height, the body scrolls while the
header and footer remain fixed.
</Typography>
))}
</DialogShell>
</>
);
},
};
/** Dialog without a footer */
export const NoFooter: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell open={open} onClose={() => setOpen(false)} title="Information">
<Typography variant="body1" sx={{ mb: 2 }}>
This dialog has no footer just a close button in the header.
</Typography>
<Typography variant="body2" color="text.secondary">
Useful for informational popups or content that doesn&apos;t need actions.
</Typography>
</DialogShell>
</>
);
},
};

View File

@@ -0,0 +1,174 @@
import React from 'react';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import type { DialogProps } from '@mui/material/Dialog';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../Typography';
import { Divider } from '../Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the DialogShell atom */
export interface DialogShellProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when the dialog is closed (close button or backdrop) */
onClose: () => void;
/** Dialog title */
title: React.ReactNode;
/** Show a back arrow before the title */
onBack?: () => void;
/** Back button aria-label */
backLabel?: string;
/** Main content — rendered in the scrollable body */
children: React.ReactNode;
/** Footer actions — rendered below the body divider */
footer?: React.ReactNode;
/** MUI Dialog maxWidth */
maxWidth?: DialogProps['maxWidth'];
/** Whether the dialog should be full-width up to maxWidth */
fullWidth?: boolean;
/** MUI sx prop for the Dialog Paper */
paperSx?: SxProps<Theme>;
/** MUI sx prop for the root Dialog */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Standard dialog container for the FA design system.
*
* Provides consistent chrome for all popup dialogs across the site:
* header (title + optional back + close), scrollable body, optional footer.
*
* Used by FilterPanel, ArrangementDialog, and any future popup pattern.
*
* Usage:
* ```tsx
* <DialogShell open={open} onClose={handleClose} title="Filters" footer={<Button>Done</Button>}>
* {filterControls}
* </DialogShell>
* ```
*/
export const DialogShell = React.forwardRef<HTMLDivElement, DialogShellProps>(
(
{
open,
onClose,
title,
onBack,
backLabel = 'Back',
children,
footer,
maxWidth = 'sm',
fullWidth = true,
paperSx,
sx,
},
ref,
) => {
const titleId = React.useId();
const titleRef = React.useRef<HTMLHeadingElement>(null);
// Focus title on open or when title changes (e.g. step transitions)
React.useEffect(() => {
if (open && titleRef.current) {
titleRef.current.focus();
}
}, [open, title]);
return (
<Dialog
ref={ref}
open={open}
onClose={onClose}
maxWidth={maxWidth}
fullWidth={fullWidth}
aria-labelledby={titleId}
sx={sx}
PaperProps={{
sx: [
{
borderRadius: 2,
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
},
...(Array.isArray(paperSx) ? paperSx : paperSx ? [paperSx] : []),
],
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 5,
pt: 2.5,
pb: 2,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
{onBack && (
<IconButton
onClick={onBack}
aria-label={backLabel}
sx={{ minWidth: 44, minHeight: 44 }}
>
<ArrowBackIcon fontSize="small" />
</IconButton>
)}
<Typography
id={titleId}
ref={titleRef}
variant="h6"
component="h2"
tabIndex={-1}
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{title}
</Typography>
</Box>
<IconButton
onClick={onClose}
aria-label="Close"
sx={{ color: 'text.secondary', flexShrink: 0, ml: 1, minWidth: 44, minHeight: 44 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Divider />
{/* Scrollable body */}
<Box
sx={{
px: 5,
py: 2.5,
overflowY: 'auto',
flex: 1,
}}
>
{children}
</Box>
{/* Footer (optional) */}
{footer && (
<>
<Divider />
<Box sx={{ px: 5, py: 2, flexShrink: 0 }}>{footer}</Box>
</>
)}
</Dialog>
);
},
);
DialogShell.displayName = 'DialogShell';
export default DialogShell;

View File

@@ -0,0 +1,2 @@
export { DialogShell } from './DialogShell';
export type { DialogShellProps } from './DialogShell';

View File

@@ -0,0 +1,117 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Divider } from './Divider';
import Box from '@mui/material/Box';
const meta: Meta<typeof Divider> = {
title: 'Atoms/Divider',
component: Divider,
tags: ['autodocs'],
argTypes: {
orientation: { control: 'select', options: ['horizontal', 'vertical'] },
variant: { control: 'select', options: ['fullWidth', 'inset', 'middle'] },
light: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Divider>;
// ─── Default ────────────────────────────────────────────────────────────────
export const Default: Story = {
decorators: [
(Story) => (
<Box sx={{ width: 400 }}>
<Story />
</Box>
),
],
};
// ─── Variants ───────────────────────────────────────────────────────────────
/** fullWidth, inset, and middle variants */
export const Variants: Story = {
render: () => (
<Box sx={{ width: 400, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>fullWidth (default)</Box>
<Divider />
</Box>
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>inset</Box>
<Divider variant="inset" />
</Box>
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>middle</Box>
<Divider variant="middle" />
</Box>
</Box>
),
};
// ─── Vertical ───────────────────────────────────────────────────────────────
/** Vertical divider inside a flex container */
export const Vertical: Story = {
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, height: 40 }}>
<Box>Left</Box>
<Divider orientation="vertical" flexItem />
<Box>Right</Box>
</Box>
),
};
// ─── With Text ──────────────────────────────────────────────────────────────
/** Divider with centered text (MUI "textAlign" support) */
export const WithText: Story = {
name: 'With Text',
render: () => (
<Box sx={{ width: 400, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Divider>OR</Divider>
<Divider textAlign="left">Section</Divider>
<Divider textAlign="right">End</Divider>
</Box>
),
};
// ─── In Content ─────────────────────────────────────────────────────────────
/** Dividers separating content sections */
export const InContent: Story = {
name: 'In Content',
render: () => (
<Box sx={{ width: 400, p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ fontWeight: 600, mb: 1 }}>Service Details</Box>
<Box sx={{ fontSize: 14, color: 'text.secondary', mb: 2 }}>
Chapel service with traditional ceremony
</Box>
<Divider />
<Box sx={{ fontWeight: 600, mt: 2, mb: 1 }}>Venue</Box>
<Box sx={{ fontSize: 14, color: 'text.secondary', mb: 2 }}>West Chapel, Strathfield</Box>
<Divider />
<Box sx={{ fontWeight: 600, mt: 2, mb: 1 }}>Total</Box>
<Box sx={{ fontSize: 14, color: 'text.primary' }}>$2,400</Box>
</Box>
),
};
// ─── Navigation List ────────────────────────────────────────────────────────
/** Dividers between navigation items (footer pattern) */
export const NavigationList: Story = {
name: 'Navigation List',
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: 20 }}>
<Box sx={{ fontSize: 14, color: 'text.secondary' }}>FAQ</Box>
<Divider orientation="vertical" flexItem />
<Box sx={{ fontSize: 14, color: 'text.secondary' }}>Contact Us</Box>
<Divider orientation="vertical" flexItem />
<Box sx={{ fontSize: 14, color: 'text.secondary' }}>Privacy</Box>
<Divider orientation="vertical" flexItem />
<Box sx={{ fontSize: 14, color: 'text.secondary' }}>Terms</Box>
</Box>
),
};

View File

@@ -0,0 +1,39 @@
import React from 'react';
import MuiDivider from '@mui/material/Divider';
import type { DividerProps as MuiDividerProps } from '@mui/material/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Divider component */
export type DividerProps = MuiDividerProps;
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Visual separator for the FA design system.
*
* Thin line for separating content sections, navigation groups, or
* list items. Wraps MUI Divider with FA border tokens.
*
* Orientations:
* - `horizontal` (default) — full-width horizontal line
* - `vertical` — full-height vertical line (use inside flex containers)
*
* Variants:
* - `fullWidth` (default) — spans the full container
* - `inset` — indented from the left (for list item separators)
* - `middle` — indented from both sides
*
* Usage:
* ```tsx
* <Divider />
* <Divider orientation="vertical" flexItem />
* <Divider variant="inset" />
* ```
*/
export const Divider = React.forwardRef<HTMLHRElement, DividerProps>((props, ref) => {
return <MuiDivider ref={ref} {...props} />;
});
Divider.displayName = 'Divider';
export default Divider;

View File

@@ -0,0 +1,2 @@
export { Divider, default } from './Divider';
export type { DividerProps } from './Divider';

View File

@@ -0,0 +1,186 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconButton } from './IconButton';
import Box from '@mui/material/Box';
import CloseIcon from '@mui/icons-material/Close';
import MenuIcon from '@mui/icons-material/Menu';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import SearchIcon from '@mui/icons-material/Search';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
const meta: Meta<typeof IconButton> = {
title: 'Atoms/IconButton',
component: IconButton,
tags: ['autodocs'],
argTypes: {
size: { control: 'select', options: ['small', 'medium', 'large'] },
color: { control: 'select', options: ['default', 'primary', 'secondary', 'error'] },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof IconButton>;
// ─── Default ────────────────────────────────────────────────────────────────
export const Default: Story = {
args: {
'aria-label': 'Close',
children: <CloseIcon />,
},
};
// ─── Sizes ──────────────────────────────────────────────────────────────────
/** Three sizes matching Button height scale */
export const Sizes: Story = {
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<IconButton size="small" aria-label="Search">
<SearchIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>small (32px)</Box>
</Box>
<Box sx={{ textAlign: 'center' }}>
<IconButton size="medium" aria-label="Search">
<SearchIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>medium (40px)</Box>
</Box>
<Box sx={{ textAlign: 'center' }}>
<IconButton size="large" aria-label="Search">
<SearchIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>large (48px)</Box>
</Box>
</Box>
),
};
// ─── Colours ────────────────────────────────────────────────────────────────
/** Colour options for different contexts */
export const Colours: Story = {
name: 'Colours',
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<IconButton color="default" aria-label="Menu">
<MenuIcon />
</IconButton>
<IconButton color="primary" aria-label="Edit">
<EditOutlinedIcon />
</IconButton>
<IconButton color="secondary" aria-label="More options">
<MoreVertIcon />
</IconButton>
<IconButton color="error" aria-label="Delete">
<DeleteOutlineIcon />
</IconButton>
</Box>
),
};
// ─── States ─────────────────────────────────────────────────────────────────
/** Interactive states: default, hover (try it), disabled */
export const States: Story = {
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<IconButton aria-label="Default">
<CloseIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>Default</Box>
</Box>
<Box sx={{ textAlign: 'center' }}>
<IconButton disabled aria-label="Disabled">
<CloseIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>Disabled</Box>
</Box>
</Box>
),
};
// ─── Common Use Cases ───────────────────────────────────────────────────────
/** Real-world icon button patterns */
export const CommonUseCases: Story = {
name: 'Common Use Cases',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Card actions toolbar */}
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>Card action toolbar</Box>
<Box
sx={{
display: 'flex',
gap: 1,
p: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
width: 'fit-content',
}}
>
<IconButton size="small" color="primary" aria-label="Favourite">
<FavoriteBorderIcon />
</IconButton>
<IconButton size="small" color="primary" aria-label="Share">
<ShareOutlinedIcon />
</IconButton>
<IconButton size="small" aria-label="More options">
<MoreVertIcon />
</IconButton>
</Box>
</Box>
{/* Dialog close */}
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>Dialog close button</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
width: 300,
}}
>
<Box sx={{ fontWeight: 600 }}>Confirm Selection</Box>
<IconButton size="small" aria-label="Close dialog">
<CloseIcon />
</IconButton>
</Box>
</Box>
{/* Navigation header */}
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>Mobile navigation toggle</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
backgroundColor: 'var(--fa-color-brand-50)',
borderRadius: 1,
width: 'fit-content',
}}
>
<IconButton size="large" aria-label="Open menu">
<MenuIcon />
</IconButton>
<Box sx={{ fontWeight: 600 }}>Funeral Arranger</Box>
</Box>
</Box>
</Box>
),
};

View File

@@ -0,0 +1,38 @@
import React from 'react';
import MuiIconButton from '@mui/material/IconButton';
import type { IconButtonProps as MuiIconButtonProps } from '@mui/material/IconButton';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA IconButton component */
export type IconButtonProps = MuiIconButtonProps;
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Icon-only button for the FA design system.
*
* Square button containing a single icon — used for close buttons, menu
* toggles, toolbar actions, and anywhere a text label would be redundant.
* Wraps MUI IconButton with FA brand tokens and consistent sizing.
*
* Sizes use the same height scale as Button:
* - `small` — 32px (compact toolbars, card actions)
* - `medium` — 40px (default, general actions)
* - `large` — 48px (mobile CTAs, meets 44px touch target)
*
* **Accessibility**: Always provide an `aria-label` prop. Icon-only
* buttons have no visible text, so screen readers rely entirely on
* the aria-label to announce the action.
* ```tsx
* <IconButton aria-label="Close dialog">
* <CloseIcon />
* </IconButton>
* ```
*/
export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>((props, ref) => {
return <MuiIconButton ref={ref} {...props} />;
});
IconButton.displayName = 'IconButton';
export default IconButton;

View File

@@ -0,0 +1,2 @@
export { IconButton, default } from './IconButton';
export type { IconButtonProps } from './IconButton';

View File

@@ -0,0 +1,501 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Input } from './Input';
import SearchIcon from '@mui/icons-material/Search';
import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined';
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import { Button } from '../Button';
const meta: Meta<typeof Input> = {
title: 'Atoms/Input',
component: Input,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/3t6fpT5inh7zzjxQdW8U5p/Design-System---Template?node-id=39-713',
},
},
argTypes: {
label: {
control: 'text',
description: 'Label text displayed above the input',
},
helperText: {
control: 'text',
description: 'Helper/description text displayed below the input',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
size: {
control: 'select',
options: ['small', 'medium'],
description: 'Size preset',
table: { defaultValue: { summary: 'medium' } },
},
error: {
control: 'boolean',
description: 'Show error validation state',
table: { defaultValue: { summary: 'false' } },
},
success: {
control: 'boolean',
description: 'Show success validation state',
table: { defaultValue: { summary: 'false' } },
},
disabled: {
control: 'boolean',
description: 'Disable the input',
table: { defaultValue: { summary: 'false' } },
},
required: {
control: 'boolean',
description: 'Mark as required (adds asterisk to label)',
table: { defaultValue: { summary: 'false' } },
},
fullWidth: {
control: 'boolean',
description: 'Stretch to full width of parent container',
table: { defaultValue: { summary: 'true' } },
},
multiline: {
control: 'boolean',
description: 'Render as a textarea',
table: { defaultValue: { summary: 'false' } },
},
},
decorators: [
(Story) => (
<div style={{ width: 400 }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Input>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default input appearance — medium size, full width */
export const Default: Story = {
args: {
label: 'Full name',
placeholder: 'Enter your full name',
helperText: 'As it appears on official documents',
},
};
// ─── Figma Mapping ──────────────────────────────────────────────────────────
/**
* Maps directly to the Figma input component properties:
* - **label=true** → `label` prop
* - **description=true** → `helperText` prop
* - **trailing.icon=true** → `endIcon` prop
* - **placeholder=true** → `placeholder` prop
*/
export const FigmaMapping: Story = {
name: 'Figma Mapping',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<Input
label="Label Header"
placeholder="Select an option"
helperText="Input Label - Description"
endIcon={<SearchIcon />}
/>
<Input
placeholder="Select an option"
helperText="Input Label - Description"
endIcon={<SearchIcon />}
/>
<Input placeholder="Select an option" endIcon={<SearchIcon />} />
<Input placeholder="Select an option" />
</div>
),
};
// ─── States ─────────────────────────────────────────────────────────────────
/** All visual states matching the Figma design */
export const AllStates: Story = {
name: 'All States',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<Input
label="Default"
placeholder="Enter text..."
helperText="Resting state — neutral border"
/>
<Input
label="Filled"
defaultValue="John Smith"
helperText="Has a value — text colour changes from placeholder to primary"
/>
<Input
label="Error (empty)"
placeholder="Enter text..."
error
helperText="This field is required"
/>
<Input
label="Error (filled)"
defaultValue="invalid@"
error
helperText="Please enter a valid email address"
/>
<Input
label="Success"
defaultValue="john.smith@example.com"
success
helperText="Email address verified"
/>
<Input
label="Disabled (empty)"
placeholder="Enter text..."
disabled
helperText="This field is currently unavailable"
/>
<Input
label="Disabled (filled)"
defaultValue="Pre-filled value"
disabled
helperText="This value cannot be changed"
/>
</div>
),
};
// ─── Required ───────────────────────────────────────────────────────────────
/** Required field with asterisk indicator */
export const Required: Story = {
args: {
label: 'Email address',
placeholder: 'you@example.com',
helperText: 'We will use this to send the arrangement confirmation',
required: true,
},
};
// ─── Sizes ──────────────────────────────────────────────────────────────────
/** Both sizes side by side */
export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<Input
label="Medium (48px) — default"
placeholder="Standard form input"
size="medium"
helperText="Matches Button large height for alignment"
/>
<Input
label="Small (40px) — compact"
placeholder="Compact form input"
size="small"
helperText="Matches Button medium height for dense layouts"
/>
</div>
),
};
/** Size comparison with Buttons (for search bar alignment) */
export const SizeAlignment: Story = {
name: 'Size Alignment with Button',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<Input placeholder="Search arrangements..." endIcon={<SearchIcon />} size="medium" />
<Button size="large" sx={{ minWidth: 100, minHeight: 48 }}>
Search
</Button>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<Input placeholder="Quick search..." endIcon={<SearchIcon />} size="small" />
<Button size="medium" sx={{ minWidth: 100, minHeight: 40 }}>
Search
</Button>
</div>
</div>
),
};
// ─── With Icons ─────────────────────────────────────────────────────────────
/** Leading and trailing icon examples */
export const WithIcons: Story = {
name: 'With Icons',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<Input label="Search" placeholder="Search services..." endIcon={<SearchIcon />} />
<Input
label="Email"
placeholder="you@example.com"
startIcon={<EmailOutlinedIcon />}
type="email"
/>
<Input
label="Phone"
placeholder="+61 400 000 000"
startIcon={<PhoneOutlinedIcon />}
type="tel"
/>
<Input label="Amount" placeholder="0.00" startIcon={<AttachMoneyIcon />} type="number" />
<Input
label="Email verified"
defaultValue="john@example.com"
startIcon={<EmailOutlinedIcon />}
endIcon={<CheckCircleOutlineIcon sx={{ color: 'success.main' }} />}
success
helperText="Email address confirmed"
/>
<Input
label="Email invalid"
defaultValue="john@"
startIcon={<EmailOutlinedIcon />}
endIcon={<ErrorOutlineIcon sx={{ color: 'error.main' }} />}
error
helperText="Please enter a valid email address"
/>
</div>
),
};
// ─── Password ───────────────────────────────────────────────────────────────
/** Password field with show/hide toggle using raw endAdornment */
export const PasswordToggle: Story = {
name: 'Password Toggle',
render: function PasswordDemo() {
const [show, setShow] = useState(false);
return (
<Input
label="Password"
placeholder="Enter your password"
type={show ? 'text' : 'password'}
required
startIcon={<LockOutlinedIcon />}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label={show ? 'Hide password' : 'Show password'}
onClick={() => setShow(!show)}
edge="end"
size="small"
>
{show ? <VisibilityOffOutlinedIcon /> : <VisibilityOutlinedIcon />}
</IconButton>
</InputAdornment>
}
helperText="Must be at least 8 characters"
/>
);
},
};
// ─── Multiline ──────────────────────────────────────────────────────────────
/** Multiline textarea for longer text */
export const Multiline: Story = {
args: {
label: 'Special instructions',
placeholder: 'Any specific requests or notes for the arrangement...',
helperText: 'Optional — include any details that may help us prepare',
multiline: true,
minRows: 3,
maxRows: 6,
},
};
// ─── Validation Example ─────────────────────────────────────────────────────
/** Interactive validation flow */
export const ValidationFlow: Story = {
name: 'Validation Flow',
render: function ValidationDemo() {
const [value, setValue] = useState('');
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const showError = value.length > 0 && !isValid;
const showSuccess = value.length > 0 && isValid;
return (
<Input
label="Email address"
placeholder="you@example.com"
required
startIcon={<EmailOutlinedIcon />}
endIcon={
showSuccess ? (
<CheckCircleOutlineIcon sx={{ color: 'success.main' }} />
) : showError ? (
<ErrorOutlineIcon sx={{ color: 'error.main' }} />
) : undefined
}
value={value}
onChange={(e) => setValue(e.target.value)}
error={showError}
success={showSuccess}
helperText={
showError
? 'Please enter a valid email address'
: showSuccess
? 'Looks good!'
: 'Required for arrangement confirmation'
}
/>
);
},
};
// ─── Realistic Form ─────────────────────────────────────────────────────────
/** Realistic arrangement form layout */
export const ArrangementForm: Story = {
name: 'Arrangement Form',
decorators: [
(Story) => (
<div style={{ width: 480 }}>
<Story />
</div>
),
],
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ fontWeight: 700, fontSize: 20, marginBottom: 4 }}>Contact details</div>
<Input
label="Full name"
placeholder="Enter your full name"
required
helperText="As it appears on official documents"
/>
<div style={{ display: 'flex', gap: 12 }}>
<Input
label="Email"
placeholder="you@example.com"
required
startIcon={<EmailOutlinedIcon />}
type="email"
/>
<Input
label="Phone"
placeholder="+61 400 000 000"
startIcon={<PhoneOutlinedIcon />}
type="tel"
/>
</div>
<Input
label="Relationship to the deceased"
placeholder="e.g. Son, Daughter, Partner, Friend"
helperText="This helps us personalise the arrangement"
/>
<Input
label="Special instructions"
placeholder="Any specific requests or notes..."
multiline
minRows={3}
helperText="Optional"
/>
</div>
),
};
// ─── Complete Matrix ────────────────────────────────────────────────────────
/** Full state matrix for visual QA — all states across both sizes */
export const CompleteMatrix: Story = {
name: 'Complete Matrix',
decorators: [
(Story) => (
<div style={{ width: 600 }}>
<Story />
</div>
),
],
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
{(['medium', 'small'] as const).map((size) => (
<div key={size}>
<div
style={{
marginBottom: 12,
fontWeight: 600,
fontSize: 14,
textTransform: 'uppercase',
letterSpacing: 1,
color: '#737373',
}}
>
Size: {size} ({size === 'medium' ? '48px' : '40px'})
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<Input
size={size}
label="Default"
placeholder="Enter text..."
helperText="Helper text"
endIcon={<SearchIcon />}
/>
<Input
size={size}
label="Filled"
defaultValue="Entered value"
helperText="Helper text"
endIcon={<SearchIcon />}
/>
<Input
size={size}
label="Required"
placeholder="Required field..."
helperText="This field is required"
required
/>
<Input
size={size}
label="Error"
defaultValue="Invalid input"
error
helperText="Validation error message"
endIcon={<ErrorOutlineIcon sx={{ color: 'error.main' }} />}
/>
<Input
size={size}
label="Success"
defaultValue="Valid input"
success
helperText="Validation success message"
endIcon={<CheckCircleOutlineIcon sx={{ color: 'success.main' }} />}
/>
<Input
size={size}
label="Disabled"
placeholder="Unavailable"
disabled
helperText="This field is disabled"
/>
<Input
size={size}
label="Disabled filled"
defaultValue="Pre-filled"
disabled
helperText="This value is locked"
/>
</div>
</div>
))}
</div>
),
};

View File

@@ -0,0 +1,190 @@
import React from 'react';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import type { OutlinedInputProps } from '@mui/material/OutlinedInput';
import FormHelperText from '@mui/material/FormHelperText';
import InputAdornment from '@mui/material/InputAdornment';
import type { Theme } from '@mui/material/styles';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Input component */
export interface InputProps extends Omit<OutlinedInputProps, 'notched' | 'label'> {
/** Label text displayed above the input */
label?: string;
/** Helper/description text displayed below the input */
helperText?: React.ReactNode;
/** Show success validation state (green border and helper text) */
success?: boolean;
/** Icon element to show at the start (left) of the input */
startIcon?: React.ReactNode;
/** Icon element to show at the end (right) of the input */
endIcon?: React.ReactNode;
/** Whether the input takes full width of its container */
fullWidth?: boolean;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Text input component for the FA design system.
*
* Wraps MUI OutlinedInput with an external label pattern, FA brand tokens,
* two sizes (small/medium), and success/error validation states.
*
* Features:
* - External label with required asterisk indicator
* - Helper text that contextually colours for error/success
* - Leading and trailing icon slots (via `startIcon`/`endIcon`)
* - Branded focus ring (warm gold double-ring from Figma)
* - Two sizes: `medium` (48px, default) and `small` (40px)
* - Multiline/textarea support via `multiline` + `rows`/`minRows`
*
* State mapping from Figma design:
* - Default → resting state, neutral border
* - Hover → darker border (CSS :hover)
* - Focus → brand.500 border + double focus ring
* - Error → `error` prop — red border + red helper text
* - Success → `success` prop — green border + green helper text
* - Disabled → `disabled` prop — grey background, muted text
*/
export const Input = React.forwardRef<HTMLDivElement, InputProps>(
(
{
label,
helperText,
success = false,
error = false,
required = false,
disabled = false,
fullWidth = true,
startIcon,
endIcon,
startAdornment,
endAdornment,
id,
size = 'medium',
sx,
...props
},
ref,
) => {
const autoId = React.useId();
const inputId = id || autoId;
const helperId = helperText ? `${inputId}-helper` : undefined;
// Prefer convenience icon props; fall back to raw adornment props
const resolvedStart = startIcon ? (
<InputAdornment position="start">{startIcon}</InputAdornment>
) : (
startAdornment
);
const resolvedEnd = endIcon ? (
<InputAdornment position="end">{endIcon}</InputAdornment>
) : (
endAdornment
);
return (
<FormControl
ref={ref}
fullWidth={fullWidth}
error={error}
disabled={disabled}
required={required}
>
{label && (
<InputLabel
htmlFor={inputId}
shrink
sx={{
position: 'static',
transform: 'none',
maxWidth: 'none',
pointerEvents: 'auto',
mb: 2.5,
// labelLg typography
fontFamily: (theme) => theme.typography.labelLg.fontFamily,
fontSize: (theme) => theme.typography.labelLg.fontSize,
fontWeight: (theme) => theme.typography.labelLg.fontWeight,
lineHeight: (theme) => theme.typography.labelLg.lineHeight,
letterSpacing: (theme) =>
(theme.typography.labelLg as { letterSpacing?: string }).letterSpacing ?? 'normal',
color: 'text.secondary',
// Label stays neutral on error/focus/success (per Figma design)
'&.Mui-focused': { color: 'text.secondary' },
'&.Mui-error': { color: 'text.secondary' },
'&.Mui-disabled': { color: 'text.disabled' },
// Required asterisk in error red
'& .MuiInputLabel-asterisk': { color: 'error.main' },
}}
>
{label}
</InputLabel>
)}
<OutlinedInput
id={inputId}
size={size}
error={error}
disabled={disabled}
required={required}
notched={false}
startAdornment={resolvedStart}
endAdornment={resolvedEnd}
aria-describedby={helperId}
sx={[
// Success border + focus ring (not a native MUI state)
success &&
!error && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'success.main',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'success.main',
},
'&.Mui-focused': {
boxShadow: (theme: Theme) =>
`0 0 0 3px ${theme.palette.common.white}, 0 0 0 5px ${theme.palette.success.main}`,
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
/>
{helperText && (
<FormHelperText
id={helperId}
error={error}
disabled={disabled}
role={error ? 'alert' : undefined}
sx={{
mx: 0,
mt: 1.5,
// caption typography
fontFamily: (theme) => theme.typography.caption.fontFamily,
fontSize: (theme) => theme.typography.caption.fontSize,
fontWeight: (theme) => theme.typography.caption.fontWeight,
lineHeight: (theme) => theme.typography.caption.lineHeight,
letterSpacing: (theme) => theme.typography.caption.letterSpacing,
// Contextual colour: error > success > secondary
...(error
? { color: 'error.main' }
: success
? { color: 'success.main' }
: { color: 'text.secondary' }),
}}
>
{helperText}
</FormHelperText>
)}
</FormControl>
);
},
);
Input.displayName = 'Input';
export default Input;

View File

@@ -0,0 +1,2 @@
export { Input, default } from './Input';
export type { InputProps } from './Input';

View File

@@ -0,0 +1,170 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Link } from './Link';
import Box from '@mui/material/Box';
const meta: Meta<typeof Link> = {
title: 'Atoms/Link',
component: Link,
tags: ['autodocs'],
argTypes: {
underline: { control: 'select', options: ['none', 'hover', 'always'] },
color: { control: 'text' },
},
};
export default meta;
type Story = StoryObj<typeof Link>;
// ─── Default ────────────────────────────────────────────────────────────────
export const Default: Story = {
args: {
href: '#',
children: 'Frequently Asked Questions',
},
};
// ─── Underline Variants ─────────────────────────────────────────────────────
/** Three underline modes */
export const UnderlineVariants: Story = {
name: 'Underline Variants',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Link href="#" underline="hover">
Hover (default)
</Link>
<Box sx={{ fontSize: 11, color: 'text.secondary' }}>underline="hover"</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Link href="#" underline="always">
Always underlined
</Link>
<Box sx={{ fontSize: 11, color: 'text.secondary' }}>underline="always"</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Link href="#" underline="none">
No underline
</Link>
<Box sx={{ fontSize: 11, color: 'text.secondary' }}>underline="none"</Box>
</Box>
</Box>
),
};
// ─── Colour Variants ────────────────────────────────────────────────────────
/** Brand (default) and secondary colour options */
export const ColourVariants: Story = {
name: 'Colour Variants',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Link href="#">Brand link (default copper, 4.8:1 contrast)</Link>
</Box>
<Box>
<Link href="#" color="text.secondary">
Secondary link (neutral grey)
</Link>
</Box>
<Box>
<Link href="#" color="text.primary">
Primary text link (charcoal)
</Link>
</Box>
<Box>
<Link href="#" color="error.main">
Error link (red for destructive actions)
</Link>
</Box>
</Box>
),
};
// ─── Inline ─────────────────────────────────────────────────────────────────
/** Links inline within body text */
export const Inline: Story = {
render: () => (
<Box sx={{ maxWidth: 500, lineHeight: 1.7 }}>
If you need help planning a funeral, our <Link href="#">arrangement guide</Link> walks you
through each step. You can also browse our <Link href="#">provider directory</Link> to find
local funeral directors, or <Link href="#">contact us</Link> directly for personalised
assistance.
</Box>
),
};
// ─── Navigation ─────────────────────────────────────────────────────────────
/** Links styled for navigation (no underline) */
export const Navigation: Story = {
render: () => (
<Box sx={{ display: 'flex', gap: 3 }}>
<Link href="#" underline="none" sx={{ fontWeight: 600 }}>
FAQ
</Link>
<Link href="#" underline="none" sx={{ fontWeight: 600 }}>
Contact Us
</Link>
<Link href="#" underline="none" sx={{ fontWeight: 600 }}>
Log In
</Link>
</Box>
),
};
// ─── Footer ─────────────────────────────────────────────────────────────────
/** Footer link pattern — secondary colour, smaller text */
export const FooterLinks: Story = {
name: 'Footer Links',
render: () => (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Link href="#" color="text.secondary" variant="body2">
Privacy Policy
</Link>
<Link href="#" color="text.secondary" variant="body2">
Terms of Service
</Link>
<Link href="#" color="text.secondary" variant="body2">
Accessibility
</Link>
<Link href="#" color="text.secondary" variant="body2">
Cookie Settings
</Link>
</Box>
),
};
// ─── On Different Backgrounds ───────────────────────────────────────────────
/** Links on white vs warm vs grey surfaces */
export const OnDifferentBackgrounds: Story = {
name: 'On Different Backgrounds',
render: () => (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box
sx={{
p: 3,
backgroundColor: 'background.default',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ fontSize: 11, color: 'text.secondary', mb: 1 }}>White</Box>
<Link href="#">Learn more</Link>
</Box>
<Box sx={{ p: 3, backgroundColor: 'var(--fa-color-surface-warm)', borderRadius: 1 }}>
<Box sx={{ fontSize: 11, color: 'text.secondary', mb: 1 }}>Warm (brand.50)</Box>
<Link href="#">Learn more</Link>
</Box>
<Box sx={{ p: 3, backgroundColor: 'var(--fa-color-surface-subtle)', borderRadius: 1 }}>
<Box sx={{ fontSize: 11, color: 'text.secondary', mb: 1 }}>Grey (neutral.50)</Box>
<Link href="#">Learn more</Link>
</Box>
</Box>
),
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import MuiLink from '@mui/material/Link';
import type { LinkProps as MuiLinkProps } from '@mui/material/Link';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Link component */
export type LinkProps = MuiLinkProps;
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Navigation text link for the FA design system.
*
* Inline or standalone text link with FA brand styling — copper colour
* (brand.600) for WCAG AA compliance on white backgrounds. Underline
* appears on hover by default.
*
* Wraps MUI Link with FA theme tokens. Uses `color.text.brand`
* (#B0610F, 4.8:1 contrast ratio on white).
*
* Usage:
* ```tsx
* <Link href="/faq">Frequently Asked Questions</Link>
* <Link href="/contact" color="text.secondary">Contact Us</Link>
* ```
*
* For button-styled links, use `Button` with `component="a"` and `href`.
* For navigation menu items, use Link with `underline="none"`.
*/
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
return <MuiLink ref={ref} {...props} />;
});
Link.displayName = 'Link';
export default Link;

View File

@@ -0,0 +1,2 @@
export { Link, default } from './Link';
export type { LinkProps } from './Link';

View File

@@ -0,0 +1,100 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapPin } from './MapPin';
const meta: Meta<typeof MapPin> = {
title: 'Atoms/MapPin',
component: MapPin,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof MapPin>;
/** Verified provider — promoted brand palette (dark copper bg, white text) */
export const Verified: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
verified: true,
},
};
/** Unverified provider — neutral grey label */
export const Unverified: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
verified: false,
},
};
/** Custom price label (e.g. "POA" for providers without a fixed starting price) */
export const CustomPriceLabel: Story = {
args: {
name: 'Premium Services',
priceLabel: 'POA',
verified: true,
},
};
/** Long name — truncated with ellipsis at 180px max */
export const LongName: Story = {
args: {
name: 'Botanical Funerals by Ian Allison',
price: 1200,
verified: true,
},
};
/** Map simulation — multiple pins on a mock map background */
export const MapSimulation: Story = {
decorators: [
(Story) => (
<Box
sx={{
position: 'relative',
width: 700,
height: 450,
bgcolor: '#E5E3DF',
borderRadius: 2,
overflow: 'hidden',
}}
>
<Story />
</Box>
),
],
render: () => (
<>
{/* Verified providers */}
<Box sx={{ position: 'absolute', top: 60, left: 80 }}>
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
<MapPin name="Lady Anne Funerals" price={1450} verified onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
</Box>
{/* Unverified providers */}
<Box sx={{ position: 'absolute', top: 100, left: 450 }}>
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
<MapPin name="Local Provider" price={1600} onClick={() => {}} />
</Box>
</>
),
};

View File

@@ -0,0 +1,237 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapPin atom */
export interface MapPinProps {
/** Provider or venue name (required — shown as line 1) */
name: string;
/** Starting package price in dollars — shown as "From $X" on line 2 */
price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string;
/** Whether this provider/venue is verified (brand palette vs neutral palette) */
verified?: boolean;
/** Click handler */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const PIN_PX = 'var(--fa-map-pin-padding-x)';
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const MAX_WIDTH = 210;
// ─── Colour sets ────────────────────────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-700)',
name: 'var(--fa-color-white)',
price: 'var(--fa-color-brand-200)',
nub: 'var(--fa-color-brand-700)',
border: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
name: 'var(--fa-color-neutral-800)',
price: 'var(--fa-color-neutral-500)',
nub: 'var(--fa-color-neutral-100)',
border: 'var(--fa-color-neutral-300)',
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Map marker pin for the FA design system.
*
* Two-line label marker showing provider name and starting package
* price. Renders as a rounded pill with a downward nub pointing to
* the exact map location.
*
* - **Line 1**: Provider name (bold, truncated)
* - **Line 2**: "From $X" (smaller, secondary colour)
*
* Visual distinction:
* - **Verified** providers: warm brand palette (dark copper bg, white text)
* - **Unverified** providers: neutral grey palette
*
* Designed for use as custom HTML markers in Google Maps. Pure CSS — no
* canvas, no SVG dependency. Selection/popup behaviour is handled at the
* organism level (ProviderMap swaps pin → popup on click).
*
* Usage:
* ```tsx
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
* <MapPin name="Smith & Sons" price={1200} />
* <MapPin name="Botanical" priceLabel="POA" verified />
* ```
*/
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
({ name, price, priceLabel, verified = false, onClick, sx }, ref) => {
const palette = verified ? colours.verified : colours.unverified;
const hasPrice = price != null || priceLabel != null;
const priceText =
priceLabel ?? (price != null ? `From $${price.toLocaleString('en-AU')}` : undefined);
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
};
return (
<Box
ref={ref}
role="button"
tabIndex={0}
aria-label={`${name}${hasPrice ? `, packages from ${priceLabel ?? `$${price?.toLocaleString('en-AU')}`}` : ''}${verified ? ', verified' : ''}`}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
// Fade in on mount — matches the popup's exit timing so the pin
// reappears smoothly when a popup closes.
'@keyframes mapPinIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'mapPinIn 180ms ease-out',
'&:hover': {
transform: 'scale(1.08)',
},
'&:focus-visible': {
outline: 'none',
'& > .MapPin-label': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Label pill */}
<Box
className="MapPin-label"
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: MAX_WIDTH,
py: 0.5,
px: PIN_PX,
borderRadius: PIN_RADIUS,
backgroundColor: palette.bg,
border: '1px solid',
borderColor: palette.border,
boxShadow: 'var(--fa-shadow-sm)',
}}
>
{/* Name row — verified icon (left) + name */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
maxWidth: '100%',
}}
>
{verified && (
// Inline SVG of Material's Verified (outlined) icon. Kept as
// inline SVG because MapPin is mounted via createRoot outside
// the MUI ThemeProvider, so @mui/icons-material wouldn't pick
// up theme defaults.
<svg
aria-hidden
width="12"
height="12"
viewBox="0 0 24 24"
style={{ flexShrink: 0, fill: palette.name }}
>
<path d="M23 11.99l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.49 6.71 4.69 3.1 5.5l.34 3.7L1 11.99l2.44 2.79-.34 3.7 3.61.82 1.89 3.2L12 21.03l3.4 1.47 1.89-3.2 3.61-.82-.34-3.69L23 11.99zm-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z" />
</svg>
)}
<Box
component="span"
sx={{
fontSize: 12,
fontWeight: 700,
fontFamily: 'var(--fa-font-family-body)',
lineHeight: 1.3,
color: palette.name,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
}}
>
{name}
</Box>
</Box>
{/* Price line */}
{hasPrice && (
<Box
component="span"
sx={{
fontSize: 11,
fontWeight: 600,
fontFamily: 'var(--fa-font-family-body)',
lineHeight: 1.2,
color: palette.price,
whiteSpace: 'nowrap',
}}
>
{priceText}
</Box>
)}
</Box>
{/* Nub — downward pointer. Two SVG paths:
• fill is an extended pentagon that overhangs 3 units *into* the
pill's bg so sub-pixel scaling artifacts (hover transform) can't
expose the pill's bottom border through the seam;
• stroke is a separate open path on the two slanted sides only,
so the nub outline is continuous with the pill's border.
overflow: visible lets the fill render above the viewBox. */}
<svg
aria-hidden
viewBox="0 0 16 8"
style={{
display: 'block',
width: `calc(2 * ${NUB_SIZE})`,
height: NUB_SIZE,
marginTop: '-1px',
overflow: 'visible',
}}
>
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
<path
d="M 0 0 L 8 8 L 16 0"
fill="none"
stroke={palette.border}
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
</Box>
);
},
);
MapPin.displayName = 'MapPin';
export default MapPin;

View File

@@ -0,0 +1,2 @@
export { MapPin, default } from './MapPin';
export type { MapPinProps } from './MapPin';

View File

@@ -0,0 +1,177 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Radio } from './Radio';
import { Typography } from '../Typography';
import { Card } from '../Card';
import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel';
import RadioGroup from '@mui/material/RadioGroup';
import FormControl from '@mui/material/FormControl';
const meta: Meta<typeof Radio> = {
title: 'Atoms/Radio',
component: Radio,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2322-42538',
},
},
argTypes: {
checked: {
control: 'boolean',
description: 'Whether the radio is selected',
},
disabled: {
control: 'boolean',
description: 'Disable the radio',
table: { defaultValue: { summary: 'false' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Radio>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default radio — unchecked */
export const Default: Story = {
args: {},
};
// ─── States ─────────────────────────────────────────────────────────────────
/** All visual states */
export const States: Story = {
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel control={<Radio />} label="Unchecked" />
<FormControlLabel control={<Radio checked />} label="Checked" />
<FormControlLabel control={<Radio disabled />} label="Disabled unchecked" />
<FormControlLabel control={<Radio disabled checked />} label="Disabled checked" />
</Box>
),
};
// ─── Radio Group ────────────────────────────────────────────────────────────
/** Standard radio group with keyboard navigation */
export const Group: Story = {
name: 'Radio Group',
render: () => (
<FormControl>
<Typography variant="label" sx={{ mb: 1 }}>
Service type
</Typography>
<RadioGroup defaultValue="chapel">
<FormControlLabel value="chapel" control={<Radio />} label="Chapel ceremony" />
<FormControlLabel value="graveside" control={<Radio />} label="Graveside service" />
<FormControlLabel value="memorial" control={<Radio />} label="Memorial service" />
<FormControlLabel value="direct" control={<Radio />} label="Direct cremation" />
</RadioGroup>
</FormControl>
),
};
// ─── Interactive: Card Selection ────────────────────────────────────────────
/**
* Radio buttons inside interactive cards — the recommended pattern
* for option selection in FA. Combines Card's selected state with
* Radio for accessible single-select.
*/
export const CardSelection: Story = {
name: 'Interactive — Card Selection',
render: () => {
const CardSelectDemo = () => {
const [selected, setSelected] = useState('standard');
const options = [
{
value: 'direct',
label: 'Direct cremation',
desc: 'Simple, dignified cremation with no service.',
price: '$1,800',
},
{
value: 'standard',
label: 'Standard service',
desc: 'Traditional chapel ceremony with viewing.',
price: '$4,200',
},
{
value: 'premium',
label: 'Premium service',
desc: 'Full service with personalised memorial.',
price: '$7,500',
},
];
return (
<Box sx={{ maxWidth: 420 }}>
<Typography variant="h4" sx={{ mb: 2 }}>
Choose a package
</Typography>
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value)}>
{options.map((opt) => (
<Card
key={opt.value}
variant="outlined"
interactive
selected={selected === opt.value}
padding="compact"
sx={{ mb: 1 }}
onClick={() => setSelected(opt.value)}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Radio value={opt.value} sx={{ mt: -0.5 }} />
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="label">{opt.label}</Typography>
<Typography variant="labelLg" color="primary">
{opt.price}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{opt.desc}
</Typography>
</Box>
</Box>
</Card>
))}
</RadioGroup>
</Box>
);
};
return <CardSelectDemo />;
},
};
// ─── Interactive: Payment Method ────────────────────────────────────────────
/** Horizontal radio group for payment method selection */
export const PaymentMethod: Story = {
name: 'Interactive — Payment Method',
render: () => (
<FormControl>
<Typography variant="label" sx={{ mb: 1 }}>
Payment method
</Typography>
<RadioGroup defaultValue="card" row>
<FormControlLabel value="card" control={<Radio />} label="Credit card" />
<FormControlLabel value="bank" control={<Radio />} label="Bank transfer" />
<FormControlLabel value="plan" control={<Radio />} label="Payment plan" />
</RadioGroup>
</FormControl>
),
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import MuiRadio from '@mui/material/Radio';
import type { RadioProps as MuiRadioProps } from '@mui/material/Radio';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Radio component */
export type RadioProps = MuiRadioProps;
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Radio button for the FA design system.
*
* Single-select control for mutually exclusive options. Wraps MUI Radio
* with FA brand tokens — warm gold fill when selected.
*
* From Parsons 1.0 Figma radio component — 16px circle with brand dot.
*
* Usage:
* - For binary on/off, use Switch instead
* - For multi-select, use Checkbox (planned) or Chip
*
* **Accessibility**: Always use inside a `RadioGroup` and wrap each Radio
* in `FormControlLabel`. A standalone Radio without a label or group is
* inaccessible — screen readers cannot announce the option or allow
* keyboard arrow-key navigation between options.
* ```tsx
* <RadioGroup value={value} onChange={handleChange}>
* <FormControlLabel value="burial" control={<Radio />} label="Burial" />
* <FormControlLabel value="cremation" control={<Radio />} label="Cremation" />
* </RadioGroup>
* ```
*/
export const Radio = React.forwardRef<HTMLButtonElement, RadioProps>((props, ref) => {
return <MuiRadio ref={ref} {...props} />;
});
Radio.displayName = 'Radio';
export default Radio;

View File

@@ -0,0 +1,2 @@
export { Radio, default } from './Radio';
export type { RadioProps } from './Radio';

View File

@@ -0,0 +1,179 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Switch } from './Switch';
import { Typography } from '../Typography';
import { Card } from '../Card';
import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
const meta: Meta<typeof Switch> = {
title: 'Atoms/Switch',
component: Switch,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2322-42538',
},
},
argTypes: {
checked: {
control: 'boolean',
description: 'Whether the switch is on',
},
disabled: {
control: 'boolean',
description: 'Disable the switch',
table: { defaultValue: { summary: 'false' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Switch>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default switch — unchecked */
export const Default: Story = {
args: {},
};
// ─── States ─────────────────────────────────────────────────────────────────
/** All visual states */
export const States: Story = {
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel control={<Switch />} label="Unchecked" />
<FormControlLabel control={<Switch defaultChecked />} label="Checked" />
<FormControlLabel control={<Switch disabled />} label="Disabled unchecked" />
<FormControlLabel control={<Switch disabled defaultChecked />} label="Disabled checked" />
</Box>
),
};
// ─── Interactive: Service Add-ons ───────────────────────────────────────────
/**
* Realistic arrangement form pattern — toggle add-on services.
*/
export const ServiceAddOns: Story = {
name: 'Interactive — Service Add-ons',
render: () => {
const AddOnDemo = () => {
const [addOns, setAddOns] = useState({
catering: true,
flowers: true,
music: false,
memorial: false,
guestBook: false,
});
const toggle = (key: keyof typeof addOns) => {
setAddOns((prev) => ({ ...prev, [key]: !prev[key] }));
};
const items = [
{
key: 'catering' as const,
label: 'Catering',
desc: 'Light refreshments after the service',
price: '$450',
},
{
key: 'flowers' as const,
label: 'Floral arrangements',
desc: 'Seasonal flowers for the chapel',
price: '$280',
},
{
key: 'music' as const,
label: 'Live music',
desc: 'Organist or solo musician',
price: '$350',
},
{
key: 'memorial' as const,
label: 'Memorial video',
desc: 'Photo slideshow with music',
price: '$200',
},
{
key: 'guestBook' as const,
label: 'Guest book',
desc: 'Leather-bound memorial guest book',
price: '$85',
},
];
const total = items.reduce(
(sum, item) => (addOns[item.key] ? sum + parseInt(item.price.replace('$', ''), 10) : sum),
0,
);
return (
<Box sx={{ maxWidth: 420 }}>
<Typography variant="h4" sx={{ mb: 2 }}>
Service add-ons
</Typography>
<FormGroup>
{items.map((item) => (
<Card key={item.key} variant="outlined" padding="compact" sx={{ mb: 1 }}>
<Box
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Box sx={{ flex: 1 }}>
<Typography variant="label">{item.label}</Typography>
<Typography variant="body2" color="text.secondary">
{item.desc}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="label" color="text.secondary">
{item.price}
</Typography>
<Switch checked={addOns[item.key]} onChange={() => toggle(item.key)} />
</Box>
</Box>
</Card>
))}
</FormGroup>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
mt: 2,
pt: 2,
borderTop: 1,
borderColor: 'divider',
}}
>
<Typography variant="labelLg">Total add-ons</Typography>
<Typography variant="labelLg" color="primary">
${total}
</Typography>
</Box>
</Box>
);
};
return <AddOnDemo />;
},
};
// ─── With Labels ────────────────────────────────────────────────────────────
/** FormControlLabel pairing — the recommended usage pattern */
export const WithLabels: Story = {
name: 'With Labels',
render: () => (
<FormGroup>
<FormControlLabel control={<Switch defaultChecked />} label="Email notifications" />
<FormControlLabel control={<Switch />} label="SMS notifications" />
<FormControlLabel control={<Switch defaultChecked />} label="Save arrangement progress" />
</FormGroup>
),
};

View File

@@ -0,0 +1,37 @@
import React from 'react';
import MuiSwitch from '@mui/material/Switch';
import type { SwitchProps as MuiSwitchProps } from '@mui/material/Switch';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Switch component */
export type SwitchProps = MuiSwitchProps;
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Toggle switch for the FA design system.
*
* Binary on/off control for enabling add-ons and options in arrangement
* forms. Wraps MUI Switch with FA brand tokens — warm gold track when
* active, bordered pill shape when inactive.
*
* From Parsons 1.0 Figma "Style One" — bordered variant with brand fill.
*
* Usage:
* - Use for boolean settings ("Include catering", "Add memorial video")
* - For mutually exclusive options, use Radio instead
*
* **Accessibility**: Always wrap in `FormControlLabel` with a `label` prop.
* A standalone Switch without a visible label is inaccessible — screen
* readers cannot announce what the toggle controls.
* ```tsx
* <FormControlLabel control={<Switch />} label="Include catering" />
* ```
*/
export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
return <MuiSwitch ref={ref} {...props} />;
});
Switch.displayName = 'Switch';
export default Switch;

View File

@@ -0,0 +1,2 @@
export { Switch, default } from './Switch';
export type { SwitchProps } from './Switch';

View File

@@ -0,0 +1,224 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { ToggleButtonGroup } from './ToggleButtonGroup';
import Box from '@mui/material/Box';
const meta: Meta<typeof ToggleButtonGroup> = {
title: 'Atoms/ToggleButtonGroup',
component: ToggleButtonGroup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
value: { control: 'text' },
error: { control: 'boolean' },
required: { control: 'boolean' },
fullWidth: { control: 'boolean' },
size: {
control: 'select',
options: ['small', 'medium', 'large'],
table: { defaultValue: { summary: 'large' } },
},
},
};
export default meta;
type Story = StoryObj<typeof ToggleButtonGroup>;
// ─── Default ─────────────────────────────────────────────────────────────────
/** Binary choice with labels only */
export const Default: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{ value: 'myself', label: 'Myself' },
{ value: 'someone', label: 'Someone else' },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── With Descriptions ──────────────────────────────────────────────────────
/** Options with label + description text */
export const WithDescriptions: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{
value: 'myself',
label: 'Myself',
description: 'I want to plan my own funeral',
},
{
value: 'someone',
label: 'Someone else',
description: 'I am arranging for a family member or friend',
},
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── Pre-selected ───────────────────────────────────────────────────────────
/** With a value already selected */
export const PreSelected: Story = {
render: () => {
const [value, setValue] = useState<string | null>('yes');
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Has the person died?"
options={[
{ value: 'yes', label: 'Yes', description: 'I need to arrange a funeral now' },
{ value: 'no', label: 'No', description: 'I am planning ahead' },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── Error state ─────────────────────────────────────────────────────────────
/** Validation error — no selection made */
export const Error: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{ value: 'myself', label: 'Myself' },
{ value: 'someone', label: 'Someone else' },
]}
value={value}
onChange={setValue}
error
helperText="We need to know who the funeral is for to show you the right options."
fullWidth
/>
</Box>
);
},
};
// ─── Three options ──────────────────────────────────────────────────────────
/** More than two options */
export const ThreeOptions: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 600 }}>
<ToggleButtonGroup
label="Service style preference"
options={[
{ value: 'traditional', label: 'Traditional' },
{ value: 'contemporary', label: 'Contemporary' },
{ value: 'religious', label: 'Religious' },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── Disabled option ────────────────────────────────────────────────────────
/** One option disabled */
export const DisabledOption: Story = {
render: () => {
const [value, setValue] = useState<string | null>('myself');
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{ value: 'myself', label: 'Myself' },
{ value: 'someone', label: 'Someone else', disabled: true },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── Small size ──────────────────────────────────────────────────────────────
/** Small size variant */
export const Small: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 400 }}>
<ToggleButtonGroup
label="Urgency"
options={[
{ value: 'immediate', label: 'Immediate' },
{ value: 'planning', label: 'Planning ahead' },
]}
value={value}
onChange={setValue}
size="small"
fullWidth
/>
</Box>
);
},
};
// ─── With helper text ───────────────────────────────────────────────────────
/** Helper text below the group */
export const WithHelperText: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Has the person died?"
helperText="This helps us tailor the process to your situation."
options={[
{ value: 'yes', label: 'Yes' },
{ value: 'no', label: 'No' },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};

View File

@@ -0,0 +1,242 @@
import React from 'react';
import MuiToggleButton from '@mui/material/ToggleButton';
import MuiToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import type { ToggleButtonGroupProps as MuiToggleButtonGroupProps } from '@mui/material/ToggleButtonGroup';
import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel';
import FormHelperText from '@mui/material/FormHelperText';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A single option in the toggle button group */
export interface ToggleOption {
/** Unique value for this option */
value: string;
/** Display label */
label: string;
/** Optional description shown below the label */
description?: string;
/** Whether this option is disabled */
disabled?: boolean;
}
/** Props for the FA ToggleButtonGroup component */
export interface ToggleButtonGroupProps extends Omit<
MuiToggleButtonGroupProps,
'onChange' | 'children'
> {
/** Available options to choose from */
options: ToggleOption[];
/** Currently selected value (single-select) */
value: string | null;
/** Callback fired when the selection changes */
onChange: (value: string | null) => void;
/** Fieldset legend / visible label above the group */
label?: string;
/** Helper text below the group */
helperText?: string;
/** Error state — shows error styling and uses helperText as error message */
error?: boolean;
/** Whether a selection is required */
required?: boolean;
/** Text alignment inside each option button */
align?: 'start' | 'center';
/** Layout direction for the button group */
direction?: 'row' | 'column';
/** MUI sx prop for the root FormControl */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Exclusive toggle button group for the FA design system.
*
* Renders a set of toggle buttons in a horizontal row (stacks on narrow
* viewports). Used for binary or small-set choices in the arrangement
* wizard (e.g. "Myself / Someone else", "Yes / No").
*
* Wraps MUI ToggleButtonGroup in a FormControl with fieldset semantics,
* external label, helper/error text, and FA brand styling.
*
* Keyboard: Arrow keys cycle options, Space/Enter selects. Tab moves
* between groups.
*
* Usage:
* ```tsx
* <ToggleButtonGroup
* label="Who is this funeral being arranged for?"
* options={[
* { value: 'myself', label: 'Myself', description: 'I want to plan my own funeral' },
* { value: 'someone', label: 'Someone else', description: 'I am arranging for a family member or friend' },
* ]}
* value={forWhom}
* onChange={setForWhom}
* />
* ```
*/
export const ToggleButtonGroup = React.forwardRef<HTMLFieldSetElement, ToggleButtonGroupProps>(
(
{
options,
value,
onChange,
label,
helperText,
error = false,
required = false,
align = 'start',
direction = 'row',
fullWidth,
size = 'large',
sx,
...groupProps
},
ref,
) => {
const handleChange = (_event: React.MouseEvent<HTMLElement>, newValue: string | null) => {
// Enforce exclusive selection — don't allow deselect
if (newValue !== null) {
onChange(newValue);
}
};
return (
<FormControl
ref={ref}
component="fieldset"
error={error}
required={required}
fullWidth={fullWidth}
sx={[...(Array.isArray(sx) ? sx : [sx])]}
>
{label && (
<FormLabel
component="legend"
sx={{
color: 'text.primary',
fontWeight: 600,
fontSize: '1rem',
mb: 2,
'&.Mui-focused': { color: 'text.primary' },
'&.Mui-error': { color: 'text.primary' },
}}
>
{label}
</FormLabel>
)}
<MuiToggleButtonGroup
exclusive
value={value}
onChange={handleChange}
fullWidth={fullWidth}
size={size}
aria-label={label}
sx={{
gap: 2,
...(direction === 'column' && { flexDirection: 'column' }),
// Remove MUI's connected-button styling (grouped borders)
'& .MuiToggleButtonGroup-grouped': {
border: '2px solid',
borderColor: 'var(--fa-color-neutral-200)',
borderRadius: (theme: Theme) => `${theme.shape.borderRadius}px !important`,
'&:not(:first-of-type)': {
borderLeft: '2px solid',
borderColor: 'var(--fa-color-neutral-200)',
marginLeft: 0,
...(direction === 'column' && { marginTop: 0 }),
},
'&.Mui-selected': {
borderColor: 'primary.main',
},
},
}}
{...groupProps}
>
{options.map((option) => (
<MuiToggleButton
key={option.value}
value={option.value}
disabled={option.disabled}
sx={{
flex: direction === 'row' ? 1 : undefined,
textTransform: 'none',
flexDirection: 'column',
alignItems: align === 'center' ? 'center' : 'flex-start',
justifyContent: 'flex-start',
gap: 0.5,
py: option.description ? 2 : 1.5,
px: 3,
bgcolor: 'var(--fa-color-neutral-100)',
color: 'text.primary',
transition: (theme: Theme) =>
theme.transitions.create(['background-color', 'border-color', 'box-shadow'], {
duration: theme.transitions.duration.short,
}),
'&:hover': {
bgcolor: 'var(--fa-color-neutral-200)',
},
// Selected state — brand styling
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-50)',
borderColor: 'primary.main',
color: 'text.primary',
'&:hover': {
bgcolor: 'var(--fa-color-brand-100)',
},
},
// Error border — copper, not red (D034)
...(error && {
borderColor: 'var(--fa-color-text-brand)',
'&.Mui-selected': {
borderColor: 'primary.main',
},
}),
// Focus ring
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
// Disabled
'&.Mui-disabled': {
opacity: 0.4,
},
}}
>
<Box
component="span"
sx={{ fontWeight: 600, fontSize: size === 'large' ? '1rem' : '0.875rem' }}
>
{option.label}
</Box>
{option.description && (
<Typography
variant="body2"
color="text.primary"
component="span"
sx={{ fontWeight: 400, lineHeight: 1.4 }}
>
{option.description}
</Typography>
)}
</MuiToggleButton>
))}
</MuiToggleButtonGroup>
{helperText && <FormHelperText sx={{ mt: 1, mx: 0 }}>{helperText}</FormHelperText>}
</FormControl>
);
},
);
ToggleButtonGroup.displayName = 'ToggleButtonGroup';
export default ToggleButtonGroup;

View File

@@ -0,0 +1,2 @@
export { default } from './ToggleButtonGroup';
export * from './ToggleButtonGroup';

View File

@@ -0,0 +1,433 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Typography } from './Typography';
const meta: Meta<typeof Typography> = {
title: 'Atoms/Typography',
component: Typography,
tags: ['autodocs'],
parameters: {
layout: 'padded',
design: {
type: 'figma',
url: 'https://www.figma.com/design/3t6fpT5inh7zzjxQdW8U5p/Design-System---Template?node-id=23-30',
},
},
argTypes: {
variant: {
control: 'select',
options: [
'displayHero',
'display1',
'display2',
'display3',
'displaySm',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'bodyLg',
'body1',
'body2',
'bodyXs',
'labelLg',
'label',
'labelSm',
'caption',
'captionSm',
'overline',
'overlineSm',
],
description: 'Typography variant — 21 variants across 6 categories',
table: { defaultValue: { summary: 'body1' } },
},
color: {
control: 'select',
options: ['textPrimary', 'textSecondary', 'textDisabled', 'primary', 'secondary', 'error'],
},
maxLines: { control: 'number' },
gutterBottom: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Typography>;
const SAMPLE = 'Discover, Explore, and Plan Funerals in Minutes, Not Hours';
// ─── Default ────────────────────────────────────────────────────────────────
export const Default: Story = {
args: {
children:
'Funeral Arranger helps families find transparent, affordable funeral services across Australia.',
},
};
// ─── Display (Noto Serif SC, Regular) ───────────────────────────────────────
/** 5 display levels — Noto Serif SC Regular. For hero/marketing text. All scale down on mobile. */
export const Display: Story = {
name: 'Display (Serif)',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Typography variant="captionSm" color="textSecondary">
displayHero 80px
</Typography>
<Typography variant="displayHero">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
display1 64px
</Typography>
<Typography variant="display1">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
display2 52px
</Typography>
<Typography variant="display2">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
display3 40px
</Typography>
<Typography variant="display3">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
displaySm 32px
</Typography>
<Typography variant="displaySm">{SAMPLE}</Typography>
</div>
</div>
),
};
// ─── Headings (Montserrat, Bold) ────────────────────────────────────────────
/** 6 heading levels — Montserrat Bold. For content structure. All scale down on mobile. */
export const Headings: Story = {
name: 'Headings (Sans-serif)',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div>
<Typography variant="captionSm" color="textSecondary">
h1 36px
</Typography>
<Typography variant="h1">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
h2 30px
</Typography>
<Typography variant="h2">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
h3 24px
</Typography>
<Typography variant="h3">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
h4 20px
</Typography>
<Typography variant="h4">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
h5 18px
</Typography>
<Typography variant="h5">{SAMPLE}</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
h6 16px
</Typography>
<Typography variant="h6">{SAMPLE}</Typography>
</div>
</div>
),
};
// ─── Body (Montserrat, Medium) ──────────────────────────────────────────────
/** 4 body sizes — Montserrat Medium (500). For content text. */
export const Body: Story = {
name: 'Body Text',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640 }}>
<div>
<Typography variant="overline" gutterBottom>
bodyLg 18px
</Typography>
<Typography variant="bodyLg">
Planning a funeral is one of the most difficult tasks a family faces. Funeral Arranger is
here to help you navigate this process with care and transparency.
</Typography>
</div>
<div>
<Typography variant="overline" gutterBottom>
body1 (default) 16px
</Typography>
<Typography variant="body1">
Compare funeral directors in your area, view transparent pricing, and make informed
decisions at your own pace. Every family deserves clarity during this time.
</Typography>
</div>
<div>
<Typography variant="overline" gutterBottom>
body2 (small) 14px
</Typography>
<Typography variant="body2">
Prices shown are indicative and may vary based on your specific requirements. Contact the
funeral director directly for a detailed quote.
</Typography>
</div>
<div>
<Typography variant="overline" gutterBottom>
bodyXs 12px
</Typography>
<Typography variant="bodyXs">
Terms and conditions apply. Funeral Arranger is a comparison service and does not directly
provide funeral services. ABN 12 345 678 901.
</Typography>
</div>
</div>
),
};
// ─── Label, Caption, Overline ───────────────────────────────────────────────
/** UI text variants — labels (medium 500), captions (regular 400), overlines (semibold 600 uppercase) */
export const UIText: Story = {
name: 'UI Text (Label / Caption / Overline)',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Typography variant="captionSm" color="textSecondary">
labelLg 16px medium
</Typography>
<Typography variant="labelLg" display="block">
Form label or section label
</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
label 14px medium
</Typography>
<Typography variant="label" display="block">
Default form label
</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
labelSm 12px medium
</Typography>
<Typography variant="labelSm" display="block">
Compact label or tag text
</Typography>
</div>
<div style={{ marginTop: 8 }}>
<Typography variant="captionSm" color="textSecondary">
caption 12px regular
</Typography>
<Typography variant="caption" display="block">
Fine print, timestamps, metadata
</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
captionSm 11px regular
</Typography>
<Typography variant="captionSm" display="block">
Compact metadata, footnotes
</Typography>
</div>
<div style={{ marginTop: 8 }}>
<Typography variant="captionSm" color="textSecondary">
overline 12px semibold uppercase
</Typography>
<Typography variant="overline" display="block">
Section overline
</Typography>
</div>
<div>
<Typography variant="captionSm" color="textSecondary">
overlineSm 11px semibold uppercase
</Typography>
<Typography variant="overlineSm" display="block">
Compact overline
</Typography>
</div>
</div>
),
};
// ─── Colours ────────────────────────────────────────────────────────────────
export const Colours: Story = {
name: 'Colours',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Typography color="textPrimary">Text Primary main body text (neutral.800)</Typography>
<Typography color="textSecondary">Text Secondary helper text (neutral.600)</Typography>
<Typography color="textDisabled">Text Disabled inactive (neutral.400)</Typography>
<Typography color="primary">Primary brand emphasis (brand.600)</Typography>
<Typography color="secondary">Secondary neutral emphasis (neutral.600)</Typography>
<Typography color="error">Error validation errors (red.600)</Typography>
<Typography color="warning.main">Warning cautionary (amber.600)</Typography>
<Typography color="success.main">Success confirmations (green.600)</Typography>
<Typography color="info.main">Info helpful tips (blue.600)</Typography>
</div>
),
};
// ─── Font Families ──────────────────────────────────────────────────────────
/** The two font families: serif for display, sans-serif for everything else */
export const FontFamilies: Story = {
name: 'Font Families',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<Typography variant="overline" gutterBottom>
Display font Noto Serif SC (Regular 400)
</Typography>
<Typography variant="display3">Warm, trustworthy, and professional</Typography>
<Typography variant="caption" color="textSecondary" sx={{ mt: 1 }}>
Used exclusively for display variants (hero through sm). Regular weight serif carries
inherent visual weight at large sizes.
</Typography>
</div>
<div>
<Typography variant="overline" gutterBottom>
Body font Montserrat
</Typography>
<Typography variant="h3" gutterBottom>
Clean, modern, and highly readable
</Typography>
<Typography>
Used for all headings (h1h6), body text, labels, captions, and UI elements. Headings use
Bold (700), body uses Medium (500), captions use Regular (400).
</Typography>
</div>
</div>
),
};
// ─── Max Lines ──────────────────────────────────────────────────────────────
export const MaxLines: Story = {
name: 'Max Lines (Truncation)',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, maxWidth: 400 }}>
<div>
<Typography variant="label" gutterBottom>
maxLines=1
</Typography>
<Typography maxLines={1}>
H. Parsons Funeral Directors trusted by Australian families for over 30 years, providing
compassionate and transparent funeral services.
</Typography>
</div>
<div>
<Typography variant="label" gutterBottom>
maxLines=2
</Typography>
<Typography maxLines={2}>
H. Parsons Funeral Directors trusted by Australian families for over 30 years, providing
compassionate and transparent funeral services across metropolitan and regional areas.
</Typography>
</div>
</div>
),
};
// ─── Realistic Content ──────────────────────────────────────────────────────
export const RealisticContent: Story = {
name: 'Realistic Content',
render: () => (
<div style={{ maxWidth: 640, display: 'flex', flexDirection: 'column', gap: 16 }}>
<Typography variant="overline">Funeral planning</Typography>
<Typography variant="display3">Compare funeral services in your area</Typography>
<Typography variant="bodyLg" color="textSecondary">
Transparent pricing and service comparison to help you make informed decisions during a
difficult time.
</Typography>
<Typography variant="h2" sx={{ mt: 2 }}>
How it works
</Typography>
<Typography>
Enter your suburb or postcode to find funeral directors near you. Each listing includes a
full price breakdown, service inclusions, and reviews from families who have used their
services.
</Typography>
<Typography variant="h3" sx={{ mt: 1 }}>
Step 1: Browse packages
</Typography>
<Typography>
Compare packages side by side. Each package clearly shows what is and isn't included, so
there are no surprises.
</Typography>
<Typography variant="caption" color="textSecondary" sx={{ mt: 2 }}>
Prices are indicative and current as of March 2026. Contact the funeral director for a
binding quote.
</Typography>
</div>
),
};
// ─── Complete Scale ─────────────────────────────────────────────────────────
/** All 21 variants in a single view — matches the Figma "Fonts - Desktop" layout */
export const CompleteScale: Story = {
name: 'Complete Scale (All 21 Variants)',
render: () => {
const variants = [
{ variant: 'displayHero', label: 'display/hero 80px' },
{ variant: 'display1', label: 'display/1 64px' },
{ variant: 'display2', label: 'display/2 52px' },
{ variant: 'display3', label: 'display/3 40px' },
{ variant: 'displaySm', label: 'display/sm 32px' },
{ variant: 'h1', label: 'heading/1 36px' },
{ variant: 'h2', label: 'heading/2 30px' },
{ variant: 'h3', label: 'heading/3 24px' },
{ variant: 'h4', label: 'heading/4 20px' },
{ variant: 'h5', label: 'heading/5 18px' },
{ variant: 'h6', label: 'heading/6 16px' },
{ variant: 'bodyLg', label: 'body/lg 18px' },
{ variant: 'body1', label: 'body/md 16px' },
{ variant: 'body2', label: 'body/sm 14px' },
{ variant: 'bodyXs', label: 'body/xs 12px' },
{ variant: 'labelLg', label: 'label/lg 16px' },
{ variant: 'label', label: 'label/md 14px' },
{ variant: 'labelSm', label: 'label/sm 12px' },
{ variant: 'caption', label: 'caption/md 12px' },
{ variant: 'captionSm', label: 'caption/sm 11px' },
{ variant: 'overline', label: 'overline/md 12px' },
{ variant: 'overlineSm', label: 'overline/sm 11px' },
] as const;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{variants.map(({ variant, label }) => (
<div key={variant} style={{ display: 'flex', alignItems: 'baseline', gap: 16 }}>
<Typography
variant="captionSm"
color="textSecondary"
sx={{ width: 160, flexShrink: 0, textAlign: 'right' }}
>
{label}
</Typography>
<Typography variant={variant}>{SAMPLE}</Typography>
</div>
))}
</div>
);
},
};

View File

@@ -0,0 +1,63 @@
import React from 'react';
import MuiTypography from '@mui/material/Typography';
import type { TypographyProps as MuiTypographyProps } from '@mui/material/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Typography component */
export interface TypographyProps extends MuiTypographyProps {
/** Truncate text with ellipsis after this many lines (CSS line-clamp) */
maxLines?: number;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Text display component for the FA design system.
*
* Wraps MUI Typography with FA brand fonts and type scale. All variant
* styles (sizes, weights, line heights) come from the MUI theme which
* maps to our design tokens.
*
* Variant guide (21 variants across 6 categories):
*
* Display (Noto Serif SC, Regular 400):
* - `displayHero` 80px, `display1` 64px, `display2` 52px, `display3` 40px, `displaySm` 32px
*
* Headings (Montserrat, Bold 700):
* - `h1` 36px, `h2` 30px, `h3` 24px, `h4` 20px, `h5` 18px, `h6` 16px
*
* Body (Montserrat, Medium 500):
* - `bodyLg` 18px, `body1` 16px, `body2` 14px, `bodyXs` 12px
*
* Label (Montserrat, Medium 500):
* - `labelLg` 16px, `label` 14px, `labelSm` 12px
*
* Caption (Montserrat, Regular 400):
* - `caption` 12px, `captionSm` 11px
*
* Overline (Montserrat, SemiBold 600, uppercase):
* - `overline` 12px, `overlineSm` 11px
*/
export const Typography = React.forwardRef<HTMLElement, TypographyProps>(
({ maxLines, sx, ...props }, ref) => {
return (
<MuiTypography
ref={ref}
sx={[
maxLines != null && {
display: '-webkit-box',
WebkitLineClamp: maxLines,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
/>
);
},
);
Typography.displayName = 'Typography';
export default Typography;

View File

@@ -0,0 +1,3 @@
export { default } from './Typography';
export { Typography } from './Typography';
export type { TypographyProps } from './Typography';

View File

@@ -0,0 +1,242 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { AddOnOption } from './AddOnOption';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
const meta: Meta<typeof AddOnOption> = {
title: 'Molecules/AddOnOption',
component: AddOnOption,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2350-40658',
},
},
argTypes: {
name: { control: 'text' },
description: { control: 'text' },
price: { control: 'number' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
},
decorators: [
(Story) => (
<Box sx={{ width: 480 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof AddOnOption>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default — unchecked add-on with description */
export const Default: Story = {
args: {
name: 'Memorial video',
description:
'Professional video tribute played during the service, compiled from family photos and footage.',
price: 350,
},
};
// ─── Checked ────────────────────────────────────────────────────────────────
/** Checked — add-on is enabled, card shows selected state */
export const Checked: Story = {
args: {
name: 'Memorial video',
description:
'Professional video tribute played during the service, compiled from family photos and footage.',
price: 350,
checked: true,
},
};
// ─── Service Add-Ons ────────────────────────────────────────────────────────
/** Realistic — arrangement flow add-ons list */
export const ServiceAddOns: Story = {
render: function Render() {
const [addOns, setAddOns] = React.useState({
catering: false,
video: true,
flowers: false,
transport: false,
webcast: false,
});
const toggle = (key: keyof typeof addOns) => (checked: boolean) =>
setAddOns({ ...addOns, [key]: checked });
const total = [
addOns.catering ? 1200 : 0,
addOns.video ? 350 : 0,
addOns.flowers ? 280 : 0,
addOns.transport ? 450 : 0,
addOns.webcast ? 150 : 0,
].reduce((a, b) => a + b, 0);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5">Optional extras</Typography>
<Typography variant="body2" color="text.secondary">
Customise the service with additional options. All prices are GST inclusive.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<AddOnOption
name="Catering"
description="Light refreshments for up to 50 guests after the service."
price={1200}
checked={addOns.catering}
onChange={toggle('catering')}
/>
<AddOnOption
name="Memorial video"
description="Professional video tribute compiled from family photos and footage."
price={350}
checked={addOns.video}
onChange={toggle('video')}
/>
<AddOnOption
name="Floral arrangements"
description="Casket spray and two standing arrangements for the chapel."
price={280}
checked={addOns.flowers}
onChange={toggle('flowers')}
/>
<AddOnOption
name="Premium transport"
description="Vintage hearse and one family limousine for the procession."
price={450}
checked={addOns.transport}
onChange={toggle('transport')}
/>
<AddOnOption
name="Live webcast"
description="Stream the service online for family and friends who cannot attend."
price={150}
checked={addOns.webcast}
onChange={toggle('webcast')}
/>
</Box>
<Divider />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="label" color="text.secondary">
Extras total
</Typography>
<Typography variant="h6" color="primary">
${total.toLocaleString('en-AU')}
</Typography>
</Box>
</Box>
);
},
};
// ─── Without Price ──────────────────────────────────────────────────────────
/** No price — some add-ons are complimentary */
export const WithoutPrice: Story = {
args: {
name: 'Order of service booklet',
description:
'Complimentary printed booklet with the service programme and a photo of your loved one.',
},
};
// ─── Without Description ────────────────────────────────────────────────────
/** No description — compact variant for simple toggles */
export const WithoutDescription: Story = {
render: function Render() {
const [checked, setChecked] = React.useState(false);
return <AddOnOption name="Include GST in pricing" checked={checked} onChange={setChecked} />;
},
};
// ─── Disabled ───────────────────────────────────────────────────────────────
/** Disabled — add-on unavailable (e.g. venue restriction) */
export const Disabled: Story = {
args: {
name: 'Catering',
description:
'Not available at this venue. Please contact the venue directly for catering options.',
price: 1200,
disabled: true,
},
};
// ─── With Line Limit ────────────────────────────────────────────────────────
/** Clamped descriptions with "View more" toggle */
export const WithLineLimit: Story = {
render: function Render() {
const [checks, setChecks] = React.useState({ a: false, b: true });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<AddOnOption
name="Premium memorial video"
description="Our most comprehensive video tribute including on-site filmed interviews with family members, professional editing with music selection, up to 20 minutes of curated content, plus a USB copy and online streaming access for 12 months."
price={2500}
maxDescriptionLines={2}
checked={checks.a}
onChange={(v) => setChecks({ ...checks, a: v })}
/>
<AddOnOption
name="Floral arrangements"
description="Casket spray and two standing arrangements."
price={280}
maxDescriptionLines={2}
checked={checks.b}
onChange={(v) => setChecks({ ...checks, b: v })}
/>
</Box>
);
},
};
// ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Edge cases — long text, high prices, missing fields */
export const EdgeCases: Story = {
render: function Render() {
const [checks, setChecks] = React.useState({ a: false, b: true, c: false });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<AddOnOption
name="Premium memorial video with extended family interview package and professional editing"
description="Our most comprehensive video tribute including on-site filmed interviews with family members, professional editing with music selection, up to 20 minutes of curated content, plus a USB copy and online streaming access for 12 months."
price={2500}
checked={checks.a}
onChange={(v) => setChecks({ ...checks, a: v })}
/>
<AddOnOption
name="Flowers"
checked={checks.b}
onChange={(v) => setChecks({ ...checks, b: v })}
/>
<AddOnOption
name="Complimentary parking"
description="Reserved parking for family vehicles at the venue."
price={0}
checked={checks.c}
onChange={(v) => setChecks({ ...checks, c: v })}
/>
</Box>
);
},
};

View File

@@ -0,0 +1,213 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import { Card } from '../../atoms/Card';
import { Collapse } from '../../atoms/Collapse';
import { Divider } from '../../atoms/Divider';
import { Typography } from '../../atoms/Typography';
import { Switch } from '../../atoms/Switch';
import { Link } from '../../atoms/Link';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA AddOnOption molecule */
export interface AddOnOptionProps {
/** Add-on name/heading */
name: string;
/** Description text explaining the add-on */
description?: string;
/** Price in dollars — shown below the heading */
price?: number;
/** Custom price label (e.g. "Price on application") — overrides formatted price */
priceLabel?: string;
/** Whether this add-on is currently enabled */
checked?: boolean;
/** Called when the toggle changes */
onChange?: (checked: boolean) => void;
/** Whether this add-on is disabled/unavailable */
disabled?: boolean;
/** Max visible lines for description before "View more" toggle. Omit for no limit. */
maxDescriptionLines?: number;
/** Sub-options rendered inside the card when checked. Appears below a divider. */
children?: React.ReactNode;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Toggleable add-on option for the FA design system.
*
* Used in the arrangement flow for optional extras — catering, memorial
* video, flowers, transport upgrades, etc. Users toggle the switch to
* include or exclude; multiple add-ons can be active simultaneously.
*
* Composes Card + Typography + Switch. Maps to the Figma
* "ListItemAddItem" component (desktop + mobile viewports).
*
* For mutually exclusive options (single-select), use ServiceOption instead.
*
* Usage:
* ```tsx
* <AddOnOption
* name="Memorial video"
* description="Professional video tribute played during the service."
* price={350}
* checked={addOns.memorialVideo}
* onChange={(on) => setAddOns({ ...addOns, memorialVideo: on })}
* />
* ```
*/
export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>(
(
{
name,
description,
price,
priceLabel,
checked = false,
onChange,
disabled = false,
maxDescriptionLines,
children,
sx,
},
ref,
) => {
const switchId = React.useId();
const [expanded, setExpanded] = React.useState(false);
const [isClamped, setIsClamped] = React.useState(false);
const descRef = React.useRef<HTMLElement>(null);
// Detect whether the description is actually truncated
React.useEffect(() => {
const el = descRef.current;
if (el && maxDescriptionLines) {
setIsClamped(el.scrollHeight > el.clientHeight + 1);
}
}, [description, maxDescriptionLines]);
const handleToggle = () => {
if (!disabled && onChange) {
onChange(!checked);
}
};
const handleSwitchChange = (_e: React.ChangeEvent<HTMLInputElement>, value: boolean) => {
if (onChange) {
onChange(value);
}
};
return (
<Card
ref={ref}
interactive={!disabled}
selected={checked}
padding="none"
onClick={handleToggle}
aria-disabled={disabled || undefined}
sx={[
{
p: 'var(--fa-card-padding-compact)',
cursor: disabled ? 'not-allowed' : 'pointer',
...(disabled && {
opacity: 'var(--fa-opacity-disabled)',
pointerEvents: 'none' as const,
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Top row: name + switch */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Typography
variant="h6"
component="span"
id={`${switchId}-label`}
sx={{ flex: 1, minWidth: 0 }}
>
{name}
</Typography>
<Switch
checked={checked}
onChange={handleSwitchChange}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
inputProps={{ 'aria-labelledby': `${switchId}-label` }}
sx={{ flexShrink: 0 }}
/>
</Box>
{/* Price — tucks directly under heading */}
{priceLabel ? (
<Typography variant="body2" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
price != null && (
<Typography variant="body2" color="primary">
${price.toLocaleString('en-AU')}
</Typography>
)
)}
{/* Description with optional line clamping */}
{description && (
<>
<Typography
ref={descRef}
variant="body2"
color="text.secondary"
sx={{
mt: 0.5,
...(maxDescriptionLines &&
!expanded && {
display: '-webkit-box',
WebkitLineClamp: maxDescriptionLines,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}),
}}
>
{description}
</Typography>
{maxDescriptionLines && isClamped && (
<Link
component="button"
variant="caption"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setExpanded((prev) => !prev);
}}
sx={{ mt: 0.5, color: 'text.secondary', fontWeight: 400 }}
>
{expanded ? 'View less' : 'View more'}
</Link>
)}
</>
)}
{/* Sub-options — rendered inside the card when checked */}
{children && (
<Collapse in={checked}>
<Divider sx={{ my: 1.5 }} />
<Box onClick={(e) => e.stopPropagation()}>{children}</Box>
</Collapse>
)}
</Card>
);
},
);
AddOnOption.displayName = 'AddOnOption';
export default AddOnOption;

View File

@@ -0,0 +1,2 @@
export { AddOnOption } from './AddOnOption';
export type { AddOnOptionProps } from './AddOnOption';

View File

@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { CartButton } from './CartButton';
import { StepIndicator } from '../StepIndicator';
import type { CartItem } from './CartButton';
const sampleItems: CartItem[] = [
{ section: 'Funeral Provider', name: 'H. Parsons — Essential Package', price: 4950 },
{ section: 'Service Venue', name: 'West Chapel', price: 900 },
{ section: 'Service Venue', name: 'Photo presentation', price: 150 },
{ section: 'Crematorium', name: 'Warrill Park Crematorium', price: 850 },
{ section: 'Coffin', name: 'Richmond Rosewood', price: 1750 },
{ section: 'Optional Extras', name: 'Live musician — Vocalist', price: 450 },
{ section: 'Optional Extras', name: 'Catering', priceLabel: 'Price on application' },
];
const meta: Meta<typeof CartButton> = {
title: 'Molecules/CartButton',
component: CartButton,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof CartButton>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Full cart with multiple sections */
export const Default: Story = {
args: {
total: 9050,
items: sampleItems,
},
};
// ─── Empty ──────────────────────────────────────────────────────────────────
/** Empty plan — no items selected yet */
export const Empty: Story = {
args: {
total: 0,
items: [],
},
};
// ─── Single item ────────────────────────────────────────────────────────────
/** Just the package selected */
export const SingleItem: Story = {
args: {
total: 4950,
items: [{ section: 'Funeral Provider', name: 'H. Parsons — Essential Package', price: 4950 }],
},
};
// ─── In progress bar context ────────────────────────────────────────────────
/** How it looks inside the wizard progress bar */
export const InProgressBar: Story = {
render: () => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
width: '100%',
maxWidth: 900,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
px: 4,
py: 1.5,
}}
>
<Box sx={{ flex: 1 }}>
<StepIndicator
steps={[
{ label: 'Details' },
{ label: 'Venues' },
{ label: 'Coffins' },
{ label: 'Extras' },
{ label: 'Review' },
]}
currentStep={2}
/>
</Box>
<CartButton total={6700} items={sampleItems.slice(0, 4)} />
</Box>
),
parameters: {
layout: 'padded',
},
};

View File

@@ -0,0 +1,175 @@
import React from 'react';
import Box from '@mui/material/Box';
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { DialogShell } from '../../atoms/DialogShell';
import { Divider } from '../../atoms/Divider';
import { LineItem } from '../LineItem';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A single item in the plan cart */
export interface CartItem {
/** Section heading (e.g. "Funeral Provider", "Venue") */
section: string;
/** Item name */
name: string;
/** Price in dollars — omit for included/complimentary items */
price?: number;
/** Custom price label (e.g. "Price on application", "Included") */
priceLabel?: string;
}
/** Props for the CartButton molecule */
export interface CartButtonProps {
/** Running total in dollars */
total: number;
/** Cart items grouped by section */
items?: CartItem[];
/** Override the structured dialog body with custom content */
children?: React.ReactNode;
/** MUI sx prop for the trigger button */
sx?: SxProps<Theme>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Group items by their section heading */
const groupBySection = (items: CartItem[]) => {
const groups: { section: string; items: CartItem[] }[] = [];
for (const item of items) {
const last = groups[groups.length - 1];
if (last && last.section === item.section) {
last.items.push(item);
} else {
groups.push({ section: item.section, items: [item] });
}
}
return groups;
};
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Cart button for the arrangement wizard progress bar.
*
* Shows the running plan total in a compact trigger button. Clicking opens
* a DialogShell with the plan contents — items grouped by section using
* LineItem molecules.
*
* Sits in the `runningTotal` slot of WizardLayout.
*
* Usage:
* ```tsx
* <CartButton
* total={6715}
* items={[
* { section: 'Funeral Provider', name: 'H. Parsons — Essential Package', price: 4950 },
* { section: 'Venue', name: 'West Chapel', price: 900 },
* { section: 'Extras', name: 'Catering', priceLabel: 'Price on application' },
* ]}
* />
* ```
*/
export const CartButton = React.forwardRef<HTMLButtonElement, CartButtonProps>(
({ total, items = [], children, sx }, ref) => {
const [open, setOpen] = React.useState(false);
const formattedTotal = `$${total.toLocaleString('en-AU')}`;
const groups = groupBySection(items);
return (
<>
{/* Trigger */}
<Button
ref={ref}
variant="outlined"
color="secondary"
size="small"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
startIcon={<ReceiptLongOutlinedIcon sx={{ fontSize: 18 }} />}
sx={[
{
borderRadius: '9999px',
textTransform: 'none',
gap: 1,
pl: 2,
pr: 2.5,
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' }, fontWeight: 500 }}>
Your Plan
</Box>
<Typography
component="span"
variant="label"
sx={{ color: 'primary.main', fontWeight: 700 }}
>
{formattedTotal}
</Typography>
</Button>
{/* Dialog */}
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Your plan so far"
maxWidth="xs"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 3, py: 2 }}>
<Button variant="text" color="secondary" onClick={() => setOpen(false)}>
Close
</Button>
</Box>
}
>
{children || (
<Box sx={{ px: 3, py: 2 }}>
{items.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', py: 4 }}
>
Your plan is empty. Selections will appear here as you build your arrangement.
</Typography>
) : (
<>
{groups.map((group, gi) => (
<Box key={group.section} sx={{ mb: gi < groups.length - 1 ? 2 : 0 }}>
<Typography
variant="labelSm"
color="text.secondary"
sx={{ mb: 1, textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
{group.section}
</Typography>
{group.items.map((item, ii) => (
<LineItem
key={`${group.section}-${ii}`}
name={item.name}
price={item.price}
priceLabel={item.priceLabel}
/>
))}
</Box>
))}
<Divider sx={{ my: 2 }} />
<LineItem name="Total" price={total} variant="total" />
</>
)}
</Box>
)}
</DialogShell>
</>
);
},
);
CartButton.displayName = 'CartButton';
export default CartButton;

View File

@@ -0,0 +1,2 @@
export { default } from './CartButton';
export * from './CartButton';

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ClusterPopup } from './ClusterPopup';
const meta: Meta<typeof ClusterPopup> = {
title: 'Molecules/ClusterPopup',
component: ClusterPopup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
decorators: [
(Story) => (
<Box sx={{ p: 4 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ClusterPopup>;
// Fixture data — mirrors the shape used in the demo
const mixedCluster = [
{
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
rating: 4.6,
startingPrice: 1800,
},
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Warrawong, NSW',
verified: true,
rating: 4.8,
startingPrice: 2450,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
startingPrice: 3400,
},
{
id: 'botanical',
name: 'Botanical Funerals',
location: 'Newtown, NSW',
verified: false,
rating: 4.9,
startingPrice: 5200,
},
];
/** Mixed-tier cluster — verified providers sorted to top */
export const Mixed: Story = {
args: {
providers: mixedCluster,
onSelectProvider: (id) => {
alert(`Drill into ${id}`);
},
onClose: () => {
alert('Close cluster');
},
},
};
/** Small pair — two providers at the same location */
export const Pair: Story = {
args: {
providers: mixedCluster.slice(0, 2),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** All verified — every provider in the cluster is a partner */
export const AllVerified: Story = {
args: {
providers: mixedCluster.filter((p) => p.verified),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** All unverified — no partners in this cluster */
export const AllUnverified: Story = {
args: {
providers: mixedCluster.filter((p) => !p.verified),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** Tall cluster — scrolls when providers exceed visible area */
export const TallCluster: Story = {
args: {
providers: [
...mixedCluster,
...mixedCluster.map((p) => ({ ...p, id: `${p.id}-2`, name: `${p.name} (Branch 2)` })),
],
onSelectProvider: () => {},
onClose: () => {},
},
};

View File

@@ -0,0 +1,360 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import ButtonBase from '@mui/material/ButtonBase';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
// ─── Types ──────────────────────────────────────────────────────────────────
/** A provider summary used in the cluster list */
export interface ClusterPopupProvider {
/** Unique provider ID */
id: string;
/** Provider display name */
name: string;
/** Location text (suburb, city) */
location: string;
/** Whether this is a verified/partner provider — drives sort order + colour accents */
verified?: boolean;
/** Average rating */
rating?: number;
/** Starting package price in dollars — shown as "From $X" on the right */
startingPrice?: number;
/** Custom price label (e.g. "POA") — overrides the formatted price */
priceLabel?: string;
}
/** Props for the FA ClusterPopup molecule */
export interface ClusterPopupProps {
/** Providers in this cluster */
providers: ClusterPopupProvider[];
/** Click handler — fires when a provider row is clicked */
onSelectProvider: (id: string) => void;
/** Close handler — fires when the close button is clicked */
onClose?: () => void;
/** When true, animates the popup out (opacity + scale) without unmounting.
* Callers should unmount after the transition completes (180ms). */
exiting?: boolean;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const POPUP_WIDTH = 320;
const MAX_CONTENT_HEIGHT = 360;
const NUB_SIZE = 8;
/** Fixed width reserved for the verified-icon slot so all row titles share
* the same x-origin regardless of whether the row is verified. */
const VERIFIED_SLOT_WIDTH = 18;
// ─── Row sub-component ──────────────────────────────────────────────────────
interface ProviderRowProps {
provider: ClusterPopupProvider;
onClick: () => void;
}
/**
* Single provider row inside the cluster list. Image-free layout:
* verified-icon slot (fixed width so titles align across rows) + name +
* location/rating meta. Full-width clickable surface. Clicking triggers
* `onClick` — in `ProviderMap` that pans+zooms the map to the provider's
* location and opens their single-provider popup.
*/
const ProviderRow: React.FC<ProviderRowProps> = ({ provider, onClick }) => {
const hasPrice = provider.startingPrice != null || provider.priceLabel != null;
const priceText =
provider.priceLabel ??
(provider.startingPrice != null ? `$${provider.startingPrice.toLocaleString('en-AU')}` : null);
return (
<ButtonBase
onClick={(e) => {
// stopPropagation so the DOM click doesn't bubble to Map.onClick
// (which would clear state the same frame we're trying to drill in).
e.stopPropagation();
onClick();
}}
sx={{
width: '100%',
display: 'flex',
// flex-start so the verified-icon slot aligns with the name's top line,
// not the vertical centre of the row.
alignItems: 'flex-start',
gap: 1,
p: 1.25,
borderRadius: 1,
textAlign: 'left',
transition: 'background-color 120ms ease-in-out',
'&:hover': {
bgcolor: provider.verified
? 'var(--fa-color-brand-50)'
: 'var(--fa-color-surface-subtle)',
},
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: 2,
},
}}
>
{/* Verified-icon slot — reserved width + fixed line-height so the icon
sits vertically on the name's line-box regardless of whether the
row has location/rating/price content below. */}
<Box
sx={{
width: VERIFIED_SLOT_WIDTH,
flexShrink: 0,
height: '1.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{provider.verified && (
<VerifiedOutlinedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-600)' }}
aria-label="Verified provider"
/>
)}
</Box>
{/* Text column — name + location/rating meta */}
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: provider.verified ? 'var(--fa-color-brand-700)' : 'text.primary',
minWidth: 0,
lineHeight: 1.25,
}}
maxLines={1}
>
{provider.name}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: 'text.secondary',
flexWrap: 'wrap',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 12 }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{provider.location}
</Typography>
</Box>
{provider.rating != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{provider.rating}
</Typography>
</Box>
)}
</Box>
</Box>
{/* Price column — right-aligned, matches MapPopup's "From $X" typography.
Verified providers get the brand-600 copper price; unverified get
text.primary. "From" label uses caption/secondary for hierarchy. */}
{hasPrice && (
<Box
sx={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
pt: '1px',
}}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 10 }}>
From
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: 13,
color: provider.verified ? 'var(--fa-color-brand-600)' : 'text.primary',
lineHeight: 1.2,
}}
>
{priceText}
</Typography>
</Box>
)}
</ButtonBase>
);
};
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Cluster popup card for the FA design system.
*
* Appears when a cluster marker is clicked. Shows the providers grouped at
* that map location as a scrollable stack of image-free rows — each row: a
* fixed-width verified-icon slot (so titles align across mixed-tier lists) +
* provider name (copper for verified, neutral for unverified) + location and
* rating meta. Clicking a row calls `onSelectProvider(id)`. In the
* ProviderMap flow, that pans and zooms the map to the provider's location
* before opening their single-provider popup — restoring spatial context
* that a list-only popup otherwise loses.
*
* Verified providers are sorted to the top of the list (business outcome:
* promote partner providers in any crowded cluster).
*
* Sibling to `MapPopup` — same card + nub treatment, same drop-shadow, same
* 320px width, same `surface-subtle` header bar convention. Designed to
* render inside a Google Maps `AdvancedMarker`.
*
* Composes: Paper + Typography + IconButton + ButtonBase + icons.
*
* Usage:
* ```tsx
* <ClusterPopup
* providers={[
* { id: 'p1', name: 'H.Parsons', location: 'Wentworth', verified: true, rating: 4.6 },
* { id: 'p2', name: 'Smith & Sons', location: 'Cronulla', verified: false, rating: 4.2 },
* ]}
* onSelectProvider={(id) => drillIntoProvider(id)}
* onClose={() => closePopup()}
* />
* ```
*/
export const ClusterPopup = React.forwardRef<HTMLDivElement, ClusterPopupProps>(
({ providers, onSelectProvider, onClose, exiting = false, sx }, ref) => {
// Verified-first sort (stable within each tier)
const sorted = React.useMemo(
() =>
[...providers].sort((a, b) => Number(b.verified ?? false) - Number(a.verified ?? false)),
[providers],
);
return (
<Box
ref={ref}
// Swallow clicks on any empty space inside the popup (header, scroll
// gutter, etc.) so they don't bubble to Map.onClick and close us.
onClick={(e) => e.stopPropagation()}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
transformOrigin: 'bottom center',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
opacity: exiting ? 0 : 1,
transform: exiting ? 'scale(0.9)' : 'scale(1)',
'@keyframes clusterPopupIn': {
from: { opacity: 0, transform: 'scale(0.9)' },
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'clusterPopupIn 180ms ease-out',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Paper
elevation={0}
sx={{
width: POPUP_WIDTH,
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
maxHeight: MAX_CONTENT_HEIGHT,
}}
>
{/* Header bar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1.25,
bgcolor: 'var(--fa-color-surface-subtle)',
borderBottom: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<MapOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', flex: 1 }}>
{providers.length} providers in this area
</Typography>
{onClose && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label="Close cluster popup"
sx={{ mr: -0.5 }}
>
<CloseRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
{/* Provider list — scrollable */}
<Box
sx={{
overflowY: 'auto',
p: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
// Thin scrollbar styling
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.2)',
borderRadius: 3,
},
}}
>
{sorted.map((p) => (
<ProviderRow key={p.id} provider={p} onClick={() => onSelectProvider(p.id)} />
))}
</Box>
</Paper>
{/* Nub — matches MapPopup (fill-only, soft shadow carries the depth) */}
<svg
aria-hidden
width={NUB_SIZE * 2}
height={NUB_SIZE}
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
>
<path
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
fill="var(--fa-color-white)"
/>
</svg>
</Box>
);
},
);
ClusterPopup.displayName = 'ClusterPopup';
export default ClusterPopup;

View File

@@ -0,0 +1 @@
export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup';

View File

@@ -0,0 +1,196 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { CompareBar } from './CompareBar';
import type { CompareBarPackage } from './CompareBar';
import { Button } from '../../atoms/Button';
import { Typography } from '../../atoms/Typography';
const samplePackages: CompareBarPackage[] = [
{ id: '1', name: 'Everyday Funeral Package', providerName: 'Wollongong City Funerals' },
{ id: '2', name: 'Traditional Cremation Service', providerName: 'Mackay Family Funerals' },
{ id: '3', name: 'Essential Burial Package', providerName: 'Inglewood Chapel' },
];
const meta: Meta<typeof CompareBar> = {
title: 'Molecules/CompareBar',
component: CompareBar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<Box sx={{ minHeight: '100vh', p: 4, bgcolor: 'var(--fa-color-surface-subtle)' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
The compare bar floats at the bottom of the viewport.
</Typography>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof CompareBar>;
// --- Default (2 packages) ---------------------------------------------------
/** Two packages selected — "2 packages ready to compare" */
export const Default: Story = {
args: {
packages: samplePackages.slice(0, 2),
onCompare: () => alert('Compare clicked'),
},
};
// --- Single Package ----------------------------------------------------------
/** One package — "Add another package to compare", CTA disabled */
export const SinglePackage: Story = {
args: {
packages: samplePackages.slice(0, 1),
onCompare: () => alert('Compare clicked'),
},
};
// --- Three Packages (Maximum) ------------------------------------------------
/** Maximum 3 packages */
export const ThreePackages: Story = {
args: {
packages: samplePackages,
onCompare: () => alert('Compare clicked'),
},
};
// --- With Error --------------------------------------------------------------
/** Error message when user tries to add a 4th package */
export const WithError: Story = {
args: {
packages: samplePackages,
onCompare: () => alert('Compare clicked'),
error: 'Maximum 3 packages',
},
};
// --- Empty (Hidden) ----------------------------------------------------------
/** No packages — bar is hidden */
export const Empty: Story = {
args: {
packages: [],
onCompare: () => {},
},
};
// --- Mobile ------------------------------------------------------------------
/** Mobile viewport — expanded by default, with a grey-filled right-chevron
* on the right of the pill. Tap the chevron to retract the pill to the
* right corner (the middle content animates to width:0, so the pill
* visually shrinks as one unit rather than swapping into a separate mini
* pill). Tap the left-chevron on the collapsed pill to expand. On add
* while collapsed, the full bar auto-peeks for 3s, then re-collapses. */
export const Mobile: Story = {
args: {
packages: samplePackages.slice(0, 2),
onCompare: () => alert('Compare clicked'),
},
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
};
/** Mobile — single package state. Same behaviour as `Mobile`, Compare
* CTA disabled ("Add another to compare"). */
export const MobileSingle: Story = {
args: {
packages: samplePackages.slice(0, 1),
onCompare: () => alert('Compare clicked'),
},
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
};
// --- Interactive Demo --------------------------------------------------------
/** Interactive demo — add packages and see the bar update */
export const Interactive: Story = {
render: () => {
const [selected, setSelected] = useState<CompareBarPackage[]>([]);
const [error, setError] = useState<string>();
const allPackages = [
...samplePackages,
{ id: '4', name: 'Catholic Service', providerName: "St Mary's Funeral Services" },
];
const handleToggle = (pkg: CompareBarPackage) => {
const isSelected = selected.some((s) => s.id === pkg.id);
if (isSelected) {
setSelected(selected.filter((s) => s.id !== pkg.id));
setError(undefined);
} else {
if (selected.length >= 3) {
setError('Maximum 3 packages');
setTimeout(() => setError(undefined), 3000);
return;
}
setSelected([...selected, pkg]);
setError(undefined);
}
};
return (
<Box sx={{ pb: 12 }}>
<Typography variant="h4" sx={{ mb: 3 }}>
Select packages to compare
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{allPackages.map((pkg) => {
const isSelected = selected.some((s) => s.id === pkg.id);
return (
<Box
key={pkg.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
border: '1px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
bgcolor: isSelected ? 'var(--fa-color-surface-warm)' : 'background.paper',
}}
>
<Box>
<Typography variant="label">{pkg.name}</Typography>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
</Box>
<Button
variant={isSelected ? 'outlined' : 'soft'}
color="secondary"
size="small"
onClick={() => handleToggle(pkg)}
>
{isSelected ? 'Remove' : 'Compare'}
</Button>
</Box>
);
})}
</Box>
<CompareBar
packages={selected}
onCompare={() => alert(`Comparing: ${selected.map((s) => s.name).join(', ')}`)}
error={error}
/>
</Box>
);
},
};

View File

@@ -0,0 +1,235 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import useMediaQuery from '@mui/material/useMediaQuery';
import IconButton from '@mui/material/IconButton';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import { useTheme, type SxProps, type Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A package in the comparison basket */
export interface CompareBarPackage {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Provider name */
providerName: string;
}
/** Props for the CompareBar molecule */
export interface CompareBarProps {
/** Packages currently in the comparison basket (max 3 user-selected) */
packages: CompareBarPackage[];
/** Called when user clicks "Compare" CTA */
onCompare: () => void;
/** Error/status message shown inline (e.g. "Maximum 3 packages") */
error?: string;
/** MUI sx prop for the root wrapper */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
/** How long the bar stays expanded after a new package is added while
* collapsed. Long enough to read, short enough not to obstruct. */
const PEEK_DURATION_MS = 3000;
/** Middle-content expand/collapse duration (width + opacity). */
const COLLAPSE_MS = 300;
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Floating comparison basket pill for the FA design system.
*
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep.
*
* **Mobile collapse** (xs only): users can tap a right-chevron to retract
* the pill to the right edge — the middle content (status text + Compare
* button) animates to width:0 while the pill stays anchored at the same
* right offset, so the whole thing appears to shrink into the corner as
* one unit rather than two separate elements. Tap again to expand. When
* a new package is added while collapsed, the bar auto-peeks for
* `PEEK_DURATION_MS` so the user sees the tally update, then re-collapses.
*
* Desktop (md+) stays expanded — there's plenty of space, and the
* collapse chevron is not rendered.
*
* Composes Badge + Button + Typography + IconButton.
*/
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const count = packages.length;
const visible = count > 0;
const canCompare = count >= 2;
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
// Collapse state — mobile only. Starts expanded; when the basket empties
// we reset so the next fresh fill starts visible.
const [collapsed, setCollapsed] = React.useState(false);
const [peeking, setPeeking] = React.useState(false);
const lastCountRef = React.useRef(count);
React.useEffect(() => {
if (!visible) setCollapsed(false);
}, [visible]);
// Auto-peek when a package is added while collapsed.
React.useEffect(() => {
const prev = lastCountRef.current;
lastCountRef.current = count;
if (collapsed && count > prev) {
setPeeking(true);
const t = window.setTimeout(() => setPeeking(false), PEEK_DURATION_MS);
return () => window.clearTimeout(t);
}
}, [count, collapsed]);
/** Effective "is the middle content hidden?" — only on mobile, when the
* user has collapsed and we're not currently peeking. */
const mobileCollapsed = isMobile && collapsed && !peeking;
return (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<Paper
ref={ref}
elevation={8}
role="status"
aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`}
sx={[
(t: Theme) => ({
position: 'fixed',
// Clear the sticky HelpBar (~40px) + breathing room. FA theme
// uses a 4px spacing base, so spacing(16) = 64px.
bottom: t.spacing(16),
// z-index sits below the mobile map-view drawer (modal: 1300)
// but above app chrome (appBar: 1100). snackbar (1400) was too
// aggressive — the drawer visually covers this bar on mobile.
zIndex: t.zIndex.drawer,
// Mobile: right-anchored so when the middle collapses the pill
// appears to retract to the right corner. Desktop: centered.
...(isMobile
? { right: t.spacing(4), left: 'auto' }
: { left: 0, right: 0, mx: 'auto' }),
width: 'fit-content',
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
gap: { xs: 1.25, md: 2 },
px: { xs: 1.5, md: 3 },
py: { xs: 0.75, md: 1.5 },
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 },
overflow: 'hidden',
transition: `padding ${COLLAPSE_MS}ms ease-out`,
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Fraction badge — shows "N/3" when expanded, just "N" when
collapsed on mobile (reads as a circle at mini size). */}
<Badge
color="brand"
variant="soft"
size={isMobile ? 'medium' : 'large'}
sx={{
flexShrink: 0,
// When collapsed, force the badge toward a circle by
// equalising min-width and min-height at the medium-badge
// height (26px).
...(mobileCollapsed && {
minWidth: 'var(--fa-badge-height-md)',
justifyContent: 'center',
px: 0,
}),
}}
>
{mobileCollapsed ? count : `${count}/3`}
</Badge>
{/* Middle content (status + Compare CTA) — animates to zero
max-width when collapsed, letting the pill shrink as one unit
with the right edge staying fixed. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: { xs: 1.25, md: 2 },
maxWidth: mobileCollapsed ? 0 : 600,
opacity: mobileCollapsed ? 0 : 1,
overflow: 'hidden',
transition: `max-width ${COLLAPSE_MS}ms ease-out, opacity ${Math.round(
COLLAPSE_MS * 0.6,
)}ms ease-out`,
}}
>
<Typography
variant={isMobile ? 'body2' : 'body1'}
role={error ? 'alert' : undefined}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
flexShrink: 0,
}}
>
{error || statusText}
</Typography>
<Button
variant="contained"
size={isMobile ? 'small' : 'medium'}
onClick={onCompare}
disabled={!canCompare}
tabIndex={mobileCollapsed ? -1 : 0}
sx={{ flexShrink: 0, borderRadius: '9999px' }}
>
Compare
</Button>
</Box>
{/* Mobile-only collapse/expand chevron — grey-filled circle that
swaps icon direction based on state. Rendered at all times so
the IconButton container stays in the layout and the icon swap
happens in place without mount/unmount. */}
{isMobile && (
<IconButton
aria-label={mobileCollapsed ? 'Show comparison basket' : 'Hide comparison basket'}
aria-expanded={!mobileCollapsed}
onClick={() => setCollapsed((c) => !c)}
size="small"
sx={{
flexShrink: 0,
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: 'var(--fa-color-neutral-200)',
color: 'text.secondary',
'&:hover': { bgcolor: 'var(--fa-color-neutral-300)' },
}}
>
{mobileCollapsed ? (
<ChevronLeftRoundedIcon fontSize="small" />
) : (
<ChevronRightRoundedIcon fontSize="small" />
)}
</IconButton>
)}
</Paper>
</Slide>
);
},
);
CompareBar.displayName = 'CompareBar';
export default CompareBar;

View File

@@ -0,0 +1,2 @@
export { CompareBar, default } from './CompareBar';
export type { CompareBarProps, CompareBarPackage } from './CompareBar';

View File

@@ -0,0 +1,159 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonColumnCard } from './ComparisonColumnCard';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
// ─── Mock data ──────────────────────────────────────────────────────────────
const verifiedPackage: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [],
};
const unverifiedPackage: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [],
};
const recommendedPackage: ComparisonPackage = {
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
rating: 4.9,
reviewCount: 203,
verified: true,
},
sections: [],
isRecommended: true,
};
const longNamePackage: ComparisonPackage = {
id: 'long-name',
name: 'Comprehensive Premium Memorial & Cremation Service Package',
price: 12500,
provider: {
name: 'The Very Long Name Funeral Services & Memorial Chapel Pty Ltd',
location: 'Wollongong',
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [],
};
const noRatingPackage: ComparisonPackage = {
id: 'no-rating',
name: 'Basic Funeral Package',
price: 4200,
provider: {
name: 'New Provider',
location: 'Sydney',
verified: true,
},
sections: [],
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta: Meta<typeof ComparisonColumnCard> = {
title: 'Molecules/ComparisonColumnCard',
component: ComparisonColumnCard,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
decorators: [
(Story) => (
<Box sx={{ maxWidth: 280, mx: 'auto', pt: 3 }}>
<Story />
</Box>
),
],
args: {
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonColumnCard>;
/** Verified provider — floating "Verified" badge above card */
export const Verified: Story = {
args: {
pkg: verifiedPackage,
},
};
/** Unverified provider — "Make Enquiry" CTA + soft button variant, no verified badge */
export const Unverified: Story = {
args: {
pkg: unverifiedPackage,
},
};
/** Recommended package — copper banner, warm selected state, no Remove link */
export const Recommended: Story = {
args: {
pkg: recommendedPackage,
},
};
/** Long provider name — truncated with tooltip on hover */
export const LongName: Story = {
args: {
pkg: longNamePackage,
},
};
/** No rating — provider without rating/review data */
export const NoRating: Story = {
args: {
pkg: noRatingPackage,
},
};
/** Side-by-side — multiple cards in a row (as used in ComparisonTable) */
export const SideBySide: Story = {
decorators: [
() => (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, pt: 3 }}>
<ComparisonColumnCard
pkg={recommendedPackage}
onArrange={(id) => alert(`Arrange: ${id}`)}
/>
<ComparisonColumnCard
pkg={verifiedPackage}
onArrange={(id) => alert(`Arrange: ${id}`)}
onRemove={(id) => alert(`Remove: ${id}`)}
/>
<ComparisonColumnCard
pkg={unverifiedPackage}
onArrange={(id) => alert(`Arrange: ${id}`)}
onRemove={(id) => alert(`Remove: ${id}`)}
/>
</Box>
),
],
};

View File

@@ -0,0 +1,255 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Card } from '../../atoms/Card';
import { Divider } from '../../atoms/Divider';
import { Link } from '../../atoms/Link';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ComparisonColumnCardProps {
/** Package data to render — same shape used by ComparisonTable */
pkg: ComparisonPackage;
/** Called when the user clicks the CTA (Make Arrangement / Make Enquiry) */
onArrange: (packageId: string) => void;
/** Called when the user clicks Remove — hidden when not provided or for recommended packages */
onRemove?: (packageId: string) => void;
/** MUI sx prop for outer wrapper overrides */
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Desktop column header card for the ComparisonTable.
*
* Shows provider info (verified/recommended badge, name, location, rating),
* package name, total price, CTA button, and optional Remove link. The badge
* floats above the card's top edge — "Recommended" (primary fill) replaces
* "Verified" (soft) when the package is recommended. Recommended packages
* also get a warm selected card state with a brand-600 border.
*
* Used as the sticky header for each column in the desktop comparison grid.
* Mobile comparison uses ComparisonPackageCard instead.
*/
export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonColumnCardProps>(
({ pkg, onArrange, onRemove, sx }, ref) => {
return (
<Box
ref={ref}
role="columnheader"
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
sx={[
{
position: 'relative',
overflow: 'visible',
display: 'flex',
flexDirection: 'column',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Floating badge — Recommended (primary fill) takes priority over Verified (soft) */}
{(pkg.isRecommended || pkg.provider.verified) && (
<Badge
color="brand"
variant={pkg.isRecommended ? 'filled' : 'soft'}
size="medium"
icon={
pkg.isRecommended ? (
<StarRoundedIcon sx={{ fontSize: 16 }} />
) : (
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
)
}
sx={{
position: 'absolute',
top: -13,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
{pkg.isRecommended ? 'Recommended' : 'Verified'}
</Badge>
)}
<Card
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={{
overflow: 'hidden',
flex: 1,
display: 'flex',
flexDirection: 'column',
...(pkg.isRecommended && {
borderColor: 'var(--fa-color-brand-600)',
}),
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
px: 2.5,
pt: 5,
pb: 3,
gap: 1,
flex: 1,
}}
>
{/* 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: 'flex-end',
justifyContent: 'center',
gap: 0.75,
maxWidth: '100%',
minHeight: 36, // 2 × (14px label × 1.286 line-height)
}}
>
{pkg.isRecommended && (
<VerifiedOutlinedIcon
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
mb: '2px',
}}
aria-label="Verified provider"
/>
)}
<Tooltip
title={pkg.provider.name}
arrow
placement="top"
disableHoverListener={pkg.provider.name.length < 50}
>
<Typography
variant="label"
sx={{
fontWeight: 600,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
}}
>
{pkg.provider.name}
</Typography>
</Tooltip>
</Box>
{/* Location */}
<Typography variant="caption" color="text.secondary">
{pkg.provider.location}
</Typography>
{/* Rating (or dash placeholder to keep card heights consistent) */}
{pkg.provider.rating != null ? (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<StarRoundedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary">
{pkg.provider.rating}
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
</Typography>
</Box>
) : (
<Typography variant="body2" color="text.secondary" aria-label="No reviews yet">
</Typography>
)}
<Divider sx={{ width: '100%', my: 1.5 }} />
<Typography variant="h6" component="p">
{pkg.name}
</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 }} />
<Button
variant={pkg.provider.verified ? 'contained' : 'soft'}
color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium"
onClick={() => onArrange(pkg.id)}
sx={{ px: 4 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
{/* 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>
);
},
);
ComparisonColumnCard.displayName = 'ComparisonColumnCard';
export default ComparisonColumnCard;

View File

@@ -0,0 +1,2 @@
export { ComparisonColumnCard, default } from './ComparisonColumnCard';
export type { ComparisonColumnCardProps } from './ComparisonColumnCard';

View File

@@ -0,0 +1,163 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonPackageCard } from './ComparisonPackageCard';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
// ─── Mock data ──────────────────────────────────────────────────────────────
const basePackage: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const unverifiedPackage: ComparisonPackage = {
...basePackage,
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
rating: 4.2,
reviewCount: 45,
verified: false,
},
};
const recommendedPackage: ComparisonPackage = {
...basePackage,
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
rating: 4.9,
reviewCount: 203,
verified: true,
},
isRecommended: true,
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta: Meta<typeof ComparisonPackageCard> = {
title: 'Molecules/ComparisonPackageCard',
component: ComparisonPackageCard,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
decorators: [
(Story) => (
<Box sx={{ maxWidth: 400, mx: 'auto' }}>
<Story />
</Box>
),
],
args: {
onArrange: (id) => alert(`Arrange: ${id}`),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonPackageCard>;
/** Verified provider — default appearance used in ComparisonPage mobile tab panel */
export const Verified: Story = {
args: {
pkg: basePackage,
},
};
/** Unverified provider — "Make Enquiry" CTA + soft button variant, no verified badge */
export const Unverified: Story = {
args: {
pkg: unverifiedPackage,
},
};
/** Recommended package — warm banner, selected card state, warm header background */
export const Recommended: Story = {
args: {
pkg: recommendedPackage,
},
};
/** Itemisation unavailable — used when a provider hasn't submitted an itemised breakdown */
export const ItemizedUnavailable: Story = {
args: {
pkg: {
...unverifiedPackage,
itemizedAvailable: false,
},
},
};

View File

@@ -0,0 +1,323 @@
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 LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
import { Card } from '../../atoms/Card';
import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ComparisonPackageCardProps {
/** Package data to render — same shape used by ComparisonTable */
pkg: ComparisonPackage;
/** Called when the user clicks the CTA (Make Arrangement / Make Enquiry) */
onArrange: (packageId: string) => void;
/** MUI sx prop for container overrides */
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, textAlign: 'right' }}>
{formatPrice(value.amount)}
</Typography>
);
case 'allowance':
return (
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
{formatPrice(value.amount)}*
</Typography>
);
case 'complimentary':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
Complimentary
</Typography>
</Box>
);
case 'included':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
Included
</Typography>
</Box>
);
case 'poa':
return (
<Typography
variant="body2"
color="text.secondary"
sx={{ fontStyle: 'italic', textAlign: 'right' }}
>
Price On Application
</Typography>
);
case 'unknown':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<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)', textAlign: 'right' }}
>
</Typography>
);
}
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Mobile package card for the ComparisonPage mobile tab panel view.
*
* Full-width card with provider header (verified badge, name, location, rating,
* package name, price, CTA) and the package's itemised sections below. Used as
* the content of each mobile tabpanel — one card visible at a time, selected
* via the tab rail.
*
* Shared by ComparisonPage (V2) and ComparisonPageV1 so that card-level tweaks
* land in a single file.
*/
export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, ComparisonPackageCardProps>(
({ pkg, onArrange, sx }, ref) => {
return (
<Card
ref={ref}
variant="outlined"
padding="none"
sx={[
{
overflow: 'hidden',
boxShadow: 'var(--fa-shadow-sm)',
// Body defaults to white; only the header carries the warm/subtle
// tint so the tint signals "provider" rather than washing the
// whole card.
bgcolor: 'background.paper',
// Match the desktop ComparisonColumnCard recommended treatment:
// explicit 2px brand-600 border (same as Card's selected state,
// but without the warm background wash that `selected` applies).
...(pkg.isRecommended && {
border: '2px solid var(--fa-color-brand-600)',
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Recommended banner */}
{pkg.isRecommended && (
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
<Typography
variant="labelSm"
sx={{
color: 'var(--fa-color-white)',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}
>
Recommended
</Typography>
</Box>
)}
{/* Provider header */}
<Box
sx={{
bgcolor: pkg.isRecommended
? 'var(--fa-color-surface-warm)'
: 'var(--fa-color-surface-subtle)',
px: 3,
pt: 3,
pb: 4,
}}
>
{/* Provider name with optional inline verified icon (matches desktop
ComparisonColumnCard treatment) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
mb: 1.25,
}}
>
{pkg.provider.verified && (
<VerifiedOutlinedIcon
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
}}
aria-label="Verified provider"
/>
)}
<Typography variant="label" sx={{ fontWeight: 600 }}>
{pkg.provider.name}
</Typography>
</Box>
{/* Location + Rating */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.provider.location}
</Typography>
</Box>
{pkg.provider.rating != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-brand-500)' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.provider.rating}
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
</Typography>
</Box>
)}
</Box>
<Divider sx={{ my: 3 }} />
{/* Package info group — name, label, price stacked with small internal gap */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Typography variant="h5" component="p">
{pkg.name}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
</Box>
</Box>
<Button
variant={pkg.provider.verified ? 'contained' : 'soft'}
color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium"
fullWidth
onClick={() => onArrange(pkg.id)}
sx={{ mt: 3 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
</Box>
{/* Sections — with left accent borders on headings */}
<Box sx={{ px: 2.5, pt: 3.5, pb: 3 }}>
{pkg.itemizedAvailable === false ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Itemised pricing not available for this provider.
</Typography>
</Box>
) : (
pkg.sections.map((section, sIdx) => (
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 5 : 0 }}>
{/* Section heading with left accent */}
<Box
sx={{
borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)',
pl: 1.5,
mb: 2.5,
}}
>
<Typography variant="h6" component="h3">
{section.heading}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{section.items.map((item) => (
<Box
key={item.name}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
py: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, flex: '1 1 50%', maxWidth: '60%' }}>
<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>
<CellValue value={item.value} />
</Box>
))}
</Box>
</Box>
))
)}
</Box>
</Card>
);
},
);
ComparisonPackageCard.displayName = 'ComparisonPackageCard';
export default ComparisonPackageCard;

View File

@@ -0,0 +1,2 @@
export { ComparisonPackageCard, default } from './ComparisonPackageCard';
export type { ComparisonPackageCardProps } from './ComparisonPackageCard';

View File

@@ -0,0 +1,151 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonTabCard } from './ComparisonTabCard';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
// ─── Mock data ──────────────────────────────────────────────────────────────
const verifiedPkg: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [],
};
const recommendedPkg: ComparisonPackage = {
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
rating: 4.9,
reviewCount: 203,
verified: true,
},
sections: [],
isRecommended: true,
};
const unverifiedPkg: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [],
};
const longNamePkg: ComparisonPackage = {
id: 'long-name',
name: 'Comprehensive Premium Memorial & Cremation Service',
price: 12500,
provider: {
name: 'The Very Long Name Funeral Services Pty Ltd',
location: 'Wollongong',
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [],
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta: Meta<typeof ComparisonTabCard> = {
title: 'Molecules/ComparisonTabCard',
component: ComparisonTabCard,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
args: {
isActive: false,
hasRecommended: false,
tabId: 'tab-0',
tabPanelId: 'panel-0',
onClick: () => alert('Tab clicked'),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonTabCard>;
/** Default inactive tab card */
export const Default: Story = {
args: { pkg: verifiedPkg },
};
/** Active/selected state — elevated shadow */
export const Active: Story = {
args: { pkg: verifiedPkg, isActive: true },
};
/** Recommended — badge + brand glow */
export const Recommended: Story = {
args: { pkg: recommendedPkg, hasRecommended: true },
};
/** Recommended + active */
export const RecommendedActive: Story = {
args: { pkg: recommendedPkg, isActive: true, hasRecommended: true },
};
/** Long name — truncated with ellipsis */
export const LongName: Story = {
args: { pkg: longNamePkg },
};
/** Rail simulation — multiple cards as they appear in the mobile tab rail */
export const Rail: Story = {
decorators: [
() => (
<Box
sx={{
display: 'flex',
gap: 1.5,
overflowX: 'auto',
py: 2,
px: 2,
}}
>
<ComparisonTabCard
pkg={recommendedPkg}
isActive={false}
hasRecommended
tabId="tab-0"
tabPanelId="panel-0"
onClick={() => alert('Recommended')}
/>
<ComparisonTabCard
pkg={verifiedPkg}
isActive
hasRecommended
tabId="tab-1"
tabPanelId="panel-1"
onClick={() => alert('Wollongong')}
/>
<ComparisonTabCard
pkg={unverifiedPkg}
isActive={false}
hasRecommended
tabId="tab-2"
tabPanelId="panel-2"
onClick={() => alert('Inglewood')}
/>
</Box>
),
],
};

View File

@@ -0,0 +1,155 @@
import React from 'react';
import Box from '@mui/material/Box';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
import { Card } from '../../atoms/Card';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ComparisonTabCardProps {
/** Package data to render */
pkg: ComparisonPackage;
/** Whether this tab is the currently active/selected one */
isActive: boolean;
/** Whether any package in the rail is recommended — controls spacer for alignment */
hasRecommended: boolean;
/** ARIA: id for the tab element */
tabId: string;
/** ARIA: id of the controlled tabpanel */
tabPanelId: string;
/** Called when the tab card is clicked */
onClick: () => void;
/** MUI sx prop for outer wrapper */
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Mini tab card for the mobile ComparisonPage tab rail.
*
* Shows provider name, package name, and price. Recommended packages get a
* floating badge (in normal flow with negative margin overlap) and a warm
* brand glow. Non-recommended cards get a spacer to keep vertical alignment
* when a recommended card is present in the rail.
*
* The page component owns scroll/centering behaviour — this is purely visual.
*/
export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabCardProps>(
({ pkg, isActive, hasRecommended, tabId, tabPanelId, onClick, sx }, ref) => {
return (
<Box
ref={ref}
sx={[
{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Recommended badge in normal flow — overlaps card via negative mb.
Matches the desktop ComparisonColumnCard styling (filled brand +
star icon) for consistency between surfaces. */}
{pkg.isRecommended ? (
<Badge
color="brand"
variant="filled"
size="small"
icon={<StarRoundedIcon sx={{ fontSize: 14 }} />}
sx={{
mb: '-10px',
zIndex: 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
whiteSpace: 'nowrap',
}}
>
Recommended
</Badge>
) : (
// Spacer keeps cards aligned when a recommended card is present
hasRecommended && <Box sx={{ height: 12 }} />
)}
<Card
role="tab"
aria-selected={isActive}
aria-controls={tabPanelId}
id={tabId}
variant="outlined"
selected={isActive}
padding="none"
onClick={onClick}
interactive
sx={{
width: 235,
cursor: 'pointer',
boxShadow: 'var(--fa-shadow-sm)',
...(pkg.isRecommended && {
borderColor: 'var(--fa-color-brand-600)',
}),
...(isActive && {
boxShadow: 'var(--fa-shadow-md)',
}),
}}
>
<Box sx={{ px: 2, pt: 3.5, pb: 2 }}>
<Typography
variant="labelSm"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
mb: 0.25,
}}
>
{pkg.provider.name}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{pkg.name}
</Typography>
<Typography
variant="caption"
sx={{
display: 'block',
fontWeight: 600,
color: 'primary.main',
mt: 0.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{formatPrice(pkg.price)}
</Typography>
</Box>
</Card>
</Box>
);
},
);
ComparisonTabCard.displayName = 'ComparisonTabCard';
export default ComparisonTabCard;

View File

@@ -0,0 +1,2 @@
export { ComparisonTabCard, default } from './ComparisonTabCard';
export type { ComparisonTabCardProps } from './ComparisonTabCard';

View File

@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FilterPanel } from './FilterPanel';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import { Chip } from '../../atoms/Chip';
const meta: Meta<typeof FilterPanel> = {
title: 'Molecules/FilterPanel',
component: FilterPanel,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
label: { control: 'text' },
activeCount: { control: 'number' },
},
decorators: [
(Story) => (
<Box sx={{ p: 4, minHeight: 400 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof FilterPanel>;
/** Default state — no active filters */
export const Default: Story = {
args: {
activeCount: 0,
children: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Chip label="Verified providers" variant="outlined" size="small" />
<Chip label="Within 10km" variant="outlined" size="small" />
<Chip label="Reviews 4+★" variant="outlined" size="small" />
</Box>
),
},
};
/** With active filters — badge count shown */
export const WithActiveFilters: Story = {
args: {
activeCount: 2,
onClear: () => {},
children: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Chip label="Verified providers" variant="outlined" size="small" selected />
<Chip label="Within 10km" variant="outlined" size="small" selected />
<Chip label="Reviews 4+★" variant="outlined" size="small" />
</Box>
),
},
};
/** Select-based filters — category + price (CoffinsStep pattern) */
export const SelectFilters: Story = {
args: {
activeCount: 1,
onClear: () => {},
children: (
<>
<TextField select label="Category" value="solid_timber" fullWidth>
<MenuItem value="all">All categories</MenuItem>
<MenuItem value="solid_timber">Solid Timber</MenuItem>
<MenuItem value="environmental">Environmental</MenuItem>
<MenuItem value="designer">Designer</MenuItem>
</TextField>
<TextField select label="Price range" value="all" fullWidth>
<MenuItem value="all">All prices</MenuItem>
<MenuItem value="under_2000">Under $2,000</MenuItem>
<MenuItem value="2000_4000">$2,000 $4,000</MenuItem>
<MenuItem value="over_4000">Over $4,000</MenuItem>
</TextField>
</>
),
},
};
/** Custom label */
export const CustomLabel: Story = {
args: {
label: 'Sort & Filter',
activeCount: 0,
children: (
<TextField select label="Sort by" value="popular" fullWidth>
<MenuItem value="popular">Most popular</MenuItem>
<MenuItem value="price_low">Price: Low to high</MenuItem>
<MenuItem value="price_high">Price: High to low</MenuItem>
</TextField>
),
},
};

View File

@@ -0,0 +1,107 @@
import React from 'react';
import Box from '@mui/material/Box';
import TuneIcon from '@mui/icons-material/Tune';
import type { SxProps, Theme } from '@mui/material/styles';
import { DialogShell } from '../../atoms/DialogShell';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FilterPanel molecule */
export interface FilterPanelProps {
/** Trigger button label */
label?: string;
/** Number of active filters (shown as count on the trigger) */
activeCount?: number;
/** Filter controls — rendered inside the dialog body */
children: React.ReactNode;
/** Callback when "Clear all" is clicked */
onClear?: () => void;
/** MUI sx prop for the trigger button */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Reusable filter panel for the FA arrangement wizard.
*
* Renders a trigger button ("Filters") that opens a DialogShell containing
* arbitrary filter controls (chips, selects, sliders, etc.) passed as
* children. Active filter count shown as a badge on the trigger.
*
* Used in ProvidersStep, VenueStep, and CoffinsStep.
*/
export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
({ label = 'Filters', activeCount = 0, children, onClear, sx }, ref) => {
const [open, setOpen] = React.useState(false);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
return (
<>
{/* Trigger button */}
<Box ref={ref} sx={[{ display: 'inline-flex' }, ...(Array.isArray(sx) ? sx : [sx])]}>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<TuneIcon />}
onClick={handleOpen}
aria-expanded={open}
aria-haspopup="dialog"
>
{label}
{activeCount > 0 && (
<Badge variant="filled" color="brand" size="small" sx={{ ml: 1 }} aria-hidden="true">
{activeCount}
</Badge>
)}
{activeCount > 0 && (
<Box
component="span"
sx={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
>
{activeCount} active filter{activeCount !== 1 ? 's' : ''}
</Box>
)}
</Button>
</Box>
{/* Filter dialog */}
<DialogShell
open={open}
onClose={handleClose}
title={label}
footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{onClear ? (
<Button
variant="text"
size="small"
color="secondary"
onClick={() => onClear()}
disabled={activeCount === 0}
>
Reset filters
</Button>
) : (
<Box />
)}
<Button variant="contained" size="small" onClick={handleClose}>
Apply
</Button>
</Box>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>{children}</Box>
</DialogShell>
</>
);
},
);
FilterPanel.displayName = 'FilterPanel';
export default FilterPanel;

View File

@@ -0,0 +1,3 @@
export { FilterPanel } from './FilterPanel';
export type { FilterPanelProps } from './FilterPanel';
export { default } from './FilterPanel';

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { HelpBar } from './HelpBar';
const meta: Meta<typeof HelpBar> = {
title: 'Molecules/HelpBar',
component: HelpBar,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
decorators: [
(Story) => (
// Fake page content so the sticky footer has something to sit under.
<Box sx={{ minHeight: 400, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flex: 1, p: 4, bgcolor: 'background.default' }}>
Page content scrolls above the help bar.
</Box>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof HelpBar>;
/** Default — uses FA's standard support number. */
export const Default: Story = {};
/** Custom number — spaces preserved in the label, stripped in the tel link. */
export const CustomNumber: Story = {
args: { phone: '1300 000 000' },
};

View File

@@ -0,0 +1,64 @@
import React from 'react';
import Box from '@mui/material/Box';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA HelpBar molecule */
export interface HelpBarProps {
/** Phone number shown in the bar. Spaces preserved in the label,
* stripped in the `tel:` href. Defaults to FA's support number. */
phone?: string;
/** MUI sx prop — merged onto the default footer chrome. */
sx?: SxProps<Theme>;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Sticky help footer used at the bottom of every wizard page. Shows a
* phone-icon prefix + "Need help? Call us on" + the support number as a
* tel-link. White fill, top border, sticky to the viewport bottom.
*
* Used by `WizardLayout` (for all variants that don't set `hideHelpBar`)
* and by pages that bypass WizardLayout's chrome (e.g. the mobile-map-first
* layout on `ProvidersStep`). Promoted from a WizardLayout-internal
* component so both sources render an identical footer — preventing drift
* if the phone number or styling ever changes.
*/
export const HelpBar = React.forwardRef<HTMLDivElement, HelpBarProps>(
({ phone = '1800 987 888', sx }, ref) => (
<Box
ref={ref}
component="footer"
sx={[
{
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
py: 1.5,
px: { xs: 2, md: 4 },
textAlign: 'center',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Typography variant="body2" color="text.secondary" component="span">
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
Need help? Call us on{' '}
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
{phone}
</Link>
</Typography>
</Box>
),
);
HelpBar.displayName = 'HelpBar';
export default HelpBar;

View File

@@ -0,0 +1 @@
export { HelpBar, type HelpBarProps } from './HelpBar';

View File

@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ImageGallery } from './ImageGallery';
import type { GalleryImage } from './ImageGallery';
import Box from '@mui/material/Box';
const venueImages: GalleryImage[] = [
{
src: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&h=600&fit=crop',
alt: 'Chapel interior with natural light',
},
{
src: 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?w=800&h=600&fit=crop',
alt: 'Chapel exterior and gardens',
},
{
src: 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=800&h=600&fit=crop',
alt: 'Reception hall',
},
{
src: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=800&h=600&fit=crop',
alt: 'Lakeside view',
},
{
src: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop',
alt: 'Mountain chapel',
},
];
const meta: Meta<typeof ImageGallery> = {
title: 'Molecules/ImageGallery',
component: ImageGallery,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
decorators: [
(Story) => (
<Box sx={{ maxWidth: 640 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ImageGallery>;
/** Default — multiple images with thumbnail strip */
export const Default: Story = {
args: {
images: venueImages,
},
};
/** Single image — no thumbnail strip shown */
export const SingleImage: Story = {
args: {
images: [venueImages[0]],
},
};
/** Two images */
export const TwoImages: Story = {
args: {
images: venueImages.slice(0, 2),
},
};
/** Custom hero height and thumbnail size */
export const CustomSizes: Story = {
args: {
images: venueImages,
heroHeight: 300,
thumbnailHeight: 48,
},
};

View File

@@ -0,0 +1,164 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A single image in the gallery */
export interface GalleryImage {
/** Image URL */
src: string;
/** Alt text for accessibility */
alt: string;
}
/** Props for the FA ImageGallery component */
export interface ImageGalleryProps {
/** Array of images to display */
images: GalleryImage[];
/** Height of the hero image area */
heroHeight?: number | { xs?: number; sm?: number; md?: number; lg?: number };
/** Height of each thumbnail (width is 4:3 ratio) */
thumbnailHeight?: number;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Image gallery with hero display and thumbnail strip.
*
* Shows a large hero image with a row of clickable thumbnails below.
* Hovering a thumbnail previews it in the hero; clicking locks the
* selection. First image is selected by default.
*
* Used on venue detail, coffin detail, and other product pages.
*
* Usage:
* ```tsx
* <ImageGallery
* images={[
* { src: '/chapel-1.jpg', alt: 'Chapel interior' },
* { src: '/chapel-2.jpg', alt: 'Chapel exterior' },
* { src: '/chapel-3.jpg', alt: 'Garden area' },
* ]}
* />
* ```
*/
export const ImageGallery = React.forwardRef<HTMLDivElement, ImageGalleryProps>(
({ images, heroHeight = { xs: 280, md: 420 }, thumbnailHeight = 64, sx }, ref) => {
const thumbnailWidth = Math.round(thumbnailHeight * (4 / 3));
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
// The image shown in the hero: hovered thumbnail takes priority over selected
const displayIndex = hoverIndex ?? selectedIndex;
const displayImage = images[displayIndex] ?? images[0];
if (!images.length) return null;
// Single image — no thumbnails needed
if (images.length === 1) {
return (
<Box ref={ref} sx={sx}>
<Box
role="img"
aria-label={displayImage.alt}
sx={{
width: '100%',
height: heroHeight,
borderRadius: 2,
backgroundImage: `url(${displayImage.src})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
}}
/>
</Box>
);
}
return (
<Box ref={ref} sx={sx}>
{/* Hero image */}
<Box
role="img"
aria-label={displayImage.alt}
sx={{
width: '100%',
height: heroHeight,
borderRadius: 2,
backgroundImage: `url(${displayImage.src})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
transition: 'background-image 200ms ease-in-out',
mb: 1.5,
}}
/>
{/* Thumbnail strip */}
<Box
role="list"
aria-label="Image gallery thumbnails"
sx={{
display: 'flex',
gap: 1,
overflowX: 'auto',
pb: 0.5,
// Thin horizontal scrollbar for many thumbnails
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { height: 4 },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.15)',
borderRadius: 2,
},
}}
>
{images.map((image, index) => (
<Box
key={index}
role="listitem"
onClick={() => setSelectedIndex(index)}
onMouseEnter={() => setHoverIndex(index)}
onMouseLeave={() => setHoverIndex(null)}
sx={{
width: thumbnailWidth,
height: thumbnailHeight,
flexShrink: 0,
borderRadius: 1,
backgroundImage: `url(${image.src})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
cursor: 'pointer',
border: '2px solid',
borderColor: index === selectedIndex ? 'primary.main' : 'transparent',
opacity: index === selectedIndex ? 1 : 0.7,
transition: 'border-color 150ms ease-in-out, opacity 150ms ease-in-out',
'&:hover': {
opacity: 1,
borderColor:
index === selectedIndex ? 'primary.main' : 'var(--fa-color-border-default)',
},
}}
aria-label={image.alt}
aria-current={index === selectedIndex ? 'true' : undefined}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedIndex(index);
}
}}
/>
))}
</Box>
</Box>
);
},
);
ImageGallery.displayName = 'ImageGallery';
export default ImageGallery;

View File

@@ -0,0 +1,2 @@
export { ImageGallery, default } from './ImageGallery';
export type { ImageGalleryProps, GalleryImage } from './ImageGallery';

View File

@@ -0,0 +1,197 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { LineItem } from './LineItem';
import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider';
const meta: Meta<typeof LineItem> = {
title: 'Molecules/LineItem',
component: LineItem,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<Box sx={{ maxWidth: 500, width: '100%' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof LineItem>;
// --- Default -----------------------------------------------------------------
/** Basic line item with name and price */
export const Default: Story = {
args: {
name: 'Professional Service Fee',
price: 1500,
info: 'Covers coordination of the entire funeral service, liaising with clergy, cemetery, and crematorium.',
},
};
// --- Allowance ---------------------------------------------------------------
/** Price marked with asterisk — indicates an allowance that can be customised */
export const Allowance: Story = {
args: {
name: 'Allowance for Coffin',
price: 1500,
isAllowance: true,
info: 'This is an allowance amount. You may upgrade or change the coffin selection during your arrangement.',
},
};
// --- No Price ----------------------------------------------------------------
/** Complimentary/included item — no price shown */
export const Complimentary: Story = {
args: {
name: 'Dressing Fee',
info: 'Included at no additional charge with this package.',
},
};
// --- Custom Price Label ------------------------------------------------------
/** Custom text instead of dollar amount */
export const CustomPriceLabel: Story = {
args: {
name: 'Transfer of Deceased',
priceLabel: 'Included',
info: 'Transfer within 50km of the funeral home.',
},
};
// --- Total Row ---------------------------------------------------------------
/** Summary total — bold with top border */
export const Total: Story = {
args: {
name: 'Total',
price: 2700,
variant: 'total',
},
};
// --- Package Contents --------------------------------------------------------
/** Realistic package breakdown — Essentials, Complimentary, Total, then Extras */
export const PackageContents: Story = {
render: () => (
<Box>
<Typography variant="label" sx={{ mb: 2, display: 'block' }}>
Essentials
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<LineItem
name="Accommodation"
price={1500}
info="Refrigerated holding of the deceased prior to the funeral service."
/>
<LineItem
name="Death Registration Certificate"
price={1500}
info="Lodgement of death registration with NSW Registry of Births, Deaths & Marriages."
/>
<LineItem
name="Doctor Fee for Cremation"
price={1500}
info="Statutory medical referee fee required for all cremations in NSW."
/>
<LineItem
name="NSW Government Levy — Cremation"
price={1500}
info="NSW Government cremation levy as set by the Department of Health."
/>
<LineItem
name="Professional Mortuary Care"
price={1500}
info="Preparation and care of the deceased."
/>
<LineItem
name="Professional Service Fee"
price={1500}
info="Coordination of all funeral arrangements and services."
/>
<LineItem
name="Allowance for Coffin"
price={1500}
isAllowance
info="Allowance amount — upgrade options available during arrangement."
/>
<LineItem
name="Allowance for Crematorium"
price={1500}
isAllowance
info="Allowance for crematorium fees — varies by location."
/>
<LineItem
name="Allowance for Hearse"
price={1500}
isAllowance
info="Allowance for hearse transfer — distance surcharges may apply."
/>
</Box>
<Divider sx={{ my: 3 }} />
<Typography variant="label" sx={{ mb: 2, display: 'block' }}>
Complimentary Items
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<LineItem
name="Dressing Fee"
info="Dressing and preparation of the deceased — included at no charge."
/>
<LineItem name="Viewing Fee" info="One private family viewing — included at no charge." />
</Box>
<LineItem name="Total" price={13500} variant="total" />
<Divider sx={{ my: 3 }} />
<Typography variant="label" sx={{ mb: 2, display: 'block' }}>
Extras
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<LineItem
name="Allowance for Flowers"
price={1500}
isAllowance
info="Seasonal floral arrangements for the service."
/>
<LineItem
name="Allowance for Master of Ceremonies"
price={1500}
isAllowance
info="Professional celebrant or MC for the funeral service."
/>
<LineItem
name="After Business Hours Service Surcharge"
price={1500}
info="Additional fee for services held outside standard business hours."
/>
<LineItem
name="After Hours Prayers"
price={1500}
info="Evening prayer service at the funeral home."
/>
<LineItem
name="Coffin Bearing by Funeral Directors"
price={1500}
info="Professional pallbearing by funeral directors."
/>
<LineItem
name="Digital Recording"
price={1500}
info="Professional video recording of the funeral service."
/>
</Box>
</Box>
),
};

View File

@@ -0,0 +1,124 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA LineItem molecule */
export interface LineItemProps {
/** Item name/label */
name: string;
/** Optional tooltip text explaining the item (shown via info icon) */
info?: string;
/** Price in dollars — omit for complimentary/included items */
price?: number;
/** Whether the price is an allowance (shows asterisk) */
isAllowance?: boolean;
/** Custom price display — overrides `price` formatting (e.g. "Included", "TBC") */
priceLabel?: string;
/** Visual weight — "default" for regular items, "total" for summary rows */
variant?: 'default' | 'total';
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* A single line item showing a name, optional info tooltip, and optional price.
*
* Used in package contents, order summaries, and invoices. The `info` prop
* renders a small info icon with a tooltip — used by providers to explain
* what each inclusion covers.
*
* Composes Typography + Tooltip.
*
* Usage:
* ```tsx
* <LineItem name="Professional Service Fee" info="Covers all coordination..." price={1500} />
* <LineItem name="Allowance for Coffin" price={1500} isAllowance info="Can be upgraded..." />
* <LineItem name="Dressing Fee" info="Included in this package" />
* <LineItem name="Total" price={2700} variant="total" />
* ```
*/
export const LineItem = React.forwardRef<HTMLDivElement, LineItemProps>(
({ name, info, price, isAllowance = false, priceLabel, variant = 'default', sx }, ref) => {
const isTotal = variant === 'total';
const formattedPrice =
priceLabel ??
(price != null ? `$${price.toLocaleString('en-AU')}${isAllowance ? '*' : ''}` : undefined);
return (
<Box
ref={ref}
sx={[
{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
...(isTotal && {
pt: 2,
pb: 0.5,
mt: 2,
borderTop: '1px solid',
borderColor: 'divider',
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Name + optional info icon */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
<Typography
variant={isTotal ? 'h6' : 'body2'}
sx={{
fontWeight: isTotal ? 600 : 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{name}
</Typography>
{info && (
<Tooltip title={info} arrow placement="top">
<InfoOutlinedIcon
aria-label="More information"
sx={{
fontSize: 16,
color: 'text.secondary',
cursor: 'help',
flexShrink: 0,
}}
/>
</Tooltip>
)}
</Box>
{/* Price */}
{formattedPrice && (
<Typography
variant={isTotal ? 'h6' : 'body2'}
color={isTotal ? 'primary' : 'text.secondary'}
sx={{
fontWeight: isTotal ? 600 : 500,
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{formattedPrice}
</Typography>
)}
</Box>
);
},
);
LineItem.displayName = 'LineItem';
export default LineItem;

View File

@@ -0,0 +1 @@
export { LineItem, type LineItemProps } from './LineItem';

View File

@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import Box from '@mui/material/Box';
import { LocationSearchInput } from './LocationSearchInput';
const meta: Meta<typeof LocationSearchInput> = {
title: 'Molecules/LocationSearchInput',
component: LocationSearchInput,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(Story) => (
<Box sx={{ width: 360, p: 2, bgcolor: 'background.default' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof LocationSearchInput>;
// Caller-provided chrome mirroring the ProvidersStep chip strip — useful
// for visualising the molecule in its real context. Users of the molecule
// on other surfaces would pass their own (or none).
const providerChromeSx = {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
borderRadius: 'var(--fa-button-border-radius-default)',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: 'var(--fa-shadow-sm)',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
},
} as const;
// ─── Stories ────────────────────────────────────────────────────────────────
/** Empty state — no committed value, no draft. The primary magnifying-glass
* stays anchored to the right edge. */
export const Empty: Story = {
render: (args) => {
const [value, setValue] = useState('');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
args: { sx: providerChromeSx },
};
/** Committed-chip state — the value renders as a chip with an X to clear. */
export const WithCommittedValue: Story = {
render: (args) => {
const [value, setValue] = useState('Wollongong, 2500');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
args: { sx: providerChromeSx },
};
/** Unstyled — no caller chrome. Shows the raw molecule output (just the
* correctness CSS kicks in; the rest is MUI defaults). */
export const Unstyled: Story = {
render: (args) => {
const [value, setValue] = useState('');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
};
/** With onCommit side-effect — logs when the user explicitly commits
* (separate from the always-fired onChange). */
export const WithOnCommit: Story = {
render: (args) => {
const [value, setValue] = useState('');
return (
<LocationSearchInput
{...args}
value={value}
onChange={setValue}
onCommit={(v) => {
console.log('committed:', v);
}}
/>
);
},
args: { sx: providerChromeSx, placeholder: 'Type a suburb and press Enter' },
};

View File

@@ -0,0 +1,199 @@
import React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import SearchIcon from '@mui/icons-material/Search';
import type { SxProps, Theme } from '@mui/material/styles';
import { Chip } from '../../atoms/Chip';
import { IconButton } from '../../atoms/IconButton';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA LocationSearchInput molecule */
export interface LocationSearchInputProps {
/** Committed location value. When non-empty, rendered as a chip inside
* the input; when empty, placeholder shows and the input accepts typing. */
value: string;
/** Fires whenever the committed value changes — on explicit commit (Enter
* or search button) with the new value, or on chip delete with ''. */
onChange: (value: string) => void;
/** Optional extra callback fired *only* on explicit commit (not on chip
* delete). Useful for triggering search side-effects beyond the value
* update (analytics, external fetch, etc.). */
onCommit?: (value: string) => void;
/** Placeholder text shown when no value is committed and no draft typed. */
placeholder?: string;
/** Accessible label for the input. */
'aria-label'?: string;
/** MUI sx prop — merged after the molecule's internal correctness CSS.
* Use this to style the outlined input's chrome (bgcolor, shadow, border,
* radius). Internal CSS targets `.MuiAutocomplete-inputRoot` whereas most
* chrome sx uses `.MuiOutlinedInput-root`, so collisions are avoided. */
sx?: SxProps<Theme>;
}
// ─── Internal correctness CSS ───────────────────────────────────────────────
/**
* Absolute-anchors the commit button (end adornment) to the right edge of
* the input — stock MUI Autocomplete does this on `.MuiAutocomplete-endAdornment`,
* but overriding `InputProps.endAdornment` puts our button inside a
* `.MuiInputAdornment-positionEnd` that defaults to `position: static` and
* would slide left as chips / draft text fill the input.
*
* `pr: 5` on the input root reserves the right-edge lane so input content
* can't run under the button. Selectors use `.MuiAutocomplete-inputRoot`
* (not `.MuiOutlinedInput-root`) so caller sx for chrome can sit alongside
* these rules without colliding on the same key.
*/
const INTERNAL_SX = {
'& .MuiAutocomplete-inputRoot': {
position: 'relative',
pr: 5,
},
'& .MuiAutocomplete-inputRoot .MuiInputAdornment-positionEnd': {
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
height: 'auto',
maxHeight: 'none',
m: 0,
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Location search input with committed-chip semantics.
*
* - **Typing produces a draft** (local state, not propagated).
* - **Pressing Enter or the primary-filled magnifying-glass button commits**
* the draft: fires `onChange(draft)` and `onCommit?.(draft)`, clears the
* draft, renders the committed value as a chip inside the input.
* - **Tapping the chip's X** clears the committed value (`onChange('')`).
*
* Capped to one chip at a time — if the user commits a new value while a
* chip exists, the new value replaces it. This matches the product intent
* (one active location per search) and keeps the UX obvious.
*
* The molecule owns the endAdornment absolute-anchoring + right-side
* padding so the commit button never drifts as chips / draft fill the input.
* Chrome (bgcolor, shadow, border, radius) is caller-controlled via `sx`.
*
* Originally extracted from ProvidersStep (D046) where the same pattern
* lived inline in both the mobile-map floating strip and the desktop/mobile
* sticky search bar.
*/
export const LocationSearchInput = React.forwardRef<HTMLDivElement, LocationSearchInputProps>(
(
{
value,
onChange,
onCommit,
placeholder = 'Search a town or suburb...',
'aria-label': ariaLabel = 'Search location',
sx,
},
ref,
) => {
const [draft, setDraft] = React.useState('');
const commit = (next: string) => {
const trimmed = next.trim();
if (!trimmed) return;
onChange(trimmed);
onCommit?.(trimmed);
setDraft('');
};
return (
<Autocomplete
ref={ref}
multiple
freeSolo
options={[]}
forcePopupIcon={false}
clearIcon={null}
value={value.trim() ? [value.trim()] : []}
inputValue={draft}
onInputChange={(_, newDraft, reason) => {
// Autocomplete fires a 'reset' input-change after a commit that
// would echo the committed value back into our draft — ignore it.
if (reason === 'reset') return;
setDraft(newDraft);
}}
onChange={(_, newValue) => {
if (newValue.length === 0) {
// Chip deleted
onChange('');
return;
}
// Cap at 1: take the most-recent entry as the new committed value.
const last = newValue[newValue.length - 1];
if (typeof last === 'string') commit(last);
}}
renderTags={(val, getTagProps) =>
val.map((option, index) => {
const { key, ...chipProps } = getTagProps({ index });
return (
<Chip
key={key}
label={option}
size="small"
aria-label={`Current location: ${option}. Press delete to clear.`}
{...chipProps}
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={value.trim() ? '' : placeholder}
size="small"
inputProps={{
...params.inputProps,
'aria-label': ariaLabel,
}}
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start" sx={{ ml: 0.5, mr: 0.5 }}>
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="Search"
onClick={() => commit(draft)}
sx={{
width: 28,
height: 28,
borderRadius: '50%',
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&:focus-visible': { outline: 'none' },
}}
>
<SearchIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
),
}}
/>
)}
sx={[INTERNAL_SX, ...(Array.isArray(sx) ? sx : [sx])]}
/>
);
},
);
LocationSearchInput.displayName = 'LocationSearchInput';
export default LocationSearchInput;

View File

@@ -0,0 +1 @@
export { LocationSearchInput, type LocationSearchInputProps } from './LocationSearchInput';

View File

@@ -0,0 +1,138 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapPopup } from './MapPopup';
import { MapPin } from '../../atoms/MapPin';
// Placeholder images
const IMG_PROVIDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=200&fit=crop&auto=format';
const IMG_VENUE =
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=200&fit=crop&auto=format';
const meta: Meta<typeof MapPopup> = {
title: 'Molecules/MapPopup',
component: MapPopup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof MapPopup>;
/** Verified provider with image, price, location, and rating */
export const VerifiedProvider: Story = {
args: {
name: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
location: 'Wollongong',
rating: 4.8,
verified: true,
},
};
/** Unverified provider — no image, no badge */
export const UnverifiedProvider: Story = {
args: {
name: 'Smith & Sons Funeral Services',
price: 1200,
location: 'Sutherland',
},
};
/** Venue popup — capacity instead of rating */
export const Venue: Story = {
args: {
name: 'Albany Creek Memorial Park — Garden Chapel',
imageUrl: IMG_VENUE,
price: 450,
location: 'Albany Creek',
capacity: 120,
},
};
/** Long name — truncated at 1 line, tooltip on hover */
export const LongName: Story = {
args: {
name: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_PROVIDER,
price: 1200,
location: 'Northern Beaches',
verified: true,
},
};
/** Minimal — just name */
export const Minimal: Story = {
args: {
name: 'Local Funeral Provider',
},
};
/** Verified without image — inline verified indicator */
export const VerifiedNoImage: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
location: 'Wollongong',
verified: true,
},
};
/** Custom price label */
export const CustomPriceLabel: Story = {
args: {
name: 'Premium Funeral Services',
imageUrl: IMG_PROVIDER,
priceLabel: 'Price on application',
location: 'Sydney CBD',
verified: true,
},
};
/** Pin + Popup composition — shows how they work together on a map */
export const WithPin: Story = {
decorators: [
(Story) => (
<Box
sx={{
position: 'relative',
width: 400,
height: 380,
bgcolor: '#E5E3DF',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
}}
>
<Story />
</Box>
),
],
render: () => (
<>
<MapPopup
name="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
price={900}
location="Wollongong"
rating={4.8}
verified
onClick={() => {}}
/>
<MapPin name="H.Parsons" price={900} verified />
</>
),
};

View File

@@ -0,0 +1,325 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Tooltip from '@mui/material/Tooltip';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapPopup molecule */
export interface MapPopupProps {
/** Provider/venue name */
name: string;
/** Hero image URL */
imageUrl?: string;
/** Price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string;
/** Location text (suburb, city) */
location?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Venue capacity */
capacity?: number;
/** Whether this provider is verified — shows icon badge in image */
verified?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** When true, animates the popup out (opacity + scale) without unmounting.
* Callers should unmount after the transition completes (180ms). */
exiting?: boolean;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const POPUP_WIDTH = 260;
const IMAGE_HEIGHT = 100;
const NUB_SIZE = 8;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Map popup card for the FA design system.
*
* Floating card anchored to a MapPin on click. Shows a compact
* preview of a provider or venue — image, name, meta, and price.
* The entire card is clickable to navigate to the provider/venue.
*
* Content hierarchy matches MiniCard: **title → meta → price**.
* Truncated names show a tooltip on hover. Verified providers
* show an icon-only badge floating in the image.
*
* Designed for use as a custom popup in Mapbox GL / Google Maps.
* The parent map container handles positioning; this component
* handles content and styling only.
*
* Composes: Paper + Typography + Tooltip.
*
* Usage:
* ```tsx
* <MapPopup
* name="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* price={900}
* location="Wollongong"
* rating={4.8}
* verified
* onClick={() => selectProvider(id)}
* />
* ```
*/
export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
(
{
name,
imageUrl,
price,
priceLabel,
location,
rating,
capacity,
verified = false,
onClick,
exiting = false,
sx,
},
ref,
) => {
const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null;
// Detect name truncation for tooltip
const nameRef = React.useRef<HTMLElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const el = nameRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
}
}, [name]);
// Swallow clicks on the popup so they don't bubble to an enclosing
// Map.onClick (which would close the popup mid-click). Always applied,
// even when onClick is unset, because callers consistently render this
// molecule inside a map context where ambient clicks should not escape.
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
return (
<Box
ref={ref}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={handleClick}
onKeyDown={
onClick
? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
aria-label={onClick ? `View ${name}` : undefined}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
cursor: onClick ? 'pointer' : 'default',
transformOrigin: 'bottom center',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
opacity: exiting ? 0 : 1,
transform: exiting ? 'scale(0.9)' : 'scale(1)',
'@keyframes mapPopupIn': {
from: { opacity: 0, transform: 'scale(0.9)' },
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'mapPopupIn 180ms ease-out',
'&:hover':
onClick && !exiting
? {
transform: 'scale(1.02)',
}
: undefined,
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
borderRadius: 'var(--fa-card-border-radius-default)',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Paper
elevation={0}
sx={{
width: POPUP_WIDTH,
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
position: 'relative',
}}
>
{/* ── Image ── */}
{imageUrl && (
<Box
role="img"
aria-label={`Photo of ${name}`}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)',
}}
>
{/* Verified icon badge — floating top-right */}
{verified && (
<Tooltip title="Verified provider" arrow placement="top">
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: 'var(--fa-color-brand-600)',
color: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />
</Box>
</Tooltip>
)}
</Box>
)}
{/* ── Content ── */}
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* 1. Name — with tooltip when truncated */}
<Tooltip
title={isTruncated ? name : ''}
arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={nameRef} variant="body2" sx={{ fontWeight: 600 }} maxLines={1}>
{name}
</Typography>
</Tooltip>
{/* 2. Meta row */}
{hasMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 12, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{location}
</Typography>
</Box>
)}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}
aria-label={`Rated ${rating} out of 5`}
>
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{rating}
</Typography>
</Box>
)}
{capacity != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<PeopleOutlinedIcon
sx={{ fontSize: 12, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{capacity}
</Typography>
</Box>
)}
</Box>
)}
{/* 3. Price */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="caption" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography variant="caption" color="primary" sx={{ fontWeight: 600 }}>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* Verified indicator (no-image fallback) */}
{verified && !imageUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VerifiedOutlinedIcon sx={{ fontSize: 14, color: 'var(--fa-color-brand-600)' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
Verified
</Typography>
</Box>
)}
</Box>
</Paper>
{/* Nub — downward pointer. SVG (fill-only; MapPopup uses a drop-shadow
for depth instead of a hard border, so no stroke needed) */}
<svg
aria-hidden
width={NUB_SIZE * 2}
height={NUB_SIZE}
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
>
<path
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
fill="var(--fa-color-white)"
/>
</svg>
</Box>
);
},
);
MapPopup.displayName = 'MapPopup';
export default MapPopup;

View File

@@ -0,0 +1,2 @@
export { MapPopup, default } from './MapPopup';
export type { MapPopupProps } from './MapPopup';

View File

@@ -0,0 +1,146 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapProviderDrawer } from './MapProviderDrawer';
const meta: Meta<typeof MapProviderDrawer> = {
title: 'Molecules/MapProviderDrawer',
component: MapProviderDrawer,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
viewport: { defaultViewport: 'mobile1' },
},
decorators: [
// Simulate the mobile map-view container: fixed-size, relatively-positioned,
// with a faux map background behind the drawer.
(Story) => (
<Box
sx={{
position: 'relative',
width: 390,
height: 700,
mx: 'auto',
overflow: 'hidden',
// Very rough map-tile fill so the drawer has contrast behind it.
background: 'linear-gradient(135deg, #C9DFC4 0%, #B5D4F0 50%, #C9DFC4 100%)',
}}
>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof MapProviderDrawer>;
// ─── Fixtures ───────────────────────────────────────────────────────────────
const parsons = {
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
imageUrl: '/images/funeral-homes/parsons-chapel.jpg',
logoUrl: '/images/providers/parsons-logo.png',
rating: 4.6,
reviewCount: 7,
startingPrice: 1800,
};
const clusterProviders = [
parsons,
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Warrawong, NSW',
verified: true,
rating: 4.8,
startingPrice: 2450,
},
{
id: 'killick',
name: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
verified: true,
rating: 4.9,
startingPrice: 3100,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
startingPrice: 3400,
},
];
const log =
(label: string) =>
(arg?: string): void => {
console.log(label, arg ?? '');
};
// ─── Stories ────────────────────────────────────────────────────────────────
/** Single-provider drawer — the whole ProviderCard is clickable and fires
* `onSelectProvider` (in production, this navigates to the packages page). */
export const SingleProvider: Story = {
args: {
active: {
provider: parsons,
cluster: null,
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Cluster drawer — verified-first list of rows. Tapping a row fires
* `onDrillIntoProvider`; in production this pans + zooms the map and
* swaps the drawer's `active` to a single-provider state. */
export const Cluster: Story = {
args: {
active: {
provider: null,
cluster: {
providers: clusterProviders,
position: { lat: -34.42, lng: 150.89 },
},
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Closed state — the drawer is in the DOM but translated off-screen. */
export const Closed: Story = {
args: {
active: null,
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Small cluster of two — verified pair. */
export const ClusterPair: Story = {
args: {
active: {
provider: null,
cluster: {
providers: clusterProviders.slice(0, 2),
position: { lat: -34.42, lng: 150.89 },
},
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};

View File

@@ -0,0 +1,267 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import ButtonBase from '@mui/material/ButtonBase';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Button } from '../../atoms/Button';
import { IconButton } from '../../atoms/IconButton';
import { Typography } from '../../atoms/Typography';
import { ProviderCard } from '../ProviderCard';
import type { ProviderData } from '../../pages/ProvidersStep';
import type { ProviderMapActiveState } from '../../organisms/ProviderMap';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapProviderDrawer molecule */
export interface MapProviderDrawerProps {
/** Current active state from `ProviderMap` (wire via `onActiveChange`).
* `null` = no active pin/cluster; drawer is hidden. */
active: ProviderMapActiveState | null;
/** Fires when the close X is tapped. Typically wired to the map's
* imperative `clearActive()`. */
onClose: () => void;
/** Fires when the single-provider card is tapped (entire card clickable).
* Typically navigates to that provider's packages. */
onSelectProvider: (id: string) => void;
/** Fires when a cluster row is tapped. Typically wired to the map's
* imperative `drillIntoProvider()` which pans + zooms + swaps the
* drawer's content to a single-provider card. */
onDrillIntoProvider: (id: string) => void;
/** MUI sx prop for the root Paper — merged onto the default positioning. */
sx?: SxProps<Theme>;
}
// ─── Cluster row ────────────────────────────────────────────────────────────
const ClusterRow: React.FC<{
provider: ProviderData;
onClick: () => void;
}> = ({ provider: p, onClick }) => (
<ButtonBase
onClick={onClick}
sx={{
width: '100%',
justifyContent: 'flex-start',
textAlign: 'left',
px: 2,
py: 1.25,
gap: 1,
// Start-align so the verified icon sits on the name's baseline —
// matches the desktop ClusterPopup row treatment.
alignItems: 'flex-start',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-of-type': { borderBottom: 'none' },
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
{/* Verified-icon slot — reserved width + fixed line-height so the icon
sits on the name's line-box regardless of location/rating meta
below. Mirrors desktop ClusterPopup's treatment (D043 refinement). */}
<Box
sx={{
width: 18,
height: '1.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{p.verified && <VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: p.verified ? 'primary.main' : 'text.primary',
lineHeight: 1.25,
mb: 0.25,
}}
>
{p.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
<Typography variant="caption">{p.location}</Typography>
{p.rating != null && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} />
<Typography variant="caption">{p.rating.toFixed(1)}</Typography>
</Box>
)}
</Box>
</Box>
{p.startingPrice != null && (
<Box sx={{ flexShrink: 0, textAlign: 'right', pl: 1 }}>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
From
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: p.verified ? 'primary.main' : 'text.primary' }}
>
${p.startingPrice.toLocaleString('en-AU')}
</Typography>
</Box>
)}
</ButtonBase>
);
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Bottom drawer that surfaces `ProviderMap`'s popup content outside the
* map itself. Used by the mobile map-first layout (see D045): the map
* runs full-bleed, and when a pin or cluster is tapped the drawer slides
* up from the bottom with the appropriate content.
*
* **Two content states, driven by `active`:**
* - `active.provider` → renders a `ProviderCard` edge-to-edge, entire card
* clickable (fires `onSelectProvider`)
* - `active.cluster` → renders a verified-first list of rows (verified icon
* slot + name + location + rating + "From $X"); tapping a row fires
* `onDrillIntoProvider` which is wired to the map's imperative
* `drillIntoProvider()` (pans + zooms, then swaps `active` to that
* provider — the drawer content flips to the single-provider card).
*
* **Animation:** slides up via `transform: translateY()` + 220ms transition.
* When `active.exiting` is true, the drawer slides down immediately (the
* map organism is in the middle of its 180ms exit fade on the hidden pin
* beneath). `visibility: hidden` kicks in only after the slide completes,
* so the drawer stays in the DOM for the exit animation.
*
* **Positioning:** uses `position: absolute; bottom: 0; left: 0; right: 0`
* by default — the consumer MUST render this inside a relatively-positioned
* container (typically the map-view `<main>`). Override via `sx` if needed.
*
* Related: row layout mirrors `ClusterPopup` (the anchored on-map variant);
* future consolidation possible if both container contracts converge.
*/
export const MapProviderDrawer = React.forwardRef<HTMLDivElement, MapProviderDrawerProps>(
({ active, onClose, onSelectProvider, onDrillIntoProvider, sx }, ref) => {
const provider = active?.provider ?? null;
const cluster = active?.cluster ?? null;
const isOpen = !!(active && !active.exiting && (provider || cluster));
const isExiting = !!active?.exiting;
const ariaLabel = provider
? `${provider.name} details`
: cluster
? `${cluster.providers.length} providers in this area`
: undefined;
return (
<Paper
ref={ref}
elevation={0}
role="dialog"
aria-label={ariaLabel}
aria-hidden={!isOpen}
sx={[
(t) => ({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
// Sit above the floating CompareBar (which uses zIndex.drawer)
// so that when a pin or cluster is active the drawer visually
// covers the bar, not vice versa.
zIndex: t.zIndex.modal,
maxHeight: '60vh',
overflow: 'auto',
borderRadius: 0,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
boxShadow: 'var(--fa-shadow-lg)',
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 220ms ease-out',
pointerEvents: isOpen ? 'auto' : 'none',
visibility: isOpen || isExiting ? 'visible' : 'hidden',
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Header strip — holds the close X (and the cluster count when
applicable) so neither sits over the card image below.
Horizontal padding matches the cluster rows (px: 2) so the
heading aligns with the row content beneath. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 40,
px: 2,
py: 0.5,
gap: 1,
bgcolor: 'var(--fa-color-surface-subtle)',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
{cluster && !provider && (
<Typography variant="labelLg" sx={{ color: 'text.secondary', display: 'block' }}>
{cluster.providers.length} providers in this area
</Typography>
)}
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{
ml: 'auto',
width: 32,
height: 32,
color: 'text.secondary',
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</Box>
{/* Single-provider content — card is display-only; a CTA button
below handles navigation to the provider's packages. */}
{provider && (
<Box>
<ProviderCard
name={provider.name}
location={provider.location}
verified={provider.verified}
imageUrl={provider.imageUrl}
logoUrl={provider.logoUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
startingPrice={provider.startingPrice}
sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }}
/>
<Box sx={{ px: 2, pb: 2, pt: 1 }}>
<Button variant="contained" fullWidth onClick={() => onSelectProvider(provider.id)}>
View Packages
</Button>
</Box>
</Box>
)}
{/* Cluster list content — tap a row to drill in */}
{cluster && !provider && (
<Box sx={{ pb: 1 }}>
{[...cluster.providers]
.sort((a, b) => Number(!!b.verified) - Number(!!a.verified))
.map((p) => (
<ClusterRow key={p.id} provider={p} onClick={() => onDrillIntoProvider(p.id)} />
))}
</Box>
)}
</Paper>
);
},
);
MapProviderDrawer.displayName = 'MapProviderDrawer';
export default MapProviderDrawer;

View File

@@ -0,0 +1 @@
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';

View File

@@ -0,0 +1,166 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MiniCard } from './MiniCard';
// Placeholder images for stories
const IMG_PROVIDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=240&fit=crop&auto=format';
const IMG_VENUE =
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=400&h=240&fit=crop&auto=format';
const IMG_CHAPEL =
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=240&fit=crop&auto=format';
const IMG_GARDEN =
'https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=400&h=240&fit=crop&auto=format';
const meta: Meta<typeof MiniCard> = {
title: 'Molecules/MiniCard',
component: MiniCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<Box sx={{ width: 240 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof MiniCard>;
/** Default — verified provider with image, location, and price */
export const Default: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
price: 900,
location: 'Wollongong',
},
};
/** With all optional fields populated */
export const FullyLoaded: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
location: 'Wollongong',
rating: 4.8,
price: 900,
badges: [{ label: 'Online Arrangement', color: 'success' }],
chips: ['Burial', 'Cremation'],
},
};
/** Unverified provider — no badge in image */
export const Unverified: Story = {
args: {
title: 'Smith & Sons Funeral Services',
imageUrl: IMG_VENUE,
price: 1200,
location: 'Sutherland',
},
};
/** Venue card usage — capacity instead of rating */
export const Venue: Story = {
args: {
title: 'Albany Creek Memorial Park',
imageUrl: IMG_CHAPEL,
price: 450,
location: 'Albany Creek',
capacity: 120,
},
};
/** Package card usage — custom price label */
export const Package: Story = {
args: {
title: 'Essential Cremation Package',
imageUrl: IMG_GARDEN,
priceLabel: 'From $2,800',
badges: [{ label: 'Most Popular', color: 'brand' }],
},
};
/** Minimal — just title and image */
export const Minimal: Story = {
args: {
title: 'Lady Anne Funerals',
imageUrl: IMG_VENUE,
},
};
/** Selected state — brand border + warm background */
export const Selected: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
price: 900,
location: 'Wollongong',
selected: true,
},
};
/** Long title — truncated at 2 lines, hover tooltip shows full text */
export const LongTitle: Story = {
args: {
title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_GARDEN,
verified: true,
location: 'Northern Beaches',
rating: 4.9,
price: 1200,
},
};
/** Multiple cards in a responsive grid — mix of verified and unverified */
export const Grid: Story = {
decorators: [
(Story) => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 2,
width: 680,
}}
>
<Story />
</Box>
),
],
render: () => (
<>
<MiniCard
title="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
verified
location="Wollongong"
rating={4.8}
price={900}
chips={['Burial', 'Cremation']}
onClick={() => {}}
/>
<MiniCard
title="Albany Creek Memorial Park"
imageUrl={IMG_CHAPEL}
location="Albany Creek"
capacity={120}
price={450}
onClick={() => {}}
/>
<MiniCard
title="Lady Anne Funerals"
imageUrl={IMG_VENUE}
location="Sutherland Shire"
onClick={() => {}}
/>
</>
),
};

View File

@@ -0,0 +1,311 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import type { SxProps, Theme } from '@mui/material/styles';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
import type { BadgeProps } from '../../atoms/Badge/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A badge to render inside the MiniCard content area */
export interface MiniCardBadge {
/** Label text */
label: string;
/** Badge colour intent */
color?: BadgeProps['color'];
/** Badge variant */
variant?: BadgeProps['variant'];
/** Optional leading icon */
icon?: React.ReactNode;
}
/** Props for the FA MiniCard molecule */
export interface MiniCardProps {
/** Card title — provider name, venue name, package name, etc. */
title: string;
/** Hero image URL */
imageUrl: string;
/** Alt text for the image — defaults to title */
imageAlt?: string;
/** Whether this provider/venue is verified — shows icon badge in image */
verified?: boolean;
/** Price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA", "Included") — overrides formatted price */
priceLabel?: string;
/** Location text (suburb, city) */
location?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Venue capacity (e.g. 120) */
capacity?: number;
/** Badge items rendered after the price row */
badges?: MiniCardBadge[];
/** Chip labels rendered as small soft badges (after badges) */
chips?: string[];
/** Whether this card is currently selected */
selected?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const IMAGE_HEIGHT = 'var(--fa-mini-card-image-height)';
const CONTENT_PADDING = 'var(--fa-mini-card-content-padding)';
const CONTENT_GAP = 'var(--fa-mini-card-content-gap)';
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Compact vertical card for the FA design system.
*
* A smaller, flexible card for displaying providers, venues, or packages
* in grids, recommendation rows, and map popups. Shows an image with
* a title and optional meta, price, badges, and chips.
*
* Content hierarchy: **title → meta → price → chips/badges**.
*
* Verified providers show a small icon-only badge floating in the
* image (top-right). Truncated titles show a tooltip on hover with
* the full text.
*
* Composes: Card + Typography + Badge + Tooltip.
*
* Usage:
* ```tsx
* <MiniCard
* title="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* verified
* price={900}
* location="Wollongong"
* rating={4.8}
* onClick={() => navigate('/providers/parsons')}
* />
* ```
*/
export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
(
{
title,
imageUrl,
imageAlt,
verified = false,
price,
priceLabel,
location,
rating,
capacity,
badges,
chips,
selected = false,
onClick,
sx,
},
ref,
) => {
const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null;
// Detect title truncation for tooltip
const titleRef = React.useRef<HTMLElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const el = titleRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
}
}, [title]);
return (
<Card
ref={ref}
interactive={!!onClick}
selected={selected}
padding="none"
onClick={onClick}
sx={[
{
overflow: 'hidden',
'&:hover': {
backgroundColor: 'background.paper',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Image ── */}
<Box
role="img"
aria-label={imageAlt ?? title}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)',
}}
>
{/* Verified icon badge — floating top-right */}
{verified && (
<Tooltip title="Verified provider" arrow placement="top">
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: 'var(--fa-color-brand-600)',
color: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
</Box>
</Tooltip>
)}
</Box>
{/* ── Content ── */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: CONTENT_GAP,
p: CONTENT_PADDING,
}}
>
{/* 1. Title — with tooltip when truncated */}
<Tooltip
title={isTruncated ? title : ''}
arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={titleRef} variant="h6" maxLines={2}>
{title}
</Typography>
</Tooltip>
{/* 2. Meta row: location / rating / capacity */}
{hasMeta && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexWrap: 'wrap',
}}
>
{location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{location}
</Typography>
</Box>
)}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
aria-label={`Rated ${rating} out of 5`}
>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{rating}
</Typography>
</Box>
)}
{capacity != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PeopleOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{capacity} guests
</Typography>
</Box>
)}
</Box>
)}
{/* 3. Price */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="body2" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography
variant="body2"
component="span"
color="primary"
sx={{ fontWeight: 600 }}
>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* 4. Badges */}
{badges && badges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{badges.map((badge) => (
<Badge
key={badge.label}
color={badge.color}
variant={badge.variant}
size="small"
icon={badge.icon}
>
{badge.label}
</Badge>
))}
</Box>
)}
{/* 5. Chips */}
{chips && chips.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chips.map((chip) => (
<Badge key={chip} color="default" variant="soft" size="small">
{chip}
</Badge>
))}
</Box>
)}
</Box>
</Card>
);
},
);
MiniCard.displayName = 'MiniCard';
export default MiniCard;

View File

@@ -0,0 +1,2 @@
export { MiniCard, default } from './MiniCard';
export type { MiniCardProps, MiniCardBadge } from './MiniCard';

View File

@@ -0,0 +1,439 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ProviderCard } from './ProviderCard';
import Box from '@mui/material/Box';
// Placeholder images for stories
const HERO_PLACEHOLDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600&h=300&fit=crop&auto=format';
const HERO_PLACEHOLDER_2 =
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=600&h=300&fit=crop&auto=format';
const HERO_PLACEHOLDER_3 =
'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=600&h=300&fit=crop&auto=format';
// Rounded-rect logo placeholder (matches new logo shape)
const LOGO_PLACEHOLDER =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="12" fill="%23E8E8E8"/><text x="48" y="54" text-anchor="middle" font-family="sans-serif" font-size="14" fill="%23737373">Logo</text></svg>',
);
const meta: Meta<typeof ProviderCard> = {
title: 'Molecules/ProviderCard',
component: ProviderCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=5369-140263',
},
},
argTypes: {
name: { control: 'text' },
location: { control: 'text' },
verified: { control: 'boolean' },
rating: { control: { type: 'number', min: 0, max: 5, step: 0.1 } },
reviewCount: { control: 'number' },
capabilityLabel: { control: 'text' },
capabilityColor: {
control: 'select',
options: ['default', 'success', 'warning', 'error', 'info'],
},
capabilityDescription: { control: 'text' },
startingPrice: { control: 'number' },
onClick: { action: 'clicked' },
},
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ProviderCard>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default — verified provider with all fields */
export const Default: Story = {
args: {
name: 'H.Parsons Funeral Directors',
location: 'Wollongong',
verified: true,
imageUrl: HERO_PLACEHOLDER,
logoUrl: LOGO_PLACEHOLDER,
rating: 4.8,
reviewCount: 127,
capabilityLabel: 'Online Arrangement',
capabilityColor: 'success',
capabilityDescription:
'Complete your arrangement entirely online — no in-person visit required.',
startingPrice: 900,
},
};
// ─── Verified Provider ──────────────────────────────────────────────────────
/** Full verified provider card with all elements */
export const VerifiedProvider: Story = {
name: 'Verified Provider',
render: () => (
<ProviderCard
name="Parsons Ladies Funeral Directors"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={900}
onClick={() => {}}
/>
),
};
// ─── Unverified Provider ────────────────────────────────────────────────────
/** Unverified provider — text only with top accent bar, no image/logo/trusted badge */
export const UnverifiedProvider: Story = {
name: 'Unverified Provider',
render: () => (
<ProviderCard
name="Rankins Funeral's Heathcote"
location="Heathcote"
rating={4.2}
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
capabilityDescription="This provider uses a third-party service to manage arrangements."
startingPrice={1200}
onClick={() => {}}
/>
),
};
// ─── List Layout ────────────────────────────────────────────────────────────
/**
* Mixed verified and unverified providers in a scrollable list on
* neutral.50 background. This is the primary use case — text alignment
* should be consistent across both card types for scan readability.
* Unverified cards have a top accent bar for visibility.
*/
export const ListLayout: Story = {
name: 'List Layout — Mixed',
decorators: [
(Story) => (
<Box
sx={{
width: 400,
maxHeight: 700,
overflow: 'auto',
backgroundColor: 'var(--fa-color-surface-subtle)',
p: 2,
borderRadius: 1,
}}
>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<ProviderCard
name="Parsons Ladies Funeral Directors"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={900}
onClick={() => {}}
/>
<ProviderCard
name="H.Parsons Wollongong"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER_2}
logoUrl={LOGO_PLACEHOLDER}
rating={4.6}
reviewCount={89}
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={1100}
onClick={() => {}}
/>
<ProviderCard
name="Rankins Funeral's Heathcote"
location="Heathcote"
rating={4.2}
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
capabilityDescription="This provider uses a third-party service to manage arrangements."
startingPrice={1200}
onClick={() => {}}
/>
<ProviderCard
name="Stella Armois Funerals"
location="Corrimal"
rating={3.9}
reviewCount={18}
capabilityLabel="Partial Arrangement"
capabilityColor="warning"
capabilityDescription="Some steps can be completed online, but an in-person visit is required to finalise."
startingPrice={950}
onClick={() => {}}
/>
<ProviderCard
name="St. Angelo of God Funeral Directors"
location="Fairy Meadow"
verified
imageUrl={HERO_PLACEHOLDER_3}
logoUrl={LOGO_PLACEHOLDER}
rating={4.9}
reviewCount={203}
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={1500}
onClick={() => {}}
/>
</Box>
),
};
// ─── Capability Variants ────────────────────────────────────────────────────
/** Three capability badge colours with hover tooltips */
export const CapabilityVariants: Story = {
name: 'Capability Variants',
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<ProviderCard
name="Online Provider"
location="Sydney"
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={900}
onClick={() => {}}
/>
<ProviderCard
name="Partial Provider"
location="Melbourne"
capabilityLabel="Partial Arrangement"
capabilityColor="warning"
capabilityDescription="Some steps can be completed online, but an in-person visit is required to finalise."
startingPrice={1100}
onClick={() => {}}
/>
<ProviderCard
name="Outsourced Provider"
location="Brisbane"
capabilityLabel="Outsourced"
capabilityColor="default"
capabilityDescription="This provider uses a third-party service to manage arrangements."
startingPrice={800}
onClick={() => {}}
/>
</Box>
),
};
// ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Edge cases: long name, missing fields, extremes */
export const EdgeCases: Story = {
name: 'Edge Cases',
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Long name — tests maxLines truncation */}
<ProviderCard
name="The Most Honourable and Distinguished Funeral Directors of the Greater Wollongong Metropolitan Area"
location="Wollongong"
rating={4.5}
reviewCount={67}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={2500}
onClick={() => {}}
/>
{/* No reviews */}
<ProviderCard
name="New Provider"
location="Perth"
capabilityLabel="Partial Arrangement"
capabilityColor="warning"
startingPrice={750}
onClick={() => {}}
/>
{/* No capability badge */}
<ProviderCard
name="Basic Listing"
location="Adelaide"
rating={3.2}
reviewCount={5}
startingPrice={600}
onClick={() => {}}
/>
{/* No price */}
<ProviderCard
name="Price Unavailable"
location="Darwin"
rating={4.0}
reviewCount={12}
capabilityLabel="Outsourced"
capabilityColor="default"
onClick={() => {}}
/>
{/* Minimal — just name and location */}
<ProviderCard name="Minimal Card" location="Hobart" onClick={() => {}} />
</Box>
),
};
// ─── Responsive ─────────────────────────────────────────────────────────────
/** Cards at different viewport widths */
export const Responsive: Story = {
decorators: [
(Story) => (
<Box sx={{ width: 'auto' }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', gap: 3, alignItems: 'start', flexWrap: 'wrap' }}>
{[280, 340, 420].map((width) => (
<Box key={width} sx={{ width }}>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>{width}px</Box>
<ProviderCard
name="H.Parsons Funeral Directors"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={900}
onClick={() => {}}
/>
</Box>
))}
</Box>
),
};
// ─── On Different Backgrounds ───────────────────────────────────────────────
/** Cards on white vs grey surfaces — unverified on grey is the expected usage */
export const OnDifferentBackgrounds: Story = {
name: 'On Different Backgrounds',
decorators: [
(Story) => (
<Box sx={{ width: 'auto' }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ width: 360, p: 3, backgroundColor: 'background.default' }}>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>White surface</Box>
<ProviderCard
name="H.Parsons"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
startingPrice={900}
onClick={() => {}}
/>
</Box>
<Box sx={{ width: 360, p: 3, backgroundColor: 'var(--fa-color-surface-subtle)' }}>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>Grey surface (neutral.50)</Box>
<ProviderCard
name="Rankins Funerals"
location="Heathcote"
rating={4.2}
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
startingPrice={1200}
onClick={() => {}}
/>
</Box>
</Box>
),
};
// ─── Interactive Demo ───────────────────────────────────────────────────────
/** Click any card — fires onClick in Storybook actions panel */
export const InteractiveDemo: Story = {
name: 'Interactive — Click to Navigate',
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<ProviderCard
name="Parsons Ladies Funeral Directors"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={900}
onClick={() => alert('Navigate to Parsons Ladies packages')}
/>
<ProviderCard
name="Rankins Funeral's Heathcote"
location="Heathcote"
rating={4.2}
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
startingPrice={1200}
onClick={() => alert('Navigate to Rankins packages')}
/>
</Box>
),
};

View File

@@ -0,0 +1,276 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import type { SxProps, Theme } from '@mui/material/styles';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { Card } from '../../atoms/Card';
import { Badge } from '../../atoms/Badge';
import type { BadgeColor } from '../../atoms/Badge/Badge';
import { Typography } from '../../atoms/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Own props for the FA ProviderCard molecule (excludes HTML/Card passthrough) */
export interface ProviderCardOwnProps {
/** Provider display name */
name: string;
/** Location text (suburb, city) */
location: string;
/** Whether this provider is a verified/trusted partner */
verified?: boolean;
/** Hero image URL — only rendered when verified */
imageUrl?: string;
/** Provider logo URL — rounded rectangle overlay on image, only rendered when verified */
logoUrl?: string;
/** Average rating (e.g. 4.8). Omit to hide reviews. */
rating?: number;
/** Number of reviews (e.g. 127). Omit to hide review count. */
reviewCount?: number;
/** Capability badge label (e.g. "Online Arrangement") */
capabilityLabel?: string;
/** Capability badge colour intent — maps to Badge colour */
capabilityColor?: BadgeColor;
/** Tooltip description for the capability badge (shown on hover/focus) */
capabilityDescription?: string;
/** Starting price in dollars (shown as "From $X") */
startingPrice?: number;
/** Whether this card is the currently selected provider */
selected?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
/** Props for the FA ProviderCard molecule — includes HTML/ARIA passthrough to Card */
export type ProviderCardProps = ProviderCardOwnProps &
Omit<React.HTMLAttributes<HTMLDivElement>, keyof ProviderCardOwnProps>;
// ─── Constants ───────────────────────────────────────────────────────────────
const LOGO_SIZE = 'var(--fa-provider-card-logo-size)';
const LOGO_BORDER_RADIUS = 'var(--fa-provider-card-logo-border-radius)';
const IMAGE_HEIGHT = 'var(--fa-provider-card-image-height)';
const CONTENT_PADDING = 'var(--fa-provider-card-content-padding)';
const CONTENT_GAP = 'var(--fa-provider-card-content-gap)';
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Provider listing card for the FA design system.
*
* Displays a funeral provider in the provider select screen's scrollable
* list. Supports verified (paid partner) and unverified (scraped listing)
* providers with consistent text alignment for scan readability.
*
* **Verified providers** get a hero image, logo (rounded rectangle inside
* image area), and "Verified" badge. **Unverified providers** show text
* content only with a subtle top accent bar for visibility in mixed lists.
*
* Composes: Card (interactive, padding="none"), Badge, Typography.
*
* Usage:
* ```tsx
* <ProviderCard
* name="H.Parsons Funeral Directors"
* location="Wollongong"
* verified
* imageUrl="/images/parsons-hero.jpg"
* logoUrl="/images/parsons-logo.png"
* rating={4.8}
* reviewCount={127}
* capabilityLabel="Online Arrangement"
* capabilityColor="success"
* startingPrice={900}
* onClick={() => navigate(`/providers/parsons`)}
* />
* ```
*/
export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
(
{
name,
location,
verified = false,
imageUrl,
logoUrl,
rating,
reviewCount,
capabilityLabel,
capabilityColor = 'default',
capabilityDescription,
selected = false,
startingPrice,
onClick,
sx,
...rest
},
ref,
) => {
const showImage = verified && imageUrl;
const showLogo = verified && logoUrl;
return (
<Card
ref={ref}
interactive
selected={selected}
padding="none"
onClick={onClick}
{...rest}
sx={[
{
overflow: 'hidden',
// Override Card's default hover bg fill — shadow lift is enough
// for listing cards. The grey bg fill blends into the shadow.
'&:hover': {
backgroundColor: 'background.paper',
},
// Unverified cards: subtle top accent so they don't get lost
// in a mixed list. Verified cards have the hero image as anchor.
...(!showImage && {
borderTop: '3px solid var(--fa-color-border-default)',
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Image area (verified only) ── */}
{showImage && (
<Box
role="img"
aria-label={`Photo of ${name}`}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-brand-50)', // warm fallback if image fails
}}
>
{/* Verified badge */}
<Box sx={{ position: 'absolute', top: 12, right: 12 }}>
<Badge variant="filled" color="brand" size="medium" icon={<VerifiedOutlinedIcon />}>
Verified
</Badge>
</Box>
{/* Logo — fully inside image area, bottom-left */}
{showLogo && (
<Box
component="img"
src={logoUrl}
alt={`${name} logo`}
sx={{
position: 'absolute',
bottom: 12,
left: CONTENT_PADDING,
width: LOGO_SIZE,
height: LOGO_SIZE,
borderRadius: LOGO_BORDER_RADIUS,
// 'contain' so wide/tall logos scale proportionally inside
// the square slot rather than cropping. Background fills any
// letterboxed space so it still reads as a tile.
objectFit: 'contain',
backgroundColor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
border: '2px solid var(--fa-color-white)',
}}
/>
)}
</Box>
)}
{/* ── Content area ── */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: CONTENT_GAP,
p: CONTENT_PADDING,
}}
>
{/* Provider name — full width, no logo competition */}
<Typography variant="h5" maxLines={2}>
{name}
</Typography>
{/* Price — "Packages from $X" with subtle size differentiation */}
{startingPrice != null && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Packages from
</Typography>
<Typography
variant="h6"
component="span"
color="primary"
sx={{ fontWeight: 500, letterSpacing: '-0.01em' }}
>
${startingPrice.toLocaleString('en-AU')}
</Typography>
</Box>
)}
{/* Meta row: location + reviews */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: 'wrap',
}}
>
{/* Location */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} />
<Typography variant="caption" color="text.secondary">
{location}
</Typography>
</Box>
{/* Reviews */}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
aria-label={`Rated ${rating} out of 5${reviewCount != null ? `, ${reviewCount} reviews` : ''}`}
>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{rating}
{reviewCount != null && ` (${reviewCount.toLocaleString('en-AU')})`}
</Typography>
</Box>
)}
</Box>
{/* Capability badge — trailing info icon signals hover-for-definition */}
{capabilityLabel && (
<Box>
{capabilityDescription ? (
<Tooltip title={capabilityDescription} arrow placement="top" enterTouchDelay={0}>
<Badge color={capabilityColor} size="medium" sx={{ cursor: 'help' }}>
{capabilityLabel}
<InfoOutlinedIcon />
</Badge>
</Tooltip>
) : (
<Badge color={capabilityColor} size="medium">
{capabilityLabel}
<InfoOutlinedIcon />
</Badge>
)}
</Box>
)}
</Box>
</Card>
);
},
);
ProviderCard.displayName = 'ProviderCard';
export default ProviderCard;

View File

@@ -0,0 +1,2 @@
export { ProviderCard, default } from './ProviderCard';
export type { ProviderCardProps } from './ProviderCard';

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ProviderCardCompact } from './ProviderCardCompact';
const DEMO_IMAGE =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
const meta: Meta<typeof ProviderCardCompact> = {
title: 'Molecules/ProviderCardCompact',
component: ProviderCardCompact,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<Box sx={{ maxWidth: 480, width: '100%' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ProviderCardCompact>;
// --- Default -----------------------------------------------------------------
/** Compact provider card with image, name, location, and rating */
export const Default: Story = {
args: {
name: 'H.Parsons',
location: 'Wentworth',
imageUrl: DEMO_IMAGE,
rating: 4.5,
reviewCount: 11,
},
};
// --- Without Image -----------------------------------------------------------
/** Provider without a photo — text only */
export const WithoutImage: Story = {
args: {
name: 'Smith & Sons Funerals',
location: 'Parramatta',
rating: 4.2,
reviewCount: 38,
},
};
// --- Without Rating ----------------------------------------------------------
/** Provider without reviews */
export const WithoutRating: Story = {
args: {
name: 'Peaceful Rest Funerals',
location: 'Mildura',
imageUrl: DEMO_IMAGE,
},
};
// --- Interactive -------------------------------------------------------------
/** Clickable — navigates back to provider details */
export const Interactive: Story = {
args: {
name: 'H.Parsons',
location: 'Wentworth',
imageUrl: DEMO_IMAGE,
rating: 4.5,
reviewCount: 11,
onClick: () => alert('Navigate to provider details'),
},
};
// --- In Context --------------------------------------------------------------
/** As it appears at the top of the Package Select page */
export const InContext: Story = {
render: () => (
<Box>
<Box
component="button"
onClick={() => {}}
sx={{
background: 'none',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 0.5,
mb: 3,
p: 0,
color: 'text.secondary',
fontFamily: 'inherit',
fontSize: '1rem',
'&:hover': { color: 'text.primary' },
}}
>
Back
</Box>
<Box sx={{ typography: 'h3', mb: 3 }}>Select a package</Box>
<ProviderCardCompact
name="H.Parsons"
location="Wentworth"
imageUrl={DEMO_IMAGE}
rating={4.5}
reviewCount={11}
onClick={() => alert('View provider')}
/>
</Box>
),
};

Some files were not shown because too many files have changed in this diff Show More