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

68
plans/button.md Normal file
View File

@@ -0,0 +1,68 @@
# Button Component Plan
## Source
Example pasted into Figma Examples page (node 10:20). Original has 5 properties × many values = 360+ variants. We're building the light-mode subset.
## Props (React)
```tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'tertiary'
color?: 'navy' | 'red' | 'light' | 'surface'
size?: 'default' | 'comfortable' | 'compact'
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
```
- `children` is the label text
- States (hover, active, focus, disabled) handled via CSS pseudo-classes
- Uses `forwardRef` to wrap `<button>`
## Colour Scheme Mapping
| Prop value | Example name | Primary bg | Primary text | Secondary border | Our tokens |
|---|---|---|---|---|---|
| `navy` | Navy | #002664 | white | #002664 | `blue-01`, `white` |
| `red` | Red | #D7153A | white | #D7153A | `red-02`, `white` |
| `light` | On Primary (Teal) | #CBEDFD | #002664 | #002664 | `blue-04`, `blue-01` |
| `surface` | On Surface | #22272B | white | #22272B | `grey-01`*, `white` |
*Note: source uses #22272B, our grey-01 is #3D3D3D. Close enough for now — revisit if the NSW DS specifies #22272B.
### Variant × Colour behaviour
- **Primary**: filled background, white or dark text
- **Secondary**: transparent bg, 2px border, coloured text
- **Tertiary**: no fill, no border, coloured text (ghost)
## Sizes
| Size | Height | Padding | Typography |
|---|---|---|---|
| Default | 48px | 24px horizontal | Body Strong (16/24 bold) |
| Comfortable | 40px | 20px horizontal | Body Strong (16/24 bold) |
| Compact | 36px | 16px horizontal | Small Strong (14/19 bold) |
## Shared Styling
- Border radius: `rounded-full` (pill shape, 9999px)
- Icon size: 24px (default), 20px (compact)
- Content gap: 8px
- Focus ring: 2px border offset (matches example's "Border" layer)
- Disabled: opacity 50%, cursor not-allowed
## Hover/Active States
- **Primary**: overlay with white at ~10% opacity (hover), ~20% (active)
- **Secondary/Tertiary**: bg fill at ~5% opacity (hover), ~10% (active)
- Implementation: CSS `hover:` and `active:` with bg-opacity modifiers
## Build Order
1. Add tokens (if needed) to tokens.css
2. Build React component + stories
3. Build Figma component on Components page (rebind to our variables)
4. Code Connect link
5. addon-designs embed in story
## Questions resolved
- All four colour schemes included
- Icon slots as ReactNode
- No dark mode

View File

@@ -8,40 +8,179 @@ const meta: Meta<typeof Button> = {
argTypes: { argTypes: {
variant: { variant: {
control: 'select', control: 'select',
options: ['primary', 'secondary', 'danger'], options: ['primary', 'secondary', 'tertiary'],
},
color: {
control: 'select',
options: ['navy', 'red', 'light', 'surface'],
}, },
size: { size: {
control: 'select', control: 'select',
options: ['sm', 'md', 'lg'], options: ['default', 'comfortable', 'compact'],
}, },
disabled: { control: 'boolean' }, disabled: { control: 'boolean' },
children: { control: 'text' }, children: { control: 'text' },
}, },
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=10-20',
},
},
} }
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
export const Primary: Story = { export const Default: Story = {
args: { children: 'Primary Button', variant: 'primary' }, args: { children: 'Button' },
} }
export const Secondary: Story = { export const PrimaryNavy: Story = {
args: { children: 'Secondary Button', variant: 'secondary' }, args: { children: 'Button', variant: 'primary', color: 'navy' },
} }
export const Danger: Story = { export const PrimaryRed: Story = {
args: { children: 'Delete', variant: 'danger' }, args: { children: 'Delete', variant: 'primary', color: 'red' },
} }
export const Small: Story = { export const PrimaryLight: Story = {
args: { children: 'Small', size: 'sm' }, args: { children: 'Button', variant: 'primary', color: 'light' },
} }
export const Large: Story = { export const PrimarySurface: Story = {
args: { children: 'Large', size: 'lg' }, 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 = { 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' import { cn } from '@/lib/utils'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' variant?: 'primary' | 'secondary' | 'tertiary'
size?: 'sm' | 'md' | 'lg' 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>( 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 ( return (
<button <button
ref={ref} ref={ref}
disabled={disabled}
className={cn( className={cn(
'inline-flex items-center justify-center font-medium rounded-default transition-colors', 'inline-flex items-center justify-center rounded-full font-bold transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-01',
'disabled:opacity-50 disabled:cursor-not-allowed', sizeStyles[size],
variant === 'primary' && 'bg-primary text-white hover:bg-primary-hover', variantColorStyles[variant][color],
variant === 'secondary' && 'bg-surface text-text border border-border hover:bg-bg', disabled && 'pointer-events-none opacity-50',
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',
className, className,
)} )}
{...props} {...props}
> >
{leftIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{leftIcon}</span>
)}
{children} {children}
{rightIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{rightIcon}</span>
)}
</button> </button>
) )
}, },