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:
346
src/components/atoms/Badge/Badge.stories.tsx
Normal file
346
src/components/atoms/Badge/Badge.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
140
src/components/atoms/Badge/Badge.tsx
Normal file
140
src/components/atoms/Badge/Badge.tsx
Normal 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;
|
||||
2
src/components/atoms/Badge/index.ts
Normal file
2
src/components/atoms/Badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Badge, type BadgeProps, type BadgeColor } from './Badge';
|
||||
export { default } from './Badge';
|
||||
429
src/components/atoms/Button/Button.stories.tsx
Normal file
429
src/components/atoms/Button/Button.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
96
src/components/atoms/Button/Button.tsx
Normal file
96
src/components/atoms/Button/Button.tsx
Normal 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;
|
||||
3
src/components/atoms/Button/index.ts
Normal file
3
src/components/atoms/Button/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Button';
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps } from './Button';
|
||||
493
src/components/atoms/Card/Card.stories.tsx
Normal file
493
src/components/atoms/Card/Card.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
127
src/components/atoms/Card/Card.tsx
Normal file
127
src/components/atoms/Card/Card.tsx
Normal 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;
|
||||
2
src/components/atoms/Card/index.ts
Normal file
2
src/components/atoms/Card/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Card, type CardProps } from './Card';
|
||||
export { default } from './Card';
|
||||
144
src/components/atoms/Checkbox/Checkbox.stories.tsx
Normal file
144
src/components/atoms/Checkbox/Checkbox.stories.tsx
Normal 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 />;
|
||||
},
|
||||
};
|
||||
35
src/components/atoms/Checkbox/Checkbox.tsx
Normal file
35
src/components/atoms/Checkbox/Checkbox.tsx
Normal 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;
|
||||
2
src/components/atoms/Checkbox/index.ts
Normal file
2
src/components/atoms/Checkbox/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Checkbox, default } from './Checkbox';
|
||||
export type { CheckboxProps } from './Checkbox';
|
||||
383
src/components/atoms/Chip/Chip.stories.tsx
Normal file
383
src/components/atoms/Chip/Chip.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
72
src/components/atoms/Chip/Chip.tsx
Normal file
72
src/components/atoms/Chip/Chip.tsx
Normal 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;
|
||||
2
src/components/atoms/Chip/index.ts
Normal file
2
src/components/atoms/Chip/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Chip, default } from './Chip';
|
||||
export type { ChipProps } from './Chip';
|
||||
77
src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx
Normal file
77
src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal 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;
|
||||
1
src/components/atoms/ClusterMarker/index.ts
Normal file
1
src/components/atoms/ClusterMarker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';
|
||||
165
src/components/atoms/Collapse/Collapse.stories.tsx
Normal file
165
src/components/atoms/Collapse/Collapse.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
43
src/components/atoms/Collapse/Collapse.tsx
Normal file
43
src/components/atoms/Collapse/Collapse.tsx
Normal 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;
|
||||
2
src/components/atoms/Collapse/index.ts
Normal file
2
src/components/atoms/Collapse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './Collapse';
|
||||
export * from './Collapse';
|
||||
137
src/components/atoms/DialogShell/DialogShell.stories.tsx
Normal file
137
src/components/atoms/DialogShell/DialogShell.stories.tsx
Normal 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'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't need actions.
|
||||
</Typography>
|
||||
</DialogShell>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
174
src/components/atoms/DialogShell/DialogShell.tsx
Normal file
174
src/components/atoms/DialogShell/DialogShell.tsx
Normal 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;
|
||||
2
src/components/atoms/DialogShell/index.ts
Normal file
2
src/components/atoms/DialogShell/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DialogShell } from './DialogShell';
|
||||
export type { DialogShellProps } from './DialogShell';
|
||||
117
src/components/atoms/Divider/Divider.stories.tsx
Normal file
117
src/components/atoms/Divider/Divider.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
39
src/components/atoms/Divider/Divider.tsx
Normal file
39
src/components/atoms/Divider/Divider.tsx
Normal 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;
|
||||
2
src/components/atoms/Divider/index.ts
Normal file
2
src/components/atoms/Divider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Divider, default } from './Divider';
|
||||
export type { DividerProps } from './Divider';
|
||||
186
src/components/atoms/IconButton/IconButton.stories.tsx
Normal file
186
src/components/atoms/IconButton/IconButton.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
38
src/components/atoms/IconButton/IconButton.tsx
Normal file
38
src/components/atoms/IconButton/IconButton.tsx
Normal 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;
|
||||
2
src/components/atoms/IconButton/index.ts
Normal file
2
src/components/atoms/IconButton/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { IconButton, default } from './IconButton';
|
||||
export type { IconButtonProps } from './IconButton';
|
||||
501
src/components/atoms/Input/Input.stories.tsx
Normal file
501
src/components/atoms/Input/Input.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
190
src/components/atoms/Input/Input.tsx
Normal file
190
src/components/atoms/Input/Input.tsx
Normal 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;
|
||||
2
src/components/atoms/Input/index.ts
Normal file
2
src/components/atoms/Input/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Input, default } from './Input';
|
||||
export type { InputProps } from './Input';
|
||||
170
src/components/atoms/Link/Link.stories.tsx
Normal file
170
src/components/atoms/Link/Link.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
36
src/components/atoms/Link/Link.tsx
Normal file
36
src/components/atoms/Link/Link.tsx
Normal 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;
|
||||
2
src/components/atoms/Link/index.ts
Normal file
2
src/components/atoms/Link/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Link, default } from './Link';
|
||||
export type { LinkProps } from './Link';
|
||||
100
src/components/atoms/MapPin/MapPin.stories.tsx
Normal file
100
src/components/atoms/MapPin/MapPin.stories.tsx
Normal 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>
|
||||
</>
|
||||
),
|
||||
};
|
||||
237
src/components/atoms/MapPin/MapPin.tsx
Normal file
237
src/components/atoms/MapPin/MapPin.tsx
Normal 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;
|
||||
2
src/components/atoms/MapPin/index.ts
Normal file
2
src/components/atoms/MapPin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MapPin, default } from './MapPin';
|
||||
export type { MapPinProps } from './MapPin';
|
||||
177
src/components/atoms/Radio/Radio.stories.tsx
Normal file
177
src/components/atoms/Radio/Radio.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
40
src/components/atoms/Radio/Radio.tsx
Normal file
40
src/components/atoms/Radio/Radio.tsx
Normal 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;
|
||||
2
src/components/atoms/Radio/index.ts
Normal file
2
src/components/atoms/Radio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Radio, default } from './Radio';
|
||||
export type { RadioProps } from './Radio';
|
||||
179
src/components/atoms/Switch/Switch.stories.tsx
Normal file
179
src/components/atoms/Switch/Switch.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
37
src/components/atoms/Switch/Switch.tsx
Normal file
37
src/components/atoms/Switch/Switch.tsx
Normal 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;
|
||||
2
src/components/atoms/Switch/index.ts
Normal file
2
src/components/atoms/Switch/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Switch, default } from './Switch';
|
||||
export type { SwitchProps } from './Switch';
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
242
src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx
Normal file
242
src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx
Normal 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;
|
||||
2
src/components/atoms/ToggleButtonGroup/index.ts
Normal file
2
src/components/atoms/ToggleButtonGroup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './ToggleButtonGroup';
|
||||
export * from './ToggleButtonGroup';
|
||||
433
src/components/atoms/Typography/Typography.stories.tsx
Normal file
433
src/components/atoms/Typography/Typography.stories.tsx
Normal 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 (h1–h6), 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
63
src/components/atoms/Typography/Typography.tsx
Normal file
63
src/components/atoms/Typography/Typography.tsx
Normal 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;
|
||||
3
src/components/atoms/Typography/index.ts
Normal file
3
src/components/atoms/Typography/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Typography';
|
||||
export { Typography } from './Typography';
|
||||
export type { TypographyProps } from './Typography';
|
||||
242
src/components/molecules/AddOnOption/AddOnOption.stories.tsx
Normal file
242
src/components/molecules/AddOnOption/AddOnOption.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
213
src/components/molecules/AddOnOption/AddOnOption.tsx
Normal file
213
src/components/molecules/AddOnOption/AddOnOption.tsx
Normal 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;
|
||||
2
src/components/molecules/AddOnOption/index.ts
Normal file
2
src/components/molecules/AddOnOption/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AddOnOption } from './AddOnOption';
|
||||
export type { AddOnOptionProps } from './AddOnOption';
|
||||
97
src/components/molecules/CartButton/CartButton.stories.tsx
Normal file
97
src/components/molecules/CartButton/CartButton.stories.tsx
Normal 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',
|
||||
},
|
||||
};
|
||||
175
src/components/molecules/CartButton/CartButton.tsx
Normal file
175
src/components/molecules/CartButton/CartButton.tsx
Normal 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;
|
||||
2
src/components/molecules/CartButton/index.ts
Normal file
2
src/components/molecules/CartButton/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './CartButton';
|
||||
export * from './CartButton';
|
||||
114
src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx
Normal file
114
src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx
Normal 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: () => {},
|
||||
},
|
||||
};
|
||||
360
src/components/molecules/ClusterPopup/ClusterPopup.tsx
Normal file
360
src/components/molecules/ClusterPopup/ClusterPopup.tsx
Normal 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;
|
||||
1
src/components/molecules/ClusterPopup/index.ts
Normal file
1
src/components/molecules/ClusterPopup/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup';
|
||||
196
src/components/molecules/CompareBar/CompareBar.stories.tsx
Normal file
196
src/components/molecules/CompareBar/CompareBar.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
235
src/components/molecules/CompareBar/CompareBar.tsx
Normal file
235
src/components/molecules/CompareBar/CompareBar.tsx
Normal 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;
|
||||
2
src/components/molecules/CompareBar/index.ts
Normal file
2
src/components/molecules/CompareBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CompareBar, default } from './CompareBar';
|
||||
export type { CompareBarProps, CompareBarPackage } from './CompareBar';
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
2
src/components/molecules/ComparisonColumnCard/index.ts
Normal file
2
src/components/molecules/ComparisonColumnCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonColumnCard, default } from './ComparisonColumnCard';
|
||||
export type { ComparisonColumnCardProps } from './ComparisonColumnCard';
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
2
src/components/molecules/ComparisonPackageCard/index.ts
Normal file
2
src/components/molecules/ComparisonPackageCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonPackageCard, default } from './ComparisonPackageCard';
|
||||
export type { ComparisonPackageCardProps } from './ComparisonPackageCard';
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
155
src/components/molecules/ComparisonTabCard/ComparisonTabCard.tsx
Normal file
155
src/components/molecules/ComparisonTabCard/ComparisonTabCard.tsx
Normal 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;
|
||||
2
src/components/molecules/ComparisonTabCard/index.ts
Normal file
2
src/components/molecules/ComparisonTabCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonTabCard, default } from './ComparisonTabCard';
|
||||
export type { ComparisonTabCardProps } from './ComparisonTabCard';
|
||||
97
src/components/molecules/FilterPanel/FilterPanel.stories.tsx
Normal file
97
src/components/molecules/FilterPanel/FilterPanel.stories.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
107
src/components/molecules/FilterPanel/FilterPanel.tsx
Normal file
107
src/components/molecules/FilterPanel/FilterPanel.tsx
Normal 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;
|
||||
3
src/components/molecules/FilterPanel/index.ts
Normal file
3
src/components/molecules/FilterPanel/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { FilterPanel } from './FilterPanel';
|
||||
export type { FilterPanelProps } from './FilterPanel';
|
||||
export { default } from './FilterPanel';
|
||||
32
src/components/molecules/HelpBar/HelpBar.stories.tsx
Normal file
32
src/components/molecules/HelpBar/HelpBar.stories.tsx
Normal 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' },
|
||||
};
|
||||
64
src/components/molecules/HelpBar/HelpBar.tsx
Normal file
64
src/components/molecules/HelpBar/HelpBar.tsx
Normal 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;
|
||||
1
src/components/molecules/HelpBar/index.ts
Normal file
1
src/components/molecules/HelpBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HelpBar, type HelpBarProps } from './HelpBar';
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
164
src/components/molecules/ImageGallery/ImageGallery.tsx
Normal file
164
src/components/molecules/ImageGallery/ImageGallery.tsx
Normal 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;
|
||||
2
src/components/molecules/ImageGallery/index.ts
Normal file
2
src/components/molecules/ImageGallery/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ImageGallery, default } from './ImageGallery';
|
||||
export type { ImageGalleryProps, GalleryImage } from './ImageGallery';
|
||||
197
src/components/molecules/LineItem/LineItem.stories.tsx
Normal file
197
src/components/molecules/LineItem/LineItem.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
124
src/components/molecules/LineItem/LineItem.tsx
Normal file
124
src/components/molecules/LineItem/LineItem.tsx
Normal 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;
|
||||
1
src/components/molecules/LineItem/index.ts
Normal file
1
src/components/molecules/LineItem/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LineItem, type LineItemProps } from './LineItem';
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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;
|
||||
1
src/components/molecules/LocationSearchInput/index.ts
Normal file
1
src/components/molecules/LocationSearchInput/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LocationSearchInput, type LocationSearchInputProps } from './LocationSearchInput';
|
||||
138
src/components/molecules/MapPopup/MapPopup.stories.tsx
Normal file
138
src/components/molecules/MapPopup/MapPopup.stories.tsx
Normal 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 />
|
||||
</>
|
||||
),
|
||||
};
|
||||
325
src/components/molecules/MapPopup/MapPopup.tsx
Normal file
325
src/components/molecules/MapPopup/MapPopup.tsx
Normal 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;
|
||||
2
src/components/molecules/MapPopup/index.ts
Normal file
2
src/components/molecules/MapPopup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MapPopup, default } from './MapPopup';
|
||||
export type { MapPopupProps } from './MapPopup';
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
267
src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx
Normal file
267
src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx
Normal 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;
|
||||
1
src/components/molecules/MapProviderDrawer/index.ts
Normal file
1
src/components/molecules/MapProviderDrawer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';
|
||||
166
src/components/molecules/MiniCard/MiniCard.stories.tsx
Normal file
166
src/components/molecules/MiniCard/MiniCard.stories.tsx
Normal 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={() => {}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
};
|
||||
311
src/components/molecules/MiniCard/MiniCard.tsx
Normal file
311
src/components/molecules/MiniCard/MiniCard.tsx
Normal 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;
|
||||
2
src/components/molecules/MiniCard/index.ts
Normal file
2
src/components/molecules/MiniCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MiniCard, default } from './MiniCard';
|
||||
export type { MiniCardProps, MiniCardBadge } from './MiniCard';
|
||||
439
src/components/molecules/ProviderCard/ProviderCard.stories.tsx
Normal file
439
src/components/molecules/ProviderCard/ProviderCard.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
276
src/components/molecules/ProviderCard/ProviderCard.tsx
Normal file
276
src/components/molecules/ProviderCard/ProviderCard.tsx
Normal 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;
|
||||
2
src/components/molecules/ProviderCard/index.ts
Normal file
2
src/components/molecules/ProviderCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProviderCard, default } from './ProviderCard';
|
||||
export type { ProviderCardProps } from './ProviderCard';
|
||||
@@ -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
Reference in New Issue
Block a user