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

View File

@@ -3,30 +3,37 @@ import { cn } from '@/lib/utils'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'tertiary' variant?: 'primary' | 'secondary' | 'tertiary'
color?: 'navy' | 'red' | 'light' | 'surface' intent?: 'default' | 'danger' | 'subtle' | 'neutral'
size?: 'default' | 'comfortable' | 'compact' size?: 'default' | 'comfortable' | 'compact'
loading?: boolean
leftIcon?: React.ReactNode leftIcon?: React.ReactNode
rightIcon?: React.ReactNode rightIcon?: React.ReactNode
} }
const variantColorStyles: Record<string, Record<string, string>> = { const variantIntentStyles: Record<string, Record<string, string>> = {
primary: { primary: {
navy: 'bg-blue-01 text-white hover:bg-blue-01/90 active:bg-blue-01/80', default: 'bg-button-default text-white hover:bg-button-default/90 active:bg-button-default/80',
red: 'bg-red-02 text-white hover:bg-red-02/90 active:bg-red-02/80', danger: 'bg-button-danger text-white hover:bg-button-danger/90 active:bg-button-danger/80',
light: 'bg-blue-04 text-blue-01 hover:bg-blue-04/80 active:bg-blue-04/60', subtle:
surface: 'bg-grey-01 text-white hover:bg-grey-01/90 active:bg-grey-01/80', '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: { secondary: {
navy: 'border-2 border-blue-01 text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10', default:
red: 'border-2 border-red-02 text-red-02 hover:bg-red-02/5 active:bg-red-02/10', 'border-2 border-button-default text-button-default hover:bg-button-default/5 active:bg-button-default/10',
light: 'border-2 border-blue-01 text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10', danger:
surface: 'border-2 border-grey-01 text-grey-01 hover:bg-grey-01/5 active:bg-grey-01/10', '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: { tertiary: {
navy: 'text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10', default: 'text-button-default hover:bg-button-default/5 active:bg-button-default/10',
red: 'text-red-02 hover:bg-red-02/5 active:bg-red-02/10', danger: 'text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
light: 'text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10', subtle:
surface: 'text-grey-01 hover:bg-grey-01/5 active:bg-grey-01/10', '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', 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>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
( (
{ {
variant = 'primary', variant = 'primary',
color = 'navy', intent = 'default',
size = 'default', size = 'default',
loading = false,
leftIcon, leftIcon,
rightIcon, rightIcon,
disabled, disabled,
@@ -57,26 +81,40 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
}, },
ref, ref,
) => { ) => {
const isDisabled = disabled || loading
return ( return (
<button <button
ref={ref} ref={ref}
disabled={disabled} disabled={isDisabled}
aria-busy={loading || undefined}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-full font-bold 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-blue-01', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
sizeStyles[size], sizeStyles[size],
variantColorStyles[variant][color], variantIntentStyles[variant][intent],
disabled && 'pointer-events-none opacity-50', disabled && 'pointer-events-none opacity-50',
loading && 'pointer-events-none',
className, className,
)} )}
{...props} {...props}
> >
{leftIcon && ( {loading ? (
<span className={cn('shrink-0', iconSizeStyles[size])}>{leftIcon}</span> <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} {children}
{rightIcon && ( {!loading && rightIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{rightIcon}</span> <span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
{rightIcon}
</span>
)} )}
</button> </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', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95', 'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50', '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} {...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', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95', 'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50', '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} {...props}
/> />

View File

@@ -82,6 +82,13 @@
--color-control-bg: var(--color-white); --color-control-bg: var(--color-white);
--color-control-bg-readonly: var(--color-off-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 */
--radius-sm: 4px; --radius-sm: 4px;
--radius-default: 6px; --radius-default: 6px;