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:
361
src/components/atoms/Chip/Chip.stories.tsx
Normal file
361
src/components/atoms/Chip/Chip.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user