Initial commit: FA 2.0 Design System foundation
Token pipeline (Style Dictionary v4, DTCG format): - Primitive tokens: colour palettes (brand, sage, neutral, feedback), typography (3 font families, 21-variant type scale), spacing (4px grid), border radius, shadows, opacity - Semantic tokens: text, surface, border, interactive, feedback colours; typography roles; layout spacing - Component tokens: Button (4 sizes), Input (2 sizes) - Generated outputs: CSS custom properties, JS ES6 module, flat JSON Atoms (3 components): - Button: contained/soft/outlined/text × primary/secondary, 4 sizes, loading state, underline for text variant - Typography: 21 variants across display/heading/body/label/caption/overline, maxLines truncation - Input: external label, helper text, error/success validation, start/end icons, required indicator, 2 sizes, multiline support Infrastructure: - MUI v5 theme with full token mapping - Storybook 8 with autodocs - Claude Code agents and skills for token/component workflows - Design system documentation and cross-session memory Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
365
src/components/atoms/Button/Button.stories.tsx
Normal file
365
src/components/atoms/Button/Button.stories.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
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>
|
||||
),
|
||||
};
|
||||
79
src/components/atoms/Button/Button.tsx
Normal file
79
src/components/atoms/Button/Button.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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)
|
||||
*/
|
||||
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}
|
||||
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}
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</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';
|
||||
506
src/components/atoms/Input/Input.stories.tsx
Normal file
506
src/components/atoms/Input/Input.stories.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
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>
|
||||
),
|
||||
};
|
||||
184
src/components/atoms/Input/Input.tsx
Normal file
184
src/components/atoms/Input/Input.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
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';
|
||||
|
||||
// ─── 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: '10px',
|
||||
// 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: Record<string, any>) =>
|
||||
`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: '6px',
|
||||
// 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';
|
||||
345
src/components/atoms/Typography/Typography.stories.tsx
Normal file
345
src/components/atoms/Typography/Typography.stories.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
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';
|
||||
Reference in New Issue
Block a user