Add Button component with NSW DS variants, colours, sizes, and icons

Pill-shaped button with three variants (primary/secondary/tertiary),
four colour schemes (navy/red/light/surface), three sizes, and optional
left/right icon slots. 17 Storybook stories cover all combinations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 11:56:03 +10:00
parent ba796fb247
commit 40d53f86dd
3 changed files with 284 additions and 25 deletions

View File

@@ -8,40 +8,179 @@ const meta: Meta<typeof Button> = {
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
options: ['primary', 'secondary', 'tertiary'],
},
color: {
control: 'select',
options: ['navy', 'red', 'light', 'surface'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
options: ['default', 'comfortable', 'compact'],
},
disabled: { control: 'boolean' },
children: { control: 'text' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=10-20',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: { children: 'Primary Button', variant: 'primary' },
export const Default: Story = {
args: { children: 'Button' },
}
export const Secondary: Story = {
args: { children: 'Secondary Button', variant: 'secondary' },
export const PrimaryNavy: Story = {
args: { children: 'Button', variant: 'primary', color: 'navy' },
}
export const Danger: Story = {
args: { children: 'Delete', variant: 'danger' },
export const PrimaryRed: Story = {
args: { children: 'Delete', variant: 'primary', color: 'red' },
}
export const Small: Story = {
args: { children: 'Small', size: 'sm' },
export const PrimaryLight: Story = {
args: { children: 'Button', variant: 'primary', color: 'light' },
}
export const Large: Story = {
args: { children: 'Large', size: 'lg' },
export const PrimarySurface: Story = {
args: { children: 'Button', variant: 'primary', color: 'surface' },
}
export const SecondaryNavy: Story = {
args: { children: 'Button', variant: 'secondary', color: 'navy' },
}
export const SecondaryRed: Story = {
args: { children: 'Cancel', variant: 'secondary', color: 'red' },
}
export const SecondarySurface: Story = {
args: { children: 'Button', variant: 'secondary', color: 'surface' },
}
export const TertiaryNavy: Story = {
args: { children: 'Button', variant: 'tertiary', color: 'navy' },
}
export const TertiaryRed: Story = {
args: { children: 'Remove', variant: 'tertiary', color: 'red' },
}
export const TertiarySurface: Story = {
args: { children: 'Button', variant: 'tertiary', color: 'surface' },
}
const ArrowIcon = () => (
<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="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
)
const LockIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)
export const WithLeftIcon: Story = {
args: { children: 'Button', leftIcon: <LockIcon /> },
}
export const WithRightIcon: Story = {
args: { children: 'Button', rightIcon: <ArrowIcon /> },
}
export const WithBothIcons: Story = {
args: {
children: 'Button',
leftIcon: <LockIcon />,
rightIcon: <ArrowIcon />,
},
}
export const Comfortable: Story = {
args: { children: 'Button', size: 'comfortable' },
}
export const Compact: Story = {
args: { children: 'Button', size: 'compact' },
}
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Button size="default">Default</Button>
<Button size="comfortable">Comfortable</Button>
<Button size="compact">Compact</Button>
</div>
),
}
export const AllVariants: Story = {
render: () => (
<div className="flex items-center gap-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="tertiary">Tertiary</Button>
</div>
),
}
export const AllColours: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Button color="navy">Navy</Button>
<Button color="red">Red</Button>
<Button color="light">Light</Button>
<Button color="surface">Surface</Button>
</div>
<div className="flex items-center gap-4">
<Button variant="secondary" color="navy">Navy</Button>
<Button variant="secondary" color="red">Red</Button>
<Button variant="secondary" color="light">Light</Button>
<Button variant="secondary" color="surface">Surface</Button>
</div>
<div className="flex items-center gap-4">
<Button variant="tertiary" color="navy">Navy</Button>
<Button variant="tertiary" color="red">Red</Button>
<Button variant="tertiary" color="light">Light</Button>
<Button variant="tertiary" color="surface">Surface</Button>
</div>
</div>
),
}
export const Disabled: Story = {
args: { children: 'Disabled', disabled: true },
render: () => (
<div className="flex items-center gap-4">
<Button disabled>Primary</Button>
<Button variant="secondary" disabled>Secondary</Button>
<Button variant="tertiary" disabled>Tertiary</Button>
</div>
),
}

View File

@@ -2,30 +2,82 @@ import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
variant?: 'primary' | 'secondary' | 'tertiary'
color?: 'navy' | 'red' | 'light' | 'surface'
size?: 'default' | 'comfortable' | 'compact'
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
const variantColorStyles: Record<string, Record<string, string>> = {
primary: {
navy: 'bg-blue-01 text-white hover:bg-blue-01/90 active:bg-blue-01/80',
red: 'bg-red-02 text-white hover:bg-red-02/90 active:bg-red-02/80',
light: 'bg-blue-04 text-blue-01 hover:bg-blue-04/80 active:bg-blue-04/60',
surface: 'bg-grey-01 text-white hover:bg-grey-01/90 active:bg-grey-01/80',
},
secondary: {
navy: 'border-2 border-blue-01 text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
red: 'border-2 border-red-02 text-red-02 hover:bg-red-02/5 active:bg-red-02/10',
light: 'border-2 border-blue-01 text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
surface: 'border-2 border-grey-01 text-grey-01 hover:bg-grey-01/5 active:bg-grey-01/10',
},
tertiary: {
navy: 'text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
red: 'text-red-02 hover:bg-red-02/5 active:bg-red-02/10',
light: 'text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
surface: 'text-grey-01 hover:bg-grey-01/5 active:bg-grey-01/10',
},
}
const sizeStyles: Record<string, string> = {
default: 'h-12 px-6 text-body gap-2',
comfortable: 'h-10 px-5 text-body gap-2',
compact: 'h-9 px-4 text-small gap-1.5',
}
const iconSizeStyles: Record<string, string> = {
default: 'size-6',
comfortable: 'size-5',
compact: 'size-5',
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', children, ...props }, ref) => {
(
{
variant = 'primary',
color = 'navy',
size = 'default',
leftIcon,
rightIcon,
disabled,
className,
children,
...props
},
ref,
) => {
return (
<button
ref={ref}
disabled={disabled}
className={cn(
'inline-flex items-center justify-center font-medium rounded-default transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
'disabled:opacity-50 disabled:cursor-not-allowed',
variant === 'primary' && 'bg-primary text-white hover:bg-primary-hover',
variant === 'secondary' && 'bg-surface text-text border border-border hover:bg-bg',
variant === 'danger' && 'bg-error text-white hover:bg-red-700',
size === 'sm' && 'px-3 py-1.5 text-sm',
size === 'md' && 'px-4 py-2 text-base',
size === 'lg' && 'px-6 py-3 text-lg',
'inline-flex items-center justify-center rounded-full font-bold transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-01',
sizeStyles[size],
variantColorStyles[variant][color],
disabled && 'pointer-events-none opacity-50',
className,
)}
{...props}
>
{leftIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{leftIcon}</span>
)}
{children}
{rightIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{rightIcon}</span>
)}
</button>
)
},