Refactor Button to semantic tokens and intent prop, fix error focus rings

Replace palette token references (blue-01, red-02, grey-01) with dedicated
button domain tokens. Rename `color` prop to `intent` with semantic values
(default, danger, subtle, neutral). Add loading state with spinner and
aria-busy. Fix Checkbox and Radio error states leaking teal focus ring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 14:19:36 +10:00
parent 3e7de78721
commit c00335ef84
5 changed files with 119 additions and 77 deletions

View File

@@ -10,14 +10,15 @@ const meta: Meta<typeof Button> = {
control: 'select',
options: ['primary', 'secondary', 'tertiary'],
},
color: {
intent: {
control: 'select',
options: ['navy', 'red', 'light', 'surface'],
options: ['default', 'danger', 'subtle', 'neutral'],
},
size: {
control: 'select',
options: ['default', 'comfortable', 'compact'],
},
loading: { control: 'boolean' },
disabled: { control: 'boolean' },
children: { control: 'text' },
},
@@ -36,45 +37,25 @@ export const Default: Story = {
args: { children: 'Button' },
}
export const PrimaryNavy: Story = {
args: { children: 'Button', variant: 'primary', color: 'navy' },
// --- Intents ---
export const IntentDefault: Story = {
args: { children: 'Submit', intent: 'default' },
}
export const PrimaryRed: Story = {
args: { children: 'Delete', variant: 'primary', color: 'red' },
export const IntentDanger: Story = {
args: { children: 'Delete', intent: 'danger' },
}
export const PrimaryLight: Story = {
args: { children: 'Button', variant: 'primary', color: 'light' },
export const IntentSubtle: Story = {
args: { children: 'Learn more', intent: 'subtle' },
}
export const PrimarySurface: Story = {
args: { children: 'Button', variant: 'primary', color: 'surface' },
export const IntentNeutral: Story = {
args: { children: 'Cancel', intent: 'neutral' },
}
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' },
}
// --- Icons ---
const ArrowIcon = () => (
<svg
@@ -107,29 +88,37 @@ const LockIcon = () => (
)
export const WithLeftIcon: Story = {
args: { children: 'Button', leftIcon: <LockIcon /> },
args: { children: 'Secure', leftIcon: <LockIcon /> },
}
export const WithRightIcon: Story = {
args: { children: 'Button', rightIcon: <ArrowIcon /> },
args: { children: 'Continue', rightIcon: <ArrowIcon /> },
}
export const WithBothIcons: Story = {
args: {
children: 'Button',
children: 'Secure',
leftIcon: <LockIcon />,
rightIcon: <ArrowIcon />,
},
}
export const Comfortable: Story = {
args: { children: 'Button', size: 'comfortable' },
// --- Loading ---
export const Loading: Story = {
args: { children: 'Submitting...', loading: true },
}
export const Compact: Story = {
args: { children: 'Button', size: 'compact' },
export const LoadingDanger: Story = {
args: { children: 'Deleting...', loading: true, intent: 'danger' },
}
export const LoadingSecondary: Story = {
args: { children: 'Loading...', loading: true, variant: 'secondary' },
}
// --- Sizes ---
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-4">
@@ -140,6 +129,8 @@ export const AllSizes: Story = {
),
}
// --- Variants ---
export const AllVariants: Story = {
render: () => (
<div className="flex items-center gap-4">
@@ -150,31 +141,35 @@ export const AllVariants: Story = {
),
}
export const AllColours: Story = {
// --- Full matrix ---
export const AllIntents: 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>
<Button intent="default">Default</Button>
<Button intent="danger">Danger</Button>
<Button intent="subtle">Subtle</Button>
<Button intent="neutral">Neutral</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>
<Button variant="secondary" intent="default">Default</Button>
<Button variant="secondary" intent="danger">Danger</Button>
<Button variant="secondary" intent="subtle">Subtle</Button>
<Button variant="secondary" intent="neutral">Neutral</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>
<Button variant="tertiary" intent="default">Default</Button>
<Button variant="tertiary" intent="danger">Danger</Button>
<Button variant="tertiary" intent="subtle">Subtle</Button>
<Button variant="tertiary" intent="neutral">Neutral</Button>
</div>
</div>
),
}
// --- Disabled ---
export const Disabled: Story = {
render: () => (
<div className="flex items-center gap-4">

View File

@@ -3,30 +3,37 @@ import { cn } from '@/lib/utils'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'tertiary'
color?: 'navy' | 'red' | 'light' | 'surface'
intent?: 'default' | 'danger' | 'subtle' | 'neutral'
size?: 'default' | 'comfortable' | 'compact'
loading?: boolean
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
const variantColorStyles: Record<string, Record<string, string>> = {
const variantIntentStyles: 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',
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',
subtle:
'bg-button-subtle-bg text-button-subtle-text hover:bg-button-subtle-bg/80 active:bg-button-subtle-bg/60',
neutral: 'bg-button-neutral text-white hover:bg-button-neutral/90 active:bg-button-neutral/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',
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',
subtle:
'border border-button-default/40 text-button-subtle-text hover:bg-button-default/5 active:bg-button-default/10',
neutral:
'border-2 border-button-neutral text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/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',
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',
subtle:
'text-button-subtle-text/70 hover:text-button-subtle-text hover:bg-button-default/5 active:bg-button-default/10',
neutral: 'text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
},
}
@@ -42,12 +49,29 @@ const iconSizeStyles: Record<string, string> = {
compact: 'size-5',
}
const Spinner = ({ className }: { className?: string }) => (
<svg
className={cn('animate-spin', className)}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
color = 'navy',
intent = 'default',
size = 'default',
loading = false,
leftIcon,
rightIcon,
disabled,
@@ -57,26 +81,40 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
},
ref,
) => {
const isDisabled = disabled || loading
return (
<button
ref={ref}
disabled={disabled}
disabled={isDisabled}
aria-busy={loading || undefined}
className={cn(
'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',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
sizeStyles[size],
variantColorStyles[variant][color],
variantIntentStyles[variant][intent],
disabled && 'pointer-events-none opacity-50',
loading && 'pointer-events-none',
className,
)}
{...props}
>
{leftIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{leftIcon}</span>
{loading ? (
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
<Spinner />
</span>
) : (
leftIcon && (
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
{leftIcon}
</span>
)
)}
{children}
{rightIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{rightIcon}</span>
{!loading && rightIcon && (
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
{rightIcon}
</span>
)}
</button>
)

View File

@@ -64,7 +64,8 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50',
hasError && 'border-control-error checked:border-control-error checked:bg-control-error',
hasError &&
'border-control-error hover:border-control-error checked:border-control-error checked:bg-control-error checked:hover:border-control-error checked:hover:bg-control-error focus-visible:ring-red-03',
)}
{...props}
/>

View File

@@ -168,7 +168,8 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50',
hasError && 'border-control-error checked:border-control-error',
hasError &&
'border-control-error hover:border-control-error checked:border-control-error checked:hover:border-control-error focus-visible:ring-red-03',
)}
{...props}
/>

View File

@@ -82,6 +82,13 @@
--color-control-bg: var(--color-white);
--color-control-bg-readonly: var(--color-off-white);
/* Button */
--color-button-default: var(--color-blue-01);
--color-button-danger: var(--color-red-02);
--color-button-neutral: var(--color-grey-01);
--color-button-subtle-bg: var(--color-blue-04);
--color-button-subtle-text: var(--color-blue-01);
/* Radius */
--radius-sm: 4px;
--radius-default: 6px;