refactor: Migrate to monorepo
BREAKING CHANGE: Complete structure overhaul
This commit is contained in:
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'
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user