refactor: Migrate to monorepo

BREAKING CHANGE: Complete structure overhaul
This commit is contained in:
deytenit committed 2026-01-24 23:28:36 +03:00
1 parent 5300b4bae0
commit 46a2f68342
496 files changed
+45388 -7531

No files matched your search

@@ -0,0 +1,42 @@
import React, { memo } from 'react';
import { Stack } from '../Stack/Stack';
import type { StackProps } from '../Stack/Stack';
/**
* Props for the HStack component (horizontal stack).
* Omits direction since HStack is always flex-row.
*/
export interface HStackProps extends Omit<StackProps, 'direction'> {
/** Content to be arranged horizontally */
children: React.ReactNode;
}
/**
* 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
* <HStack gap={4}>
* <button>First</button>
* <button>Second</button>
* <button>Third</button>
* </HStack>
*
* // Centered horizontal stack with space between
* <HStack align="center" justify="between">
* <div>Logo</div>
* <nav>Navigation</nav>
* <div>Profile</div>
* </HStack>
* ```
*/
export const HStack = memo(function HStack(props: HStackProps) {
return <Stack direction="row" {...props} />;
});
@@ -0,0 +1,321 @@
import React from 'react';
import { Stack } from './Stack';
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.
*/
const meta: Meta<typeof Stack> = {
title: 'components/shared/Stack',
component: Stack,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A flexible layout component using CSS Flexbox for consistent spacing and alignment.',
},
},
},
argTypes: {
direction: {
control: 'select',
options: ['row', 'col', 'row-reverse', 'col-reverse'],
description: 'Flexbox direction',
},
justify: {
control: 'select',
options: ['start', 'end', 'center', 'between', 'around', 'evenly'],
description: 'Main axis alignment',
},
align: {
control: 'select',
options: ['start', 'end', 'center', 'baseline', 'stretch'],
description: 'Cross axis alignment',
},
wrap: {
control: 'select',
options: ['nowrap', 'wrap', 'wrap-reverse'],
description: 'Flex wrap behavior',
},
gap: {
control: 'select',
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',
},
},
args: {
direction: 'row',
justify: 'start',
align: 'start',
wrap: 'nowrap',
gap: 4,
},
};
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`}>
{children}
</div>
);
/**
* Default horizontal stack with basic spacing.
*/
export const Default: Story = {
render: (args) => (
<Stack {...args}>
<SampleBox>First</SampleBox>
<SampleBox color="bg-secondary">Second</SampleBox>
<SampleBox color="bg-accent">Third</SampleBox>
</Stack>
),
};
/**
* Vertical stack arrangement with items stacked in a column.
*/
export const Vertical: Story = {
args: {
direction: 'col',
gap: 3,
},
render: (args) => (
<Stack {...args}>
<SampleBox>Top</SampleBox>
<SampleBox color="bg-secondary">Middle</SampleBox>
<SampleBox color="bg-accent">Bottom</SampleBox>
</Stack>
),
};
/**
* Centered alignment on both axes.
*/
export const Centered: Story = {
args: {
direction: 'col',
justify: 'center',
align: 'center',
gap: 4,
},
render: (args) => (
<div className="h-64 w-full border-2 border-dashed border-border">
<Stack {...args} className="h-full w-full">
<SampleBox>Centered</SampleBox>
<SampleBox color="bg-secondary">Content</SampleBox>
</Stack>
</div>
),
};
/**
* Space between items with justified distribution.
*/
export const SpaceBetween: Story = {
args: {
direction: 'row',
justify: 'between',
align: 'center',
gap: 0,
},
render: (args) => (
<div className="w-80 border-2 border-dashed border-border p-4">
<Stack {...args} className="w-full">
<SampleBox>Start</SampleBox>
<SampleBox color="bg-secondary">Center</SampleBox>
<SampleBox color="bg-accent">End</SampleBox>
</Stack>
</div>
),
};
/**
* Wrapping layout that allows items to flow to new lines.
*/
export const Wrapping: Story = {
args: {
direction: 'row',
wrap: 'wrap',
gap: 2,
},
render: (args) => (
<div className="w-64 border-2 border-dashed border-border p-4">
<Stack {...args} className="w-full">
<SampleBox>Item 1</SampleBox>
<SampleBox color="bg-secondary">Item 2</SampleBox>
<SampleBox color="bg-accent">Item 3</SampleBox>
<SampleBox color="bg-destructive">Item 4</SampleBox>
<SampleBox>Item 5</SampleBox>
<SampleBox color="bg-secondary">Item 6</SampleBox>
</Stack>
</div>
),
};
/**
* Reverse direction stacks.
*/
export const Reversed: Story = {
render: () => (
<div className="space-y-8">
<div>
<h3 className="text-sm font-semibold mb-2">Row Reverse</h3>
<Stack direction="row-reverse" gap={3}>
<SampleBox>1</SampleBox>
<SampleBox color="bg-secondary">2</SampleBox>
<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}>
<SampleBox>1</SampleBox>
<SampleBox color="bg-secondary">2</SampleBox>
<SampleBox color="bg-accent">3</SampleBox>
</Stack>
</div>
</div>
),
};
/**
* Different gap sizes demonstration including decimal values.
*/
export const GapSizes: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 0.5 (Decimal)</h3>
<Stack gap={0.5}>
<SampleBox>A</SampleBox>
<SampleBox color="bg-secondary">B</SampleBox>
<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}>
<SampleBox>A</SampleBox>
<SampleBox color="bg-secondary">B</SampleBox>
<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}>
<SampleBox>A</SampleBox>
<SampleBox color="bg-secondary">B</SampleBox>
<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}>
<SampleBox>A</SampleBox>
<SampleBox color="bg-secondary">B</SampleBox>
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 4 (Integer)</h3>
<Stack gap={4}>
<SampleBox>A</SampleBox>
<SampleBox color="bg-secondary">B</SampleBox>
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 8 (Large)</h3>
<Stack gap={8}>
<SampleBox>A</SampleBox>
<SampleBox color="bg-secondary">B</SampleBox>
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
</div>
),
};
/**
* Alignment variations on the cross axis.
*/
export const AlignmentVariations: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold mb-2">Align: Start</h3>
<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>
<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>
<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">
<Stack align="stretch" className="h-full" gap={2}>
<SampleBox>Stretched</SampleBox>
<SampleBox color="bg-secondary">Stretched</SampleBox>
<SampleBox color="bg-accent">Stretched</SampleBox>
</Stack>
</div>
</div>
</div>
),
};
/**
* Interactive playground for experimenting with all Stack properties.
*/
export const Playground: Story = {
args: {
direction: 'row',
justify: 'start',
align: 'center',
wrap: 'nowrap',
gap: 4,
},
render: (args) => (
<div className="w-96 h-64 border-2 border-dashed border-border p-4">
<Stack {...args} className="h-full w-full">
<SampleBox>One</SampleBox>
<SampleBox color="bg-secondary">Two</SampleBox>
<SampleBox color="bg-accent">Three</SampleBox>
<SampleBox color="bg-destructive">Four</SampleBox>
</Stack>
</div>
),
};
@@ -0,0 +1,171 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
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;
/**
* Flexbox direction values.
*/
type FlexDirection = 'row' | 'col' | 'row-reverse' | 'col-reverse';
/**
* Flexbox justify-content values.
*/
type JustifyContent = 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly';
/**
* Flexbox align-items values.
*/
type AlignItems = 'start' | 'end' | 'center' | 'baseline' | 'stretch';
/**
* Flexbox flex-wrap values.
*/
type FlexWrap = 'nowrap' | 'wrap' | 'wrap-reverse';
/**
* Props for the flexible Stack layout component.
*/
export interface StackProps extends ClassNameProps, TestIdProps {
/** Content to be arranged in the stack */
children: React.ReactNode;
/** Flexbox direction (row, column, or their reverse variants) */
direction?: FlexDirection;
/** How to distribute space between and around content items along the main axis */
justify?: JustifyContent;
/** How to align items along the cross axis */
align?: AlignItems;
/** Whether items should wrap to new lines/columns */
wrap?: FlexWrap;
/** Spacing between items using Tailwind's spacing scale */
gap?: GapScale;
}
/**
* 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
* <Stack direction="row" align="center" gap={4}>
* <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>
* ```
*/
export const Stack = memo(function Stack({
children,
direction = 'row',
justify = 'start',
align = 'start',
wrap = 'nowrap',
gap = 0,
className,
'data-testid': testId,
}: StackProps) {
const directionClasses = {
row: 'flex-row',
col: 'flex-col',
'row-reverse': 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
};
const justifyClasses = {
start: 'justify-start',
end: 'justify-end',
center: 'justify-center',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
};
const alignClasses = {
start: 'items-start',
end: 'items-end',
center: 'items-center',
baseline: 'items-baseline',
stretch: 'items-stretch',
};
const wrapClasses = {
nowrap: 'flex-nowrap',
wrap: 'flex-wrap',
'wrap-reverse': 'flex-wrap-reverse',
};
// Convert gap scale to Tailwind class using explicit mapping
// This ensures all gap classes are detected by Tailwind's static analysis
const gapClasses = {
0: '',
0.5: 'gap-0.5',
1: 'gap-1',
1.5: 'gap-1.5',
2: 'gap-2',
2.5: 'gap-2.5',
3: 'gap-3',
3.5: 'gap-3.5',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
7: 'gap-7',
8: 'gap-8',
9: 'gap-9',
10: 'gap-10',
11: 'gap-11',
12: 'gap-12',
14: 'gap-14',
16: 'gap-16',
20: 'gap-20',
24: 'gap-24',
28: 'gap-28',
32: 'gap-32',
36: 'gap-36',
40: 'gap-40',
44: 'gap-44',
48: 'gap-48',
52: 'gap-52',
56: 'gap-56',
60: 'gap-60',
64: 'gap-64',
72: 'gap-72',
80: 'gap-80',
96: 'gap-96',
} as const;
const gapClass = gapClasses[gap];
const stackClasses = cn(
'flex',
directionClasses[direction],
justifyClasses[justify],
alignClasses[align],
wrapClasses[wrap],
gapClass,
className
);
return (
<div
className={stackClasses}
data-testid={testId}
>
{children}
</div>
);
});
@@ -0,0 +1,42 @@
import React, { memo } from 'react';
import { Stack } from '../Stack/Stack';
import type { StackProps } from '../Stack/Stack';
/**
* Props for the VStack component (vertical stack).
* Omits direction since VStack is always flex-col.
*/
export interface VStackProps extends Omit<StackProps, 'direction'> {
/** Content to be arranged vertically */
children: React.ReactNode;
}
/**
* 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
* <VStack gap={4}>
* <div>Header</div>
* <div>Content</div>
* <div>Footer</div>
* </VStack>
*
* // Centered vertical stack with spacing
* <VStack align="center" justify="center" gap={6}>
* <h1>Title</h1>
* <p>Description</p>
* <button>Action</button>
* </VStack>
* ```
*/
export const VStack = memo(function VStack(props: VStackProps) {
return <Stack direction="col" {...props} />;
});
@@ -0,0 +1,2 @@
export { HStack } from './HStack/HStack';
export { VStack } from './VStack/VStack';
@@ -0,0 +1,128 @@
import React from 'react';
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/paper-kit/Button',
component: Button,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A newspaper-style button following traditional print design patterns.',
},
},
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'primary', 'secondary'],
description: 'Visual style variant',
},
children: {
control: 'text',
description: 'The button text content (string only)',
},
isDisabled: {
control: 'boolean',
description: 'Whether the button is disabled',
},
},
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.
*/
export const Default: Story = {
args: {
children: 'Explore all the articles',
onPress: () => console.log('Default button pressed'),
},
};
/**
* Primary variant with enhanced visual prominence.
*/
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Read more stories',
onPress: () => console.log('Primary button pressed'),
},
};
/**
* Secondary variant for supporting actions.
*/
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Subscribe now',
onPress: () => console.log('Secondary button pressed'),
},
};
/**
* Disabled state of the button.
*/
export const Disabled: Story = {
args: {
children: 'Unavailable action',
isDisabled: true,
},
};
/**
* Long text content to test text wrapping and layout.
*/
export const LongText: Story = {
args: {
children: 'Explore all the fascinating articles and stories from around the world',
onPress: () => console.log('Long text button pressed'),
},
};
/**
* 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>
),
};
@@ -0,0 +1,148 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Button } from './Button';
import { render, screen } from '../../../test-utils';
/**
* Test suite for the newspaper-style Button component
*/
describe('components/paper-kit/Button/Button', () => {
const defaultProps = {
children: 'Test Button',
};
it('renders correctly with default props', () => {
render(<Button {...defaultProps} />);
const button = screen.getByRole('button', { name: 'Test Button' });
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent('Test Button');
});
it('applies correct styling classes', () => {
render(<Button {...defaultProps} />);
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');
});
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>
);
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');
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();
render(
<Button isDisabled onPress={handlePress}>
{defaultProps.children}
</Button>
);
const button = screen.getByRole('button');
await user.click(button);
expect(handlePress).not.toHaveBeenCalled();
});
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('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('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();
});
});
@@ -0,0 +1,101 @@
import React, { memo } from 'react';
import { useButton } from 'react-aria';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { AriaButtonProps } from 'react-aria';
/**
* Props for the newspaper-style Button component.
*/
export interface ButtonProps extends AriaButtonProps, ClassNameProps, TestIdProps {
/** The button text content */
children: string;
/** Visual style variant */
variant?: 'default' | 'primary' | 'secondary';
}
/**
* 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
*
* @example
* ```tsx
* <Button onPress={() => console.log('Clicked!')}>
* Explore all the articles
* </Button>
* ```
*/
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);
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',
];
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',
],
};
const allClasses = [
...baseClasses,
...variantClasses[variant],
className,
].join(' ');
return (
<button
{...ariaButtonProps}
ref={ref}
className={allClasses}
data-testid={testId}
>
{children}
</button>
);
});
@@ -0,0 +1,331 @@
import React from 'react';
import { Separator } from './Separator';
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
* - Horizontal and vertical orientations
* - Optional thinned gradient fade effect at line ends (10% fade length)
* - 2px stroke width following print design standards
* - Semantic div elements with proper ARIA separator role for accessibility
*/
const meta: Meta<typeof Separator> = {
title: 'components/paper-kit/Separator',
component: Separator,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A newspaper-style separator following traditional print design patterns for content division.',
},
},
},
argTypes: {
type: {
control: 'select',
options: ['single', 'double'],
description: 'Type of separator line styling',
},
color: {
control: 'select',
options: ['black', 'outline', 'outline-light'],
description: 'Color variant following newspaper design patterns',
},
direction: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Orientation of the separator',
},
thinned: {
control: 'boolean',
description: 'Whether line ends should be thinned with gradient fade (10% fade length)',
},
},
args: {
type: 'single',
direction: 'horizontal',
thinned: true,
},
};
export default meta;
/**
* Story type for Separator component stories.
*/
type Story = StoryObj<typeof meta>;
/**
* The default single-line separator with light outline color.
* Uses the newspaper-standard 2px stroke with thinned gradient ends.
*/
export const Default: Story = {
args: {},
decorators: [
(Story) => (
<div className="w-96 p-8">
<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>
),
],
};
/**
* Double-line separator with standard outline color.
* Features 4px spacing between the two parallel lines.
*/
export const Double: Story = {
args: {
type: 'double',
},
decorators: [
(Story) => (
<div className="w-96 p-8">
<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>
),
],
};
/**
* Black color variant for high contrast content separation.
* Most prominent separator option following newspaper header styling.
*/
export const Black: Story = {
args: {
color: 'black',
},
decorators: [
(Story) => (
<div className="w-96 p-8">
<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>
),
],
};
/**
* Light outline color for subtle content divisions.
* Ideal for separating related content sections.
*/
export const OutlineLight: Story = {
args: {
color: 'outline-light',
},
decorators: [
(Story) => (
<div className="w-96 p-8">
<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>
),
],
};
/**
* Vertical separator for side-by-side content division.
* Fills the height of its container.
*/
export const Vertical: Story = {
args: {
direction: 'vertical',
},
decorators: [
(Story) => (
<div className="flex h-48 p-8">
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Left Content
</div>
<div className="px-4">
<Story />
</div>
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Right Content
</div>
</div>
),
],
};
/**
* Double vertical separator for enhanced visual separation.
*/
export const VerticalDouble: Story = {
args: {
type: 'double',
direction: 'vertical',
},
decorators: [
(Story) => (
<div className="flex h-48 p-8">
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Left Content
</div>
<div className="px-4">
<Story />
</div>
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Right Content
</div>
</div>
),
],
};
/**
* Separator without thinned ends showing clean, sharp line edges.
* Compare with default to see the gradient fade difference.
*/
export const NonThinned: Story = {
args: {
thinned: false,
},
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above (Sharp Edges)
</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below (Sharp Edges)
</div>
</div>
),
],
};
/**
* Interactive demonstration showing all color variants side by side.
* Useful for comparing color intensity and theme compatibility.
*/
export const AllColors: Story = {
render: () => (
<div className="space-y-8 w-96">
<div>
<h3 className="text-sm font-medium mb-2">Black</h3>
<Separator color="black" />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Outline (Default for Double)</h3>
<Separator color="outline" />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Outline Light (Default for Single)</h3>
<Separator color="outline-light" />
</div>
</div>
),
};
/**
* Demonstration of all type variants with their default colors.
* Shows the automatic color selection behavior.
*/
export const AllTypes: Story = {
render: () => (
<div className="space-y-8 w-96">
<div>
<h3 className="text-sm font-medium mb-2">Single (Default: Outline Light)</h3>
<Separator type="single" />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Double (Default: Outline)</h3>
<Separator type="double" />
</div>
</div>
),
};
/**
* Comparison between thinned and non-thinned effects.
* Demonstrates the 10% gradient fade at line ends.
*/
export const ThinnedComparison: Story = {
render: () => (
<div className="space-y-8 w-96">
<div>
<h3 className="text-sm font-medium mb-2">With Thinned Ends (Default)</h3>
<Separator thinned />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Without Thinned Ends</h3>
<Separator thinned={false} />
</div>
</div>
),
};
/**
* Comprehensive showcase of vertical separators in different contexts.
* Demonstrates container height adaptation and spacing.
*/
export const VerticalShowcase: Story = {
render: () => (
<div className="flex space-x-8">
<div className="flex h-32">
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Short
</div>
<div className="px-2">
<Separator direction="vertical" />
</div>
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Content
</div>
</div>
<div className="flex h-48">
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Medium
</div>
<div className="px-2">
<Separator direction="vertical" type="double" />
</div>
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Content
</div>
</div>
<div className="flex h-64">
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Tall
</div>
<div className="px-2">
<Separator color="black" direction="vertical" />
</div>
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Content
</div>
</div>
</div>
),
};
@@ -0,0 +1,156 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import { VStack } from '../../generic/VStack/VStack';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Props for the newspaper-style Separator component.
*/
export interface SeparatorProps extends ClassNameProps, TestIdProps {
/** Type of separator line styling */
type?: 'single' | 'double';
/** Color variant following newspaper design patterns */
color?: 'black' | 'outline' | 'outline-light';
/** Orientation of the separator */
direction?: 'horizontal' | 'vertical';
/** Whether line ends should be thinned with gradient fade */
thinned?: boolean;
}
/**
* 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
* - Horizontal and vertical orientations
* - 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({
type = 'single',
color,
direction = 'horizontal',
thinned = true,
className,
'data-testid': testId,
}: SeparatorProps) {
// Default color based on type as specified
const defaultColor = type === 'single' ? 'outline-light' : 'outline';
const finalColor = color ?? defaultColor;
// Color mapping to CSS custom properties
const colorClasses = {
black: 'border-foreground',
outline: 'border-border',
'outline-light': 'border-muted',
};
// Base line styles
const lineBaseClasses = [
'border-0',
'border-solid',
colorClasses[finalColor],
// 2px stroke as specified
direction === 'horizontal' ? 'border-t-2' : 'border-l-2',
];
// 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)]',
] : [];
// 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)
// Single line implementation
if (type === 'single') {
const lineClasses = cn(
...lineBaseClasses,
...thinnedClasses,
...sizeClasses,
className
);
return (
<div
aria-orientation={direction}
className={lineClasses}
data-testid={testId}
role="separator"
/>
);
}
// Double line implementation
const singleLineClasses = cn(
...lineBaseClasses,
...thinnedClasses,
...sizeClasses
);
const containerClasses = cn(
direction === 'horizontal' ? 'w-full' : 'h-full',
className
);
if (direction === 'horizontal') {
return (
<div
aria-orientation="horizontal"
className={containerClasses}
data-testid={testId}
role="separator"
>
<VStack gap={1}>
<div
aria-hidden="true"
className={singleLineClasses}
/>
<div
aria-hidden="true"
className={singleLineClasses}
/>
</VStack>
</div>
);
} else {
return (
<div
aria-orientation="vertical"
className={cn('flex flex-row gap-1', containerClasses)}
data-testid={testId}
role="separator"
>
<div aria-hidden="true" className={singleLineClasses} />
<div aria-hidden="true" className={singleLineClasses} />
</div>
);
}
});
@@ -0,0 +1,2 @@
export { Button } from './PaperButton/Button'
export { Separator } from './PaperSeparator/Separator'
+6
View File
@@ -0,0 +1,6 @@
// Export all components
export * from './components/paper';
export * from './components/generic';
// Import styles to ensure they're bundled
import './styles/index.css';
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+315
View File
@@ -0,0 +1,315 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:where(.dark, .dark *));
/*
@layer base defaults inspired by shadcn UI Typography
Provides opinionated, utility-driven defaults for HTML elements.
*/
@layer base {
/* Headings */
h1 {
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
}
h2 {
@apply mt-10 scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0;
}
h3,
h4,
h5,
h6 {
@apply mt-8 scroll-m-20 text-2xl font-semibold tracking-tight;
}
/* Paragraphs */
p {
@apply leading-7 [&:not(:first-child)]:mt-6;
}
/* Links */
a {
@apply text-ring font-medium underline underline-offset-2;
}
/* Blockquotes */
blockquote {
@apply mt-6 border-l-2 pl-6 italic;
}
/* Code */
code {
@apply bg-muted rounded px-1 font-mono text-sm;
}
pre {
@apply bg-muted overflow-auto rounded-lg p-4 font-mono text-sm;
}
/* Images and Media */
img,
video {
@apply max-w-full rounded-lg;
}
/* Horizontal rules */
hr {
@apply border-border my-6 border-t;
}
}
@theme {
--font-sans: 'Lato', sans-serif;
--font-serif: 'EB Garamond', serif;
}
@theme {
--color-rainby-red-50: #fef4f2;
--color-rainby-red-100: #ffe7e1;
--color-rainby-red-200: #ffd2c8;
--color-rainby-red-300: #ffb4a3;
--color-rainby-red-400: #fd876c;
--color-rainby-red-500: #f5603e;
--color-rainby-red-600: #e24420;
--color-rainby-red-700: #be3617;
--color-rainby-red-800: #9d3017;
--color-rainby-red-900: #822d1a;
--color-rainby-red-950: #471408;
--color-rainby-orange-50: #fff8ed;
--color-rainby-orange-100: #ffefd5;
--color-rainby-orange-200: #fedbaa;
--color-rainby-orange-300: #fdc175;
--color-rainby-orange-400: #fb9b3c;
--color-rainby-orange-500: #f97e16;
--color-rainby-orange-600: #ea630c;
--color-rainby-orange-700: #c24a0c;
--color-rainby-orange-800: #9a3b12;
--color-rainby-orange-900: #7c3212;
--color-rainby-orange-950: #431707;
--color-rainby-yellow-50: #fefde8;
--color-rainby-yellow-100: #fefdc3;
--color-rainby-yellow-200: #fef9a3;
--color-rainby-yellow-300: #fdec47;
--color-rainby-yellow-400: #f9db16;
--color-rainby-yellow-500: #e9c209;
--color-rainby-yellow-600: #ca9704;
--color-rainby-yellow-700: #a16c07;
--color-rainby-yellow-800: #85550e;
--color-rainby-yellow-900: #714512;
--color-rainby-yellow-950: #422406;
--color-rainby-green-50: #f8faeb;
--color-rainby-green-100: #edf4d3;
--color-rainby-green-200: #ddebab;
--color-rainby-green-300: #c4dc7a;
--color-rainby-green-400: #b6d167;
--color-rainby-green-500: #8daf33;
--color-rainby-green-600: #6e8b25;
--color-rainby-green-700: #546b20;
--color-rainby-green-800: #44551f;
--color-rainby-green-900: #3a491e;
--color-rainby-green-950: #1e280b;
--color-rainby-cyan-50: #effbfc;
--color-rainby-cyan-100: #d7f2f6;
--color-rainby-cyan-200: #b4e6ed;
--color-rainby-cyan-300: #81d3e0;
--color-rainby-cyan-400: #45b6cb;
--color-rainby-cyan-500: #2a9ab0;
--color-rainby-cyan-600: #257d95;
--color-rainby-cyan-700: #24657a;
--color-rainby-cyan-800: #265464;
--color-rainby-cyan-900: #234756;
--color-rainby-cyan-950: #122e3a;
--color-rainby-blue-50: #eff5ff;
--color-rainby-blue-100: #dbe8fe;
--color-rainby-blue-200: #bfd8fe;
--color-rainby-blue-300: #a2c9fe;
--color-rainby-blue-400: #5f9efb;
--color-rainby-blue-500: #3a79f7;
--color-rainby-blue-600: #2358ed;
--color-rainby-blue-700: #1c44d9;
--color-rainby-blue-800: #1d38b0;
--color-rainby-blue-900: #1d358b;
--color-rainby-blue-950: #172254;
--color-rainby-violet-50: #eef0ff;
--color-rainby-violet-100: #dfe3ff;
--color-rainby-violet-200: #bdc2ff;
--color-rainby-violet-300: #a3a8fe;
--color-rainby-violet-400: #837ffa;
--color-rainby-violet-500: #6e60f4;
--color-rainby-violet-600: #6043e8;
--color-rainby-violet-700: #5435cd;
--color-rainby-violet-800: #442ea5;
--color-rainby-violet-900: #3b2c83;
--color-rainby-violet-950: #231a4c;
--color-rainby-neutral-50: #f6f6f6;
--color-rainby-neutral-100: #e7e7e7;
--color-rainby-neutral-200: #d1d1d1;
--color-rainby-neutral-300: #b0b0b0;
--color-rainby-neutral-400: #888888;
--color-rainby-neutral-500: #737373;
--color-rainby-neutral-600: #5d5d5d;
--color-rainby-neutral-700: #4f4f4f;
--color-rainby-neutral-800: #454545;
--color-rainby-neutral-900: #3d3d3d;
--color-rainby-neutral-950: #262626;
}
:root {
/* Core palette */
--background: var(--color-rainby-neutral-50);
--border: var(--color-rainby-neutral-500);
--input: var(--color-rainby-neutral-800);
--foreground: var(--color-rainby-neutral-950);
--ring: var(--color-rainby-cyan-700);
/* Surfaces */
--card: var(--color-rainby-neutral-50);
--card-foreground: var(--color-rainby-neutral-950);
--popover: var(--color-rainby-neutral-50);
--popover-foreground: var(--color-rainby-neutral-950);
/* Semantic */
--primary: var(--color-rainby-cyan-200);
--primary-foreground: var(--color-rainby-cyan-950);
--secondary: var(--color-rainby-green-200);
--secondary-foreground: var(--color-rainby-green-950);
--muted: var(--color-rainby-neutral-300);
--muted-foreground: var(--color-rainby-neutral-600);
--accent: var(--color-rainby-violet-200);
--accent-foreground: var(--color-rainby-violet-950);
--destructive: var(--color-rainby-red-200);
--destructive-foreground: var(--color-rainby-red-950);
/* Charts (e.g. Recharts in ShadCN examples) */
--chart-1: var(--color-rainby-blue-500);
--chart-2: var(--color-rainby-green-500);
--chart-3: var(--color-rainby-yellow-500);
--chart-4: var(--color-rainby-orange-500);
--chart-5: var(--color-rainby-red-500);
/* Sidebar (if you use ShadCN’s sidebar components) */
--sidebar: var(--color-rainby-neutral-50);
--sidebar-foreground: var(--color-rainby-neutral-950);
--sidebar-primary: var(--color-rainby-cyan-200);
--sidebar-primary-foreground: var(--color-rainby-cyan-950);
--sidebar-accent: var(--color-rainby-violet-200);
--sidebar-accent-foreground: var(--color-rainby-violet-950);
--sidebar-border: var(--color-rainby-neutral-500);
--sidebar-ring: var(--color-rainby-cyan-700);
/* Radii */
--radius: 0.625rem;
}
/* ──────────────── Dark Mode Override ──────────────── */
.dark {
/* Core palette flips */
--background: var(--color-rainby-neutral-950);
--ring: var(--color-rainby-cyan-300);
--border: var(--color-rainby-neutral-500);
--input: var(--color-rainby-neutral-300);
--foreground: var(--color-rainby-neutral-50);
/* Surfaces */
--card: var(--color-rainby-neutral-950);
--card-foreground: var(--color-rainby-neutral-50);
--popover: var(--color-rainby-neutral-950);
--popover-foreground: var(--color-rainby-neutral-50);
/* Semantic */
--primary: var(--color-rainby-cyan-800);
--primary-foreground: var(--color-rainby-cyan-50);
--secondary: var(--color-rainby-green-800);
--secondary-foreground: var(--color-rainby-green-50);
--muted: var(--color-rainby-neutral-600);
--muted-foreground: var(--color-rainby-neutral-100);
--accent: var(--color-rainby-violet-800);
--accent-foreground: var(--color-rainby-violet-50);
--destructive: var(--color-rainby-red-800);
--destructive-foreground: var(--color-rainby-red-50);
/* Charts */
--chart-1: var(--color-rainby-blue-400);
--chart-2: var(--color-rainby-green-400);
--chart-3: var(--color-rainby-yellow-400);
--chart-4: var(--color-rainby-orange-400);
--chart-5: var(--color-rainby-red-400);
/* Sidebar */
--sidebar: var(--color-rainby-neutral-950);
--sidebar-foreground: var(--color-rainby-neutral-50);
--sidebar-primary: var(--color-rainby-cyan-800);
--sidebar-primary-foreground: var(--color-rainby-cyan-50);
--sidebar-accent: var(--color-rainby-violet-800);
--sidebar-accent-foreground: var(--color-rainby-violet-50);
--sidebar-border: var(--color-rainby-neutral-500);
--sidebar-ring: var(--color-rainby-cyan-700);
/* Radii stays same */
--radius: 0.625rem;
}
@utility frosted-glass {
@apply bg-background/30 rounded-lg shadow-lg backdrop-blur-md backdrop-opacity-75 backdrop-filter;
}
@utility vstack {
@apply flex flex-col;
}
@utility hstack {
@apply flex flex-row;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { render } from '@testing-library/react';
import type { RenderOptions, RenderResult } from '@testing-library/react';
import type { ReactElement } from 'react';
/**
* Custom render function that sets up any required providers
* @param ui - React component to render
* @param options - Render options excluding wrapper
* @returns RenderResult from testing library
* @example
* ```tsx
* render(<Button>Click me</Button>);
* ```
*/
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
): RenderResult {
return render(ui, {
// Add any providers here if needed in the future
...options,
});
}
// Re-export everything from testing library
// eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react';
// Override render method
export { customRender as render };