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:
68
plans/button.md
Normal file
68
plans/button.md
Normal 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
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user