Add Badge, Chip, and IconButton components with token layers

- Badge: 11 solid-colour variants (navy, info, success, error, warning + light variants, neutral, white) with icon slots
- Chip: bordered interactive element with selected, dismissible, and disabled states
- IconButton: circle/square shapes, 3 variants × 3 intents × 3 sizes, uses lucide-react icons
- Add badge and chip domain token layers to tokens.css
- Configure extendTailwindMerge in cn() to recognise custom font-size utilities (text-small, text-body, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:01:06 +10:00
parent c00335ef84
commit e025c0eb34
13 changed files with 710 additions and 1 deletions

11
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-storybook": "^10.4.0", "eslint-plugin-storybook": "^10.4.0",
"globals": "^17.6.0", "globals": "^17.6.0",
"lucide-react": "^1.16.0",
"playwright": "^1.60.0", "playwright": "^1.60.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0", "prettier-plugin-tailwindcss": "^0.8.0",
@@ -5167,6 +5168,16 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -41,6 +41,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-storybook": "^10.4.0", "eslint-plugin-storybook": "^10.4.0",
"globals": "^17.6.0", "globals": "^17.6.0",
"lucide-react": "^1.16.0",
"playwright": "^1.60.0", "playwright": "^1.60.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0", "prettier-plugin-tailwindcss": "^0.8.0",

View File

@@ -0,0 +1,173 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Badge } from './Badge'
const meta: Meta<typeof Badge> = {
title: 'UI/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: [
'navy',
'info',
'info-light',
'success',
'success-light',
'error',
'error-light',
'warning',
'warning-light',
'neutral',
'white',
],
},
children: { control: 'text' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=78-1035',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { children: 'Status here' },
}
// --- Variants ---
export const Navy: Story = {
args: { children: 'Primary', variant: 'navy' },
}
export const Info: Story = {
args: { children: 'Information', variant: 'info' },
}
export const InfoLight: Story = {
args: { children: 'Information', variant: 'info-light' },
}
export const Success: Story = {
args: { children: 'Complete', variant: 'success' },
}
export const SuccessLight: Story = {
args: { children: 'Complete', variant: 'success-light' },
}
export const Error: Story = {
args: { children: 'Failed', variant: 'error' },
}
export const ErrorLight: Story = {
args: { children: 'Failed', variant: 'error-light' },
}
export const Warning: Story = {
args: { children: 'Pending', variant: 'warning' },
}
export const WarningLight: Story = {
args: { children: 'Pending', variant: 'warning-light' },
}
export const Neutral: Story = {
args: { children: 'Draft', variant: 'neutral' },
}
export const White: Story = {
args: { children: 'Default', variant: 'white' },
}
// --- Icons ---
const CheckIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
)
const CalendarIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
)
export const WithIcons: Story = {
args: {
children: 'Status here',
variant: 'info',
leftIcon: <CheckIcon />,
rightIcon: <CalendarIcon />,
},
}
// --- All variants ---
export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Badge variant="navy">Navy</Badge>
<Badge variant="info">Info</Badge>
<Badge variant="info-light">Info light</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="success-light">Success light</Badge>
<Badge variant="error">Error</Badge>
<Badge variant="error-light">Error light</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="warning-light">Warning light</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="white">White</Badge>
</div>
),
}
export const StrongVariants: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Badge variant="navy">Navy</Badge>
<Badge variant="info">Info</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="error">Error</Badge>
<Badge variant="warning">Warning</Badge>
</div>
),
}
export const LightVariants: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Badge variant="info-light">Info</Badge>
<Badge variant="success-light">Success</Badge>
<Badge variant="error-light">Error</Badge>
<Badge variant="warning-light">Warning</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="white">White</Badge>
</div>
),
}

View File

