From ba7395cb32bb7451ff2bda911e77cf018a2ffb5d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 18 May 2026 20:44:50 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20make=20Button=20polymorphic=20=E2=80=94?= =?UTF-8?q?=20renders=20=20when=20href=20is=20provided?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/shared/ui/Button/ui/Button.test.tsx | 27 ++++++++++++++ src/shared/ui/Button/ui/Button.tsx | 47 ++++++++++++++++++++----- 2 files changed, 66 insertions(+), 8 deletions(-) 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, );