diff --git a/src/shared/ui/Link/index.ts b/src/shared/ui/Link/index.ts new file mode 100644 index 0000000..a2e27cb --- /dev/null +++ b/src/shared/ui/Link/index.ts @@ -0,0 +1 @@ +export { Link } from './ui/Link/Link'; diff --git a/src/shared/ui/Link/ui/Link/Link.stories.tsx b/src/shared/ui/Link/ui/Link/Link.stories.tsx new file mode 100644 index 0000000..db957c1 --- /dev/null +++ b/src/shared/ui/Link/ui/Link/Link.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Link } from './Link'; + +const meta: Meta = { + title: 'Shared/Link', + component: Link, +}; + +export default meta; + +type Story = StoryObj; + +export const Internal: Story = { + args: { + href: '/about', + children: 'Internal page', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const External: Story = { + args: { + href: 'https://example.com', + external: true, + children: 'External site', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/src/shared/ui/Link/ui/Link/Link.test.tsx b/src/shared/ui/Link/ui/Link/Link.test.tsx new file mode 100644 index 0000000..d122e04 --- /dev/null +++ b/src/shared/ui/Link/ui/Link/Link.test.tsx @@ -0,0 +1,82 @@ +vi.mock('next/link', () => ({ + default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => ( + + {children} + + ), +})); + +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'; + +describe('internal link', () => { + it('renders an anchor element', () => { + render(About); + expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument(); + }); + + it('has correct href', () => { + render(About); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + }); + + it('does not have target attribute', () => { + render(About); + expect(screen.getByRole('link', { name: 'About' })).not.toHaveAttribute('target'); + }); + + it('applies base classes', () => { + render(About); + const link = screen.getByRole('link', { name: 'About' }); + for (const cls of BASE.split(' ')) { + expect(link).toHaveClass(cls); + } + }); +}); + +describe('external link', () => { + it('has target="_blank"', () => { + render( + + External + , + ); + expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('target', '_blank'); + }); + + it('has rel="noopener noreferrer"', () => { + render( + + External + , + ); + expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('has correct href', () => { + render( + + External + , + ); + expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('href', 'https://example.com'); + }); +}); + +describe('className passthrough', () => { + it('merges custom className with base classes', () => { + render( + + Styled + , + ); + const link = screen.getByRole('link', { name: 'Styled' }); + expect(link).toHaveClass('text-red-500'); + for (const cls of BASE.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 new file mode 100644 index 0000000..f76a8cb --- /dev/null +++ b/src/shared/ui/Link/ui/Link/Link.tsx @@ -0,0 +1,47 @@ +import NextLink from 'next/link'; +import type { ReactNode } from 'react'; +import { cn } from '$shared/lib'; + +/** + * Props for Link. + */ +interface Props { + /** + * Destination URL. Use a path (e.g. /about) for internal routes, or a full URL for external. + */ + href: string; + /** + * Link content + */ + children: ReactNode; + /** + * CSS classes + */ + className?: string; + /** + * When true, renders a plain with target="_blank" rel="noopener noreferrer". + * Use for links that open outside the app. + */ + external?: boolean; +} + +const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity'; + +/** + * Inline text link. + * Renders as Next.js Link for internal routes, plain for external links. + */ +export function Link({ href, children, className, external }: Props) { + if (external) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index c19f36e..69a21bf 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -6,6 +6,7 @@ export type { CardBackground } from './Card'; export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card'; export { Input, Textarea } from './Input'; +export { Link } from './Link'; export { RichText } from './RichText'; export type { ContainerSize, SectionBackground } from './Section'; export { Container, Section } from './Section';