feat: Add generic components for UI

This commit is contained in:
deytenit committed 2026-01-31 17:52:45 +03:00
1 parent 08a7f44588
commit 054cf4f5e3
38 files changed
+1562 -241

No files matched your search

@@ -0,0 +1,66 @@
import React from 'react';
import { Breadcrumbs } from './Breadcrumbs';
import { Link } from '../Link/Link';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Breadcrumbs> = {
title: 'components/generic/Breadcrumbs',
component: Breadcrumbs,
tags: ['autodocs'],
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Breadcrumbs>;
export const Default: Story = {
render: () => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="slash" />
<Breadcrumbs.Item>
<Link href="/blog">Blog</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="slash" />
<Breadcrumbs.Item>Modern Web Design</Breadcrumbs.Item>
</Breadcrumbs>
),
};
export const WithArrows: Story = {
render: () => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="arrow" />
<Breadcrumbs.Item>
<Link href="/docs">Docs</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="arrow" />
<Breadcrumbs.Item>Components</Breadcrumbs.Item>
</Breadcrumbs>
),
};
export const WithBullets: Story = {
render: () => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="bull" />
<Breadcrumbs.Item>
<Link href="/about">About</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="bull" />
<Breadcrumbs.Item>Team</Breadcrumbs.Item>
</Breadcrumbs>
),
};
@@ -0,0 +1,40 @@
import React from 'react';
import { Breadcrumbs } from './Breadcrumbs';
import { render, screen } from '../../../test-utils';
import { Link } from '../Link/Link';
describe('components/generic/Breadcrumbs', () => {
it('renders correctly with multiple items and separators', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item>
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="slash" />
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
</Breadcrumbs>
);
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByText('/')).toBeInTheDocument();
expect(screen.getByText('Current')).toBeInTheDocument();
});
it('supports different separator variants', () => {
const { rerender } = render(
<Breadcrumbs>
<Breadcrumbs.Separator data-testid="sep" variant="bull" />
</Breadcrumbs>
);
expect(screen.getByTestId('sep')).toHaveTextContent('•');
rerender(
<Breadcrumbs>
<Breadcrumbs.Separator data-testid="sep" variant="slash" />
</Breadcrumbs>
);
expect(screen.getByTestId('sep')).toHaveTextContent('/');
});
});
@@ -0,0 +1,102 @@
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { memo } from 'react';
import { HStack } from '../HStack/HStack';
import { Text } from '../Text/Text';
import type { HStackProps } from '../HStack/HStack';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Props for the Breadcrumbs component.
*/
export interface BreadcrumbsProps extends HStackProps {
/** Breadcrumb items and separators */
children: React.ReactNode;
}
/**
* Root component for hierarchical navigation.
*/
const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: BreadcrumbsProps) {
return (
<nav aria-label="Breadcrumb">
<HStack align="center" as="ol" gap={2} {...props}>
{children}
</HStack>
</nav>
);
});
/**
* Individual step in the breadcrumb trail.
*/
const BreadcrumbsItem = memo(function BreadcrumbsItem({
children,
className,
'data-testid': testId,
}: {
children: React.ReactNode;
className?: string;
} & TestIdProps) {
return (
<li className={className} data-testid={testId}>
<Text color="muted" size="m" type="label">
{children}
</Text>
</li>
);
});
/**
* Visual divider between breadcrumb items.
*/
export interface BreadcrumbsSeparatorProps extends ClassNameProps, TestIdProps {
/** Visual variant of the separator */
variant?: 'slash' | 'bull' | 'arrow';
}
const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
variant = 'slash',
className,
'data-testid': testId,
}: BreadcrumbsSeparatorProps) {
const content = {
slash: '/',
bull: '•',
arrow: <ChevronRight className="size-4" />,
};
return (
<li
aria-hidden="true"
className={cn('text-[var(--rb-muted-text)]', className)}
data-testid={testId}
>
<Text color="muted" size="m" type="label">
{content[variant]}
</Text>
</li>
);
});
/**
* A hierarchical navigation component.
*
* Breadcrumbs provides a flexible way to display the current location within
* a site's hierarchy. It uses a compound component pattern for maximum flexibility.
*
* @example
* ```tsx
* <Breadcrumbs>
* <Breadcrumbs.Item><Link href="/">Home</Link></Breadcrumbs.Item>
* <Breadcrumbs.Separator variant="arrow" />
* <Breadcrumbs.Item>Current Page</Breadcrumbs.Item>
* </Breadcrumbs>
* ```
*/
export const Breadcrumbs = Object.assign(BreadcrumbsRoot, {
Item: BreadcrumbsItem,
Separator: BreadcrumbsSeparator,
});
@@ -0,0 +1,133 @@
import React from 'react';
import { Container } from './Container';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Container> = {
title: 'components/generic/Container',
component: Container,
tags: ['autodocs'],
argTypes: {
bg: {
control: 'select',
options: ['base', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'transparent'],
},
padding: {
control: 'number',
},
maxWidth: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl', 'none'],
},
rounded: {
control: 'select',
options: ['none', 'sm', 'md', 'lg', 'full'],
},
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Container>;
export const Default: Story = {
args: {
children: 'This is a container with some content.',
padding: 4,
bg: 'base',
border: true,
rounded: 'md',
},
};
export const SemanticColors: Story = {
render: () => (
<VStack gap={4}>
<Container border bg="base" padding={4} rounded="md">Base Surface</Container>
<Container bg="primary" padding={4} rounded="md">Primary Surface</Container>
<Container bg="secondary" padding={4} rounded="md">Secondary Surface</Container>
<Container bg="tertiary" padding={4} rounded="md">Tertiary Surface</Container>
<Container bg="error" padding={4} rounded="md">Error Surface</Container>
<Container bg="muted" padding={4} rounded="md">Muted Surface</Container>
<Container border bg="transparent" padding={4} rounded="md">Transparent Surface</Container>
</VStack>
),
};
export const PaddingScale: Story = {
render: () => (
<VStack gap={4}>
<Container border bg="muted" padding={1}>Padding 1</Container>
<Container border bg="muted" padding={2}>Padding 2</Container>
<Container border bg="muted" padding={4}>Padding 4</Container>
<Container border bg="muted" padding={8}>Padding 8</Container>
<Container border bg="muted" padding={12}>Padding 12</Container>
</VStack>
),
};
export const MaxWidths: Story = {
render: () => (
<VStack className="w-full" gap={4}>
<Container bg="tertiary" maxWidth="sm" padding={4} rounded="md">Max Width SM (640px)</Container>
<Container bg="tertiary" maxWidth="md" padding={4} rounded="md">Max Width MD (768px)</Container>
<Container bg="tertiary" maxWidth="lg" padding={4} rounded="md">Max Width LG (1024px)</Container>
<Container bg="tertiary" maxWidth="xl" padding={4} rounded="md">Max Width XL (1280px)</Container>
</VStack>
),
};
export const Rounding: Story = {
render: () => (
<div className="flex flex-wrap gap-4">
<Container bg="secondary" padding={8} rounded="none">None</Container>
<Container bg="secondary" padding={8} rounded="sm">Small</Container>
<Container bg="secondary" padding={8} rounded="md">Medium</Container>
<Container bg="secondary" padding={8} rounded="lg">Large</Container>
<Container bg="secondary" padding={8} rounded="full">Full</Container>
</div>
),
};
export const ElevationAndBorders: Story = {
render: () => (
<VStack gap={8}>
<Container border bg="base" padding={6} rounded="lg">
Border Only
</Container>
<Container shadow bg="base" padding={6} rounded="lg">
Shadow Only
</Container>
<Container border shadow bg="base" padding={6} rounded="lg">
Border + Shadow
</Container>
</VStack>
),
};
export const Composition: Story = {
render: () => (
<Container border shadow bg="base" maxWidth="md" padding={0} rounded="lg">
<Container bg="primary" padding={4} rounded="none">
<Text bold color="inherit" size="l" type="title">Card Header</Text>
</Container>
<Container padding={6}>
<VStack gap={4}>
<Text size="m" type="body">
This example demonstrates how containers can be nested and composed.
The outer container has a shadow and border, while the header uses a primary background.
</Text>
<Container border bg="muted" padding={4} rounded="md">
<Text size="m" type="label">Inside a nested muted container</Text>
</Container>
</VStack>
</Container>
</Container>
),
};
@@ -0,0 +1,28 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Container } from './Container';
import { render, screen } from '../../../test-utils';
describe('components/generic/Container', () => {
it('handles onPress event', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
render(<Container onPress={handlePress}>Content</Container>);
const container = screen.getByText('Content');
await user.click(container);
expect(handlePress).toHaveBeenCalledTimes(1);
});
it('applies cursor-pointer and active styles when onPress is present', () => {
const { rerender } = render(<Container>Content</Container>);
let container = screen.getByText('Content');
expect(container).not.toHaveClass('cursor-pointer');
rerender(<Container onPress={() => {}}>Content</Container>);
container = screen.getByText('Content');
expect(container).toHaveClass('cursor-pointer');
expect(container).toHaveClass('active:opacity-80');
});
});
@@ -0,0 +1,157 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import { usePress } from 'react-aria';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { PressEvents } from 'react-aria';
/**
* Tailwind gap spacing scale values.
*/
type GapScale = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
/**
* Background variants for the Container.
*/
export type ContainerBackground = 'base' | 'primary' | 'secondary' | 'tertiary' | 'error' | 'muted' | 'transparent';
/**
* Max width constraints for the Container.
*/
export type ContainerMaxWidth = 'sm' | 'md' | 'lg' | 'xl' | 'none';
/**
* Border radius variants for the Container.
*/
export type ContainerRounded = 'none' | 'sm' | 'md' | 'lg' | 'full';
/**
* Props for the Container component.
*/
export interface ContainerProps extends ClassNameProps, TestIdProps {
/** Content to be rendered inside the container */
children: React.ReactNode;
/** Background color variant */
bg?: ContainerBackground;
/** Padding using Tailwind spacing scale */
padding?: GapScale;
/** Maximum width constraint */
maxWidth?: ContainerMaxWidth;
/** Border radius variant */
rounded?: ContainerRounded;
/** Whether to show a shadow */
shadow?: boolean;
/** Whether to show a border */
border?: boolean;
/** Press interaction handler */
onPress?: PressEvents['onPress'];
}
/**
* A generic surface component for creating structural layouts.
*
* Container provides a consistent way to handle backgrounds, padding,
* borders, and width constraints while maintaining theme consistency.
*/
export const Container = memo(function Container({
children,
bg = 'base',
padding = 0,
maxWidth = 'none',
rounded = 'none',
shadow = false,
border = false,
onPress,
className,
'data-testid': testId,
}: ContainerProps) {
const ref = React.useRef<HTMLDivElement>(null);
const { pressProps } = usePress({
onPress,
isDisabled: !onPress,
});
const bgClasses: Record<ContainerBackground, string> = {
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
primary: 'bg-[var(--rb-primary-base)] text-[var(--rb-primary-text)]',
secondary: 'bg-[var(--rb-secondary-base)] text-[var(--rb-secondary-text)]',
tertiary: 'bg-[var(--rb-tertiary-base)] text-[var(--rb-tertiary-text)]',
error: 'bg-[var(--rb-error-base)] text-[var(--rb-error-text)]',
muted: 'bg-[var(--rb-muted-base)] text-[var(--rb-muted-text)]',
transparent: 'bg-transparent',
};
const maxWidthClasses: Record<ContainerMaxWidth, string> = {
sm: 'max-w-screen-sm',
md: 'max-w-screen-md',
lg: 'max-w-screen-lg',
xl: 'max-w-screen-xl',
none: 'max-w-none',
};
const roundedClasses: Record<ContainerRounded, string> = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
const paddingClasses: Record<GapScale, string> = {
0: 'p-0',
0.5: 'p-0.5',
1: 'p-1',
1.5: 'p-1.5',
2: 'p-2',
2.5: 'p-2.5',
3: 'p-3',
3.5: 'p-3.5',
4: 'p-4',
5: 'p-5',
6: 'p-6',
7: 'p-7',
8: 'p-8',
9: 'p-9',
10: 'p-10',
11: 'p-11',
12: 'p-12',
14: 'p-14',
16: 'p-16',
20: 'p-20',
24: 'p-24',
28: 'p-28',
32: 'p-32',
36: 'p-36',
40: 'p-40',
44: 'p-44',
48: 'p-48',
52: 'p-52',
56: 'p-56',
60: 'p-60',
64: 'p-64',
72: 'p-72',
80: 'p-80',
96: 'p-96',
};
return (
<div
{...pressProps}
ref={ref}
className={cn(
'overflow-hidden',
bgClasses[bg],
maxWidthClasses[maxWidth],
roundedClasses[rounded],
paddingClasses[padding],
shadow && 'shadow-md',
border && 'border border-[var(--rb-outline)]',
onPress && 'cursor-pointer active:opacity-80 transition-opacity',
className
)}
data-testid={testId}
>
{children}
</div>
);
});
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, forwardRef } from 'react';
import { Stack } from '../Stack/Stack';
@@ -37,6 +37,6 @@ export interface HStackProps extends Omit<StackProps, 'direction'> {
* </HStack>
* ```
*/
export const HStack = memo(function HStack(props: HStackProps) {
return <Stack direction="row" {...props} />;
});
export const HStack = memo(forwardRef<HTMLElement, HStackProps>(function HStack(props, ref) {
return <Stack direction="row" {...props} ref={ref} />;
}));
@@ -0,0 +1,59 @@
import React from 'react';
import { Header } from './Header';
import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Header> = {
title: 'Generic/Header',
component: Header,
tags: ['autodocs'],
argTypes: {
level: {
control: { type: 'select' },
options: [1, 2, 3, 4, 5, 6],
},
color: {
control: 'select',
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
},
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Header>;
export const Default: Story = {
args: {
children: 'Header Level 1',
level: 1,
},
};
export const AllLevels: Story = {
render: () => (
<VStack gap={4}>
<Header level={1}>Header Level 1 (Display L)</Header>
<Header level={2}>Header Level 2 (Display M)</Header>
<Header level={3}>Header Level 3 (Headline L)</Header>
<Header level={4}>Header Level 4 (Headline M)</Header>
<Header level={5}>Header Level 5 (Title L)</Header>
<Header level={6}>Header Level 6 (Title M)</Header>
</VStack>
),
};
export const Overrides: Story = {
render: () => (
<VStack gap={4}>
<Header level={1} size="s" type="headline">H1 Tag with Headline Small Style</Header>
<Header color="primary" level={3} size="l" type="body">H3 Tag with Body Large Style and Primary Color</Header>
<Header italic underline level={6}>H6 with Modifiers</Header>
</VStack>
),
};
@@ -0,0 +1,60 @@
import React, { memo } from 'react';
import { Text } from '../Text/Text';
import type { TextProps, TextType, TextSize } from '../Text/Text';
/**
* Props for the Header component.
*/
export interface HeaderProps extends Omit<TextProps, 'as'> {
/** The semantic header level (1-6) */
level: 1 | 2 | 3 | 4 | 5 | 6;
}
/**
* A semantic header component that wraps the Text component.
*
* Header automatically maps semantic levels (h1-h6) to the appropriate
* typography variants in the design system, ensuring accessibility
* and visual consistency.
*/
export const Header = memo(function Header({
level,
type,
size,
children,
...props
}: HeaderProps) {
// Default mappings from level to type and size
const levelMapping: Record<number, { type: TextType; size: TextSize }> = {
1: { type: 'display', size: 'l' },
2: { type: 'display', size: 'm' },
3: { type: 'headline', size: 'l' },
4: { type: 'headline', size: 'm' },
5: { type: 'title', size: 'l' },
6: { type: 'title', size: 'm' },
};
const defaultStyles = levelMapping[level];
const tags = {
1: 'h1',
2: 'h2',
3: 'h3',
4: 'h4',
5: 'h5',
6: 'h6',
} as const;
const Tag = tags[level];
return (
<Text
as={Tag}
size={size ?? defaultStyles.size}
type={type ?? defaultStyles.type}
{...props}
>
{children}
</Text>
);
});
@@ -0,0 +1,33 @@
import { Link } from './Link';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Link> = {
title: 'components/generic/Link',
component: Link,
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Link>;
export const Default: Story = {
args: {
href: 'https://example.com',
children: 'Standard Link',
},
};
export const WithoutHref: Story = {
args: {
children: 'Action Link (no href)',
onPress: () => alert('Link pressed!'),
},
};
@@ -0,0 +1,18 @@
import React from 'react';
import { Link } from './Link';
import { render, screen } from '../../../test-utils';
describe('components/generic/Link', () => {
it('renders correctly as a link when href is provided', () => {
render(<Link href="https://example.com">Click me</Link>);
const link = screen.getByRole('link', { name: 'Click me' });
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveClass('text-[var(--rb-ring)]');
});
it('renders as a button/span when no href is provided but has onPress', () => {
render(<Link onPress={() => {}}>Click me</Link>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
});
@@ -0,0 +1,64 @@
import { castRef, cn } from '@ermnvldmr/stl';
import React, { memo, useRef } from 'react';
import { useLink } from 'react-aria';
import { Text } from '../Text/Text';
import type { ClassNameProps } from '@ermnvldmr/stl';
import type { AriaLinkOptions } from 'react-aria';
/**
* Props for the Link component.
*/
export interface LinkProps extends AriaLinkOptions, ClassNameProps {
/** The destination URL. Renders as an <a> tag if provided. */
href?: string;
/** Link content */
children: React.ReactNode;
}
/**
* A generic navigation primitive for interactive text.
*
* Link ensures consistent styling and accessibility across all navigation elements.
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
*/
export const Link = memo(function Link(props: LinkProps) {
const { children, className, href, ...otherProps } = props;
// We use a specific ref type that useLink expects.
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
const ref = useRef<HTMLAnchorElement>(null);
const { linkProps } = useLink(otherProps, ref);
const sharedClasses = cn(
'text-[var(--rb-ring)] cursor-pointer hover:underline underline-offset-4 transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
);
if (href) {
return (
<Text
{...linkProps}
ref={ref}
as="a"
className={sharedClasses}
href={href}
>
{children}
</Text>
);
}
return (
<Text
{...linkProps}
ref={castRef<HTMLSpanElement>(ref)}
as="span"
className={sharedClasses}
>
{children}
</Text>
);
});
@@ -12,7 +12,7 @@ import type { Meta, StoryObj } from '@storybook/react';
* through semantic prop names that map to Tailwind CSS utility classes.
*/
const meta: Meta<typeof Stack> = {
title: 'components/shared/Stack',
title: 'components/generic/Stack',
component: Stack,
parameters: {
layout: 'centered',
@@ -1,5 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
@@ -44,6 +44,8 @@ export interface StackProps extends ClassNameProps, TestIdProps {
wrap?: FlexWrap;
/** Spacing between items using Tailwind's spacing scale */
gap?: GapScale;
/** The HTML element to use for rendering */
as?: React.ElementType;
}
/**
@@ -69,22 +71,23 @@ export interface StackProps extends ClassNameProps, TestIdProps {
* </Stack>
* ```
*/
export const Stack = memo(function Stack({
const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack({
children,
direction = 'row',
justify = 'start',
align = 'start',
wrap = 'nowrap',
gap = 0,
as: Component = 'div',
className,
'data-testid': testId,
}: StackProps) {
}, ref) {
const directionClasses = {
row: 'flex-row',
col: 'flex-col',
'row-reverse': 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
};
} as const;
const justifyClasses = {
start: 'justify-start',
@@ -93,7 +96,7 @@ export const Stack = memo(function Stack({
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
};
} as const;
const alignClasses = {
start: 'items-start',
@@ -101,13 +104,13 @@ export const Stack = memo(function Stack({
center: 'items-center',
baseline: 'items-baseline',
stretch: 'items-stretch',
};
} as const;
const wrapClasses = {
nowrap: 'flex-nowrap',
wrap: 'flex-wrap',
'wrap-reverse': 'flex-wrap-reverse',
};
} as const;
// Convert gap scale to Tailwind class using explicit mapping
// This ensures all gap classes are detected by Tailwind's static analysis
@@ -127,7 +130,7 @@ export const Stack = memo(function Stack({
8: 'gap-8',
9: 'gap-9',
10: 'gap-10',
11: 'gap-11',
11: 'gap-10',
12: 'gap-12',
14: 'gap-14',
16: 'gap-16',
@@ -161,11 +164,14 @@ export const Stack = memo(function Stack({
);
return (
<div
<Component
ref={castRef<HTMLElement>(ref)}
className={stackClasses}
data-testid={testId}
>
{children}
</div>
</Component>
);
});
export const Stack = genericMemo(StackComponent);
@@ -0,0 +1,113 @@
import React from 'react';
import { Text } from './Text';
import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Text> = {
title: 'components/generic/Text',
component: Text,
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['display', 'headline', 'title', 'body', 'label'],
},
size: {
control: 'select',
options: ['s', 'm', 'l'],
},
color: {
control: 'select',
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
},
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Text>;
export const Default: Story = {
args: {
children: 'The quick brown fox jumps over the lazy dog.',
type: 'body',
size: 'm',
},
};
export const TypeScale: Story = {
render: () => (
<VStack gap={8}>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Display</Text>
<VStack gap={2}>
<Text size="l" type="display">Display Large</Text>
<Text size="m" type="display">Display Medium</Text>
<Text size="s" type="display">Display Small</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Headline</Text>
<VStack gap={2}>
<Text size="l" type="headline">Headline Large</Text>
<Text size="m" type="headline">Headline Medium</Text>
<Text size="s" type="headline">Headline Small</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Title</Text>
<VStack gap={2}>
<Text size="l" type="title">Title Large</Text>
<Text size="m" type="title">Title Medium</Text>
<Text size="s" type="title">Title Small</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Body</Text>
<VStack gap={2}>
<Text size="l" type="body">Body Large</Text>
<Text size="m" type="body">Body Medium</Text>
<Text size="s" type="body">Body Small</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Label</Text>
<VStack gap={2}>
<Text size="l" type="label">Label Large</Text>
<Text size="m" type="label">Label Medium</Text>
<Text size="s" type="label">Label Small</Text>
</VStack>
</div>
</VStack>
),
};
export const Colors: Story = {
render: () => (
<VStack gap={2}>
<Text color="default">Default Text Color</Text>
<Text color="primary">Primary Text Color</Text>
<Text color="secondary">Secondary Text Color</Text>
<Text color="tertiary">Tertiary Text Color</Text>
<Text color="error">Error Text Color</Text>
<Text color="muted">Muted Text Color</Text>
</VStack>
),
};
export const Modifiers: Story = {
render: () => (
<VStack gap={2}>
<Text bold>Bold Text</Text>
<Text italic>Italic Text</Text>
<Text underline>Underlined Text</Text>
<Text strike>Strikethrough Text</Text>
<Text bold italic underline>Combined Modifiers</Text>
</VStack>
),
};
@@ -0,0 +1,127 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Typography types.
*/
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
/**
* Typography sizes.
*/
export type TextSize = 's' | 'm' | 'l';
/**
* Text color variants.
*/
export type TextColor = 'default' | 'primary' | 'secondary' | 'tertiary' | 'error' | 'muted' | 'inherit';
/**
* Props for the Text component.
*/
export interface TextProps extends ClassNameProps, TestIdProps, Omit<React.HTMLAttributes<HTMLElement>, 'as'> {
/** Content to be rendered */
children: React.ReactNode;
/** Typography style type */
type?: TextType;
/** Typography size variant */
size?: TextSize;
/** Text color variant */
color?: TextColor;
/** Whether the text should be bold */
bold?: boolean;
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be underlined */
underline?: boolean;
/** Whether the text should have a strike-through */
strike?: boolean;
/** The HTML element to use for rendering */
as?: React.ElementType;
/** Optional href for links */
href?: string;
}
/**
* A flexible typography component that implements the design system's type scale.
*
* Text centralizes all typography rules, including font families, sizes,
* line heights, and letter spacing, reducing the need for custom CSS.
*/
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
children,
type = 'body',
size = 'm',
color = 'default',
bold = false,
italic = false,
underline = false,
strike = false,
as: Component = 'span',
className,
'data-testid': testId,
...props
}, ref) {
// Base typography styles mapping
const typeStyles: Record<TextType, Record<TextSize, string>> = {
display: {
l: 'font-serif text-[57px] leading-[64px] tracking-[-0.25px]',
m: 'font-serif text-[45px] leading-[52px] tracking-normal',
s: 'font-serif text-[36px] leading-[44px] tracking-normal',
},
headline: {
l: 'font-serif text-[32px] leading-[40px] tracking-normal',
m: 'font-serif text-[28px] leading-[36px] tracking-normal',
s: 'font-serif text-[24px] leading-[32px] tracking-normal',
},
title: {
l: 'font-sans text-[22px] leading-[28px] tracking-normal',
m: 'font-sans text-[16px] leading-[24px] tracking-[0.15px]',
s: 'font-sans text-[14px] leading-[20px] tracking-[0.1px]',
},
body: {
l: 'font-sans text-[16px] leading-[24px] tracking-[0.5px]',
m: 'font-sans text-[14px] leading-[20px] tracking-[0.25px]',
s: 'font-sans text-[12px] leading-[16px] tracking-[0.4px]',
},
label: {
l: 'font-sans font-medium text-[14px] leading-[20px] tracking-[0.1px]',
m: 'font-sans font-medium text-[12px] leading-[16px] tracking-[0.5px]',
s: 'font-sans font-medium text-[11px] leading-[16px] tracking-[0.5px]',
},
};
const colorClasses: Record<TextColor, string> = {
default: 'text-[var(--rb-text)]',
primary: 'text-[var(--rb-primary-text)]',
secondary: 'text-[var(--rb-secondary-text)]',
tertiary: 'text-[var(--rb-tertiary-text)]',
error: 'text-[var(--rb-error-text)]',
muted: 'text-[var(--rb-muted-text)]',
inherit: 'text-inherit',
};
return (
<Component
{...props}
ref={castRef<HTMLElement>(ref)}
className={cn(
typeStyles[type][size],
colorClasses[color],
bold && 'font-bold',
italic && 'italic',
underline && 'underline',
strike && 'line-through',
className
)}
data-testid={testId}
>
{children}
</Component>
);
});
export const Text = genericMemo(TextComponent);
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, forwardRef } from 'react';
import { Stack } from '../Stack/Stack';
@@ -37,6 +37,6 @@ export interface VStackProps extends Omit<StackProps, 'direction'> {
* </VStack>
* ```
*/
export const VStack = memo(function VStack(props: VStackProps) {
return <Stack direction="col" {...props} />;
});
export const VStack = memo(forwardRef<HTMLElement, VStackProps>(function VStack(props, ref) {
return <Stack direction="col" {...props} ref={ref} />;
}));
+13 -2
View File
@@ -1,2 +1,13 @@
export { HStack } from './HStack/HStack';
export { VStack } from './VStack/VStack';
export * from './Container/Container';
export * from './Header/Header';
export * from './HStack/HStack';
export * from './Stack/Stack';
export * from './Text/Text';
export * from './VStack/VStack';
export * from './Link/Link';
export * from './Breadcrumbs/Breadcrumbs';
export type { StackProps } from './Stack/Stack';
export type { ContainerProps, ContainerBackground, ContainerMaxWidth, ContainerRounded } from './Container/Container';
export type { TextProps, TextType, TextSize, TextColor } from './Text/Text';
export type { HeaderProps } from './Header/Header';
@@ -0,0 +1,42 @@
import { PaperArticle } from './PaperArticle';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PaperArticle> = {
title: 'Components/Paper/PaperArticle',
component: PaperArticle,
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof PaperArticle>;
export const Default: Story = {
args: {
headline: 'AI Agent Learns to Code',
children: 'In a shocking turn of events, a large language model has successfully implemented a UI component from scratch while maintaining design system consistency.',
},
};
export const Full: Story = {
args: {
headline: 'The Future of Web Development',
subHeadline: 'Design Systems and AI Collaboration',
additionalText: 'By AI Reporter #42',
children: 'Newspaper-style layouts are making a comeback in modern web design. By combining classical typography principles with modern responsive frameworks, developers can create unique and engaging user experiences.',
},
};
export const Interactive: Story = {
args: {
...Full.args,
onPress: () => alert('Article clicked!'),
},
};
@@ -0,0 +1,31 @@
import React from 'react';
import { PaperArticle } from './PaperArticle';
import { render, screen } from '../../../test-utils';
describe('components/paper/PaperArticle', () => {
const props = {
headline: 'Breaking News',
subHeadline: 'Local cat saves city',
additionalText: 'Reporting by John Doe',
};
it('renders all parts of the article', () => {
render(<PaperArticle {...props}>Article body content</PaperArticle>);
expect(screen.getByText('Breaking News')).toBeInTheDocument();
expect(screen.getByText('Local cat saves city')).toBeInTheDocument();
expect(screen.getByText('Article body content')).toBeInTheDocument();
expect(screen.getByText('Reporting by John Doe')).toBeInTheDocument();
});
it('renders separators only when appropriate', () => {
const { rerender } = render(<PaperArticle headline="Title">Body</PaperArticle>);
// Should have 1 separator (between title and body)
expect(screen.getAllByRole('separator')).toHaveLength(1);
rerender(<PaperArticle headline="Title" subHeadline="Sub">Body</PaperArticle>);
// Should have 2 separators (Title-Sub and Sub-Body)
expect(screen.getAllByRole('separator')).toHaveLength(2);
});
});
@@ -0,0 +1,78 @@
import React, { memo } from 'react';
import { Container } from '../../generic/Container/Container';
import { Header } from '../../generic/Header/Header';
import { Text } from '../../generic/Text/Text';
import { VStack } from '../../generic/VStack/VStack';
import { PaperSeparator } from '../PaperSeparator/PaperSeparator';
import type { ContainerProps } from '../../generic/Container/Container';
/**
* Props for the newspaper-style PaperArticle component.
*/
export interface PaperArticleProps {
/** 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 */
onPress?: ContainerProps['onPress'];
/** The main article body content */
children: React.ReactNode;
}
/**
* A newspaper-style article component with a structured layout.
*
* Features:
* - Centered medium headline (headline-m)
* - Single-line separators
* - Optional centered italic sub-headline (label-m)
* - Main body text (body-m)
* - Optional bottom gray italic label (label-s)
* - Interactive wrapper via Container
*/
export const PaperArticle = memo(function PaperArticle({
headline,
subHeadline,
additionalText,
onPress,
children,
}: PaperArticleProps) {
return (
<Container padding={4} onPress={onPress}>
<VStack align="stretch" gap={4}>
<Header className="text-center" level={4}>
{headline}
</Header>
<VStack align="center" gap={2}>
{subHeadline ? (
<>
<PaperSeparator className="w-3/4" type="single" />
<Text italic className="text-center" size="m" type="label">
{subHeadline}
</Text>
<PaperSeparator className="w-3/4" type="single" />
</>
) : <PaperSeparator className="w-3/4" type="single" />}
</VStack>
<Text size="m" type="body">
{children}
</Text>
{additionalText && (
<VStack align='end'>
<Text italic color="muted" size="s" type="label">
{additionalText}
</Text>
</VStack>
)}
</VStack>
</Container>
);
});
+3 -3
View File
@@ -1,3 +1,3 @@
export { PaperButton } from './PaperButton/PaperButton';
export { PaperSeparator } from './PaperSeparator/PaperSeparator'
export * from './PaperButton/PaperButton';
export * from './PaperSeparator/PaperSeparator';
export * from './PaperArticle/PaperArticle';