@@ -0,0 +1,61 @@
import type { HTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?:
| 'navy'
| 'info'
| 'info-light'
| 'success'
| 'success-light'
| 'error'
| 'error-light'
| 'warning'
| 'warning-light'
| 'neutral'
| 'white'
leftIcon?: ReactNode
rightIcon?: ReactNode
}
const variantStyles: Record<string, string> = {
navy: 'bg-badge-navy text-white',
info: 'bg-badge-info text-white',
'info-light': 'bg-badge-info-light text-primary-dark',
success: 'bg-badge-success text-white',
'success-light': 'bg-badge-success-light text-badge-on-success-light',
error: 'bg-badge-error text-white',
'error-light': 'bg-badge-error-light text-badge-on-error-light',
warning: 'bg-badge-warning text-white',
'warning-light': 'bg-badge-warning-light text-badge-on-warning-light',
neutral: 'bg-badge-neutral text-text-secondary',
white: 'bg-surface text-primary-dark border border-primary-dark',
}
export function Badge({
variant = 'info',
leftIcon,
rightIcon,
className,
children,
...props
}: BadgeProps) {
return (
<span
className={cn(
'inline-flex h-[30px] items-center gap-1 rounded-lg px-3 text-small font-bold leading-[19px]',
variantStyles[variant],
className,
)}
{...props}
>
{leftIcon && (
<span className="shrink-0 [&>svg]:size-full size-[18px]">{leftIcon}</span>
)}
{children}
{rightIcon && (
<span className="shrink-0 [&>svg]:size-full size-[18px]">{rightIcon}</span>
)}
</span>
)
}

View File

@@ -0,0 +1,2 @@
export { Badge } from './Badge'
export type { BadgeProps } from './Badge'

View File

@@ -0,0 +1,118 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Chip } from './Chip'
const meta: Meta<typeof Chip> = {
title: 'UI/Chip',
component: Chip,
tags: ['autodocs'],
argTypes: {
selected: { control: 'boolean' },
disabled: { control: 'boolean' },
children: { control: 'text' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=63-6084',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { children: 'Label' },
}
// --- Interactive ---
export const Clickable: Story = {
args: { children: 'Filter', onClick: () => {} },
}
export const Selected: Story = {
args: { children: 'Active filter', selected: true, onClick: () => {} },
}
export const Disabled: Story = {
args: { children: 'Unavailable', disabled: true, onClick: () => {} },
}
export const DisabledSelected: Story = {
args: { children: 'Locked', disabled: true, selected: true, onClick: () => {} },
}
// --- Dismissible ---
export const Dismissible: Story = {
args: { children: 'Remove me', onDismiss: () => {} },
}
// --- Custom icon ---
const DropdownIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
)
export const WithDropdownIcon: Story = {
args: { children: 'Category', rightIcon: <DropdownIcon />, onClick: () => {} },
}
// --- Toggle example ---
export const ToggleGroup: Story = {
render: () => {
const labels = ['Qualitative', 'Quantitative', 'Mixed methods']
const [selected, setSelected] = useState<Set<string>>(new Set())
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 (
<div className="flex flex-wrap gap-2">
{labels.map((label) => (
<Chip key={label} selected={selected.has(label)} onClick={() => toggle(label)}>
{label}
</Chip>
))}
</div>
)
},
}
// --- Dismissible list ---
export const DismissibleList: Story = {
render: () => {
const [items, setItems] = useState(['Education', 'Psychology', 'Sociology', 'Linguistics'])
return (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<Chip key={item} onDismiss={() => setItems((prev) => prev.filter((i) => i !== item))}>
{item}
</Chip>
))}
{items.length === 0 && <span className="text-small text-text-secondary">All removed</span>}
</div>
)
},
}

View File

