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