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:
2026-03-25 15:08:15 +11:00
commit 732c872576
56 changed files with 12690 additions and 0 deletions

View 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>
),
};

View 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;

View File

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