diff --git a/packages/ui/src/components/Article/Article.stories.tsx b/packages/ui/src/components/Article/Article.stories.tsx index 2ff2528..ad4505b 100644 --- a/packages/ui/src/components/Article/Article.stories.tsx +++ b/packages/ui/src/components/Article/Article.stories.tsx @@ -8,6 +8,7 @@ const meta: Meta = { tags: ['autodocs'], argTypes: { onPress: { action: 'pressed' }, + href: { control: 'text' }, }, }; @@ -35,9 +36,9 @@ export const Full: Story = { }, }; -export const Interactive: Story = { +export const Linked: Story = { args: { ...Full.args, - onPress: () => alert('Article clicked!'), + href: '#', }, }; diff --git a/packages/ui/src/components/Article/Article.test.tsx b/packages/ui/src/components/Article/Article.test.tsx index fa78cb3..2e1ddf5 100644 --- a/packages/ui/src/components/Article/Article.test.tsx +++ b/packages/ui/src/components/Article/Article.test.tsx @@ -19,17 +19,32 @@ describe('components/Article', () => { expect(screen.getByText('Reporting by John Doe')).toBeInTheDocument(); }); - it('renders separators only when appropriate', () => { + it('renders separators correctly based on subHeadline presence', () => { const { rerender } = render(
Body
); - // Should have 1 separator (between title and body) - expect(screen.getAllByRole('separator')).toHaveLength(1); + let separators = screen.getAllByRole('separator'); + expect(separators).toHaveLength(1); + expect(separators[0].className).toContain('mask-image'); rerender(
Body
); - // Should have 2 separators (Title-Sub and Sub-Body) - expect(screen.getAllByRole('separator')).toHaveLength(2); + separators = screen.getAllByRole('separator'); + expect(separators).toHaveLength(2); + expect(separators[0].className).not.toContain('mask-image'); + expect(separators[1].className).toContain('mask-image'); + }); + + it('renders as a link when href is provided', () => { + render( +
+ Article body content +
+ ); + + const articleContainer = screen.getByText('Breaking News').closest('a'); + expect(articleContainer).toBeInTheDocument(); + expect(articleContainer).toHaveAttribute('href', '/test-article'); }); }); diff --git a/packages/ui/src/components/Article/Article.tsx b/packages/ui/src/components/Article/Article.tsx index 6fdca80..ab52085 100644 --- a/packages/ui/src/components/Article/Article.tsx +++ b/packages/ui/src/components/Article/Article.tsx @@ -6,20 +6,29 @@ import { Separator } from '../Separator/Separator'; import { Text } from '../Text/Text'; import { VStack } from '../VStack/VStack'; +import type { ClassNameProps } from '@ermnvldmr/stl'; import type { ContainerProps } from '../Container/Container'; /** * Props for the Article component. */ -export interface ArticleProps { +export interface ArticleProps extends ClassNameProps { /** The main article headline */ headline: string; /** Optional sub-headline displayed between separators */ subHeadline?: string; /** Optional additional text (e.g., author) displayed at the bottom in gray italic label */ additionalText?: string; - /** Press interaction handler propagated to the wrapping Container */ + /** + * Press interaction handler. + * @note Cannot be used with `href`. + */ onPress?: ContainerProps['onPress']; + /** + * If provided, the container will be rendered as an `` tag for navigation. + * @note Cannot be used with `onPress`. + */ + href?: ContainerProps['href']; /** The main article body content */ children: React.ReactNode; } @@ -28,38 +37,38 @@ export interface ArticleProps { * A article component with a structured layout. * * Features: - * - Centered medium headline (headline-m) - * - Single-line separators - * - Optional centered italic sub-headline (label-m) + * - Left-aligned medium headline (headline-m) + * - Single-line separators with optional end fade + * - Optional italic sub-headline (label-m) * - Main body text (body-m) * - Optional bottom gray italic label (label-s) - * - Interactive wrapper via Container + * - Interactive wrapper via Container, supporting both `onPress` and `href` */ export const Article = memo(function Article({ headline, subHeadline, additionalText, onPress, + href, children, + className, }: ArticleProps) { return ( - + -
- {headline} -
+
{headline}
- + {subHeadline ? ( <> - - + + {subHeadline} - + ) : ( - + )} diff --git a/packages/ui/src/components/Container/Container.stories.tsx b/packages/ui/src/components/Container/Container.stories.tsx index a3ba892..5ce813b 100644 --- a/packages/ui/src/components/Container/Container.stories.tsx +++ b/packages/ui/src/components/Container/Container.stories.tsx @@ -26,6 +26,9 @@ const meta: Meta = { control: 'select', options: ['none', 'sm', 'md', 'lg', 'full'], }, + href: { + control: 'text', + }, }, }; @@ -45,6 +48,14 @@ export const Default: Story = { }, }; +export const AsLink: Story = { + args: { + ...Default.args, + href: '#', + children: 'This container is a link.', + }, +}; + export const SemanticColors: Story = { render: () => ( diff --git a/packages/ui/src/components/Container/Container.test.tsx b/packages/ui/src/components/Container/Container.test.tsx index 2002bc8..beb4ab6 100644 --- a/packages/ui/src/components/Container/Container.test.tsx +++ b/packages/ui/src/components/Container/Container.test.tsx @@ -15,7 +15,7 @@ describe('components/Container', () => { expect(handlePress).toHaveBeenCalledTimes(1); }); - it('applies cursor-pointer and active styles when onPress is present', () => { + it('applies interactive styles when onPress is present', () => { const { rerender } = render(Content); let container = screen.getByText('Content'); expect(container).not.toHaveClass('cursor-pointer'); @@ -23,6 +23,7 @@ describe('components/Container', () => { rerender( {}}>Content); container = screen.getByText('Content'); expect(container).toHaveClass('cursor-pointer'); + expect(container).toHaveClass('hover:brightness-95'); expect(container).toHaveClass('active:opacity-80'); }); @@ -36,4 +37,38 @@ describe('components/Container', () => { rerender(Section Content); expect(screen.getByText('Section Content').tagName).toBe('SECTION'); }); + + it('renders as an anchor tag with href when href prop is provided', () => { + render(Link Content); + const linkElement = screen.getByText('Link Content'); + expect(linkElement.tagName).toBe('A'); + expect(linkElement).toHaveAttribute('href', '/test-path'); + }); + + it('gives precedence to href over onPress', async () => { + const user = userEvent.setup(); + const handlePress = vi.fn(); + render( + + Link Content + + ); + const linkElement = screen.getByText('Link Content'); + await user.click(linkElement); + expect(handlePress).not.toHaveBeenCalled(); + expect(linkElement.tagName).toBe('A'); + }); + + it('warns when both href and onPress are provided', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + {}}> + Content + + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.', + ); + consoleWarnSpy.mockRestore(); + }); }); diff --git a/packages/ui/src/components/Container/Container.tsx b/packages/ui/src/components/Container/Container.tsx index 4901eb6..8b58e44 100644 --- a/packages/ui/src/components/Container/Container.tsx +++ b/packages/ui/src/components/Container/Container.tsx @@ -84,9 +84,17 @@ export interface ContainerProps extends ClassNameProps, TestIdProps { shadow?: boolean; /** Whether to show a border */ border?: boolean; - /** Press interaction handler */ + /** + * Press interaction handler. + * @note Cannot be used with `href`. + */ onPress?: PressEvents['onPress']; - /** The HTML element to use for rendering */ + /** + * If provided, the container will be rendered as an `
` tag. + * @note Cannot be used with `onPress`. + */ + href?: string; + /** The HTML element to use for rendering. Overridden to `a` if `href` is provided. */ as?: React.ElementType; } @@ -105,17 +113,27 @@ export const Container = memo(function Container({ shadow = false, border = false, onPress, + href, as: Component = 'div', className, 'data-testid': testId, }: ContainerProps) { + if (process.env.NODE_ENV !== 'production' && onPress && href) { + // eslint-disable-next-line no-console + console.warn('[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.'); + } + + const isLink = !!href; + const isPressable = !!onPress && !isLink; + const ref = React.useRef(null); const { pressProps } = usePress({ onPress, - isDisabled: !onPress, + isDisabled: !isPressable, }); - const interactionProps = onPress ? pressProps : {}; + const interactionProps = isPressable ? pressProps : {}; + const FinalComponent = isLink ? 'a' : Component; const bgClasses: Record = { base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]', @@ -181,9 +199,10 @@ export const Container = memo(function Container({ }; return ( - {children} - + ); }); diff --git a/services/www/src/app/articles/index.tsx b/services/www/src/app/articles/index.tsx index f7f59c7..b6457c4 100644 --- a/services/www/src/app/articles/index.tsx +++ b/services/www/src/app/articles/index.tsx @@ -1,4 +1,4 @@ -import { Header, Link, Separator, Article, VStack } from '@ermnvldmr/ui'; +import { Header, Separator, Article, VStack } from '@ermnvldmr/ui'; import React from 'react'; import { sortedArticles } from '../../../content/registry'; @@ -20,22 +20,19 @@ createPage( )} - -
- {article.description} -
- + {article.description} + ))} @@ -46,5 +43,5 @@ createPage( { title: `Articles | ${SITE_TITLE}`, description: 'Browse all articles and projects.', - } + }, );