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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user