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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
|
||||
<a href={href} rel="noopener noreferrer" target="_blank" className={cls} {...anchorProps}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={cls} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user