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: {
|
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>
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user