Add Chip atom component

- Interactive tag for filtering, selection, and dismissible labels
- Wraps MUI Chip with FA tokens: 12 component tokens (height, padding, font, icon sizes)
- Two variants (filled/outlined) × two colours (default/primary) × two sizes (sm/md)
- Custom `selected` prop promotes to brand colour with warm bg (outlined)
- MUI theme overrides: soft tonal fills, branded outlines, hover/focus states
- 10 Storybook stories including interactive filter and removable tag demos
- Preflight passed all 5 checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:45:24 +11:00
parent 1188eef96e
commit 21877ce4e6
10 changed files with 631 additions and 2 deletions

View File

@@ -0,0 +1,361 @@
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>
);
},
};