feat: Update styles for Article

This commit is contained in:
deytenit committed 2026-03-06 23:27:01 +03:00
1 parent 7b574a1a66
commit d2903a3715
7 files changed
+135 -47

No files matched your search

@@ -8,6 +8,7 @@ const meta: Meta<typeof Article> = {
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
onPress: { action: 'pressed' }, onPress: { action: 'pressed' },
href: { control: 'text' },
}, },
}; };
@@ -35,9 +36,9 @@ export const Full: Story = {
}, },
}; };
export const Interactive: Story = { export const Linked: Story = {
args: { args: {
...Full.args, ...Full.args,
onPress: () => alert('Article clicked!'), href: '#',
}, },
}; };
@@ -19,17 +19,32 @@ describe('components/Article', () => {
expect(screen.getByText('Reporting by John Doe')).toBeInTheDocument(); 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(<Article headline="Title">Body</Article>); const { rerender } = render(<Article headline="Title">Body</Article>);
// Should have 1 separator (between title and body) let separators = screen.getAllByRole('separator');
expect(screen.getAllByRole('separator')).toHaveLength(1); expect(separators).toHaveLength(1);
expect(separators[0].className).toContain('mask-image');
rerender( rerender(
<Article headline="Title" subHeadline="Sub"> <Article headline="Title" subHeadline="Sub">
Body Body
</Article> </Article>
); );
// Should have 2 separators (Title-Sub and Sub-Body) separators = screen.getAllByRole('separator');
expect(screen.getAllByRole('separator')).toHaveLength(2); 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 {...props} href="/test-article">
Article body content
</Article>
);
const articleContainer = screen.getByText('Breaking News').closest('a');
expect(articleContainer).toBeInTheDocument();
expect(articleContainer).toHaveAttribute('href', '/test-article');
}); });
}); });
+24 -15
View File
@@ -6,20 +6,29 @@ import { Separator } from '../Separator/Separator';
import { Text } from '../Text/Text'; import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack'; import { VStack } from '../VStack/VStack';
import type { ClassNameProps } from '@ermnvldmr/stl';
import type { ContainerProps } from '../Container/Container'; import type { ContainerProps } from '../Container/Container';
/** /**
* Props for the Article component. * Props for the Article component.
*/ */
export interface ArticleProps { export interface ArticleProps extends ClassNameProps {
/** The main article headline */ /** The main article headline */
headline: string; headline: string;
/** Optional sub-headline displayed between separators */ /** Optional sub-headline displayed between separators */
subHeadline?: string; subHeadline?: string;
/** Optional additional text (e.g., author) displayed at the bottom in gray italic label */ /** Optional additional text (e.g., author) displayed at the bottom in gray italic label */
additionalText?: string; additionalText?: string;
/** Press interaction handler propagated to the wrapping Container */ /**
* Press interaction handler.
* @note Cannot be used with `href`.
*/
onPress?: ContainerProps['onPress']; onPress?: ContainerProps['onPress'];
/**
* If provided, the container will be rendered as an `<a>` tag for navigation.
* @note Cannot be used with `onPress`.
*/
href?: ContainerProps['href'];
/** The main article body content */ /** The main article body content */
children: React.ReactNode; children: React.ReactNode;
} }
@@ -28,38 +37,38 @@ export interface ArticleProps {
* A article component with a structured layout. * A article component with a structured layout.
* *
* Features: * Features:
* - Centered medium headline (headline-m) * - Left-aligned medium headline (headline-m)
* - Single-line separators * - Single-line separators with optional end fade
* - Optional centered italic sub-headline (label-m) * - Optional italic sub-headline (label-m)
* - Main body text (body-m) * - Main body text (body-m)
* - Optional bottom gray italic label (label-s) * - 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({ export const Article = memo(function Article({
headline, headline,
subHeadline, subHeadline,
additionalText, additionalText,
onPress, onPress,
href,
children, children,
className,
}: ArticleProps) { }: ArticleProps) {
return ( return (
<Container padding={4} onPress={onPress}> <Container padding={4} href={href} onPress={onPress} className={className}>
<VStack align="stretch" gap={4}> <VStack align="stretch" gap={4}>
<Header className="text-center" level={4}> <Header level={4}>{headline}</Header>
{headline}
</Header>
<VStack align="center" gap={2}> <VStack align="stretch" gap={2}>
{subHeadline ? ( {subHeadline ? (
<> <>
<Separator className="w-3/4" type="single" /> <Separator thinned="none" type="single" />
<Text italic className="text-center" size="m" type="label"> <Text italic size="m" type="label">
{subHeadline} {subHeadline}
</Text> </Text>
<Separator className="w-3/4" type="single" /> <Separator thinned="thinned-end" type="single" />
</> </>
) : ( ) : (
<Separator className="w-3/4" type="single" /> <Separator thinned="thinned-end" type="single" />
)} )}
</VStack> </VStack>
@@ -26,6 +26,9 @@ const meta: Meta<typeof Container> = {
control: 'select', control: 'select',
options: ['none', 'sm', 'md', 'lg', 'full'], 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 = { export const SemanticColors: Story = {
render: () => ( render: () => (
<VStack gap={4}> <VStack gap={4}>
@@ -15,7 +15,7 @@ describe('components/Container', () => {
expect(handlePress).toHaveBeenCalledTimes(1); 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(<Container>Content</Container>); const { rerender } = render(<Container>Content</Container>);
let container = screen.getByText('Content'); let container = screen.getByText('Content');
expect(container).not.toHaveClass('cursor-pointer'); expect(container).not.toHaveClass('cursor-pointer');
@@ -23,6 +23,7 @@ describe('components/Container', () => {
rerender(<Container onPress={() => {}}>Content</Container>); rerender(<Container onPress={() => {}}>Content</Container>);
container = screen.getByText('Content'); container = screen.getByText('Content');
expect(container).toHaveClass('cursor-pointer'); expect(container).toHaveClass('cursor-pointer');
expect(container).toHaveClass('hover:brightness-95');
expect(container).toHaveClass('active:opacity-80'); expect(container).toHaveClass('active:opacity-80');
}); });
@@ -36,4 +37,38 @@ describe('components/Container', () => {
rerender(<Container as="section">Section Content</Container>); rerender(<Container as="section">Section Content</Container>);
expect(screen.getByText('Section Content').tagName).toBe('SECTION'); expect(screen.getByText('Section Content').tagName).toBe('SECTION');
}); });
it('renders as an anchor tag with href when href prop is provided', () => {
render(<Container href="/test-path">Link Content</Container>);
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(
<Container href="/test-path" onPress={handlePress}>
Link Content
</Container>
);
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(
<Container href="/test-path" onPress={() => {}}>
Content
</Container>
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.',
);
consoleWarnSpy.mockRestore();
});
}); });
@@ -84,9 +84,17 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
shadow?: boolean; shadow?: boolean;
/** Whether to show a border */ /** Whether to show a border */
border?: boolean; border?: boolean;
/** Press interaction handler */ /**
* Press interaction handler.
* @note Cannot be used with `href`.
*/
onPress?: PressEvents['onPress']; onPress?: PressEvents['onPress'];
/** The HTML element to use for rendering */ /**
* If provided, the container will be rendered as an `<a>` 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; as?: React.ElementType;
} }
@@ -105,17 +113,27 @@ export const Container = memo(function Container({
shadow = false, shadow = false,
border = false, border = false,
onPress, onPress,
href,
as: Component = 'div', as: Component = 'div',
className, className,
'data-testid': testId, 'data-testid': testId,
}: ContainerProps) { }: 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<HTMLElement>(null); const ref = React.useRef<HTMLElement>(null);
const { pressProps } = usePress({ const { pressProps } = usePress({
onPress, onPress,
isDisabled: !onPress, isDisabled: !isPressable,
}); });
const interactionProps = onPress ? pressProps : {}; const interactionProps = isPressable ? pressProps : {};
const FinalComponent = isLink ? 'a' : Component;
const bgClasses: Record<ContainerBackground, string> = { const bgClasses: Record<ContainerBackground, string> = {
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]', base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
@@ -181,9 +199,10 @@ export const Container = memo(function Container({
}; };
return ( return (
<Component <FinalComponent
{...interactionProps} {...interactionProps}
ref={ref} ref={ref}
href={href}
className={cn( className={cn(
'overflow-hidden', 'overflow-hidden',
bgClasses[bg], bgClasses[bg],
@@ -192,12 +211,13 @@ export const Container = memo(function Container({
paddingClasses[padding], paddingClasses[padding],
shadow && 'shadow-md', shadow && 'shadow-md',
border && 'border border-[var(--rb-outline)]', border && 'border border-[var(--rb-outline)]',
onPress && 'cursor-pointer active:opacity-80 transition-opacity', (onPress || href) &&
className 'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
className,
)} )}
data-testid={testId} data-testid={testId}
> >
{children} {children}
</Component> </FinalComponent>
); );
}); });
+13 -16
View File
@@ -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 React from 'react';
import { sortedArticles } from '../../../content/registry'; import { sortedArticles } from '../../../content/registry';
@@ -20,22 +20,19 @@ createPage(
<Separator className="w-full" type="double" /> <Separator className="w-full" type="double" />
</div> </div>
)} )}
<Link <Article
className="group block h-full no-underline!" className="h-full"
href={`/articles/${article.slug}`} href={`/articles/${article.slug}`}
additionalText={article.tags?.join(' • ')}
headline={article.title}
subHeadline={article.createdDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
> >
<Article {article.description}
additionalText={article.tags?.join(' • ')} </Article>
headline={article.title}
subHeadline={article.createdDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
>
{article.description}
</Article>
</Link>
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
@@ -46,5 +43,5 @@ createPage(
{ {
title: `Articles | ${SITE_TITLE}`, title: `Articles | ${SITE_TITLE}`,
description: 'Browse all articles and projects.', description: 'Browse all articles and projects.',
} },
); );