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');
|
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';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
type BaseProps = {
|
||||||
/**
|
/**
|
||||||
* Visual variant
|
* Visual variant
|
||||||
* @default 'primary'
|
* @default 'primary'
|
||||||
@@ -19,9 +19,28 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
* Button content
|
* Button content
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
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:
|
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)]',
|
'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:
|
secondary:
|
||||||
@@ -29,14 +48,14 @@ const VARIANTS: Record<ButtonVariant, string> = {
|
|||||||
outline:
|
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',
|
'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:
|
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',
|
sm: 'px-4 py-2 text-sm',
|
||||||
md: 'px-6 py-3 text-base',
|
md: 'px-6 py-3 text-base',
|
||||||
lg: 'px-8 py-4 text-lg',
|
lg: 'px-8 py-4 text-lg',
|
||||||
};
|
} as const satisfies Record<ButtonSize, string>;
|
||||||
|
|
||||||
/* box-shadow excluded from transition intentionally — snaps instantly so the
|
/* box-shadow excluded from transition intentionally — snaps instantly so the
|
||||||
* eye follows the 130ms button movement, not the shadow change. */
|
* 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.
|
* 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) {
|
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 (
|
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}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user