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'],
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: '#',
},
};
@@ -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(<Article headline="Title">Body</Article>);
// 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(
<Article headline="Title" subHeadline="Sub">
Body
</Article>
);
// 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 {...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 { 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 `<a>` 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 (
<Container padding={4} onPress={onPress}>
<Container padding={4} href={href} onPress={onPress} className={className}>
<VStack align="stretch" gap={4}>
<Header className="text-center" level={4}>
{headline}
</Header>
<Header level={4}>{headline}</Header>
<VStack align="center" gap={2}>
<VStack align="stretch" gap={2}>
{subHeadline ? (
<>
<Separator className="w-3/4" type="single" />
<Text italic className="text-center" size="m" type="label">
<Separator thinned="none" type="single" />
<Text italic size="m" type="label">
{subHeadline}
</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>
@@ -26,6 +26,9 @@ const meta: Meta<typeof Container> = {
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: () => (
<VStack gap={4}>
@@ -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(<Container>Content</Container>);
let container = screen.getByText('Content');
expect(container).not.toHaveClass('cursor-pointer');
@@ -23,6 +23,7 @@ describe('components/Container', () => {
rerender(<Container onPress={() => {}}>Content</Container>);
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(<Container as="section">Section Content</Container>);
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;
/** 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 `<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;
}
@@ -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<HTMLElement>(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<ContainerBackground, string> = {
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
@@ -181,9 +199,10 @@ export const Container = memo(function Container({
};
return (
<Component
<FinalComponent
{...interactionProps}
ref={ref}
href={href}
className={cn(
'overflow-hidden',
bgClasses[bg],
@@ -192,12 +211,13 @@ export const Container = memo(function Container({
paddingClasses[padding],
shadow && 'shadow-md',
border && 'border border-[var(--rb-outline)]',
onPress && 'cursor-pointer active:opacity-80 transition-opacity',
className
(onPress || href) &&
'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
className,
)}
data-testid={testId}
>
{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 { sortedArticles } from '../../../content/registry';
@@ -20,22 +20,19 @@ createPage(
<Separator className="w-full" type="double" />
</div>
)}
<Link
className="group block h-full no-underline!"
<Article
className="h-full"
href={`/articles/${article.slug}`}
additionalText={article.tags?.join(' • ')}
headline={article.title}
subHeadline={article.createdDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
>
<Article
additionalText={article.tags?.join(' • ')}
headline={article.title}
subHeadline={article.createdDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
>
{article.description}
</Article>
</Link>
{article.description}
</Article>
</React.Fragment>
))}
</div>
@@ -46,5 +43,5 @@ createPage(
{
title: `Articles | ${SITE_TITLE}`,
description: 'Browse all articles and projects.',
}
},
);