feat: make Button polymorphic — renders <a> when href is provided

Discriminated union types (AsButton | AsAnchor), isAnchorProps type guard
eliminates all 'as' casts. as const satisfies for VARIANTS/SIZES lookup
tables. brutal-border replaces border-[3px] in ghost variant.
This commit is contained in:
Ilia Mashkov
2026-05-18 20:44:50 +03:00
parent 7e542597d0
commit ba7395cb32
2 changed files with 66 additions and 8 deletions
+27
View File
@@ -63,4 +63,31 @@ describe('Button', () => {
expect(screen.getByRole('button')).toHaveClass('w-full');
});
});
describe('as anchor', () => {
it('renders an anchor when href is provided', () => {
render(<Button href="/cv.pdf">Download</Button>);
expect(screen.getByRole('link', { name: 'Download' })).toBeInTheDocument();
});
it('sets href on the anchor', () => {
render(<Button href="/cv.pdf">Download</Button>);
expect(screen.getByRole('link')).toHaveAttribute('href', '/cv.pdf');
});
it('sets download attribute when provided', () => {
render(
<Button href="/cv.pdf" download>
Download
</Button>,
);
expect(screen.getByRole('link')).toHaveAttribute('download');
});
it('applies the same variant and size classes as button', () => {
render(
<Button href="/test" variant="primary" size="sm">
Go
</Button>,
);
const link = screen.getByRole('link');
expect(link).toHaveClass('bg-blue', 'px-4', 'py-2');
});
});
});
+39 -8
View File
@@ -1,10 +1,10 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '$shared/lib';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
type BaseProps = {
/**
* Visual variant
* @default 'primary'
@@ -19,9 +19,28 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
* Button content
*/
children: ReactNode;
/**
* CSS classes
*/
className?: string;
};
type AsButton = BaseProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: never };
type AsAnchor = BaseProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
type Props = AsButton | AsAnchor;
type RestButton = Omit<AsButton, keyof BaseProps>;
type RestAnchor = Omit<AsAnchor, keyof BaseProps>;
/**
* Narrows spread props to anchor shape when href is a non-undefined string.
*/
function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
return typeof props.href === 'string';
}
const VARIANTS: Record<ButtonVariant, string> = {
const VARIANTS = {
primary:
'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
secondary:
@@ -29,14 +48,14 @@ const VARIANTS: Record<ButtonVariant, string> = {
outline:
'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
ghost:
'border-[3px] border-solid border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
};
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
} as const satisfies Record<ButtonVariant, string>;
const SIZES: Record<ButtonSize, string> = {
const SIZES = {
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg',
};
} as const satisfies Record<ButtonSize, string>;
/* box-shadow excluded from transition intentionally — snaps instantly so the
* eye follows the 130ms button movement, not the shadow change. */
@@ -44,10 +63,22 @@ const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
/**
* Brutalist button with variants and sizes.
* Renders as <a> when href is provided, <button> otherwise.
*/
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
const cls = cn(BASE, VARIANTS[variant], SIZES[size], className);
if (isAnchorProps(props)) {
const { href, ...anchorProps } = props;
return (
<a href={href} rel="noopener noreferrer" target="_blank" className={cls} {...anchorProps}>
{children}
</a>
);
}
return (
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
<button className={cls} {...props}>
{children}
</button>
);