feat: Update styles for Article
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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.',
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user