feat: Add more base component to UI
This commit is contained in:
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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 'react';</span>
|
||||
<span className="block text-[var(--rb-text)]"> </span>
|
||||
<span className="block text-[var(--rb-secondary-text)]">
|
||||
export const MyComponent = () => {
|
||||
</span>
|
||||
<span className="block text-[var(--rb-text)] pl-4">
|
||||
return <div>Hello World</div>;
|
||||
</span>
|
||||
<span className="block text-[var(--rb-secondary-text)]">};</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("hello");</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');
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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 "loading" then "error" 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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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>
|
||||
"Simplicity is the ultimate sophistication."
|
||||
<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'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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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} />;
|
||||
})
|
||||
);
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user