From 41af0b90a03a52cdb7835bc1067f4ad9d4ab2d57 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 19 May 2026 18:06:10 +0300 Subject: [PATCH] feat: add Link secondary variant with border-bottom at sm+ Secondary variant drops text-decoration, uses opacity-60/hover-100 and brutal-border-bottom at sm+ for use in icon+label links where the underline should only appear alongside the label. --- src/shared/ui/Link/ui/Link/Link.stories.tsx | 51 ++++++++++++++------- src/shared/ui/Link/ui/Link/Link.test.tsx | 46 +++++++++++++++++-- src/shared/ui/Link/ui/Link/Link.tsx | 22 +++++++-- src/shared/ui/index.ts | 2 + 4 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/shared/ui/Link/ui/Link/Link.stories.tsx b/src/shared/ui/Link/ui/Link/Link.stories.tsx index db957c1..c2a79d6 100644 --- a/src/shared/ui/Link/ui/Link/Link.stories.tsx +++ b/src/shared/ui/Link/ui/Link/Link.stories.tsx @@ -10,31 +10,50 @@ export default meta; type Story = StoryObj; -export const Internal: Story = { +const decorator = (Story: React.ComponentType) => ( +
+ +
+); + +export const Primary: Story = { args: { href: '/about', children: 'Internal page', }, - decorators: [ - (Story) => ( -
- -
- ), - ], + decorators: [decorator], }; -export const External: Story = { +export const PrimaryExternal: Story = { args: { href: 'https://example.com', external: true, children: 'External site', }, - decorators: [ - (Story) => ( -
- -
- ), - ], + decorators: [decorator], +}; + +export const Secondary: Story = { + args: { + href: 'https://github.com', + external: true, + variant: 'secondary', + children: 'GitHub', + }, + decorators: [decorator], +}; + +export const SecondaryWithIcon: Story = { + args: { + href: 'https://github.com', + external: true, + variant: 'secondary', + className: 'flex items-center gap-1.5 text-sm', + children: ( + <> + GitHub + + ), + }, + decorators: [decorator], }; diff --git a/src/shared/ui/Link/ui/Link/Link.test.tsx b/src/shared/ui/Link/ui/Link/Link.test.tsx index d122e04..5fce10a 100644 --- a/src/shared/ui/Link/ui/Link/Link.test.tsx +++ b/src/shared/ui/Link/ui/Link/Link.test.tsx @@ -10,7 +10,8 @@ import { render, screen } from '@testing-library/react'; import type React from 'react'; import { Link } from './Link'; -const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity'; +const PRIMARY = 'underline underline-offset-2 hover:opacity-60 transition-opacity'; +const SECONDARY = 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity'; describe('internal link', () => { it('renders an anchor element', () => { @@ -28,10 +29,10 @@ describe('internal link', () => { expect(screen.getByRole('link', { name: 'About' })).not.toHaveAttribute('target'); }); - it('applies base classes', () => { + it('applies primary classes by default', () => { render(About); const link = screen.getByRole('link', { name: 'About' }); - for (const cls of BASE.split(' ')) { + for (const cls of PRIMARY.split(' ')) { expect(link).toHaveClass(cls); } }); @@ -66,8 +67,43 @@ describe('external link', () => { }); }); +describe('variant', () => { + it('primary applies underline classes', () => { + render( + + About + , + ); + const link = screen.getByRole('link', { name: 'About' }); + for (const cls of PRIMARY.split(' ')) { + expect(link).toHaveClass(cls); + } + }); + + it('secondary applies secondary classes', () => { + render( + + About + , + ); + const link = screen.getByRole('link', { name: 'About' }); + for (const cls of SECONDARY.split(' ')) { + expect(link).toHaveClass(cls); + } + }); + + it('secondary does not apply underline', () => { + render( + + About + , + ); + expect(screen.getByRole('link', { name: 'About' })).not.toHaveClass('underline'); + }); +}); + describe('className passthrough', () => { - it('merges custom className with base classes', () => { + it('merges custom className with variant classes', () => { render( Styled @@ -75,7 +111,7 @@ describe('className passthrough', () => { ); const link = screen.getByRole('link', { name: 'Styled' }); expect(link).toHaveClass('text-red-500'); - for (const cls of BASE.split(' ')) { + for (const cls of PRIMARY.split(' ')) { expect(link).toHaveClass(cls); } }); diff --git a/src/shared/ui/Link/ui/Link/Link.tsx b/src/shared/ui/Link/ui/Link/Link.tsx index f76a8cb..b1327fa 100644 --- a/src/shared/ui/Link/ui/Link/Link.tsx +++ b/src/shared/ui/Link/ui/Link/Link.tsx @@ -2,6 +2,8 @@ import NextLink from 'next/link'; import type { ReactNode } from 'react'; import { cn } from '$shared/lib'; +export type LinkVariant = 'primary' | 'secondary'; + /** * Props for Link. */ @@ -18,6 +20,13 @@ interface Props { * CSS classes */ className?: string; + /** + * Visual variant. + * primary — text-decoration underline. + * secondary — border-bottom at sm+, no underline on mobile (for icon+label links). + * @default 'primary' + */ + variant?: LinkVariant; /** * When true, renders a plain with target="_blank" rel="noopener noreferrer". * Use for links that open outside the app. @@ -25,22 +34,27 @@ interface Props { external?: boolean; } -const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity'; +const VARIANTS = { + primary: 'underline underline-offset-2 hover:opacity-60 transition-opacity', + secondary: 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity', +} as const satisfies Record; /** * Inline text link. * Renders as Next.js Link for internal routes, plain for external links. */ -export function Link({ href, children, className, external }: Props) { +export function Link({ href, children, className, variant = 'primary', external }: Props) { + const cls = cn(VARIANTS[variant], className); + if (external) { return ( - + {children} ); } return ( - + {children} ); diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 69a21bf..401c379 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,7 +5,9 @@ export { Button } from './Button'; export type { CardBackground } from './Card'; export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card'; +export { InlineSvg } from './InlineSvg'; export { Input, Textarea } from './Input'; +export type { LinkVariant } from './Link'; export { Link } from './Link'; export { RichText } from './RichText'; export type { ContainerSize, SectionBackground } from './Section';