@@ -0,0 +1,85 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
children: ReactNode
selected?: boolean
onDismiss?: () => void
rightIcon?: ReactNode
}
const DismissIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
)
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
({ children, selected = false, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => {
const isInteractive = !!(onClick || onDismiss)
const Component = isInteractive ? 'button' : 'span'
const trailing = rightIcon ?? (onDismiss ? <DismissIcon /> : null)
return (
<Component
ref={isInteractive ? ref : undefined}
type={isInteractive ? 'button' : undefined}
disabled={isInteractive ? disabled : undefined}
aria-pressed={isInteractive && selected ? true : undefined}
onClick={
isInteractive
? (e: React.MouseEvent<HTMLButtonElement>) => {
if (onDismiss && !rightIcon) {
onDismiss()
} else {
onClick?.(e)
}
}
: undefined
}
className={cn(
'inline-flex h-8 items-center gap-2 rounded-lg border px-3 py-1.5 text-small leading-[19px]',
selected
? 'border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text'
: 'border-chip-border bg-chip-bg text-chip-text',
isInteractive && !disabled && !selected && 'hover:bg-chip-border/5 active:bg-chip-border/10',
isInteractive && !disabled && selected && 'hover:bg-chip-selected-bg/90 active:bg-chip-selected-bg/80',
isInteractive && 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-chip-border',
disabled && 'pointer-events-none opacity-50',
className,
)}
{...(props as Record<string, unknown>)}
>
{children}
{trailing && (
<span
className="shrink-0 [&>svg]:size-full size-[18px]"
onClick={
onDismiss && onClick
? (e) => {
e.stopPropagation()
onDismiss()
}
: undefined
}
>
{trailing}
</span>
)}
</Component>
)
},
)
Chip.displayName = 'Chip'

View File

@@ -0,0 +1,2 @@
export { Chip } from './Chip'
export type { ChipProps } from './Chip'

View File

@@ -0,0 +1,139 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { X, Plus, Trash2, Menu, ChevronDown, Search, ArrowRight } from 'lucide-react'
import { IconButton } from './IconButton'
const meta: Meta<typeof IconButton> = {
title: 'UI/IconButton',
component: IconButton,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'tertiary'],
},
intent: {
control: 'select',
options: ['default', 'danger', 'neutral'],
},
size: {
control: 'select',
options: ['large', 'default', 'compact'],
},
shape: {
control: 'select',
options: ['circle', 'square'],
},
disabled: { control: 'boolean' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=81-1137',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { icon: <X />, 'aria-label': 'Close' },
}
// --- Intents ---
export const IntentDefault: Story = {
args: { icon: <Plus />, 'aria-label': 'Add', intent: 'default' },
}
export const IntentDanger: Story = {
args: { icon: <Trash2 />, 'aria-label': 'Delete', intent: 'danger' },
}
export const IntentNeutral: Story = {
args: { icon: <Menu />, 'aria-label': 'Menu', intent: 'neutral' },
}
// --- Shapes ---
export const Circle: Story = {
args: { icon: <Plus />, 'aria-label': 'Add', shape: 'circle' },
}
export const Square: Story = {
args: { icon: <Plus />, 'aria-label': 'Add', shape: 'square' },
}
export const SquareSecondary: Story = {
args: { icon: <ChevronDown />, 'aria-label': 'Expand', shape: 'square', variant: 'secondary' },
}
// --- Sizes ---
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<IconButton size="large" icon={<X />} aria-label="Close" />
<IconButton size="default" icon={<X />} aria-label="Close" />
<IconButton size="compact" icon={<X />} aria-label="Close" />
</div>
),
}
// --- Disabled ---
export const Disabled: Story = {
render: () => (
<div className="flex items-center gap-4">
<IconButton disabled icon={<X />} aria-label="Close" />
<IconButton disabled variant="secondary" icon={<X />} aria-label="Close" />
<IconButton disabled variant="tertiary" icon={<X />} aria-label="Close" />
</div>
),
}
// --- Full matrix ---
export const AllVariantsAndIntents: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<IconButton size="large" variant="primary" intent="default" icon={<Plus />} aria-label="Add" />
<IconButton size="large" variant="primary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
<IconButton size="large" variant="primary" intent="neutral" icon={<Menu />} aria-label="Menu" />
</div>
<div className="flex items-center gap-4">
<IconButton size="large" variant="secondary" intent="default" icon={<Search />} aria-label="Search" />
<IconButton size="large" variant="secondary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
<IconButton size="large" variant="secondary" intent="neutral" icon={<Menu />} aria-label="Menu" />
</div>
<div className="flex items-center gap-4">
<IconButton size="large" variant="tertiary" intent="default" icon={<ArrowRight />} aria-label="Next" />
<IconButton size="large" variant="tertiary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
<IconButton size="large" variant="tertiary" intent="neutral" icon={<Menu />} aria-label="Menu" />
</div>
</div>
),
}
export const SquareMatrix: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<IconButton shape="square" size="large" variant="primary" intent="default" icon={<Plus />} aria-label="Add" />
<IconButton shape="square" size="large" variant="primary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
<IconButton shape="square" size="large" variant="primary" intent="neutral" icon={<Menu />} aria-label="Menu" />
</div>
<div className="flex items-center gap-4">
<IconButton shape="square" size="large" variant="secondary" intent="default" icon={<Search />} aria-label="Search" />
<IconButton shape="square" size="large" variant="secondary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
<IconButton shape="square" size="large" variant="secondary" intent="neutral" icon={<Menu />} aria-label="Menu" />
</div>
<div className="flex items-center gap-4">
<IconButton shape="square" size="large" variant="tertiary" intent="default" icon={<ArrowRight />} aria-label="Next" />
<IconButton shape="square" size="large" variant="tertiary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
<IconButton shape="square" size="large" variant="tertiary" intent="neutral" icon={<Menu />} aria-label="Menu" />
</div>
</div>
),
}

