diff --git a/src/shared/ui/Button/ui/Button.test.tsx b/src/shared/ui/Button/ui/Button.test.tsx
index 3f3c125..e6535d1 100644
--- a/src/shared/ui/Button/ui/Button.test.tsx
+++ b/src/shared/ui/Button/ui/Button.test.tsx
@@ -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();
+ expect(screen.getByRole('link', { name: 'Download' })).toBeInTheDocument();
+ });
+ it('sets href on the anchor', () => {
+ render();
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/cv.pdf');
+ });
+ it('sets download attribute when provided', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('link')).toHaveAttribute('download');
+ });
+ it('applies the same variant and size classes as button', () => {
+ render(
+ ,
+ );
+ const link = screen.getByRole('link');
+ expect(link).toHaveClass('bg-blue', 'px-4', 'py-2');
+ });
+ });
});
diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx
index b3db774..bd0c39b 100644
--- a/src/shared/ui/Button/ui/Button.tsx
+++ b/src/shared/ui/Button/ui/Button.tsx
@@ -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 {
+type BaseProps = {
/**
* Visual variant
* @default 'primary'
@@ -19,9 +19,28 @@ interface Props extends ButtonHTMLAttributes {
* Button content
*/
children: ReactNode;
+ /**
+ * CSS classes
+ */
+ className?: string;
+};
+
+type AsButton = BaseProps & ButtonHTMLAttributes & { href?: never };
+type AsAnchor = BaseProps & AnchorHTMLAttributes & { href: string };
+
+type Props = AsButton | AsAnchor;
+
+type RestButton = Omit;
+type RestAnchor = Omit;
+
+/**
+ * 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 = {
+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 = {
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;
-const SIZES: Record = {
+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;
/* 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 when href is provided,