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'],
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -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.',
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
Reference in new issue
Block a user