feat: Add more base component to UI

This commit is contained in:
deytenit committed 2026-02-01 16:24:45 +03:00
1 parent ca20310460
commit df2abc06f2
116 files changed
+10379 -3473

No files matched your search

@@ -1,6 +1,5 @@
import { Article } from './Article';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Article> = {
@@ -21,7 +20,8 @@ type Story = StoryObj<typeof Article>;
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.',
children:
'In a shocking turn of events, a large language model has successfully implemented a UI component from scratch while maintaining design system consistency.',
},
};
@@ -30,7 +30,8 @@ export const Full: Story = {
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.',
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.',
},
};
@@ -3,7 +3,7 @@ import React from 'react';
import { Article } from './Article';
import { render, screen } from '../../test-utils';
describe('components/paper/Article', () => {
describe('components/Article', () => {
const props = {
headline: 'Breaking News',
subHeadline: 'Local cat saves city',
@@ -12,7 +12,7 @@ describe('components/paper/Article', () => {
it('renders all parts of the article', () => {
render(<Article {...props}>Article body content</Article>);
expect(screen.getByText('Breaking News')).toBeInTheDocument();
expect(screen.getByText('Local cat saves city')).toBeInTheDocument();
expect(screen.getByText('Article body content')).toBeInTheDocument();
@@ -24,7 +24,11 @@ describe('components/paper/Article', () => {
// Should have 1 separator (between title and body)
expect(screen.getAllByRole('separator')).toHaveLength(1);
rerender(<Article headline="Title" subHeadline="Sub">Body</Article>);
rerender(
<Article headline="Title" subHeadline="Sub">
Body
</Article>
);
// Should have 2 separators (Title-Sub and Sub-Body)
expect(screen.getAllByRole('separator')).toHaveLength(2);
});
+16 -14
View File
@@ -26,7 +26,7 @@ export interface ArticleProps {
/**
* A article component with a structured layout.
*
*
* Features:
* - Centered medium headline (headline-m)
* - Single-line separators
@@ -48,25 +48,27 @@ export const Article = memo(function Article({
<Header className="text-center" level={4}>
{headline}
</Header>
<VStack align="center" gap={2}>
{subHeadline ? (
<>
<Separator className="w-3/4" type="single" />
<Text italic className="text-center" size="m" type="label">
{subHeadline}
</Text>
<Separator className="w-3/4" type="single" />
</>
) : <Separator className="w-3/4" type="single" />}
</VStack>
<VStack align="center" gap={2}>
{subHeadline ? (
<>
<Separator className="w-3/4" type="single" />
<Text italic className="text-center" size="m" type="label">
{subHeadline}
</Text>
<Separator className="w-3/4" type="single" />
</>
) : (
<Separator className="w-3/4" type="single" />
)}
</VStack>
<Text size="m" type="body">
{children}
</Text>
{additionalText && (
<VStack align='end'>
<VStack align="end">
<Text italic color="muted" size="s" type="label">
{additionalText}
</Text>
@@ -0,0 +1,38 @@
import React from 'react';
import { Blockquote } from './Blockquote';
import { Paragraph } from '../Paragraph/Paragraph';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Blockquote> = {
title: 'Components/Blockquote',
component: Blockquote,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Blockquote>;
export const Default: Story = {
render: (args) => (
<Blockquote {...args}>
<Paragraph>
Design is not just what it looks like and feels like. Design is how it works.
</Paragraph>
<Blockquote.Citation>Steve Jobs</Blockquote.Citation>
</Blockquote>
),
};
export const Inset: Story = {
args: {
variant: 'inset',
},
render: (args) => (
<Blockquote {...args}>
<Paragraph italic>The only way to do great work is to love what you do.</Paragraph>
<Blockquote.Citation>Steve Jobs</Blockquote.Citation>
</Blockquote>
),
};
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Blockquote } from './Blockquote';
describe('Blockquote', () => {
it('renders children correctly', () => {
render(<Blockquote>Quote content</Blockquote>);
expect(screen.getByText('Quote content')).toBeInTheDocument();
});
it('renders as a blockquote element', () => {
const { container } = render(<Blockquote>Quote</Blockquote>);
expect(container.querySelector('blockquote')).toBeInTheDocument();
});
it('applies gutterBottom class when prop is true', () => {
const { container } = render(<Blockquote gutterBottom>Quote</Blockquote>);
expect(container.querySelector('blockquote')).toHaveClass('mb-6');
});
it('applies inset styles when variant is inset', () => {
const { container } = render(<Blockquote variant="inset">Quote</Blockquote>);
expect(container.querySelector('blockquote')).toHaveClass('bg-[var(--rb-surface-variant)]');
});
it('renders citation with prefix', () => {
render(
<Blockquote>
Quote
<Blockquote.Citation>Author Name</Blockquote.Citation>
</Blockquote>
);
expect(screen.getByText('— Author Name')).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { Text } from '../Text/Text';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
*
*/
export interface BlockquoteProps
extends ClassNameProps,
TestIdProps,
React.BlockquoteHTMLAttributes<HTMLQuoteElement> {
children: React.ReactNode;
variant?: 'default' | 'inset';
gutterBottom?: boolean;
indent?: boolean;
}
const BlockquoteComponent = forwardRef<HTMLQuoteElement, BlockquoteProps>(function Blockquote(
{
children,
variant = 'default',
gutterBottom = false,
indent = true,
className,
'data-testid': testId,
...props
},
ref
) {
return (
<blockquote
{...props}
ref={ref}
className={cn(
'border-l-4 border-[var(--rb-primary)]',
indent && 'pl-6',
gutterBottom && 'mb-6',
variant === 'inset' && 'bg-[var(--rb-surface-variant)] p-6 rounded-r-lg',
className
)}
data-testid={testId}
>
{children}
</blockquote>
);
});
/**
*
*/
export interface BlockquoteCitationProps
extends ClassNameProps,
TestIdProps,
Omit<React.HTMLAttributes<HTMLElement>, 'prefix'> {
children: React.ReactNode;
prefix?: React.ReactNode;
}
const CitationComponent = forwardRef<HTMLElement, BlockquoteCitationProps>(function Citation(
{ children, prefix = '— ', className, 'data-testid': testId, ...props },
ref
) {
return (
<footer
{...props}
ref={castRef<HTMLElement>(ref)}
className={cn('mt-4 flex items-center', className)}
data-testid={testId}
>
<Text color="muted" size="m" type="label">
{prefix}
{children}
</Text>
</footer>
);
});
export const Blockquote = Object.assign(genericMemo(BlockquoteComponent), {
Citation: genericMemo(CitationComponent),
});
@@ -0,0 +1 @@
export * from './Blockquote';
@@ -1,10 +1,10 @@
import React from 'react';
import { Breadcrumbs } from './Breadcrumbs';
import { render, screen } from '../../../test-utils';
import { render, screen } from '../../test-utils';
import { Link } from '../Link/Link';
describe('components/generic/Breadcrumbs', () => {
describe('components/Breadcrumbs', () => {
it('renders correctly with multiple items and separators', () => {
render(
<Breadcrumbs>
@@ -15,7 +15,7 @@ describe('components/generic/Breadcrumbs', () => {
<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();
@@ -32,11 +32,11 @@ const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: Breadc
/**
* Individual step in the breadcrumb trail.
*/
const BreadcrumbsItem = memo(function BreadcrumbsItem({
children,
const BreadcrumbsItem = memo(function BreadcrumbsItem({
children,
className,
'data-testid': testId,
}: {
}: {
children: React.ReactNode;
className?: string;
} & TestIdProps) {
@@ -57,7 +57,7 @@ export interface BreadcrumbsSeparatorProps extends ClassNameProps, TestIdProps {
variant?: 'slash' | 'bull' | 'arrow';
}
const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
variant = 'slash',
className,
'data-testid': testId,
@@ -69,8 +69,8 @@ const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
};
return (
<li
aria-hidden="true"
<li
aria-hidden="true"
className={cn('text-[var(--rb-muted-text)]', className)}
data-testid={testId}
>
@@ -83,10 +83,10 @@ const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
/**
* 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>
@@ -4,125 +4,188 @@ import { Button } from './Button';
import type { Meta, StoryObj } from '@storybook/react';
/**
* A newspaper-style button component with italic Lato font and underlined text.
*
* Features:
* - Rectangular design with subtle background
* - Italic Medium Lato font (500 weight)
* - Underlined text decoration
* - Accessible interaction patterns via react-aria
* - Responsive hover and focus states
*/
const meta: Meta<typeof Button> = {
title: 'components/Button',
component: Button,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A newspaper-style button following traditional print design patterns.',
},
},
},
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
variant: {
control: 'select',
options: ['default', 'primary', 'secondary'],
description: 'Visual style variant',
options: ['solid', 'outline', 'ghost', 'link'],
},
children: {
control: 'text',
description: 'The button text content (string only)',
color: {
control: 'select',
options: [
'primary',
'primary-negative',
'secondary',
'secondary-negative',
'tertiary',
'tertiary-negative',
'error',
'error-negative',
'neutral',
'neutral-negative',
],
},
isDisabled: {
control: 'boolean',
description: 'Whether the button is disabled',
size: {
control: 'radio',
options: ['s', 'm', 'l'],
},
rounded: {
control: 'radio',
options: ['none', 'md', 'full'],
},
},
args: {
children: 'Explore all the articles',
variant: 'default',
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof meta>;
/**
* The default newspaper-style button with underlined italic text.
* Story helper type.
*/
type Story = StoryObj<typeof Button>;
/**
* The default Button story demonstrating the standard configuration.
*/
export const Default: Story = {
args: {
children: 'Explore all the articles',
onPress: () => console.log('Default button pressed'),
children: 'Button',
variant: 'solid',
color: 'primary',
size: 'm',
},
};
/**
* Primary variant with enhanced visual prominence.
* A gallery of all available Button variants.
*/
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Read more stories',
onPress: () => console.log('Primary button pressed'),
},
export const Variants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex gap-4 items-center">
<Button variant="solid">Solid</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link Variant</Button>
</div>
</div>
),
};
/**
* Secondary variant for supporting actions.
* A gallery of all available Button color themes, including negative variants.
*/
export const Secondary: Story = {
export const Colors: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex gap-4 flex-wrap">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="tertiary">Tertiary</Button>
<Button color="error">Error</Button>
<Button color="neutral">Neutral</Button>
</div>
<div className="flex gap-4 flex-wrap p-4 bg-slate-900 rounded">
<Button color="primary-negative">Primary Neg</Button>
<Button color="secondary-negative">Secondary Neg</Button>
<Button color="tertiary-negative">Tertiary Neg</Button>
<Button color="error-negative">Error Neg</Button>
<Button color="neutral-negative">Neutral Neg</Button>
</div>
</div>
),
};
/**
* A comparison of the available Button sizes.
*/
export const Sizes: Story = {
render: () => (
<div className="flex gap-4 items-end">
<Button size="s">Small</Button>
<Button size="m">Medium</Button>
<Button size="l">Large</Button>
</div>
),
};
/**
* A comparison of the available border radius variants.
*/
export const Rounded: Story = {
render: () => (
<div className="flex gap-4 items-center">
<Button rounded="none">None</Button>
<Button rounded="md">Medium (Default)</Button>
<Button rounded="full">Full (Pill)</Button>
</div>
),
};
/**
* Helper component to render a simple SVG icon.
* @param className - CSS class names for styling.
* @returns An SVG icon element.
* @example
* <Icon className="w-4 h-4" />
*/
const Icon = (className: string): React.ReactNode => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
);
/**
* Story demonstrating the Button component with both start and end icons.
*/
export const WithIcons: Story = {
render: () => (
<div className="flex gap-4">
<Button renderStartIcon={Icon}>Start Icon</Button>
<Button renderEndIcon={Icon}>End Icon</Button>
<Button renderEndIcon={Icon} renderStartIcon={Icon}>
Both Icons
</Button>
<Button renderStartIcon={Icon} />
</div>
),
};
/**
* Story demonstrating the Button component acting as a link (polymorphic 'a' tag).
*/
export const AsLink: Story = {
args: {
variant: 'secondary',
children: 'Subscribe now',
onPress: () => console.log('Secondary button pressed'),
children: 'Go to Example',
href: 'https://example.com',
target: '_blank',
},
};
/**
* Disabled state of the button.
* Story demonstrating the disabled state of the Button component.
*/
export const Disabled: Story = {
args: {
children: 'Unavailable action',
children: 'Disabled Button',
isDisabled: true,
},
};
/**
* Long text content to test text wrapping and layout.
* Story demonstrating the full width capability of the Button component.
*/
export const LongText: Story = {
export const FullWidth: Story = {
args: {
children: 'Explore all the fascinating articles and stories from around the world',
onPress: () => console.log('Long text button pressed'),
children: 'Full Width Button',
fullWidth: true,
},
};
/**
* Interactive demonstration showing all variants side by side.
*/
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 items-start">
<Button variant="default" onPress={() => console.log('Default')}>
Default Style
</Button>
<Button variant="primary" onPress={() => console.log('Primary')}>
Primary Style
</Button>
<Button variant="secondary" onPress={() => console.log('Secondary')}>
Secondary Style
</Button>
<Button isDisabled>
Disabled State
</Button>
</div>
),
};
+56 -122
View File
@@ -1,148 +1,82 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Button } from './Button';
import { render, screen } from '../../test-utils';
import { render, screen, fireEvent } from '../../test-utils';
/**
* Test suite for the newspaper-style Button component
*/
describe('components/paper/Button/Button', () => {
const defaultProps = {
children: 'Test Button',
};
it('renders correctly with default props', () => {
render(<Button {...defaultProps} />);
const button = screen.getByRole('button', { name: 'Test Button' });
describe('components/Button', () => {
it('renders correctly as a button by default', () => {
render(<Button>Click me</Button>);
const button = screen.getByRole('button', { name: 'Click me' });
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent('Test Button');
expect(button.tagName).toBe('BUTTON');
});
it('applies correct styling classes', () => {
render(<Button {...defaultProps} />);
it('renders correctly as an anchor when href is provided', () => {
render(<Button href="https://example.com">Link Button</Button>);
const link = screen.getByRole('link', { name: 'Link Button' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
});
it('calls onPress when clicked in button mode', () => {
const onPress = jest.fn();
render(<Button onPress={onPress}>Click me</Button>);
const button = screen.getByRole('button');
// Check for key newspaper styling classes
expect(button).toHaveClass('italic');
expect(button).toHaveClass('underline');
expect(button).toHaveClass('font-medium');
expect(button).toHaveClass('bg-background');
expect(button).toHaveClass('cursor-pointer');
fireEvent.click(button);
expect(onPress).toHaveBeenCalled();
});
it('handles click events through onPress prop', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
render(<Button onPress={handlePress}>{defaultProps.children}</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handlePress).toHaveBeenCalledTimes(1);
});
it('supports different variants', () => {
const { rerender } = render(
<Button variant="primary">{defaultProps.children}</Button>
it('does not call onPress when disabled', () => {
const onPress = jest.fn();
render(
<Button isDisabled onPress={onPress}>
Disabled
</Button>
);
let button = screen.getByRole('button');
expect(button).toHaveClass('bg-primary');
rerender(<Button variant="secondary">{defaultProps.children}</Button>);
button = screen.getByRole('button');
expect(button).toHaveClass('bg-secondary');
});
it('handles disabled state correctly', () => {
render(<Button isDisabled>{defaultProps.children}</Button>);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(onPress).not.toHaveBeenCalled();
expect(button).toBeDisabled();
expect(button).toHaveClass('disabled:opacity-50');
expect(button).toHaveClass('disabled:cursor-not-allowed');
});
it('does not trigger onPress when disabled', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
it('renders start and end icons correctly', () => {
const StartIcon = (className: string) => (
<span className={className} data-testid="start-icon" />
);
const EndIcon = (className: string) => <span className={className} data-testid="end-icon" />;
render(
<Button isDisabled onPress={handlePress}>
{defaultProps.children}
<Button renderEndIcon={EndIcon} renderStartIcon={StartIcon}>
Icon Button
</Button>
);
const button = screen.getByRole('button');
await user.click(button);
expect(handlePress).not.toHaveBeenCalled();
expect(screen.getByTestId('start-icon')).toBeInTheDocument();
expect(screen.getByTestId('end-icon')).toBeInTheDocument();
expect(screen.getByText('Icon Button')).toBeInTheDocument();
});
it('supports custom className prop', () => {
const customClass = 'custom-button-class';
render(<Button className={customClass}>{defaultProps.children}</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass(customClass);
it('applies correct size classes', () => {
const { rerender } = render(<Button size="s">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('h-8');
rerender(<Button size="l">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('h-12');
});
it('supports data-testid prop', () => {
const testId = 'my-test-button';
render(<Button data-testid={testId}>{defaultProps.children}</Button>);
const button = screen.getByTestId(testId);
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent('Test Button');
it('applies correct rounded classes', () => {
const { rerender } = render(<Button rounded="full">Pill</Button>);
expect(screen.getByRole('button')).toHaveClass('rounded-full');
rerender(<Button rounded="none">Square</Button>);
expect(screen.getByRole('button')).toHaveClass('rounded-none');
rerender(<Button rounded="md">Default</Button>);
expect(screen.getByRole('button')).toHaveClass('rounded-md');
});
it('only accepts string children', () => {
// This test verifies TypeScript constraint - should compile without error
render(<Button>Simple string</Button>);
expect(screen.getByText('Simple string')).toBeInTheDocument();
});
it('maintains accessibility attributes', () => {
render(
<Button
aria-describedby="button-description"
aria-label="Custom accessibility label"
>
{defaultProps.children}
</Button>
);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'Custom accessibility label');
expect(button).toHaveAttribute('aria-describedby', 'button-description');
});
it('handles keyboard navigation', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
render(<Button onPress={handlePress}>{defaultProps.children}</Button>);
const button = screen.getByRole('button');
button.focus();
expect(button).toHaveFocus();
await user.keyboard('{Enter}');
expect(handlePress).toHaveBeenCalledTimes(1);
await user.keyboard(' ');
expect(handlePress).toHaveBeenCalledTimes(2);
});
it('renders string content correctly', () => {
render(<Button>Simple text content</Button>);
expect(screen.getByText('Simple text content')).toBeInTheDocument();
it('applies fullWidth class', () => {
render(<Button fullWidth>Full Width</Button>);
expect(screen.getByRole('button')).toHaveClass('w-full');
});
});
+202 -78
View File
@@ -1,101 +1,225 @@
import React, { memo } from 'react';
import { useButton } from 'react-aria';
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef, useRef } from 'react';
import { useButton, useLink, useFocusRing, useHover } from 'react-aria';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { AriaButtonProps } from 'react-aria';
import type { AriaButtonProps, AriaLinkOptions } from 'react-aria';
/**
* Props for the Button component.
* Button visual style variants.
*/
export interface ButtonProps extends AriaButtonProps, ClassNameProps, TestIdProps {
/** The button text content */
children: string;
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link';
/**
* Button color themes.
*/
export type ButtonColor =
| 'primary'
| 'primary-negative'
| 'secondary'
| 'secondary-negative'
| 'tertiary'
| 'tertiary-negative'
| 'error'
| 'error-negative'
| 'neutral'
| 'neutral-negative';
/**
* Button size variants.
*/
export type ButtonSize = 's' | 'm' | 'l';
/**
* Props shared by both action and link button variants.
*/
interface BaseButtonProps extends ClassNameProps, TestIdProps {
/** Content to be rendered inside the button */
children?: React.ReactNode;
/** Visual style variant */
variant?: 'default' | 'primary' | 'secondary';
variant?: ButtonVariant;
/** Color theme */
color?: ButtonColor;
/** Button size */
size?: ButtonSize;
/** Render prop for icon at the start */
renderStartIcon?: (className: string) => React.ReactNode;
/** Render prop for icon at the end */
renderEndIcon?: (className: string) => React.ReactNode;
/** Whether the button takes full width of container */
fullWidth?: boolean;
/** Corner radius variant */
rounded?: 'none' | 'md' | 'full';
/** Whether the button is disabled */
isDisabled?: boolean;
}
/**
* A button component with italic Lato font and underlined text.
*
* Features:
* - Rectangular design with subtle background
* - Italic Medium Lato font (500 weight)
* - Underlined text decoration
* - Accessible interaction patterns via react-aria
* - Responsive hover and focus states
*
* @example
* ```tsx
* <Button onPress={() => console.log('Clicked!')}>
* Explore all the articles
* </Button>
* ```
* Props for a button acting as a standard button element.
*/
export const Button = memo(function Button({
children,
variant = 'default',
className = '',
'data-testid': testId,
...buttonProps
}: ButtonProps) {
const ref = React.useRef<HTMLButtonElement>(null);
const { buttonProps: ariaButtonProps } = useButton(buttonProps, ref);
interface ActionButtonProps
extends BaseButtonProps,
Omit<AriaButtonProps, 'children' | 'isDisabled' | 'elementType'> {
href?: never;
}
const baseClasses = [
// Layout and spacing
'inline-flex items-center justify-center',
'px-3 py-2',
'min-h-[40px]',
// Typography
'font-sans font-medium italic',
'text-base leading-6 tracking-[0.015em]',
'text-center underline',
// Appearance
'border border-border',
'rounded-none',
'transition-all duration-200',
// Interactive states
'cursor-pointer',
'hover:bg-muted/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'active:bg-muted',
'disabled:opacity-50 disabled:cursor-not-allowed',
];
/**
* Props for a button acting as a navigation link.
*/
interface LinkButtonProps
extends BaseButtonProps,
Omit<AriaLinkOptions, 'children' | 'isDisabled' | 'elementType'> {
href: string;
}
const variantClasses = {
default: [
'bg-background text-foreground',
'hover:bg-muted/30',
],
primary: [
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
],
secondary: [
'bg-secondary text-secondary-foreground',
'hover:bg-secondary/90',
],
/**
* Unified Button props supporting both action and link modes.
*/
export type ButtonProps = ActionButtonProps | LinkButtonProps;
/**
* A highly extensible, polymorphic button component.
*
* Button handles both action-based and navigation-based interactions while
* maintaining a unified visual style adhering to the Rainby design system.
*/
const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(props, ref) {
const {
children,
variant = 'solid',
color = 'primary',
size = 'm',
renderStartIcon,
renderEndIcon,
fullWidth = false,
rounded = 'md',
className,
'data-testid': testId,
isDisabled,
href,
} = props;
const internalRef = useRef<HTMLElement>(null);
const targetRef = ref ? castRef<HTMLElement>(ref) : internalRef;
// Hooks for interactions
// We explicitly construct the props object to avoid unsafe casting of the Union type.
const buttonHookProps = {
...props,
isDisabled,
elementType: href ? ('a' as const) : ('button' as const),
};
const allClasses = [
...baseClasses,
...variantClasses[variant],
className,
].join(' ');
const { buttonProps } = useButton(buttonHookProps, castRef<HTMLButtonElement>(targetRef));
const linkHookProps: AriaLinkOptions = {
...props,
isDisabled,
elementType: 'a',
href: typeof href === 'string' ? href : undefined,
};
const { linkProps } = useLink(linkHookProps, castRef<HTMLAnchorElement>(targetRef));
const { focusProps, isFocusVisible } = useFocusRing();
const { hoverProps, isHovered } = useHover({ isDisabled });
// Styles mapping
const sizeClasses: Record<ButtonSize, string> = {
s: 'h-8 px-3 text-sm gap-1.5',
m: 'h-10 px-4 text-base gap-2',
l: 'h-12 px-6 text-lg gap-3',
};
const iconClasses: Record<ButtonSize, string> = {
s: 'w-4 h-4',
m: 'w-5 h-5',
l: 'w-6 h-6',
};
const roundedClasses: Record<string, string> = {
none: 'rounded-none',
md: 'rounded-md',
full: 'rounded-full',
};
const getVariantClasses = (): string => {
const isNegative = color.endsWith('-negative');
const baseColor = color.replace('-negative', '');
const colors: Record<string, { base: string; text: string }> = {
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
};
const colorValues = colors[baseColor];
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
const foregroundColor = isNegative ? colorValues.base : colorValues.text;
switch (variant) {
case 'solid':
return `bg-[${backgroundColor}] text-[${foregroundColor}] border-transparent`;
case 'outline':
return `bg-transparent text-[${backgroundColor}] border-[${backgroundColor}] border-2`;
case 'ghost':
return `bg-transparent text-[${backgroundColor}] border-transparent hover:bg-[${backgroundColor}]/10`;
case 'link':
return `bg-transparent text-[${backgroundColor}] border-transparent underline-offset-4 hover:underline px-0 h-auto`;
default:
return '';
}
};
const sharedClasses = cn(
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden',
'active:scale-[0.98]',
sizeClasses[size],
roundedClasses[rounded],
getVariantClasses(),
fullWidth && 'w-full',
isDisabled && 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2',
isHovered && !isDisabled && 'brightness-95 dark:brightness-105',
className
);
const content = (
<>
{renderStartIcon?.(iconClasses[size])}
{children}
{renderEndIcon?.(iconClasses[size])}
</>
);
if (href) {
return (
<a
{...linkProps}
{...focusProps}
{...hoverProps}
ref={castRef<HTMLAnchorElement>(targetRef)}
className={sharedClasses}
data-testid={testId}
>
{content}
</a>
);
}
return (
<button
{...ariaButtonProps}
ref={ref}
className={allClasses}
{...buttonProps}
{...focusProps}
{...hoverProps}
ref={castRef<HTMLButtonElement>(targetRef)}
className={sharedClasses}
data-testid={testId}
>
{children}
{content}
</button>
);
});
export const Button = genericMemo(ButtonComponent);
@@ -0,0 +1,103 @@
import { Code } from './Code';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Code> = {
title: 'Components/Code',
component: Code,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'radio',
options: ['block', 'inline'],
},
showLineNumbers: {
control: 'boolean',
},
label: {
control: 'text',
},
},
};
export default meta;
type Story = StoryObj<typeof Code>;
// Helper to simulate highlighted lines
const MockHighlightedCode = () => (
<>
<span className="block text-[var(--rb-primary-text)]">import React from &apos;react&apos;;</span>
<span className="block text-[var(--rb-text)]"> </span>
<span className="block text-[var(--rb-secondary-text)]">
export const MyComponent = () =&gt; &#123;
</span>
<span className="block text-[var(--rb-text)] pl-4">
return &lt;div&gt;Hello World&lt;/div&gt;;
</span>
<span className="block text-[var(--rb-secondary-text)]">&#125;;</span>
</>
);
const LongHighlightedCode = () => (
<>
{Array.from({ length: 50 }).map((_, i) => (
<span key={`line-${String(i)}`} className="block">
<span className="text-[var(--rb-tertiary-text)]">line_{i + 1}</span>:{' '}
<span className="text-[var(--rb-text)]">const x = {Math.random()};</span>
</span>
))}
</>
);
export const DefaultBlock: Story = {
args: {
children: <MockHighlightedCode />,
variant: 'block',
label: 'MyComponent.tsx',
},
};
export const WithoutLabel: Story = {
args: {
children: <MockHighlightedCode />,
variant: 'block',
showLineNumbers: true,
},
};
export const WithoutLineNumbers: Story = {
args: {
children: <MockHighlightedCode />,
variant: 'block',
showLineNumbers: false,
label: 'no-lines.js',
},
};
export const Inline: Story = {
args: {
children: 'npm install @ermnvldmr/ui',
variant: 'inline',
},
};
export const LongWithScroll: Story = {
args: {
children: <LongHighlightedCode />,
variant: 'block',
label: 'long-file.ts',
maxHeight: '300px',
},
};
export const PlainText: Story = {
args: {
children: `const simple = "string content";
console.log(simple);`,
variant: 'block',
label: 'plain-text.js',
// Note: Line numbers won't work automatically on plain text unless we split it into elements
// This story demonstrates behavior with raw text (counters will count the single text node as 1 line or 0)
showLineNumbers: false,
},
};
@@ -0,0 +1,80 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import React from 'react';
import { Code } from './Code';
// Mock clipboard API
const mockWriteText = jest.fn();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
describe('Code Component', () => {
beforeEach(() => {
mockWriteText.mockClear();
});
it('renders inline code correctly', () => {
render(<Code variant="inline">const x = 1;</Code>);
const codeElement = screen.getByText('const x = 1;');
expect(codeElement.tagName).toBe('CODE');
expect(codeElement).toHaveClass('rounded-sm');
});
it('renders block code correctly', () => {
render(
<Code label="test.ts" variant="block">
<span>console.log(&quot;hello&quot;);</span>
</Code>
);
// Should verify it's inside a pre tag
const preElement = screen.getByText('console.log("hello");').closest('pre');
expect(preElement).toBeInTheDocument();
// Should see label
expect(screen.getByText('test.ts')).toBeInTheDocument();
});
it('copies content on click in inline mode', () => {
render(
<Code copyValue="copied!" variant="inline">
Display Text
</Code>
);
const codeElement = screen.getByText('Display Text');
act(() => {
fireEvent.click(codeElement);
});
expect(mockWriteText).toHaveBeenCalledWith('copied!');
});
it('copies content via button in block mode', () => {
render(
<Code copyValue="block copy" variant="block">
<span>some code</span>
</Code>
);
const copyButton = screen.getByLabelText('Copy code');
act(() => {
fireEvent.click(copyButton);
});
expect(mockWriteText).toHaveBeenCalledWith('block copy');
});
it('extracts text from children if copyValue is not provided', () => {
render(<Code variant="inline">extracted</Code>);
const codeElement = screen.getByText('extracted');
act(() => {
fireEvent.click(codeElement);
});
expect(mockWriteText).toHaveBeenCalledWith('extracted');
});
});
+201
View File
@@ -0,0 +1,201 @@
import { cn } from '@ermnvldmr/stl';
import { Check, Copy } from 'lucide-react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { usePress } from 'react-aria';
import { Button } from '../Button/Button';
import { Container } from '../Container/Container';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
*
*/
export interface CodeProps extends ClassNameProps, TestIdProps {
/** The content to display. Can be a string or pre-highlighted HTML elements. */
children: React.ReactNode;
/**
* Display style of the code.
* - 'block': Multi-line container with optional scroll, line numbers, and header controls.
* - 'inline': Compact, clickable snippet for embedding in text.
* @default 'block'
*/
variant?: 'block' | 'inline';
/**
* Optional label text (e.g., filename "index.ts", language "bash")
* displayed in the top-left corner of block variants.
*/
label?: string;
/**
* Whether to render line number gutters in block mode.
* Requires children to be rendered as block-level elements (e.g., via a highlighter).
* @default true
*/
showLineNumbers?: boolean;
/**
* Raw string value to copy to clipboard.
* If omitted, the component will attempt to extract text from `children`.
*/
copyValue?: string;
/**
* Maximum height of the code block before vertical scrolling is enabled.
*/
maxHeight?: string | number;
}
/**
* A general-purpose wrapper for displaying source code.
*
* It supports both block-level (multi-line) and inline (snippet) display modes,
* with features like line numbering, syntax highlighting containers, and copy-to-clipboard functionality.
*/
export const Code = memo(function Code({
children,
variant = 'block',
label,
showLineNumbers = true,
copyValue,
maxHeight,
className,
'data-testid': testId,
}: CodeProps) {
const [isCopied, setIsCopied] = useState(false);
const codeRef = useRef<HTMLElement>(null);
const handleCopy = useCallback(async () => {
try {
let textToCopy = copyValue;
if (!textToCopy && codeRef.current) {
textToCopy = codeRef.current.innerText;
if (!textToCopy) {
textToCopy = codeRef.current.textContent ?? undefined;
}
}
if (textToCopy) {
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
}
} catch (err) {
console.error('Failed to copy code:', err);
}
}, [copyValue]);
const onCopyPress = useCallback(() => {
void handleCopy();
}, [handleCopy]);
useEffect(() => {
if (isCopied) {
const timer = setTimeout(() => setIsCopied(false), 2000);
return () => clearTimeout(timer);
}
}, [isCopied]);
// Inline Variant
const { pressProps } = usePress({
onPress: onCopyPress,
});
if (variant === 'inline') {
return (
<code
{...pressProps}
ref={codeRef}
aria-label="Copy code snippet"
className={cn(
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
'bg-[var(--rb-muted-base)]/50 text-[var(--rb-text)] border border-[var(--rb-outline)]/20',
'cursor-pointer transition-all duration-200',
'hover:bg-[var(--rb-muted-base)]/80',
'active:scale-95',
isCopied && 'ring-1 ring-[var(--rb-secondary-base)]',
className
)}
data-testid={testId}
role="button"
tabIndex={0}
>
{children}
</code>
);
}
// Block Variant
return (
<Container
border
bg="base"
className={cn('relative group overflow-hidden', className)}
data-testid={testId}
rounded="md"
>
{/* Header Controls */}
<div className="absolute top-0 left-0 right-0 h-0 z-10 pointer-events-none">
{label && (
<span className="absolute top-2 left-4 text-xs font-mono text-[var(--rb-muted-text)] select-none bg-[var(--rb-container-base)]/80 backdrop-blur-sm px-1.5 rounded pointer-events-auto">
{label}
</span>
)}
<div className="absolute top-2 right-2 pointer-events-auto">
<Button
aria-label="Copy code"
className={cn(
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
!isCopied && 'opacity-0 group-hover:opacity-100 focus:opacity-100'
)}
color="neutral"
size="s"
variant="ghost"
onPress={onCopyPress}
>
{isCopied ? (
<Check className="w-3.5 h-3.5 text-[var(--rb-secondary-text)]" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</Button>
</div>
</div>
{/* Code Area */}
<pre
className={cn(
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text',
label && 'pt-8' // Add padding if label is present to avoid overlap
)}
style={{ maxHeight: maxHeight }}
>
<code
ref={codeRef}
className={cn(
'block min-w-full',
showLineNumbers && [
'[counter-reset:line] pl-10',
'[&>*]:relative [&>*]:block',
'[&>*]:[counter-increment:line]',
'[&>*]:before:content-[counter(line)]',
'[&>*]:before:absolute [&>*]:before:left-[-2.5rem]',
'[&>*]:before:inline-block',
'[&>*]:before:w-8',
'[&>*]:before:text-right',
'[&>*]:before:text-[var(--rb-muted-text)]/50',
'[&>*]:before:select-none',
'[&>*]:before:border-r',
'[&>*]:before:border-[var(--rb-outline)]/10',
'[&>*]:before:pr-3',
'[&>*]:before:font-mono [&>*]:before:text-[10px]', // Use mono for numbers too
]
)}
>
{children}
</code>
</pre>
</Container>
);
});
@@ -6,7 +6,6 @@ import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Container> = {
title: 'components/Container',
component: Container,
@@ -49,13 +48,27 @@ export const Default: Story = {
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>
<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>
),
};
@@ -63,11 +76,21 @@ export const SemanticColors: Story = {
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>
<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>
),
};
@@ -75,10 +98,18 @@ export const PaddingScale: Story = {
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>
<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>
),
};
@@ -86,11 +117,21 @@ export const MaxWidths: Story = {
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>
<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>
),
};
@@ -115,16 +156,20 @@ 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>
<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.
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>
<Text size="m" type="label">
Inside a nested muted container
</Text>
</Container>
</VStack>
</Container>
@@ -2,14 +2,14 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { Container } from './Container';
import { render, screen } from '../../../test-utils';
import { render, screen } from '../../test-utils';
describe('components/generic/Container', () => {
describe('components/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);
@@ -25,4 +25,4 @@ describe('components/generic/Container', () => {
expect(container).toHaveClass('cursor-pointer');
expect(container).toHaveClass('active:opacity-80');
});
});
});
@@ -8,12 +8,53 @@ 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;
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';
export type ContainerBackground =
| 'base'
| 'primary'
| 'secondary'
| 'tertiary'
| 'error'
| 'muted'
| 'transparent';
/**
* Max width constraints for the Container.
@@ -49,8 +90,8 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
/**
* A generic surface component for creating structural layouts.
*
* Container provides a consistent way to handle backgrounds, padding,
*
* Container provides a consistent way to handle backgrounds, padding,
* borders, and width constraints while maintaining theme consistency.
*/
export const Container = memo(function Container({
+8 -6
View File
@@ -15,11 +15,11 @@ export interface HStackProps extends Omit<StackProps, 'direction'> {
/**
* A horizontal stack layout component that arranges children in a row using CSS Flexbox.
*
*
* This is a specialized version of the Stack component with a fixed horizontal direction.
* It provides a convenient, semantic way to create horizontal layouts without needing
* to specify direction="row" every time.
*
*
* @example
* ```tsx
* // Simple horizontal stack
@@ -28,7 +28,7 @@ export interface HStackProps extends Omit<StackProps, 'direction'> {
* <button>Second</button>
* <button>Third</button>
* </HStack>
*
*
* // Centered horizontal stack with space between
* <HStack align="center" justify="between">
* <div>Logo</div>
@@ -37,6 +37,8 @@ export interface HStackProps extends Omit<StackProps, 'direction'> {
* </HStack>
* ```
*/
export const HStack = memo(forwardRef<HTMLElement, HStackProps>(function HStack(props, ref) {
return <Stack direction="row" {...props} ref={ref} />;
}));
export const HStack = memo(
forwardRef<HTMLElement, HStackProps>(function HStack(props, ref) {
return <Stack direction="row" {...props} ref={ref} />;
})
);
@@ -5,7 +5,6 @@ import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Header> = {
title: 'components/Header',
component: Header,
@@ -51,9 +50,15 @@ export const AllLevels: Story = {
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>
<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>
),
};
+5 -16
View File
@@ -14,18 +14,12 @@ export interface HeaderProps extends Omit<TextProps, 'as'> {
/**
* 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
*
* 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) {
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' },
@@ -48,12 +42,7 @@ export const Header = memo(function Header({
const Tag = tags[level];
return (
<Text
as={Tag}
size={size ?? defaultStyles.size}
type={type ?? defaultStyles.type}
{...props}
>
<Text as={Tag} size={size ?? defaultStyles.size} type={type ?? defaultStyles.type} {...props}>
{children}
</Text>
);
@@ -0,0 +1,124 @@
import React from 'react';
import { Image } from './Image';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Image> = {
title: 'components/Image',
component: Image,
tags: ['autodocs'],
argTypes: {
rounded: {
control: 'radio',
options: ['none', 'sm', 'md', 'lg', 'full'],
},
shadow: {
control: 'radio',
options: ['none', 'sm', 'md', 'lg'],
},
objectFit: {
control: 'select',
options: ['cover', 'contain', 'fill'],
},
},
};
export default meta;
type Story = StoryObj<typeof Image>;
export const Default: Story = {
args: {
src: 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=2070&auto=format&fit=crop',
alt: 'Beautiful Landscape',
width: '100%',
ratio: '16/9',
rounded: 'lg',
shadow: 'md',
},
};
export const AspectRatios: Story = {
render: () => (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">16:9 (Landscape)</span>
<Image
alt="16:9"
ratio="16/9"
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=2070&auto=format&fit=crop"
width="100%"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">1:1 (Square)</span>
<Image
alt="1:1"
ratio="1/1"
rounded="full"
src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?q=80&w=2000&auto=format&fit=crop"
width="100%"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">4:5 (Portrait)</span>
<Image
alt="4:5"
ratio="4/5"
src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=1964&auto=format&fit=crop"
width="100%"
/>
</div>
</div>
),
};
export const ObjectFit: Story = {
render: () => (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Cover (Default)</span>
<Image
alt="Cover"
height="200px"
objectFit="cover"
src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=2074&auto=format&fit=crop"
width="100%"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Contain</span>
<Image
alt="Contain"
className="bg-neutral-100 dark:bg-neutral-800"
height="200px"
objectFit="contain"
src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=2074&auto=format&fit=crop"
width="100%"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Fill</span>
<Image
alt="Fill"
height="200px"
objectFit="fill"
src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=2074&auto=format&fit=crop"
width="100%"
/>
</div>
</div>
),
};
export const LoadingState: Story = {
render: () => (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Simulated Loading (Using Stub)</span>
<Image alt="Loading" ratio="16/9" src="https://invalid-url.jpg" width="300px" />
<p className="text-xs text-neutral-500">Note: This will stay in &quot;loading&quot; then &quot;error&quot; state because URL is invalid.</p>
</div>
),
};
@@ -0,0 +1,70 @@
import React from 'react';
import { Image } from './Image';
import { render, screen, fireEvent } from '../../test-utils';
describe('components/Image', () => {
const defaultProps = {
src: 'https://example.com/image.jpg',
alt: 'Example image',
width: '300px',
height: '200px',
};
it('renders with correct dimensions for CLS safety', () => {
render(<Image {...defaultProps} />);
const wrapper = screen.getByTestId('image-wrapper');
expect(wrapper).toHaveStyle({ width: '300px', height: '200px' });
});
it('renders with aspect ratio when provided', () => {
render(<Image alt="test" ratio="16/9" src="test.jpg" width="100%" />);
const wrapper = screen.getByTestId('image-wrapper');
expect(wrapper).toHaveStyle({ width: '100%', aspectRatio: '16/9' });
});
it('shows stub while loading and hides it after load', () => {
render(<Image {...defaultProps} />);
// Stub should be visible
expect(screen.getByTestId('stub')).toBeInTheDocument();
const img = screen.getByTestId('image-element');
expect(img).toHaveClass('opacity-0');
// Simulate load
fireEvent.load(img);
// Image should be visible (opacity-100)
expect(img).toHaveClass('opacity-100');
// Stub is removed from DOM when isLoaded is true in implementation
expect(screen.queryByTestId('stub')).not.toBeInTheDocument();
});
it('stops stub animation on error', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('image-element');
// Simulate error
fireEvent.error(img);
// Stub should be present but not animating
const stub = screen.getByTestId('stub');
expect(stub).not.toHaveClass('animate-shimmer');
});
it('applies rounding and shadow classes', () => {
render(<Image {...defaultProps} rounded="full" shadow="lg" />);
const wrapper = screen.getByTestId('image-wrapper');
expect(wrapper).toHaveClass('rounded-full');
expect(wrapper).toHaveClass('shadow-lg');
});
it('passes correct attributes to native img tag', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('image-element');
expect(img).toHaveAttribute('src', defaultProps.src);
expect(img).toHaveAttribute('alt', defaultProps.alt);
expect(img).toHaveAttribute('loading', 'lazy');
expect(img).toHaveAttribute('decoding', 'async');
});
});
+108
View File
@@ -0,0 +1,108 @@
import { cn } from '@ermnvldmr/stl';
import React, { useState } from 'react';
import { Stub } from '../Stub/Stub';
/**
* Image dimension constraints to ensure CLS safety.
* Requires either:
* - Both width and height
* - Width and ratio
* - Height and ratio
*/
type ImageDimensions =
| { width: number | string; height: number | string; ratio?: never }
| { width: number | string; ratio: number | string; height?: never }
| { height: number | string; ratio: number | string; width?: never };
/**
* Base props for the Image component.
*/
interface ImageBaseProps {
/** Image source URL */
src: string;
/** Accessible alt text */
alt: string;
/** Corner radius variant */
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full';
/** Shadow variant */
shadow?: 'none' | 'sm' | 'md' | 'lg';
/** CSS object-fit property */
objectFit?: 'cover' | 'contain' | 'fill';
/** Additional CSS classes for the wrapper */
className?: string;
}
/**
* Unified Image props.
*/
export type ImageProps = ImageBaseProps & ImageDimensions;
/**
* A performance-optimized Image component with built-in CLS prevention,
* loading states, and Rainby design system styles.
*
* @param props - Component properties
* @returns A styled image component with loading skeleton
* @example
* <Image src="path/to/img.jpg" alt="Description" width={300} ratio="16/9" />
*/
export const Image: React.FC<ImageProps> = (props) => {
const { src, alt, rounded = 'none', shadow = 'none', objectFit = 'cover', className } = props;
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
const shadowClasses = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
};
const wrapperStyle: React.CSSProperties = {
width: 'width' in props ? props.width : undefined,
height: 'height' in props ? props.height : undefined,
aspectRatio: 'ratio' in props ? String(props.ratio) : undefined,
};
return (
<div
className={cn(
'relative overflow-hidden',
roundedClasses[rounded],
shadowClasses[shadow],
className
)}
data-testid="image-wrapper"
style={wrapperStyle}
>
{!isLoaded && (
<Stub animate={!hasError} className="absolute inset-0 z-10" rounded={rounded} />
)}
<img
alt={alt}
className={cn(
'w-full h-full transition-opacity duration-300',
objectFit === 'cover' && 'object-cover',
objectFit === 'contain' && 'object-contain',
objectFit === 'fill' && 'object-fill',
isLoaded ? 'opacity-100' : 'opacity-0'
)}
data-testid="image-element"
decoding="async"
loading="lazy"
src={src}
onError={() => setHasError(true)}
onLoad={() => setIsLoaded(true)}
/>
</div>
);
};
@@ -1,6 +1,5 @@
import { Link } from './Link';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Link> = {
@@ -1,9 +1,9 @@
import React from 'react';
import { Link } from './Link';
import { render, screen } from '../../../test-utils';
import { render, screen } from '../../test-utils';
describe('components/generic/Link', () => {
describe('components/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' });
+5 -16
View File
@@ -19,14 +19,14 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
/**
* 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.
// 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);
@@ -39,25 +39,14 @@ export const Link = memo(function Link(props: LinkProps) {
if (href) {
return (
<Text
{...linkProps}
ref={ref}
as="a"
className={sharedClasses}
href={href}
>
<Text {...linkProps} ref={ref} as="a" className={sharedClasses} href={href}>
{children}
</Text>
);
}
return (
<Text
{...linkProps}
ref={castRef<HTMLSpanElement>(ref)}
as="span"
className={sharedClasses}
>
<Text {...linkProps} ref={castRef<HTMLSpanElement>(ref)} as="span" className={sharedClasses}>
{children}
</Text>
);
@@ -0,0 +1,72 @@
import React from 'react';
import { List } from './List';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof List> = {
title: 'Components/List',
component: List,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof List>;
export const Unordered: Story = {
args: {
variant: 'unordered',
spacing: 's',
children: (
<>
<List.Item>First item</List.Item>
<List.Item>Second item</List.Item>
<List.Item>Third item</List.Item>
</>
),
},
};
export const Ordered: Story = {
args: {
variant: 'ordered',
spacing: 's',
children: (
<>
<List.Item>First item</List.Item>
<List.Item>Second item</List.Item>
<List.Item>Third item</List.Item>
</>
),
},
};
export const ExplicitUsage: Story = {
render: () => (
<div className="space-y-4">
<List spacing="s" variant="unordered">
<List.Item>Unordered Item 1</List.Item>
<List.Item>Unordered Item 2</List.Item>
</List>
<List spacing="s" variant="ordered">
<List.Item>Ordered Item 1</List.Item>
<List.Item>Ordered Item 2</List.Item>
</List>
</div>
),
};
export const Nested: Story = {
render: () => (
<List spacing="m" variant="unordered">
<List.Item>
Parent Item 1
<List className="mt-2" spacing="s" variant="unordered">
<List.Item>Child Item 1</List.Item>
<List.Item>Child Item 2</List.Item>
</List>
</List.Item>
<List.Item>Parent Item 2</List.Item>
</List>
),
};
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { List } from './List';
describe('List', () => {
it('renders children correctly', () => {
render(
<List>
<List.Item>Item 1</List.Item>
</List>
);
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
it('renders as ul by default', () => {
const { container } = render(
<List>
<List.Item>Item 1</List.Item>
</List>
);
expect(container.querySelector('ul')).toBeInTheDocument();
});
it('applies ordered styles', () => {
const { container } = render(
<List variant="ordered">
<List.Item>1</List.Item>
</List>
);
expect(container.querySelector('ol')).toHaveClass('list-decimal');
});
it('applies spacing', () => {
const { container } = render(
<List spacing="m">
<List.Item>1</List.Item>
</List>
);
expect(container.firstChild).toHaveClass('gap-2');
});
it('renders custom marker', () => {
render(
<List marker={<span data-testid="custom-marker">*</span>}>
<List.Item>Item 1</List.Item>
</List>
);
expect(screen.getByTestId('custom-marker')).toBeInTheDocument();
});
it('item marker overrides list marker', () => {
render(
<List marker={<span>Global</span>}>
<List.Item marker={<span data-testid="item-marker">Item</span>}>Item 1</List.Item>
</List>
);
expect(screen.getByTestId('item-marker')).toBeInTheDocument();
expect(screen.queryByText('Global')).not.toBeInTheDocument();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Supported list visual variants.
*/
export type ListVariant = 'unordered' | 'ordered' | 'plain';
/**
* Supported spacing values between list items.
*/
export type ListSpacing = 'none' | 's' | 'm' | 'l';
/**
* Props for the List component.
*/
export interface ListProps extends ClassNameProps, TestIdProps, React.HTMLAttributes<HTMLElement> {
/** List content, typically List.Item or Li components */
children: React.ReactNode;
/** Visual style variant */
variant?: ListVariant;
/** Spacing between items */
spacing?: ListSpacing;
/** Global marker override for all items in this list */
marker?: React.ReactNode;
/** Custom HTML element to render (defaults to ul/ol based on variant) */
as?: React.ElementType;
}
const spacingStyles: Record<ListSpacing, string> = {
none: 'gap-0',
s: 'gap-1',
m: 'gap-2',
l: 'gap-4',
};
const variantStyles: Record<ListVariant, string> = {
unordered: 'list-disc pl-5',
ordered: 'list-decimal pl-5',
plain: 'list-none pl-0',
};
const ListContext = React.createContext<{ marker?: React.ReactNode }>({});
const ListComponent = forwardRef<HTMLElement, ListProps>(function List(
{
children,
variant = 'unordered',
spacing = 'none',
marker,
as,
className,
'data-testid': testId,
...props
},
ref
) {
const Component = as ?? (variant === 'ordered' ? 'ol' : 'ul');
const hasCustomMarker = !!marker;
return (
<ListContext.Provider value={{ marker }}>
<Component
{...props}
ref={castRef<HTMLElement>(ref)}
className={cn(
'm-0 flex flex-col',
// Hide default markers if a global custom marker is provided
hasCustomMarker ? 'list-none pl-0' : variantStyles[variant],
spacingStyles[spacing],
className
)}
data-testid={testId}
>
{children}
</Component>
</ListContext.Provider>
);
});
/**
* Props for a List item.
*/
export interface ListItemProps
extends ClassNameProps,
TestIdProps,
React.HTMLAttributes<HTMLElement> {
/** Item content */
children: React.ReactNode;
/** Specific marker override for this individual item */
marker?: React.ReactNode;
}
const ListItemComponent = forwardRef<HTMLElement, ListItemProps>(function ListItem(
{ children, marker: itemMarker, className, 'data-testid': testId, ...props },
ref
) {
const { marker: listMarker } = React.useContext(ListContext);
const effectiveMarker = itemMarker ?? listMarker;
if (effectiveMarker) {
return (
<li
{...props}
ref={castRef<HTMLLIElement>(ref)}
className={cn('flex gap-3 items-start', className)}
data-testid={testId}
>
<span className="flex-shrink-0 flex items-center justify-center min-w-[1.25rem] min-h-[1.5rem]">
{effectiveMarker}
</span>
<div className="flex-1">{children}</div>
</li>
);
}
return (
<li
{...props}
ref={castRef<HTMLLIElement>(ref)}
className={cn('relative', className)}
data-testid={testId}
>
{children}
</li>
);
});
export const List = Object.assign(genericMemo(ListComponent), {
Item: genericMemo(ListItemComponent),
});
+1
View File
@@ -0,0 +1 @@
export * from './List';
@@ -26,12 +26,12 @@ export const Default: Story = {
children: (
<>
<p>
This is a <strong>Markdown</strong> component that uses Tailwind CSS Typography (prose)
to render content with consistent styling.
This is a <strong>Markdown</strong> component that uses Tailwind CSS Typography (prose) to
render content with consistent styling.
</p>
<p>
It is designed to be used with rendered HTML from markdown processors,
but works with any HTML content that needs standard document styling.
It is designed to be used with rendered HTML from markdown processors, but works with any
HTML content that needs standard document styling.
</p>
</>
),
@@ -72,7 +72,8 @@ export const Lists: Story = {
<h3>Unordered List</h3>
<ul>
<li>First item</li>
<li>Second item
<li>
Second item
<ul>
<li>Nested item A</li>
<li>Nested item B</li>
@@ -84,7 +85,8 @@ export const Lists: Story = {
<h3>Ordered List</h3>
<ol>
<li>Step one</li>
<li>Step two
<li>
Step two
<ol>
<li>Sub-step 2.1</li>
<li>Sub-step 2.2</li>
@@ -183,16 +185,23 @@ export const KitchenSink: Story = {
A lead paragraph that stands out and sets the tone for the rest of the document.
</p>
<p>
Markdown is a lightweight markup language with plain-text-formatting syntax.
Its design allows it to be converted to many output formats, but the name
originally referred to the tool for converting it to HTML.
Markdown is a lightweight markup language with plain-text-formatting syntax. Its design
allows it to be converted to many output formats, but the name originally referred to the
tool for converting it to HTML.
</p>
<h2>Key Features</h2>
<ul>
<li><strong>Easy to read</strong> - The source text looks like the final output.</li>
<li><strong>Easy to write</strong> - Minimal syntax overhead.</li>
<li><strong>Versatile</strong> - Supports many elements like <em>italics</em>, <del>strikethrough</del>, and <code>code</code>.</li>
<li>
<strong>Easy to read</strong> - The source text looks like the final output.
</li>
<li>
<strong>Easy to write</strong> - Minimal syntax overhead.
</li>
<li>
<strong>Versatile</strong> - Supports many elements like <em>italics</em>,{' '}
<del>strikethrough</del>, and <code>code</code>.
</li>
</ul>
<h3>Example Code Block</h3>
@@ -209,8 +218,7 @@ export const MyPage = () => (
<blockquote>
&quot;Simplicity is the ultimate sophistication.&quot;
<br />
— Leonardo da Vinci
<br />— Leonardo da Vinci
</blockquote>
<hr />
@@ -245,7 +253,10 @@ export const MyPage = () => (
<h3>Media Elements</h3>
<p>Images should be rounded and have a nice shadow.</p>
<img alt="Coding" src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=1200&q=80" />
<img
alt="Coding"
src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=1200&q=80"
/>
<p>Conclusion: We&apos;ve built a robust system for rendering content.</p>
</>
@@ -31,13 +31,5 @@ export interface MarkdownProps {
* ```
*/
export const Markdown: React.FC<MarkdownProps> = ({ children, className }) => {
return (
<div
className={cn(
className
)}
>
{children}
</div>
);
return <div className={cn(className)}>{children}</div>;
};
@@ -0,0 +1,77 @@
import React from 'react';
import { Paragraph } from './Paragraph';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Paragraph> = {
title: 'components/Paragraph',
component: Paragraph,
tags: ['autodocs'],
argTypes: {
align: { control: 'select', options: ['left', 'center', 'right', 'justify'] },
wrap: { control: 'select', options: ['nowrap', 'balance', 'pretty'] },
gutterBottom: { control: 'boolean' },
indent: { control: 'boolean' },
dropCap: { control: 'boolean' },
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;
/**
* Story helper type for Paragraph.
*/
type Story = StoryObj<typeof Paragraph>;
/**
* The default Paragraph story showing standard body text.
*/
export const Default: Story = {
args: {
children:
'This is a standard paragraph used for body text. It wraps naturally and provides good readability defaults using the body type and medium size from the design system.',
},
};
export const Editorial: Story = {
args: {
dropCap: true,
indent: true,
align: 'justify',
className: 'max-w-md',
children:
'Once upon a time, there was a design system that needed a robust paragraph component. It was designed to handle long-form content with grace, supporting traditional typesetting features like drop caps and first-line indentation. This helps create a more engaging reading experience for long-form articles or documentation.',
},
};
export const VerticalRhythm: Story = {
render: () => (
<div className="max-w-md border p-4">
<Paragraph gutterBottom>
This is the first paragraph with <code>gutterBottom</code> enabled. It adds a margin at the
bottom to separate it from the next block of text, establishing a clear vertical rhythm.
</Paragraph>
<Paragraph gutterBottom>
The second paragraph also has <code>gutterBottom</code>. Notice the consistent spacing
between these blocks.
</Paragraph>
<Paragraph>
This final paragraph does not have <code>gutterBottom</code>, so any subsequent elements
would be placed immediately after it without the extra spacing.
</Paragraph>
</div>
),
};
@@ -0,0 +1,26 @@
import React from 'react';
import { Paragraph } from './Paragraph';
import { render, screen } from '../../test-utils';
describe('components/Paragraph', () => {
it('renders as a p tag by default', () => {
render(<Paragraph>Content</Paragraph>);
expect(screen.getByText('Content').tagName).toBe('P');
});
it('applies gutterBottom', () => {
render(<Paragraph gutterBottom>Content</Paragraph>);
expect(screen.getByText('Content')).toHaveClass('mb-4');
});
it('applies indent', () => {
render(<Paragraph indent>Content</Paragraph>);
expect(screen.getByText('Content')).toHaveClass('indent-8');
});
it('passes through Text props like align', () => {
render(<Paragraph align="center">Content</Paragraph>);
expect(screen.getByText('Content')).toHaveClass('text-center');
});
});
@@ -0,0 +1,49 @@
import { cn, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { Text } from '../Text/Text';
import type { TextProps } from '../Text/Text';
/**
* Props for the Paragraph component.
*/
export interface ParagraphProps extends Omit<TextProps, 'as'> {
/** Whether to add a bottom margin (standard vertical rhythm) */
gutterBottom?: boolean;
/** Whether to indent the first line */
indent?: boolean;
/** Whether to style the first letter as a drop cap */
dropCap?: boolean;
}
/**
* A semantic paragraph component that wraps the Text component.
*
* Paragraph provides specialized block-level features like vertical spacing,
* indentation, and drop caps while inheriting all typographic capabilities
* of the Text component.
*/
const ParagraphComponent = forwardRef<HTMLElement, ParagraphProps>(function Paragraph(
{ children, gutterBottom = false, indent = false, dropCap = false, className, ...props },
ref
) {
return (
<Text
ref={ref}
as="p"
className={cn(
gutterBottom && 'mb-4',
indent && 'indent-8',
dropCap &&
'first-letter:text-7xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:leading-none',
className
)}
{...props}
>
{children}
</Text>
);
});
export const Paragraph = genericMemo(ParagraphComponent);
@@ -6,7 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react';
/**
* A newspaper-style separator component for dividing content sections.
*
*
* Features:
* - Single or double line variants inspired by newspaper layout traditions
* - Multiple color options (black, outline, outline-light) with theme support
@@ -22,7 +22,8 @@ const meta: Meta<typeof Separator> = {
layout: 'centered',
docs: {
description: {
component: 'A newspaper-style separator following traditional print design patterns for content division.',
component:
'A newspaper-style separator following traditional print design patterns for content division.',
},
},
},
@@ -69,13 +70,9 @@ export const Default: Story = {
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above
</div>
<div className="text-center text-sm text-muted-foreground mb-4">Content Above</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below
</div>
<div className="text-center text-sm text-muted-foreground mt-4">Content Below</div>
</div>
),
],
@@ -92,13 +89,9 @@ export const Double: Story = {
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above
</div>
<div className="text-center text-sm text-muted-foreground mb-4">Content Above</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below
</div>
<div className="text-center text-sm text-muted-foreground mt-4">Content Below</div>
</div>
),
],
@@ -115,13 +108,9 @@ export const Black: Story = {
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above
</div>
<div className="text-center text-sm text-muted-foreground mb-4">Content Above</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below
</div>
<div className="text-center text-sm text-muted-foreground mt-4">Content Below</div>
</div>
),
],
@@ -138,13 +127,9 @@ export const OutlineLight: Story = {
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above
</div>
<div className="text-center text-sm text-muted-foreground mb-4">Content Above</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below
</div>
<div className="text-center text-sm text-muted-foreground mt-4">Content Below</div>
</div>
),
],
@@ -302,7 +287,7 @@ export const VerticalShowcase: Story = {
Content
</div>
</div>
<div className="flex h-48">
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Medium
@@ -314,7 +299,7 @@ export const VerticalShowcase: Story = {
Content
</div>
</div>
<div className="flex h-64">
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Tall
@@ -5,7 +5,6 @@ import { VStack } from '../VStack/VStack';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Props for the Separator component.
*/
@@ -22,7 +21,7 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
/**
* A separator component for dividing content sections.
*
*
* Features:
* - Single or double line variants inspired by newspaper layout traditions
* - Multiple color options (black, outline, outline-light) with theme support
@@ -30,26 +29,26 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
* - Optional thinned gradient fade effect at line ends (10% fade length)
* - 2px stroke width following print design standards
* - Semantic HTML with proper ARIA attributes for accessibility
*
*
* The component uses CSS mask-image for the thinned effect and CSS custom properties
* for theme-aware colors. Double separators have 4px spacing between lines.
*
*
* @example
* ```tsx
* // Basic horizontal separator
* <Separator />
*
*
* // Double-line separator with black color
* <Separator type="double" color="black" />
*
*
* // Vertical separator without thinned ends
* <Separator direction="vertical" thinned={false} />
*
*
* // Light separator for subtle content division
* <Separator color="outline-light" />
* ```
*/
export const Separator = memo(function Separator({
export const Separator = memo(function Separator({
type = 'single',
color,
direction = 'horizontal',
@@ -64,7 +63,7 @@ export const Separator = memo(function Separator({
// Color mapping to CSS custom properties
const colorClasses = {
black: 'border-foreground',
outline: 'border-border',
outline: 'border-border',
'outline-light': 'border-muted',
};
@@ -78,25 +77,23 @@ export const Separator = memo(function Separator({
];
// Thinned effect using CSS mask
const thinnedClasses = thinned ? [
direction === 'horizontal'
? '[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]'
: '[mask-image:linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]',
] : [];
const thinnedClasses = thinned
? [
direction === 'horizontal'
? '[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]'
: '[mask-image:linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]',
]
: [];
// Size classes - fill container by default
const sizeClasses = direction === 'horizontal'
? ['w-full', 'h-0'] // Full width, no height (border provides the visual)
: ['h-full', 'w-0']; // Full height, no width (border provides the visual)
const sizeClasses =
direction === 'horizontal'
? ['w-full', 'h-0'] // Full width, no height (border provides the visual)
: ['h-full', 'w-0']; // Full height, no width (border provides the visual)
// Single line implementation
if (type === 'single') {
const lineClasses = cn(
...lineBaseClasses,
...thinnedClasses,
...sizeClasses,
className
);
const lineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses, className);
return (
<div
@@ -109,16 +106,9 @@ export const Separator = memo(function Separator({
}
// Double line implementation
const singleLineClasses = cn(
...lineBaseClasses,
...thinnedClasses,
...sizeClasses
);
const singleLineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses);
const containerClasses = cn(
direction === 'horizontal' ? 'w-full' : 'h-full',
className
);
const containerClasses = cn(direction === 'horizontal' ? 'w-full' : 'h-full', className);
if (direction === 'horizontal') {
return (
@@ -129,20 +119,14 @@ export const Separator = memo(function Separator({
role="separator"
>
<VStack gap={1}>
<div
aria-hidden="true"
className={singleLineClasses}
/>
<div
aria-hidden="true"
className={singleLineClasses}
/>
<div aria-hidden="true" className={singleLineClasses} />
<div aria-hidden="true" className={singleLineClasses} />
</VStack>
</div>
);
} else {
return (
<div
<div
aria-orientation="vertical"
className={cn('flex flex-row gap-1', containerClasses)}
data-testid={testId}
@@ -6,7 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react';
/**
* A flexible layout component that arranges children using CSS Flexbox.
*
*
* The Stack component provides a consistent, type-safe way to create flexible layouts
* with proper spacing and alignment. It supports all major flexbox properties
* through semantic prop names that map to Tailwind CSS utility classes.
@@ -18,7 +18,8 @@ const meta: Meta<typeof Stack> = {
layout: 'centered',
docs: {
description: {
component: 'A flexible layout component using CSS Flexbox for consistent spacing and alignment.',
component:
'A flexible layout component using CSS Flexbox for consistent spacing and alignment.',
},
},
},
@@ -34,7 +35,7 @@ const meta: Meta<typeof Stack> = {
description: 'Main axis alignment',
},
align: {
control: 'select',
control: 'select',
options: ['start', 'end', 'center', 'baseline', 'stretch'],
description: 'Cross axis alignment',
},
@@ -48,6 +49,10 @@ const meta: Meta<typeof Stack> = {
options: [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 12, 16, 20, 24],
description: 'Spacing between items',
},
scrollable: {
control: 'boolean',
description: 'Enable scrolling on the main axis',
},
},
args: {
direction: 'row',
@@ -55,6 +60,7 @@ const meta: Meta<typeof Stack> = {
align: 'start',
wrap: 'nowrap',
gap: 4,
scrollable: false,
},
};
@@ -65,8 +71,16 @@ export default meta;
type Story = StoryObj<typeof meta>;
// Sample content components for demonstrations
const SampleBox = ({ children, color = 'bg-primary' }: { children: React.ReactNode; color?: string }): React.JSX.Element => (
<div className={`${color} text-primary-foreground px-4 py-2 rounded text-sm font-medium min-w-16 text-center`}>
const SampleBox = ({
children,
color = 'bg-primary',
}: {
children: React.ReactNode;
color?: string;
}): React.JSX.Element => (
<div
className={`${color} text-primary-foreground px-4 py-2 rounded text-sm font-medium min-w-16 text-center`}
>
{children}
</div>
);
@@ -179,7 +193,7 @@ export const Reversed: Story = {
<SampleBox color="bg-accent">3</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Column Reverse</h3>
<Stack direction="col-reverse" gap={3}>
@@ -206,7 +220,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 1.5 (Decimal)</h3>
<Stack gap={1.5}>
@@ -215,7 +229,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 2.5 (Decimal)</h3>
<Stack gap={2.5}>
@@ -224,7 +238,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 3.5 (Decimal)</h3>
<Stack gap={3.5}>
@@ -233,7 +247,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 4 (Integer)</h3>
<Stack gap={4}>
@@ -242,7 +256,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 8 (Large)</h3>
<Stack gap={8}>
@@ -266,23 +280,27 @@ export const AlignmentVariations: Story = {
<div className="h-20 border-2 border-dashed border-border">
<Stack align="start" className="h-full" gap={2}>
<SampleBox>Short</SampleBox>
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">Tall</div>
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">
Tall
</div>
<SampleBox>Short</SampleBox>
</Stack>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Align: Center</h3>
<div className="h-20 border-2 border-dashed border-border">
<Stack align="center" className="h-full" gap={2}>
<SampleBox>Short</SampleBox>
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">Tall</div>
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">
Tall
</div>
<SampleBox>Short</SampleBox>
</Stack>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Align: Stretch</h3>
<div className="h-20 border-2 border-dashed border-border">
@@ -297,6 +315,37 @@ export const AlignmentVariations: Story = {
),
};
/**
* Scrollable stack when content overflows.
*/
export const Scrollable: Story = {
render: () => (
<div className="space-y-8">
<div>
<h3 className="text-sm font-semibold mb-2">Horizontal Scroll</h3>
<div className="w-80 border-2 border-dashed border-border p-2">
<Stack scrollable direction="row" gap={4}>
{Array.from({ length: 8 }).map((_, i) => (
<SampleBox key={`SampleBox_${String(i)}`}>Item {i + 1}</SampleBox>
))}
</Stack>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Vertical Scroll</h3>
<div className="h-40 w-40 border-2 border-dashed border-border p-2">
<Stack scrollable className="h-full" direction="col" gap={4}>
{Array.from({ length: 8 }).map((_, i) => (
<SampleBox key={`SampleBox_${String(i)}`}>Item {i + 1}</SampleBox>
))}
</Stack>
</div>
</div>
</div>
),
};
/**
* Interactive playground for experimenting with all Stack properties.
*/
@@ -304,9 +353,10 @@ export const Playground: Story = {
args: {
direction: 'row',
justify: 'start',
align: 'center',
align: 'center',
wrap: 'nowrap',
gap: 4,
scrollable: false,
},
render: (args) => (
<div className="w-96 h-64 border-2 border-dashed border-border p-4">
@@ -0,0 +1,64 @@
import React from 'react';
import { Stack } from './Stack';
import { render, screen } from '../../test-utils';
describe('components/Stack', () => {
it('renders children correctly', () => {
render(
<Stack>
<div>Child 1</div>
<div>Child 2</div>
</Stack>
);
expect(screen.getByText('Child 1')).toBeInTheDocument();
expect(screen.getByText('Child 2')).toBeInTheDocument();
});
it('applies basic flex classes', () => {
const { container } = render(<Stack>Content</Stack>);
expect(container.firstChild).toHaveClass('flex');
expect(container.firstChild).toHaveClass('flex-row'); // Default
});
it('applies direction classes', () => {
const { container, rerender } = render(<Stack direction="col">Content</Stack>);
expect(container.firstChild).toHaveClass('flex-col');
rerender(<Stack direction="row-reverse">Content</Stack>);
expect(container.firstChild).toHaveClass('flex-row-reverse');
});
it('applies gap classes', () => {
const { container } = render(<Stack gap={4}>Content</Stack>);
expect(container.firstChild).toHaveClass('gap-4');
});
it('applies scrollable classes correctly', () => {
const { container, rerender } = render(
<Stack scrollable direction="row">
Content
</Stack>
);
// scrollable=true, direction=row -> overflow-x-auto
expect(container.firstChild).toHaveClass('overflow-x-auto');
expect(container.firstChild).not.toHaveClass('overflow-y-auto');
rerender(
<Stack scrollable direction="col">
Content
</Stack>
);
// scrollable=true, direction=col -> overflow-y-auto
expect(container.firstChild).toHaveClass('overflow-y-auto');
expect(container.firstChild).not.toHaveClass('overflow-x-auto');
rerender(
<Stack direction="row" scrollable={false}>
Content
</Stack>
);
expect(container.firstChild).not.toHaveClass('overflow-x-auto');
expect(container.firstChild).not.toHaveClass('overflow-y-auto');
});
});
+69 -21
View File
@@ -6,7 +6,41 @@ import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* 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;
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;
/**
* Flexbox direction values.
@@ -44,17 +78,19 @@ export interface StackProps extends ClassNameProps, TestIdProps {
wrap?: FlexWrap;
/** Spacing between items using Tailwind's spacing scale */
gap?: GapScale;
/** Whether to enable scrolling when content overflows */
scrollable?: boolean;
/** The HTML element to use for rendering */
as?: React.ElementType;
}
/**
* A flexible layout component that arranges children using CSS Flexbox.
*
*
* This component provides a consistent, type-safe way to create flexible layouts
* with proper spacing and alignment. It supports all major flexbox properties
* through semantic prop names that map to Tailwind CSS utility classes.
*
*
* @example
* ```tsx
* // Horizontal stack with center alignment
@@ -62,26 +98,35 @@ export interface StackProps extends ClassNameProps, TestIdProps {
* <button>First</button>
* <button>Second</button>
* </Stack>
*
*
* // Vertical stack with space between items
* <Stack direction="col" justify="between" gap={2}>
* <div>Header</div>
* <div>Content</div>
* <div>Footer</div>
* </Stack>
*
* // Scrollable row
* <Stack direction="row" scrollable gap={4}>
* {items.map(item => <Item key={item.id} {...item} />)}
* </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,
}, ref) {
const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
{
children,
direction = 'row',
justify = 'start',
align = 'start',
wrap = 'nowrap',
gap = 0,
scrollable = false,
as: Component = 'div',
className,
'data-testid': testId,
},
ref
) {
const directionClasses = {
row: 'flex-row',
col: 'flex-col',
@@ -150,9 +195,15 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack({
80: 'gap-80',
96: 'gap-96',
} as const;
const gapClass = gapClasses[gap];
const scrollClasses = scrollable
? direction.includes('row')
? 'overflow-x-auto'
: 'overflow-y-auto'
: '';
const stackClasses = cn(
'flex',
directionClasses[direction],
@@ -160,15 +211,12 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack({
alignClasses[align],
wrapClasses[wrap],
gapClass,
scrollClasses,
className
);
return (
<Component
ref={castRef<HTMLElement>(ref)}
className={stackClasses}
data-testid={testId}
>
<Component ref={castRef<HTMLElement>(ref)} className={stackClasses} data-testid={testId}>
{children}
</Component>
);
@@ -0,0 +1,64 @@
import React from 'react';
import { Stub } from './Stub';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Stub> = {
title: 'components/Stub',
component: Stub,
tags: ['autodocs'],
argTypes: {
rounded: {
control: 'radio',
options: ['none', 'sm', 'md', 'lg', 'full'],
},
animate: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof Stub>;
export const Default: Story = {
args: {
width: '200px',
height: '100px',
rounded: 'md',
animate: true,
},
};
export const Variants: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Text Skeletons</span>
<Stub height="1rem" width="60%" />
<Stub height="1rem" width="80%" />
<Stub height="1rem" width="40%" />
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Avatar Skeleton</span>
<Stub height="3rem" rounded="full" width="3rem" />
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Card Skeleton</span>
<Stub height="200px" rounded="lg" width="100%" />
</div>
</div>
),
};
export const Static: Story = {
args: {
width: '200px',
height: '100px',
animate: false,
},
};
@@ -0,0 +1,43 @@
import React from 'react';
import { Stub } from './Stub';
import { render, screen } from '../../test-utils';
describe('components/Stub', () => {
it('renders correctly with default props', () => {
render(<Stub />);
const stub = screen.getByTestId('stub');
expect(stub).toBeInTheDocument();
expect(stub).toHaveClass('animate-shimmer');
expect(stub).toHaveClass('rounded-md');
});
it('applies custom dimensions via style', () => {
render(<Stub height="100px" width="200px" />);
const stub = screen.getByTestId('stub');
expect(stub).toHaveStyle({ width: '200px', height: '100px' });
});
it('applies correct rounded classes', () => {
const { rerender } = render(<Stub rounded="full" />);
expect(screen.getByTestId('stub')).toHaveClass('rounded-full');
rerender(<Stub rounded="none" />);
expect(screen.getByTestId('stub')).toHaveClass('rounded-none');
rerender(<Stub rounded="lg" />);
expect(screen.getByTestId('stub')).toHaveClass('rounded-lg');
});
it('disables animation when animate prop is false', () => {
render(<Stub animate={false} />);
const stub = screen.getByTestId('stub');
expect(stub).not.toHaveClass('animate-shimmer');
});
it('applies custom className', () => {
render(<Stub className="custom-class" />);
const stub = screen.getByTestId('stub');
expect(stub).toHaveClass('custom-class');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
/**
* Props for the Stub (Skeleton) component.
*/
export interface StubProps {
/** Width of the stub */
width?: string | number;
/** Height of the stub */
height?: string | number;
/** Corner radius variant */
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full';
/** Additional CSS classes */
className?: string;
/** Whether to animate the shimmering effect */
animate?: boolean;
}
/**
* A skeleton placeholder component with a shimmering animation.
* Used to indicate loading states while maintaining layout stability.
*
* @param props - Component properties
* @param props.width - Width of the stub
* @param props.height - Height of the stub
* @param props.rounded - Corner radius variant
* @param props.className - Additional CSS classes
* @param props.animate - Whether to animate the shimmering effect
* @returns A shimmering skeleton element
* @example
* <Stub width="100%" height="20px" rounded="sm" />
*/
export const Stub: React.FC<StubProps> = ({
width,
height,
rounded = 'md',
className,
animate = true,
}) => {
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
return (
<div
className={cn(
'bg-[var(--rb-color-neutral-100)] dark:bg-[var(--rb-color-neutral-800)]',
animate && 'animate-shimmer',
roundedClasses[rounded],
className
)}
data-testid="stub"
style={{ width, height }}
/>
);
};
+127 -22
View File
@@ -5,7 +5,6 @@ import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Text> = {
title: 'components/Text',
component: Text,
@@ -23,6 +22,21 @@ const meta: Meta<typeof Text> = {
control: 'select',
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
},
align: {
control: 'select',
options: ['left', 'center', 'right', 'justify'],
},
wrap: {
control: 'select',
options: ['nowrap', 'balance', 'pretty'],
},
overflow: {
control: 'select',
options: ['ellipsis', 'clip'],
},
maxLines: {
control: 'number',
},
},
};
@@ -40,47 +54,136 @@ export const Default: Story = {
},
};
export const Layout: Story = {
render: () => (
<VStack gap={4}>
<div className="w-full max-w-md border p-4">
<Text align="center" as="div">
Centered Text
</Text>
<Text align="right" as="div">
Right Aligned Text
</Text>
<Text align="justify" as="div">
Justified text: The quick brown fox jumps over the lazy dog. This sentence is repeated to
demonstrate justification behavior in a multi-line context. The quick brown fox jumps over
the lazy dog.
</Text>
</div>
<div className="w-full max-w-xs border p-4">
<Text as="div" wrap="nowrap">
Nowrap: The quick brown fox jumps over the lazy dog.
</Text>
<Text as="div" className="mt-4" wrap="balance">
Balanced: This headline should be balanced across lines for better visual weight.
</Text>
<Text as="div" className="mt-4" wrap="pretty">
Pretty: This paragraph uses text-wrap: pretty to avoid orphans at the end of the text
block.
</Text>
</div>
</VStack>
),
};
export const Truncation: Story = {
render: () => (
<VStack gap={4}>
<div className="w-full max-w-[200px] border p-2">
<Text as="div" overflow="ellipsis">
Single line truncation with ellipsis.
</Text>
</div>
<div className="w-full max-w-[200px] border p-2">
<Text as="div" maxLines={2}>
Multi-line clamping: this text will be truncated after two lines of content.
</Text>
</div>
</VStack>
),
};
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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
@@ -107,7 +210,9 @@ export const Modifiers: Story = {
<Text italic>Italic Text</Text>
<Text underline>Underlined Text</Text>
<Text strike>Strikethrough Text</Text>
<Text bold italic underline>Combined Modifiers</Text>
<Text bold italic underline>
Combined Modifiers
</Text>
</VStack>
),
};
@@ -0,0 +1,42 @@
import React from 'react';
import { Text } from './Text';
import { render, screen } from '../../test-utils';
describe('components/Text', () => {
it('renders children correctly', () => {
render(<Text>Test content</Text>);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
it('applies alignment classes', () => {
render(<Text align="center">Centered</Text>);
expect(screen.getByText('Centered')).toHaveClass('text-center');
});
it('applies wrap classes', () => {
render(<Text wrap="balance">Balanced</Text>);
expect(screen.getByText('Balanced')).toHaveClass('text-balance');
});
it('applies maxLines (line-clamp)', () => {
render(<Text maxLines={3}>Clamped</Text>);
expect(screen.getByText('Clamped')).toHaveClass('line-clamp-3');
});
it('applies overflow ellipsis (truncate)', () => {
render(<Text overflow="ellipsis">Truncated</Text>);
expect(screen.getByText('Truncated')).toHaveClass('truncate');
});
it('maxLines takes precedence over overflow', () => {
render(
<Text maxLines={2} overflow="ellipsis">
Priority
</Text>
);
const el = screen.getByText('Priority');
expect(el).toHaveClass('line-clamp-2');
expect(el).not.toHaveClass('truncate');
});
});
+68 -20
View File
@@ -16,12 +16,22 @@ export type TextSize = 's' | 'm' | 'l';
/**
* Text color variants.
*/
export type TextColor = 'default' | 'primary' | 'secondary' | 'tertiary' | 'error' | 'muted' | 'inherit';
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'> {
export interface TextProps
extends ClassNameProps,
TestIdProps,
Omit<React.HTMLAttributes<HTMLElement>, 'as'> {
/** Content to be rendered */
children: React.ReactNode;
/** Typography style type */
@@ -42,29 +52,43 @@ export interface TextProps extends ClassNameProps, TestIdProps, Omit<React.HTMLA
as?: React.ElementType;
/** Optional href for links */
href?: string;
/** Text alignment */
align?: 'left' | 'center' | 'right' | 'justify';
/** Text wrapping behavior */
wrap?: 'nowrap' | 'balance' | 'pretty';
/** Overflow behavior for single lines */
overflow?: 'ellipsis' | 'clip';
/** Maximum number of lines to show (truncates with ellipsis) */
maxLines?: number;
}
/**
* A flexible typography component that implements the design system's type scale.
*
* Text centralizes all typography rules, including font families, sizes,
*
* 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) {
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
{
children,
type = 'body',
size = 'm',
color = 'default',
bold = false,
italic = false,
underline = false,
strike = false,
align,
wrap,
overflow,
maxLines,
as: Component = 'span',
className,
'data-testid': testId,
...props
},
ref
) {
// Base typography styles mapping
const typeStyles: Record<TextType, Record<TextSize, string>> = {
display: {
@@ -104,6 +128,27 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
inherit: 'text-inherit',
};
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
const wrapClasses = {
nowrap: 'whitespace-nowrap',
balance: 'text-balance',
pretty: 'text-pretty',
};
let truncationClass = '';
if (maxLines) {
truncationClass = `line-clamp-${maxLines}`;
} else {
if (overflow === 'ellipsis') truncationClass = 'truncate';
if (overflow === 'clip') truncationClass = 'overflow-clip';
}
return (
<Component
{...props}
@@ -115,6 +160,9 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
italic && 'italic',
underline && 'underline',
strike && 'line-through',
align && alignClasses[align],
wrap && wrapClasses[wrap],
truncationClass,
className
)}
data-testid={testId}
@@ -124,4 +172,4 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
);
});
export const Text = genericMemo(TextComponent);
export const Text = genericMemo(TextComponent);
+8 -6
View File
@@ -15,11 +15,11 @@ export interface VStackProps extends Omit<StackProps, 'direction'> {
/**
* A vertical stack layout component that arranges children in a column using CSS Flexbox.
*
*
* This is a specialized version of the Stack component with a fixed vertical direction.
* It provides a convenient, semantic way to create vertical layouts without needing
* to specify direction="col" every time.
*
*
* @example
* ```tsx
* // Simple vertical stack
@@ -28,7 +28,7 @@ export interface VStackProps extends Omit<StackProps, 'direction'> {
* <div>Content</div>
* <div>Footer</div>
* </VStack>
*
*
* // Centered vertical stack with spacing
* <VStack align="center" justify="center" gap={6}>
* <h1>Title</h1>
@@ -37,6 +37,8 @@ export interface VStackProps extends Omit<StackProps, 'direction'> {
* </VStack>
* ```
*/
export const VStack = memo(forwardRef<HTMLElement, VStackProps>(function VStack(props, ref) {
return <Stack direction="col" {...props} ref={ref} />;
}));
export const VStack = memo(
forwardRef<HTMLElement, VStackProps>(function VStack(props, ref) {
return <Stack direction="col" {...props} ref={ref} />;
})
);
+21 -2
View File
@@ -6,9 +6,28 @@ export * from './Text/Text';
export * from './VStack/VStack';
export * from './Link/Link';
export * from './Breadcrumbs/Breadcrumbs';
export * from './List/List';
export * from './Markdown/Markdown';
export * from './Paragraph/Paragraph';
export * from './Button/Button';
export * from './Code/Code';
export * from './Blockquote';
export * from './Stub/Stub';
export * from './Image/Image';
export type { StackProps } from './Stack/Stack';
export type { ContainerProps, ContainerBackground, ContainerMaxWidth, ContainerRounded } from './Container/Container';
export type { ListProps, ListVariant, ListSpacing, ListItemProps } from './List/List';
export type {
ContainerProps,
ContainerBackground,
ContainerMaxWidth,
ContainerRounded,
} from './Container/Container';
export type { TextProps, TextType, TextSize, TextColor } from './Text/Text';
export type { HeaderProps } from './Header/Header';
export type { HeaderProps } from './Header/Header';
export type { ParagraphProps } from './Paragraph/Paragraph';
export type { ButtonProps, ButtonVariant, ButtonColor, ButtonSize } from './Button/Button';
export type { CodeProps } from './Code/Code';
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
export type { StubProps } from './Stub/Stub';
export type { ImageProps } from './Image/Image';