View File

@@ -0,0 +1,83 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
variant?: 'primary' | 'secondary' | 'tertiary'
intent?: 'default' | 'danger' | 'neutral'
size?: 'default' | 'large' | 'compact'
shape?: 'circle' | 'square'
icon: ReactNode
'aria-label': string
}
const variantIntentStyles: Record<string, Record<string, string>> = {
primary: {
default: 'bg-button-default text-white hover:bg-button-default/90 active:bg-button-default/80',
danger: 'bg-button-danger text-white hover:bg-button-danger/90 active:bg-button-danger/80',
neutral: 'bg-button-neutral text-white hover:bg-button-neutral/90 active:bg-button-neutral/80',
},
secondary: {
default:
'border-2 border-button-default text-button-default hover:bg-button-default/5 active:bg-button-default/10',
danger:
'border-2 border-button-danger text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
neutral:
'border-2 border-button-neutral text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
},
tertiary: {
default: 'text-button-default hover:bg-button-default/5 active:bg-button-default/10',
danger: 'text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
neutral: 'text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
},
}
const sizeStyles: Record<string, string> = {
large: 'size-14',
default: 'size-12',
compact: 'size-10',
}
const iconSizeStyles: Record<string, string> = {
large: 'size-6',
default: 'size-6',
compact: 'size-[18px]',
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(
{
variant = 'primary',
intent = 'default',
size = 'default',
shape = 'circle',
icon,
disabled,
className,
...props
},
ref,
) => {
return (
<button
ref={ref}
disabled={disabled}
className={cn(
'inline-flex items-center justify-center transition-colors',
shape === 'circle' ? 'rounded-full' : 'rounded-lg',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
sizeStyles[size],
variantIntentStyles[variant][intent],
disabled && 'pointer-events-none opacity-50',
className,
)}
{...props}
>
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
{icon}
</span>
</button>
)
},
)
IconButton.displayName = 'IconButton'

View File

@@ -0,0 +1,2 @@
export { IconButton } from './IconButton'
export type { IconButtonProps } from './IconButton'

View File

@@ -1,5 +1,15 @@
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge' import { extendTailwindMerge } from 'tailwind-merge'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [
{ text: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'intro', 'body', 'small', 'caption'] },
],
},
},
})
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))

View File

@@ -89,6 +89,28 @@
--color-button-subtle-bg: var(--color-blue-04); --color-button-subtle-bg: var(--color-blue-04);
--color-button-subtle-text: var(--color-blue-01); --color-button-subtle-text: var(--color-blue-01);
/* Badge */
--color-badge-navy: var(--color-blue-01);
--color-badge-info: var(--color-blue-02);
--color-badge-info-light: var(--color-blue-04);
--color-badge-success: var(--color-green-02);
--color-badge-success-light: var(--color-green-04);
--color-badge-error: var(--color-red-02);
--color-badge-error-light: var(--color-red-04);
--color-badge-warning: var(--color-orange-02);
--color-badge-warning-light: var(--color-orange-04);
--color-badge-neutral: var(--color-grey-04);
--color-badge-on-success-light: var(--color-green-01);
--color-badge-on-error-light: var(--color-red-01);
--color-badge-on-warning-light: var(--color-orange-01);
/* Chip */
--color-chip-border: var(--color-blue-01);
--color-chip-text: var(--color-blue-01);
--color-chip-bg: var(--color-white);
--color-chip-selected-bg: var(--color-blue-01);
--color-chip-selected-text: var(--color-white);
/* Radius */ /* Radius */
--radius-sm: 4px; --radius-sm: 4px;
--radius-default: 6px; --radius-default: 6px;