fix: Fix containing various changes that stacked up in a period of time (#22)

This commit is contained in:
deytenit authored and GitHub committed 2026-08-10 09:04:39 +03:00
1 parent 1b3d7cecb2
commit 77afcca98e
163 files changed
+4268 -3852

No files matched your search

@@ -21,6 +21,19 @@ describe('Accordion', () => {
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
});
it('renders expansion icon with inline-icon class', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
</Accordion.Item>
</Accordion>
);
const svg = screen.getByRole('button').querySelector('svg');
expect(svg).toHaveClass('inline-icon');
expect(svg).not.toHaveClass('size-5');
});
it('toggles content on click in single mode', async () => {
const user = userEvent.setup();
render(
@@ -27,7 +27,7 @@ const triggerVariants = cva(
);
const iconVariants = cva(
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
'inline-icon shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
{
variants: {
isOpen: {
@@ -1,44 +0,0 @@
import { Article } from './Article';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Article> = {
title: 'components/Article',
component: Article,
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
href: { control: 'text' },
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Article>;
export const Default: Story = {
args: {
headline: 'AI Agent Learns to Code',
children:
'In a shocking turn of events, a large language model has successfully implemented a UI component from scratch while maintaining design system consistency.',
},
};
export const Full: Story = {
args: {
headline: 'The Future of Web Development',
subHeadline: 'Design Systems and AI Collaboration',
additionalText: 'By AI Reporter #42',
children:
'Newspaper-style layouts are making a comeback in modern web design. By combining classical typography principles with modern responsive frameworks, developers can create unique and engaging user experiences.',
},
};
export const Linked: Story = {
args: {
...Full.args,
href: '#',
},
};
@@ -1,49 +0,0 @@
import React from 'react';
import { Article } from './Article';
import { render, screen } from '../../lib/testing';
describe('components/Article', () => {
const props = {
headline: 'Breaking News',
subHeadline: 'Local cat saves city',
additionalText: 'Reporting by John Doe',
};
it('renders all parts of the article', () => {
render(<Article {...props}>Article body content</Article>);
expect(screen.getByText('Breaking News')).toBeInTheDocument();
expect(screen.getByText('Local cat saves city')).toBeInTheDocument();
expect(screen.getByText('Article body content')).toBeInTheDocument();
expect(screen.getByText('Reporting by John Doe')).toBeInTheDocument();
});
it('renders separators correctly based on subHeadline presence', () => {
const { rerender } = render(<Article headline="Title">Body</Article>);
let separators = screen.getAllByRole('separator');
expect(separators).toHaveLength(1);
expect(separators[0].className).toContain('mask-image');
rerender(
<Article headline="Title" subHeadline="Sub">
Body
</Article>
);
separators = screen.getAllByRole('separator');
expect(separators).toHaveLength(1);
expect(separators[0].className).toContain('mask-image');
});
it('renders as a link when href is provided', () => {
render(
<Article {...props} href="/test-article">
Article body content
</Article>
);
const articleContainer = screen.getByText('Breaking News').closest('a');
expect(articleContainer).toBeInTheDocument();
expect(articleContainer).toHaveAttribute('href', '/test-article');
});
});
@@ -1,87 +0,0 @@
import React, { memo } from 'react';
import { Container } from '../Container/Container';
import { Header } from '../Header/Header';
import { Separator } from '../Separator/Separator';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { ContainerProps } from '../Container/Container';
import type { ClassNameProps } from '@ermnvldmr/stl';
/**
* Props for the Article component.
*/
export interface ArticleProps extends ClassNameProps {
/** The main article headline */
headline: string;
/** Optional sub-headline displayed between separators */
subHeadline?: string;
/** Optional additional text (e.g., author) displayed at the bottom in gray italic label */
additionalText?: string;
/**
* Press interaction handler.
* Cannot be used with `href`.
*/
onPress?: ContainerProps['onPress'];
/**
* If provided, the container will be rendered as an `<a>` tag for navigation.
* Cannot be used with `onPress`.
*/
href?: ContainerProps['href'];
/** The main article body content */
children: React.ReactNode;
}
/**
* A article component with a structured layout.
*
* Features:
* - Left-aligned medium headline (headline-m)
* - Single-line separators with optional end fade
* - Optional italic sub-headline (label-m)
* - Main body text (body-m)
* - Optional bottom gray italic label (label-s)
* - Interactive wrapper via Container, supporting both `onPress` and `href`
*/
export const Article = memo(function Article({
headline,
subHeadline,
additionalText,
onPress,
href,
children,
className,
}: ArticleProps) {
return (
<Container border className={className} href={href} padding={4} rounded="md" onPress={onPress}>
<VStack align="stretch" gap={4}>
<VStack align="stretch" gap={2}>
<Header level={4}>{headline}</Header>
<VStack align="stretch" gap={2}>
{subHeadline ? (
<>
<Text italic size="m" type="label">
{subHeadline}
</Text>
<Separator thinned="thinned-end" type="single" />
</>
) : (
<Separator thinned="thinned-end" type="single" />
)}
</VStack>
</VStack>
<Text size="m" type="body">
{children}
</Text>
{additionalText && (
<VStack align="end">
<Text italic color="muted" size="s" type="label">
{additionalText}
</Text>
</VStack>
)}
</VStack>
</Container>
);
});
@@ -1,291 +0,0 @@
import { Star, Code, Layout, Send, Zap, Shield, Cpu, Gamepad2, Plane, BookOpen, Check, Gift } from 'lucide-react';
import React from 'react';
import { BentoGrid } from './BentoGrid';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof BentoGrid> = {
title: 'Components/BentoGrid',
component: BentoGrid,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof BentoGrid>;
export const Showcase: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<BentoGrid>
<BentoGrid.ImageCard
alt="Developer workstation"
colSpan={2}
overlayDescription="A deep dive into modern web architecture and performance optimization."
overlayTitle="Featured Project"
rowSpan={2}
src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&q=80&w=2072"
/>
<BentoGrid.InfoCard
description="Built on top of Rsbuild and Rspack for near-instant compilation and ultra-fast HMR."
icon={<Zap className="text-orange-500" size={24} />}
title="Lightning Fast"
/>
<BentoGrid.CTACard
buttonText="Get Started"
description="Connect with other developers and share your projects."
href="#"
title="Join the Community"
variant="primary"
/>
<BentoGrid.InfoCard
description="Strict TypeScript integration across the entire monorepo for maximum reliability."
footer={<div className="text-xs opacity-50">Strict Mode Enabled</div>}
icon={<Shield className="text-green-500" size={24} />}
title="Type Safe"
/>
<BentoGrid.ImageCard
alt="Code on screen"
colSpan={2}
overlayTitle="Clean Code"
src="https://images.unsplash.com/photo-1461749280684-dccba630e2f6?auto=format&fit=crop&q=80&w=2069"
/>
<BentoGrid.InfoCard
description="Easily extend and customize the design system with our modular component library."
icon={<Cpu className="text-blue-500" size={24} />}
title="Modular Architecture"
/>
<BentoGrid.CTACard
buttonText="View Docs"
description="Read the full documentation to learn more about the design system."
href="#"
title="Documentation"
/>
<BentoGrid.BaseCard className="bg-tertiary/10 border-dashed" colSpan={4}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-tertiary rounded-full text-tertiary-text">
<Send size={20} />
</div>
<div>
<h4 className="font-bold text-text">Subscribe to Newsletter</h4>
<p className="text-sm text-muted-text">Stay updated with the latest releases and technical articles.</p>
</div>
</div>
<button className="px-6 py-2 bg-text text-[var(--rb-base)] rounded-md font-medium">Subscribe</button>
</div>
</BentoGrid.BaseCard>
</BentoGrid>
</div>
),
};
export const LayoutPatterns: Story = {
render: () => (
<div className="w-full max-w-6xl p-4 flex flex-col gap-12">
<section>
<h3 className="mb-4 text-xl font-bold">Standard 4-Column Grid</h3>
<BentoGrid>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
<section>
<h3 className="mb-4 text-xl font-bold">Mixed Spans (Hero Pattern)</h3>
<BentoGrid>
<BentoGrid.BaseCard className="flex items-center justify-center bg-primary/20 border-primary" colSpan={2} rowSpan={2}>2x2 Hero</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
<section>
<h3 className="mb-4 text-xl font-bold">Tall & Wide Pattern</h3>
<BentoGrid>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-secondary/20 border-secondary" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-tertiary/20 border-tertiary" rowSpan={2}>1x2 Tall</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
</div>
),
};
export const AllCardTypes: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<BentoGrid>
<BentoGrid.InfoCard
description="Used for features or information. Can have an icon and a footer."
footer={<div className="text-xs font-mono">v1.0.0</div>}
icon={<Layout size={24} />}
title="Info Card"
/>
<BentoGrid.ImageCard
alt="Abstract image"
overlayDescription="Perfect for visual showcases with text overlays."
overlayTitle="Image Card"
src="https://images.unsplash.com/photo-1481487196290-c152efe083f5?auto=format&fit=crop&q=80&w=2048"
/>
<BentoGrid.CTACard
buttonText="Learn More"
description="Drives action with an outline button."
href="#"
title="CTA Card (Default)"
/>
<BentoGrid.CTACard
buttonText="Sign Up Now"
description="High prominence with a solid background."
href="#"
title="CTA Card (Primary)"
variant="primary"
/>
<BentoGrid.ListCard
colSpan={2}
defaultValue={['features']}
description="A specialized card for categorized lists, ideal for wishlists or feature matrices."
icon={<div className="p-2 bg-blue-100 text-blue-700 rounded-lg"><Star size={20} /></div>}
sections={[
{
value: 'features',
label: 'Features',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: ['Responsive', 'Accessible', 'Themeable'],
}
]}
title="List Card"
/>
<BentoGrid.BaseCard className="flex flex-col gap-4">
<div className="p-2 bg-yellow-100 text-yellow-900 rounded-md w-fit">
<Code size={20} />
</div>
<h4 className="font-bold">Base Card (Custom)</h4>
<p className="text-sm">You can put any children here to create custom card types while maintaining the bento border and padding.</p>
</BentoGrid.BaseCard>
</BentoGrid>
</div>
),
};
export const WishlistVariant: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<div className="mb-8">
<Header level={2}>My Wishlist</Header>
<Text className="text-muted-text" size="l">Things I want and things I already have, organized by category.</Text>
</div>
<BentoGrid>
<BentoGrid.ListCard
colSpan={2}
defaultValue={['wanted']}
description="My essential list of gadgets and hardware I use for daily productivity and entertainment."
icon={
<div className="p-3 bg-blue-100 text-blue-700 rounded-xl">
<Gamepad2 size={24} />
</div>
}
rowSpan={2}
sections={[
{
value: 'wanted',
label: 'Wanted',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Steam Deck OLED 1TB',
'Sony A7C II with 35mm lens',
'Keychron Mechanical Keyboard'
]
},
{
value: 'owned',
label: 'Already Owned',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: [
'MacBook Pro M3 Max',
'Sony WH-1000XM5 Headphones',
'LG 27" 4K Monitor'
]
}
]}
title="Gadgets & Tech"
/>
<BentoGrid.ListCard
colSpan={2}
description="Places I want to visit and explore around the world."
icon={
<div className="p-3 bg-green-100 text-green-700 rounded-xl">
<Plane size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Bucket List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Kyoto, Japan (Cherry Blossom Season)',
'Reykjavik, Iceland',
'Swiss Alps'
]
},
{
value: 'owned',
label: 'Visited',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: [
'Paris, France',
'Rome, Italy',
'London, UK'
]
}
]}
title="Travel Destinations"
type="single"
/>
<BentoGrid.ListCard
colSpan={2}
description="My reading backlog spanning technical books, sci-fi, and fantasy."
icon={
<div className="p-3 bg-violet-100 text-violet-700 rounded-xl">
<BookOpen size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Reading List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Designing Data-Intensive Applications',
'The Pragmatic Programmer',
'Dune Messiah'
]
}
]}
title="Books to Read"
type="single"
/>
</BentoGrid>
</div>
),
};
@@ -1,121 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { BentoGrid } from './BentoGrid';
describe('BentoGrid', () => {
it('renders all card types correctly', () => {
render(
<BentoGrid>
<BentoGrid.BaseCard data-testid="base-card">Base Card Content</BentoGrid.BaseCard>
<BentoGrid.ImageCard
alt="Test Image"
overlayDescription="Image Description"
overlayTitle="Image Title"
src="test-image.jpg"
/>
<BentoGrid.InfoCard
description="Info Description"
footer={<span>Footer Content</span>}
title="Info Title"
/>
<BentoGrid.CTACard
buttonText="Click Me"
description="CTA Description"
href="/test"
title="CTA Title"
/>
<BentoGrid.ListCard
description="List Description"
sections={[
{
value: 's1',
label: 'Section 1',
items: ['Item A', 'Item B']
}
]}
title="List Title"
/>
</BentoGrid>
);
// BaseCard
expect(screen.getByTestId('base-card')).toBeInTheDocument();
expect(screen.getByText('Base Card Content')).toBeInTheDocument();
// ImageCard
expect(screen.getByAltText('Test Image')).toBeInTheDocument();
expect(screen.getByText('Image Title')).toBeInTheDocument();
expect(screen.getByText('Image Description')).toBeInTheDocument();
// InfoCard
expect(screen.getByText('Info Title')).toBeInTheDocument();
expect(screen.getByText('Info Description')).toBeInTheDocument();
expect(screen.getByText('Footer Content')).toBeInTheDocument();
// CTACard
expect(screen.getByText('CTA Title')).toBeInTheDocument();
expect(screen.getByText('CTA Description')).toBeInTheDocument();
expect(screen.getByText('Click Me')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Click Me/i })).toHaveAttribute('href', '/test');
// ListCard
expect(screen.getByText('List Title')).toBeInTheDocument();
expect(screen.getByText('List Description')).toBeInTheDocument();
expect(screen.getByText('Section 1')).toBeInTheDocument();
expect(screen.getByText('Item A')).toBeInTheDocument();
});
it('renders ListCard with object items (stable keys)', () => {
render(
<BentoGrid.ListCard
sections={[
{
value: 's1',
label: 'Section 1',
items: [
{ key: 'item-1', node: 'Stable Item 1' },
'Raw Item 2'
]
}
]}
title="List Title"
/>
);
expect(screen.getByText('Stable Item 1')).toBeInTheDocument();
expect(screen.getByText('Raw Item 2')).toBeInTheDocument();
});
it('applies span classes correctly', () => {
render(
<BentoGrid>
<BentoGrid.BaseCard colSpan={2} rowSpan={2}>
Spanning Card
</BentoGrid.BaseCard>
</BentoGrid>
);
const card = screen.getByText('Spanning Card');
expect(card).toHaveClass('md:col-span-2');
expect(card).toHaveClass('md:row-span-2');
});
it('applies primary variant to CTACard', () => {
render(
<BentoGrid>
<BentoGrid.CTACard
buttonText="Button"
description="Description"
href="/"
title="Primary CTA"
variant="primary"
/>
</BentoGrid>
);
const title = screen.getByText('Primary CTA');
const card = title.closest('.bg-primary');
expect(card).toBeInTheDocument();
});
});
@@ -1,56 +0,0 @@
import { cn, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { BentoGridCTACard } from './BentoGridCTACard';
import { BentoGridImageCard } from './BentoGridImageCard';
import { BentoGridInfoCard } from './BentoGridInfoCard';
import { BentoGridListCard } from './BentoGridListCard';
import type { BentoGridProps } from './types';
const BentoGridComponent = forwardRef<HTMLDivElement, BentoGridProps>(function BentoGrid(
{ children, className, ...props },
ref
) {
return (
<div
{...props}
ref={ref}
className={cn(
'grid w-full grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4',
className
)}
>
{children}
</div>
);
});
/**
* A responsive Bento Grid system for creating structured, masonry-like layouts.
*
* @example
* ```tsx
* <BentoGrid>
* <BentoGrid.ImageCard
* colSpan={2}
* rowSpan={2}
* src="/image.jpg"
* alt="Showcase"
* overlayTitle="Featured"
* />
* <BentoGrid.InfoCard
* title="Details"
* description="Key features of the project."
* />
* </BentoGrid>
* ```
*/
export const BentoGrid = Object.assign(genericMemo(BentoGridComponent), {
BaseCard: BentoGridBaseCard,
ImageCard: BentoGridImageCard,
InfoCard: BentoGridInfoCard,
CTACard: BentoGridCTACard,
ListCard: BentoGridListCard,
});
@@ -1,55 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
import type { BentoCardBaseProps } from './types';
const baseCardVariants = cva(
'relative overflow-hidden rounded-xl border border-outline bg-base p-6 shadow-sm transition-all hover:shadow-md',
{
variants: {
colSpan: {
1: 'md:col-span-1',
2: 'md:col-span-2',
3: 'md:col-span-3',
4: 'md:col-span-4',
},
rowSpan: {
1: 'md:row-span-1',
2: 'md:row-span-2',
3: 'md:row-span-3',
},
},
defaultVariants: {
colSpan: 1,
rowSpan: 1,
},
}
);
/**
* Base card component that provides the foundational styling for all Bento cards.
* @example Basic usage:
*/
export const BentoGridBaseCard = ({
children,
className,
colSpan,
rowSpan,
href,
...props
}: BentoCardBaseProps) => {
// Render as an anchor when `href` is provided so the whole card is a link,
// otherwise a plain div. Styling and span variants are identical either way.
const Component: React.ElementType = href != null ? 'a' : 'div';
return (
<Component
className={cn(baseCardVariants({ colSpan, rowSpan }), href != null && 'block', className)}
href={href}
{...props}
>
{children}
</Component>
);
};
@@ -1,111 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Button } from '../Button/Button';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
const cardVariants = cva('', {
variants: {
variant: {
default: '',
primary: 'bg-primary border-primary',
},
},
defaultVariants: {
variant: 'default',
},
});
const headerVariants = cva('', {
variants: {
variant: {
default: '',
primary: 'text-primary-text',
},
},
defaultVariants: {
variant: 'default',
},
});
const descriptionVariants = cva('', {
variants: {
variant: {
default: 'text-muted-text',
primary: 'text-primary-text/80',
},
},
defaultVariants: {
variant: 'default',
},
});
/**
*
*/
export interface BentoGridCTACardProps extends BentoCardBaseProps {
/** The title of the call to action */
title: string;
/** The description text */
description: string;
/** The text to display on the action button */
buttonText: string;
/** The destination URL for the action */
href: string;
/**
* Visual variant for the card.
* 'default' uses standard card styling.
* 'primary' uses a more prominent background.
*/
variant?: 'default' | 'primary';
}
/**
* A Bento card designed specifically to drive user action.
* @example Basic usage:
*/
export const BentoGridCTACard = ({
title,
description,
buttonText,
href,
variant = 'default',
className,
...baseProps
}: BentoGridCTACardProps) => {
return (
<BentoGridBaseCard
{...baseProps}
className={cn(cardVariants({ variant }), className)}
>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
<Header className={cn(headerVariants({ variant }))} level={3}>
{title}
</Header>
<Text
className={cn(descriptionVariants({ variant }))}
size="m"
>
{description}
</Text>
</VStack>
<div className="mt-auto">
<Button
className="w-full sm:w-auto"
href={href}
variant={variant === 'primary' ? 'solid' : 'outline'}
>
{buttonText}
</Button>
</div>
</VStack>
</BentoGridBaseCard>
);
};
@@ -1,70 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Header } from '../Header/Header';
import { Image } from '../Image/Image';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridImageCardProps extends BentoCardBaseProps {
/** Source URL of the image */
src: string;
/** Alt text for the image */
alt: string;
/** Optional title to overlay on the image */
overlayTitle?: string;
/** Optional description to overlay on the image */
overlayDescription?: string;
}
/**
* A Bento card designed to showcase an image with optional textual overlays.
* @example Basic usage:
*/
export const BentoGridImageCard = ({
src,
alt,
overlayTitle,
overlayDescription,
className,
...baseProps
}: BentoGridImageCardProps) => {
const hasOverlay = !!overlayTitle || !!overlayDescription;
return (
<BentoGridBaseCard {...baseProps} className={cn('p-0', className)}>
<div className="relative h-full w-full">
<Image
alt={alt}
className="h-full w-full object-cover"
height="100%"
ratio="auto"
src={src}
/>
{hasOverlay && (
<div className="absolute inset-0 flex flex-col justify-end bg-linear-to-t from-black/80 via-black/40 to-transparent p-6 text-white">
<VStack gap={1}>
{overlayTitle && (
<Header className="text-white" level={3}>
{overlayTitle}
</Header>
)}
{overlayDescription && (
<Text className="text-white/90" size="s">
{overlayDescription}
</Text>
)}
</VStack>
</div>
)}
</div>
</BentoGridBaseCard>
);
};
@@ -1,47 +0,0 @@
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridInfoCardProps extends BentoCardBaseProps {
/** The title of the card */
title: string;
/** The description text */
description?: string;
/** Optional icon or graphic to display at the top */
icon?: React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
}
/**
* A versatile Bento card for text-heavy content or feature highlights.
* @example Basic usage:
*/
export const BentoGridInfoCard = ({
title,
description,
icon,
footer,
...baseProps
}: BentoGridInfoCardProps) => {
return (
<BentoGridBaseCard {...baseProps}>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
{icon && <div className="text-primary-text">{icon}</div>}
<Header level={3}>{title}</Header>
{description && <Text className="text-muted-text" size="m">{description}</Text>}
</VStack>
{footer && <div className="mt-auto w-full">{footer}</div>}
</VStack>
</BentoGridBaseCard>
);
};
@@ -1,126 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { Accordion } from '../Accordion';
import { Header } from '../Header/Header';
import { HStack } from '../HStack/HStack';
import { List } from '../List';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridListCardSection {
/** Unique identifier for the accordion section */
value: string;
/** Label text for the accordion trigger */
label: string;
/** Optional icon next to the label */
labelIcon?: React.ReactNode;
/** Optional class name for the label text color (e.g. 'text-rb-orange-600') */
labelClassName?: string;
/** List of items to display when expanded. Can be raw nodes or objects with keys. */
items: (React.ReactNode | { key: string; node: React.ReactNode })[];
}
/**
*
*/
export interface BentoGridListCardProps extends BentoCardBaseProps {
/** Title of the card */
title: string;
/** Description text */
description?: string;
/** Optional icon next to the title. For colored backgrounds, wrap the icon in a styled div. */
icon?: React.ReactNode;
/** Sections containing lists of items, rendered as Accordions */
sections: BentoGridListCardSection[];
/** Which sections are expanded by default (uses section value) */
defaultValue?: string[];
/** Whether multiple sections can be open at once */
type?: 'single' | 'multiple';
}
/**
* A Bento card designed for categorized lists of items using Accordions.
*
* @example
* ```tsx
* <BentoGrid.ListCard
* title="My List"
* sections={[
* { value: 's1', label: 'Section 1', items: ['Item 1', 'Item 2'] }
* ]}
* />
* ```
*/
export const BentoGridListCard = ({
title,
description,
icon,
sections,
defaultValue,
type = 'multiple',
...baseProps
}: BentoGridListCardProps) => {
return (
<BentoGridBaseCard {...baseProps} className={cn('flex flex-col', baseProps.className)}>
<VStack className="h-full" gap={4}>
<HStack align="center" gap={3}>
{icon && <div>{icon}</div>}
<Header level={3}>{title}</Header>
</HStack>
{description && (
<Text className="text-muted-text">
{description}
</Text>
)}
<div className="flex-grow w-full">
<Accordion collapsible defaultValue={defaultValue} type={type}>
{sections.map((section) => (
<Accordion.Item key={section.value} value={section.value}>
<Accordion.Trigger className={section.labelClassName}>
<HStack align="center" gap={2}>
{section.labelIcon}
<Text size="l" type="body">{section.label}</Text>
</HStack>
</Accordion.Trigger>
<Accordion.Content>
<List>
{section.items.map((item, index) => {
const content = (() => {
if (item && typeof item === 'object' && 'node' in item) {
return item.node;
}
return item;
})();
const itemKey =
item && typeof item === 'object' && 'key' in item
? item.key
: typeof item === 'string'
? item
: index;
return (
<List.Item key={itemKey}>
<Text size="m">{content}</Text>
</List.Item>
);
})}
</List>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
</div>
</VStack>
</BentoGridBaseCard>
);
};
@@ -1,7 +0,0 @@
export * from './BentoGrid';
export * from './types';
export * from './BentoGridBaseCard';
export * from './BentoGridImageCard';
export * from './BentoGridInfoCard';
export * from './BentoGridCTACard';
export * from './BentoGridListCard';
@@ -1,47 +0,0 @@
import type React from 'react';
/**
* Common props for all BentoGrid card variations.
*
* Typed against `HTMLElement` since a card can render as either a `div` or,
* when `href` is provided, an `a`.
*/
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLElement> {
/**
* Number of columns to span on medium screens and up.
* @default 1
*/
colSpan?: 1 | 2 | 3 | 4;
/**
* Number of rows to span on medium screens and up.
* @default 1
*/
rowSpan?: 1 | 2 | 3;
/**
* Additional CSS classes.
*/
className?: string;
/**
* The content of the card.
*/
children?: React.ReactNode;
/**
* If provided, the card renders as an anchor (`<a>`) so the entire card is a
* navigable link. Card styling and colSpan/rowSpan are preserved.
*/
href?: string;
}
/**
* Props for the root BentoGrid component.
*/
export interface BentoGridProps {
/**
* The cards to display in the grid.
*/
children: React.ReactNode;
/**
* Additional CSS classes.
*/
className?: string;
}
@@ -19,7 +19,9 @@ export interface BlockquoteProps
indent?: boolean;
}
const blockquoteVariants = cva('border-l-4 border-[var(--rb-primary)]', {
const blockquoteVariants = cva(
'border-l-4 border-[var(--rb-primary)] [&>p:last-child]:mb-0 [&>*:last-child]:mb-0',
{
variants: {
variant: {
default: '',
@@ -24,6 +24,18 @@ describe('components/Breadcrumbs', () => {
expect(currentItem).toHaveAttribute('aria-current', 'page');
});
it('applies typography tokens to root container and items inherit size', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item href="/test">Home</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item isCurrent>Current</Breadcrumbs.Item>
</Breadcrumbs>
);
const ol = screen.getByRole('list');
expect(ol.className).toContain('text-label-m');
});
it('supports isCurrent state', () => {
render(
<Breadcrumbs>
@@ -21,10 +21,10 @@ export interface BreadcrumbsProps extends HStackProps {
/**
* Root component for hierarchical navigation.
*/
const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: BreadcrumbsProps) {
const BreadcrumbsRoot = memo(function Breadcrumbs({ children, className, ...props }: BreadcrumbsProps) {
return (
<nav aria-label="Breadcrumb">
<HStack align="center" as="ol" gap={2} {...props}>
<nav aria-label="Breadcrumb" className={className}>
<HStack align="center" as="ol" className="text-label-m font-sans font-medium" gap={2} {...props}>
{children}
</HStack>
</nav>
@@ -102,8 +102,6 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
className={cn(itemTextVariants({ isCurrent }))}
color={isCurrent ? 'default' : 'muted'}
href={resolvedHref}
size="m"
type="label"
onClick={onClick}
>
{children}
@@ -137,7 +135,7 @@ const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
className={cn('text-[var(--rb-muted-text)]', className)}
data-testid={testId}
>
<Text color="muted" size="m" type="label">
<Text color="muted">
{content[variant]}
</Text>
</li>
+15 -4
View File
@@ -53,6 +53,8 @@ interface BaseButtonProps extends ClassNameProps, TestIdProps {
rounded?: 'none' | 'md' | 'full';
/** Whether the button is disabled */
isDisabled?: boolean;
/** Explicitly flag button as icon-only for square aspect ratio */
isIconOnly?: boolean;
}
/**
@@ -101,9 +103,13 @@ const buttonVariants = cva(
'neutral-negative': '',
},
size: {
s: 'h-8 px-3 text-sm gap-1.5',
m: 'h-10 px-4 text-base gap-2',
l: 'h-12 px-6 text-lg gap-3',
s: 'h-8 px-4 text-sm gap-1.5',
m: 'h-10 px-6 text-base gap-2',
l: 'h-12 px-8 text-lg gap-3',
},
isIconOnly: {
true: 'aspect-square px-0',
false: '',
},
rounded: {
none: 'rounded-none',
@@ -177,6 +183,7 @@ const buttonVariants = cva(
rounded: 'md',
fullWidth: false,
isDisabled: false,
isIconOnly: false,
},
}
);
@@ -200,12 +207,16 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
className,
'data-testid': testId,
isDisabled = false,
isIconOnly: isIconOnlyProp,
href,
} = props;
const internalRef = useRef<HTMLElement>(null);
const targetRef = ref ? castRef<HTMLElement>(ref) : internalRef;
const isIconOnly =
isIconOnlyProp ?? (!children && Boolean(renderStartIcon ?? renderEndIcon));
// Hooks for interactions
// We explicitly construct the props object to avoid unsafe casting of the Union type.
const buttonHookProps = {
@@ -235,7 +246,7 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
};
const sharedClasses = cn(
buttonVariants({ variant, color, size, rounded, fullWidth, isDisabled }),
buttonVariants({ variant, color, size, rounded, fullWidth, isDisabled, isIconOnly }),
isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2',
isHovered && !isDisabled && 'brightness-90 dark:brightness-110',
className
@@ -35,6 +35,14 @@ describe('Code Component', () => {
expect(codeElement).toHaveClass('rounded-sm');
});
it('renders inline code with relative sizing classes', () => {
render(<Code>const x = 1;</Code>);
const codeEl = screen.getByRole('button');
expect(codeEl.className).toContain('text-[0.9em]');
expect(codeEl.className).toContain('px-[0.3em]');
expect(codeEl.className).not.toContain('text-sm');
});
it('renders block code correctly', () => {
render(
<CodeBlock label="test.ts">
+3 -2
View File
@@ -4,12 +4,13 @@ import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { usePress } from 'react-aria';
import { useCodeContext } from './contexts/CodeContext/CodeContext';
import { codeHighlighter } from './lib/highlighter/highlighter';
import { codeHighlighter, SUPPORTED_LANGUAGES } from './lib/highlighter/highlighter';
import type { CodeLanguage } from './lib/highlighter/highlighter';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
export type { CodeLanguage };
export { SUPPORTED_LANGUAGES };
const highlightedVariants = cva('block min-w-full font-mono text-sm [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0', {
variants: {
@@ -36,7 +37,7 @@ const blockVariants = cva('block min-w-full font-mono whitespace-pre', {
});
const inlineVariants = cva(
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)] cursor-pointer transition-all duration-200 hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10 active:scale-95',
'relative inline-block rounded-sm px-[0.3em] py-[0.1em] text-[0.9em] font-mono text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)] cursor-pointer transition-all duration-200 hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10 active:scale-95',
{
variants: {
isCopied: {
@@ -71,4 +71,12 @@ describe('components/Container', () => {
);
consoleWarnSpy.mockRestore();
});
it('forwards ref to the underlying DOM element', () => {
const ref = React.createRef<HTMLDivElement>();
render(<Container ref={ref}>Ref Content</Container>);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
expect(ref.current?.textContent).toBe('Ref Content');
});
});
@@ -1,6 +1,6 @@
import { cn } from '@ermnvldmr/stl';
import { castRef, cn, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { memo } from 'react';
import React, { forwardRef } from 'react';
import { usePress } from 'react-aria';
import type { SpacingScale } from '../../lib/scale';
@@ -152,20 +152,23 @@ const containerVariants = cva('overflow-hidden', {
* Container provides a consistent way to handle backgrounds, padding,
* borders, and width constraints while maintaining theme consistency.
*/
export const Container = memo(function Container({
children,
bg = 'base',
padding = 0,
maxWidth = 'none',
rounded = 'none',
shadow = false,
border = false,
onPress,
href,
as: Component = 'div',
className,
'data-testid': testId,
}: ContainerProps) {
const ContainerComponent = forwardRef<HTMLElement, ContainerProps>(function Container(
{
children,
bg = 'base',
padding = 0,
maxWidth = 'none',
rounded = 'none',
shadow = false,
border = false,
onPress,
href,
as: Component = 'div',
className,
'data-testid': testId,
},
ref
) {
if (process.env.NODE_ENV !== 'production' && onPress && href) {
console.warn('[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.');
}
@@ -173,7 +176,6 @@ export const Container = memo(function Container({
const isLink = href != null;
const isPressable = !!onPress && !isLink;
const ref = React.useRef<HTMLElement>(null);
const { pressProps } = usePress({
onPress,
isDisabled: !isPressable,
@@ -185,7 +187,7 @@ export const Container = memo(function Container({
return (
<FinalComponent
{...interactionProps}
ref={ref}
ref={castRef<HTMLElement>(ref)}
className={cn(
containerVariants({
bg,
@@ -205,3 +207,6 @@ export const Container = memo(function Container({
</FinalComponent>
);
});
export const Container = genericMemo(ContainerComponent);
@@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { Icon } from './Icon';
const DummySvg = ({ className }: { className?: string }) => (
<svg className={className} data-testid="dummy-svg" />
);
describe('components/Icon', () => {
it('renders icon component with inline-icon class', () => {
render(<Icon icon={DummySvg} />);
const el = screen.getByTestId('dummy-svg');
expect(el).toHaveClass('inline-icon');
});
it('merges custom className', () => {
render(<Icon className="text-primary-text" icon={DummySvg} />);
const el = screen.getByTestId('dummy-svg');
expect(el).toHaveClass('inline-icon');
expect(el).toHaveClass('text-primary-text');
});
});
+19
View File
@@ -0,0 +1,19 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import type { ClassNameProps } from '@ermnvldmr/stl';
/**
* Props for the Icon component.
*/
export interface IconProps extends ClassNameProps {
/** The icon component to render. */
icon: React.ComponentType<{ className?: string }>;
}
/**
* A wrapper component for inline icons that scale with surrounding text typography.
*/
export const Icon = memo(function Icon({ icon: IconComponent, className }: IconProps) {
return <IconComponent className={cn('inline-icon', className)} />;
});
+1
View File
@@ -0,0 +1 @@
export * from './Icon';
+1 -1
View File
@@ -45,7 +45,7 @@ export const Link = memo(function Link(props: LinkProps) {
const sharedClasses = cn(
!color && 'text-[var(--rb-ring)]',
'cursor-pointer underline hover:no-underline underline-offset-4 transition-colors',
'cursor-pointer no-underline hover:underline underline-offset-4 transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
);
@@ -1,226 +0,0 @@
import React from 'react';
import { Markdown } from './Markdown';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Markdown> = {
title: 'Components/Markdown',
component: Markdown,
parameters: {
layout: 'padded',
},
};
export default meta;
type Story = StoryObj<typeof Markdown>;
/**
* A "Kitchen Sink" demonstration of all Markdown components working together
* as they would in a real article.
*/
export const KitchenSink: Story = {
render: () => (
<div className="max-w-[800px] mx-auto">
<Markdown>
<Markdown.H1>The Art of Markdown Integration</Markdown.H1>
<Markdown.P>
This article demonstrates how our design system translates standard Markdown tags into
beautiful, accessible UI primitives. From typography to complex data tables.
</Markdown.P>
<Markdown.Hr />
<Markdown.H2>Typography & Vertical Rhythm</Markdown.H2>
<Markdown.P>
Our typography system is built on a strict vertical rhythm. Headers use the{' '}
<Markdown.Code>Header</Markdown.Code> component, while paragraphs leverage the{' '}
<Markdown.Code>Paragraph</Markdown.Code> component with pre-configured gutters.
</Markdown.P>
<Markdown.Blockquote>
&quot;Design is not just what it looks like and feels like. Design is how it works.&quot;
<br />
<Markdown.A href="https://en.wikipedia.org/wiki/Steve_Jobs">— Steve Jobs</Markdown.A>
</Markdown.Blockquote>
<Markdown.H3>Nesting and Lists</Markdown.H3>
<Markdown.P>
Lists are handled by our <Markdown.Code>List</Markdown.Code> system, supporting both
ordered and unordered variants:
</Markdown.P>
<Markdown.Ul>
<Markdown.Li>Standardization across all articles</Markdown.Li>
<Markdown.Li>
Support for nested structures:
<Markdown.Ul className="mt-2">
<Markdown.Li>Nested unordered item</Markdown.Li>
<Markdown.Li>Another nested item</Markdown.Li>
</Markdown.Ul>
</Markdown.Li>
<Markdown.Li>Consistent spacing and markers</Markdown.Li>
</Markdown.Ul>
<Markdown.H2>Code & Technical Content</Markdown.H2>
<Markdown.P>
Technical articles often require code blocks. We use{' '}
<Markdown.Code>CodeBlock</Markdown.Code> for multi-line snippets:
</Markdown.P>
<Markdown.Pre label="markdown-example.tsx">
<Markdown.Code>
{`export const MarkdownP = memo(function MarkdownP(props: ParagraphProps) {
return <Paragraph gutterBottom {...props} />;
});`}
</Markdown.Code>
</Markdown.Pre>
<Markdown.H2>Data Representation</Markdown.H2>
<Markdown.P>
Tables are notoriously difficult to style in Markdown. Our components ensure they stay
within the container and maintain readable density.
</Markdown.P>
<Markdown.Table>
<Markdown.THead>
<Markdown.Tr>
<Markdown.Th>Component</Markdown.Th>
<Markdown.Th>Tag</Markdown.Th>
<Markdown.Th>Status</Markdown.Th>
</Markdown.Tr>
</Markdown.THead>
<Markdown.TBody>
<Markdown.Tr>
<Markdown.Td>MarkdownP</Markdown.Td>
<Markdown.Td>
<Markdown.Code>p</Markdown.Code>
</Markdown.Td>
<Markdown.Td>Verified</Markdown.Td>
</Markdown.Tr>
<Markdown.Tr>
<Markdown.Td>MarkdownTable</Markdown.Td>
<Markdown.Td>
<Markdown.Code>table</Markdown.Code>
</Markdown.Td>
<Markdown.Td>Verified</Markdown.Td>
</Markdown.Tr>
</Markdown.TBody>
</Markdown.Table>
<Markdown.H2>Visual Assets</Markdown.H2>
<Markdown.Img
alt="Atmospheric landscape"
ratio="16/9"
src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&fit=crop&q=80&w=1600"
width="100%"
/>
</Markdown>
</div>
),
};
/**
* Specifically showcases the header hierarchy from H1 to H6.
*/
export const HeaderHierarchy: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Markdown.H1>Header Level 1</Markdown.H1>
<Markdown.H2>Header Level 2</Markdown.H2>
<Markdown.H3>Header Level 3</Markdown.H3>
<Markdown.H4>Header Level 4</Markdown.H4>
<Markdown.H5>Header Level 5</Markdown.H5>
<Markdown.H6>Header Level 6</Markdown.H6>
</div>
),
};
/**
* Demonstrates various list types and their spacing.
*/
export const ListStyles: Story = {
render: () => (
<div className="flex flex-col gap-8">
<div>
<Markdown.H4 className="mb-4">Unordered List</Markdown.H4>
<Markdown.Ul>
<Markdown.Li>First item</Markdown.Li>
<Markdown.Li>Second item</Markdown.Li>
<Markdown.Li>Third item</Markdown.Li>
</Markdown.Ul>
</div>
<div>
<Markdown.H4 className="mb-4">Ordered List</Markdown.H4>
<Markdown.Ol>
<Markdown.Li>Step one: Preparation</Markdown.Li>
<Markdown.Li>Step two: Implementation</Markdown.Li>
<Markdown.Li>Step three: Verification</Markdown.Li>
</Markdown.Ol>
</div>
</div>
),
};
/**
* Showcases code blocks with language-specific syntax highlighting.
*/
export const CodeLanguages: Story = {
render: () => (
<div className="max-w-[800px] mx-auto space-y-8">
<div>
<Markdown.H3 className="mb-4">TypeScript</Markdown.H3>
<Markdown.Pre label="example.ts">
<Markdown.Code className="language-typescript">
{`interface User {
id: number;
name: string;
}
const greet = (user: User) => {
console.log(\`Hello, \${user.name}!\`);
};`}
</Markdown.Code>
</Markdown.Pre>
</div>
<div>
<Markdown.H3 className="mb-4">Rust</Markdown.H3>
<Markdown.Pre label="main.rs">
<Markdown.Code className="language-rust">
{`fn main() {
let name = "World";
println!("Hello, {}!", name);
}`}
</Markdown.Code>
</Markdown.Pre>
</div>
<div>
<Markdown.H3 className="mb-4">Python</Markdown.H3>
<Markdown.Pre label="script.py">
<Markdown.Code className="language-python">
{`def greet(name: str):
print(f"Hello, {name}!")
if __name__ == "__main__":
greet("World")`}
</Markdown.Code>
</Markdown.Pre>
</div>
<div>
<Markdown.H3 className="mb-4">JSON</Markdown.H3>
<Markdown.Pre label="data.json">
<Markdown.Code className="language-json">
{`{
"project": "ermnvldmr.com",
"version": "1.0.0",
"private": true
}`}
</Markdown.Code>
</Markdown.Pre>
</div>
</div>
),
};
@@ -1,46 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Markdown, MARKDOWN_COMPONENTS } from './Markdown';
describe('Markdown Components', () => {
it('renders various markdown components correctly', () => {
render(
<Markdown>
<Markdown.H1>Heading 1</Markdown.H1>
<Markdown.P>This is a paragraph.</Markdown.P>
<Markdown.A href="https://example.com">Link</Markdown.A>
<Markdown.Ul>
<Markdown.Li>Item 1</Markdown.Li>
</Markdown.Ul>
<Markdown.Table>
<Markdown.THead>
<Markdown.Tr>
<Markdown.Th>Header</Markdown.Th>
</Markdown.Tr>
</Markdown.THead>
<Markdown.TBody>
<Markdown.Tr>
<Markdown.Td>Cell</Markdown.Td>
</Markdown.Tr>
</Markdown.TBody>
</Markdown.Table>
</Markdown>
);
expect(screen.getByText('Heading 1')).toBeInTheDocument();
expect(screen.getByText('This is a paragraph.')).toHaveClass('mb-4');
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com');
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('cell')).toHaveTextContent('Cell');
});
it('MARKDOWN_COMPONENTS map contains all necessary tags', () => {
expect(MARKDOWN_COMPONENTS.p).toBeDefined();
expect(MARKDOWN_COMPONENTS.h1).toBeDefined();
expect(MARKDOWN_COMPONENTS.table).toBeDefined();
expect(MARKDOWN_COMPONENTS.pre).toBeDefined();
expect(MARKDOWN_COMPONENTS.code).toBeDefined();
});
});
@@ -1,79 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import { MarkdownA } from './MarkdownA/MarkdownA';
import { MarkdownBlockquote } from './MarkdownBlockquote/MarkdownBlockquote';
import { MarkdownCode } from './MarkdownCode/MarkdownCode';
import { MarkdownH1 } from './MarkdownH1/MarkdownH1';
import { MarkdownH2 } from './MarkdownH2/MarkdownH2';
import { MarkdownH3 } from './MarkdownH3/MarkdownH3';
import { MarkdownH4 } from './MarkdownH4/MarkdownH4';
import { MarkdownH5 } from './MarkdownH5/MarkdownH5';
import { MarkdownH6 } from './MarkdownH6/MarkdownH6';
import { MarkdownHR } from './MarkdownHR/MarkdownHR';
import { MarkdownImg } from './MarkdownImg/MarkdownImg';
import { MarkdownLI } from './MarkdownLI/MarkdownLI';
import { MarkdownOL } from './MarkdownOL/MarkdownOL';
import { MarkdownP } from './MarkdownP/MarkdownP';
import { MarkdownPre } from './MarkdownPre/MarkdownPre';
import { MarkdownTable } from './MarkdownTable/MarkdownTable';
import { MarkdownTBody } from './MarkdownTBody/MarkdownTBody';
import { MarkdownTD } from './MarkdownTD/MarkdownTD';
import { MarkdownTH } from './MarkdownTH/MarkdownTH';
import { MarkdownTHead } from './MarkdownTHead/MarkdownTHead';
import { MarkdownTR } from './MarkdownTR/MarkdownTR';
import { MarkdownUL } from './MarkdownUL/MarkdownUL';
/**
* Props for the Markdown component.
*/
export interface MarkdownProps {
/** The content to be rendered, typically HTML from a markdown processor. */
children: React.ReactNode;
/** Optional additional class names. */
className?: string;
}
export { MARKDOWN_COMPONENTS } from './components';
const MarkdownComponent = memo(function Markdown({ children, className }: MarkdownProps) {
return <div className={cn('flex flex-col gap-4', className)}>{children}</div>;
});
/**
* Markdown component provides a styled container for rendered markdown content.
*
* It also exports all necessary sub-components for mapping HTML tags to design system components.
*
* @example
* ```tsx
* <Markdown>
* <Markdown.H1>Title</Markdown.H1>
* <Markdown.P>Content goes here.</Markdown.P>
* </Markdown>
* ```
*/
export const Markdown = Object.assign(MarkdownComponent, {
P: MarkdownP,
A: MarkdownA,
H1: MarkdownH1,
H2: MarkdownH2,
H3: MarkdownH3,
H4: MarkdownH4,
H5: MarkdownH5,
H6: MarkdownH6,
Blockquote: MarkdownBlockquote,
Ul: MarkdownUL,
Ol: MarkdownOL,
Li: MarkdownLI,
Table: MarkdownTable,
THead: MarkdownTHead,
TBody: MarkdownTBody,
Tr: MarkdownTR,
Th: MarkdownTH,
Td: MarkdownTD,
Code: MarkdownCode,
Pre: MarkdownPre,
Hr: MarkdownHR,
Img: MarkdownImg,
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Link } from '../../Link/Link';
import type { LinkProps } from '../../Link/Link';
/**
* A Markdown link component that wraps the design system Link.
*/
export const MarkdownA = memo(function MarkdownA(props: LinkProps) {
return <Link {...props} />;
});
@@ -1,14 +0,0 @@
import React, { memo } from 'react';
import { Blockquote } from '../../Blockquote/Blockquote';
import type { BlockquoteProps } from '../../Blockquote/Blockquote';
/**
* A Markdown blockquote component that wraps the design system Blockquote.
*
* Automatically applies gutterBottom and indentation.
*/
export const MarkdownBlockquote = memo(function MarkdownBlockquote(props: BlockquoteProps) {
return <Blockquote gutterBottom indent {...props} />;
});
@@ -1,43 +0,0 @@
import React, { memo, useMemo } from 'react';
import { Code } from '../../Code/Code';
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
import type { CodeProps } from '../../Code/Code';
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
/**
* Checks if a string is a valid code language.
* @param lang The string to check.
* @returns True if the string is a valid code language.
* @example
* ```typescript
* isCodeLanguage('typescript'); // true
* isCodeLanguage('foo'); // false
* ```
*/
function isCodeLanguage(lang: string): lang is CodeLanguage {
return SUPPORTED_LANGUAGES.some((l) => l === lang);
}
/**
* A Markdown code component for inline snippets.
*/
export const MarkdownCode = memo(function MarkdownCode(props: CodeProps) {
const { className, ...rest } = props;
const language = useMemo(() => {
if (className) {
const match = /language-(\w+)/.exec(className);
if (match) {
const lang = match[1];
if (isCodeLanguage(lang)) {
return lang;
}
}
}
return undefined;
}, [className]);
return <Code language={language} {...rest} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
/**
* A Markdown H1 component that wraps the design system Header.
*/
export const MarkdownH1 = memo(function MarkdownH1(props: Omit<HeaderProps, 'level'>) {
return <Header level={1} {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
/**
* A Markdown H2 component that wraps the design system Header.
*/
export const MarkdownH2 = memo(function MarkdownH2(props: Omit<HeaderProps, 'level'>) {
return <Header level={2} {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
/**
* A Markdown H3 component that wraps the design system Header.
*/
export const MarkdownH3 = memo(function MarkdownH3(props: Omit<HeaderProps, 'level'>) {
return <Header level={3} {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
/**
* A Markdown H4 component that wraps the design system Header.
*/
export const MarkdownH4 = memo(function MarkdownH4(props: Omit<HeaderProps, 'level'>) {
return <Header level={4} {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
/**
* A Markdown H5 component that wraps the design system Header.
*/
export const MarkdownH5 = memo(function MarkdownH5(props: Omit<HeaderProps, 'level'>) {
return <Header level={5} {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
/**
* A Markdown H6 component that wraps the design system Header.
*/
export const MarkdownH6 = memo(function MarkdownH6(props: Omit<HeaderProps, 'level'>) {
return <Header level={6} {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Separator } from '../../Separator/Separator';
import type { SeparatorProps } from '../../Separator/Separator';
/**
* A Markdown horizontal rule component that wraps the design system Separator.
*/
export const MarkdownHR = memo(function MarkdownHR(props: SeparatorProps) {
return <Separator className="my-8" {...props} />;
});
@@ -1,17 +0,0 @@
import React, { memo } from 'react';
import { Image } from '../../Image/Image';
import type { ImageProps } from '../../Image/Image';
/**
* A Markdown image component that wraps the design system Image.
*
* Note: Markdown usually only provides src and alt. Default constraints
* might be needed if they are not provided by the processor.
*/
export const MarkdownImg = memo(function MarkdownImg(props: ImageProps) {
// Default values for common Markdown images to prevent CLS
// if specific dimensions aren't provided.
return <Image rounded="md" shadow="sm" {...props} />;
});
@@ -1,17 +0,0 @@
import React, { memo } from 'react';
import { List } from '../../List/List';
import { MarkdownP } from '../MarkdownP/MarkdownP';
import type { ListItemProps } from '../../List/List';
/**
* A Markdown list item component.
*/
export const MarkdownLI = memo(function MarkdownLI({ children, ...props }: ListItemProps) {
return (
<List.Item {...props}>
<MarkdownP>{children}</MarkdownP>
</List.Item>
);
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { List } from '../../List/List';
import type { ListProps } from '../../List/List';
/**
* A Markdown ordered list component.
*/
export const MarkdownOL = memo(function MarkdownOL(props: ListProps) {
return <List spacing="m" variant="ordered" {...props} />;
});
@@ -1,14 +0,0 @@
import React, { memo } from 'react';
import { Paragraph } from '../../Paragraph/Paragraph';
import type { ParagraphProps } from '../../Paragraph/Paragraph';
/**
* A Markdown paragraph component that wraps the design system Paragraph.
*
* Automatically applies gutterBottom for consistent vertical rhythm.
*/
export const MarkdownP = memo(function MarkdownP(props: ParagraphProps) {
return <Paragraph gutterBottom {...props} />;
});
@@ -1,52 +0,0 @@
import React, { memo, useMemo } from 'react';
import { CodeBlock } from '../../Code/CodeBlock/CodeBlock';
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
import type { CodeBlockProps } from '../../Code/CodeBlock/CodeBlock';
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
/**
* Checks if a string is a valid code language.
* @param lang The string to check.
* @returns True if the string is a valid code language.
* @example
* ```typescript
* isCodeLanguage('typescript'); // true
* isCodeLanguage('foo'); // false
* ```
*/
function isCodeLanguage(lang: string): lang is CodeLanguage {
return SUPPORTED_LANGUAGES.some((l) => l === lang);
}
/**
* A Markdown pre component for code blocks.
*
* Wraps content in the design system CodeBlock.
*/
export const MarkdownPre = memo(function MarkdownPre(props: CodeBlockProps) {
const { children, ...rest } = props;
// MDX often passes a <code> element as the only child of <pre>.
// That <code> element usually has the language class (e.g., "language-typescript").
const language = useMemo(() => {
if (React.isValidElement<{ className?: string }>(children) && children.props.className) {
const className = children.props.className;
const match = /language-(\w+)/.exec(className);
if (match) {
const lang = match[1];
if (isCodeLanguage(lang)) {
return lang;
}
}
}
return undefined;
}, [children]);
return (
<CodeBlock language={language} {...rest}>
{children}
</CodeBlock>
);
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table body (tbody) component.
*/
export const MarkdownTBody = memo(function MarkdownTBody(
props: React.HTMLAttributes<HTMLTableSectionElement>
) {
return <Table.Body {...props} />;
});
@@ -1,23 +0,0 @@
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table cell (td) component.
*
* Wraps content in Table.Content for consistent styling and alignment.
*/
export const MarkdownTD = memo(function MarkdownTD({
children,
align,
...props
}: React.TdHTMLAttributes<HTMLTableCellElement>) {
const effectiveAlign =
align === 'left' || align === 'center' || align === 'right' ? align : undefined;
return (
<Table.Cell {...props} align={effectiveAlign}>
<Table.Content>{children}</Table.Content>
</Table.Cell>
);
});
@@ -1,16 +0,0 @@
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table header cell (th) component.
*/
export const MarkdownTH = memo(function MarkdownTH({
align,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>) {
const effectiveAlign =
align === 'left' || align === 'center' || align === 'right' ? align : undefined;
return <Table.Head {...props} align={effectiveAlign} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table head (thead) component.
*/
export const MarkdownTHead = memo(function MarkdownTHead(
props: React.HTMLAttributes<HTMLTableSectionElement>
) {
return <Table.Header {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table row (tr) component.
*/
export const MarkdownTR = memo(function MarkdownTR(
props: React.HTMLAttributes<HTMLTableRowElement>
) {
return <Table.Row {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
import type { TableProps } from '../../Table/Table';
/**
* A Markdown table root component.
*/
export const MarkdownTable = memo(function MarkdownTable(props: TableProps) {
return <Table density="m" variant="surface" {...props} />;
});
@@ -1,12 +0,0 @@
import React, { memo } from 'react';
import { List } from '../../List/List';
import type { ListProps } from '../../List/List';
/**
* A Markdown unordered list component.
*/
export const MarkdownUL = memo(function MarkdownUL(props: ListProps) {
return <List spacing="m" variant="unordered" {...props} />;
});
@@ -1,53 +0,0 @@
import { MarkdownA } from './MarkdownA/MarkdownA';
import { MarkdownBlockquote } from './MarkdownBlockquote/MarkdownBlockquote';
import { MarkdownCode } from './MarkdownCode/MarkdownCode';
import { MarkdownH1 } from './MarkdownH1/MarkdownH1';
import { MarkdownH2 } from './MarkdownH2/MarkdownH2';
import { MarkdownH3 } from './MarkdownH3/MarkdownH3';
import { MarkdownH4 } from './MarkdownH4/MarkdownH4';
import { MarkdownH5 } from './MarkdownH5/MarkdownH5';
import { MarkdownH6 } from './MarkdownH6/MarkdownH6';
import { MarkdownHR } from './MarkdownHR/MarkdownHR';
import { MarkdownImg } from './MarkdownImg/MarkdownImg';
import { MarkdownLI } from './MarkdownLI/MarkdownLI';
import { MarkdownOL } from './MarkdownOL/MarkdownOL';
import { MarkdownP } from './MarkdownP/MarkdownP';
import { MarkdownPre } from './MarkdownPre/MarkdownPre';
import { MarkdownTable } from './MarkdownTable/MarkdownTable';
import { MarkdownTBody } from './MarkdownTBody/MarkdownTBody';
import { MarkdownTD } from './MarkdownTD/MarkdownTD';
import { MarkdownTH } from './MarkdownTH/MarkdownTH';
import { MarkdownTHead } from './MarkdownTHead/MarkdownTHead';
import { MarkdownTR } from './MarkdownTR/MarkdownTR';
import { MarkdownUL } from './MarkdownUL/MarkdownUL';
import type React from 'react';
/**
* A map of HTML tags to Markdown components for use with MDX or other Markdown systems.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const MARKDOWN_COMPONENTS: Record<string, React.ComponentType<any>> = {
p: MarkdownP,
a: MarkdownA,
h1: MarkdownH1,
h2: MarkdownH2,
h3: MarkdownH3,
h4: MarkdownH4,
h5: MarkdownH5,
h6: MarkdownH6,
blockquote: MarkdownBlockquote,
ul: MarkdownUL,
ol: MarkdownOL,
li: MarkdownLI,
table: MarkdownTable,
thead: MarkdownTHead,
tbody: MarkdownTBody,
tr: MarkdownTR,
th: MarkdownTH,
td: MarkdownTD,
code: MarkdownCode,
pre: MarkdownPre,
hr: MarkdownHR,
img: MarkdownImg,
};
@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { PageColumn } from './PageColumn';
describe('PageColumn', () => {
it('renders children correctly', () => {
render(
<PageColumn>
<div data-testid="child">Test Content</div>
</PageColumn>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('applies size classes', () => {
const { container: small } = render(<PageColumn size="small">Content</PageColumn>);
expect(small.firstChild).toHaveClass('lg:w-[var(--rb-page-column-small,300px)]');
const { container: full } = render(<PageColumn size="full">Content</PageColumn>);
expect(full.firstChild).toHaveClass('flex-1');
});
it('handles sticky prop', () => {
const { container } = render(<PageColumn sticky>Content</PageColumn>);
expect(container.firstChild).toHaveClass('lg:sticky');
expect(container.firstChild).toHaveClass('lg:top-24');
});
it('renders as different element', () => {
render(<PageColumn as="aside">Content</PageColumn>);
expect(screen.getByRole('complementary')).toBeInTheDocument();
});
});
@@ -1,72 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
/**
* Props for the PageColumn component.
*/
export interface PageColumnProps {
/** The content of the column. */
children: React.ReactNode;
/**
* The size of the column on desktop.
* - `small`: Fixed width (300px)
* - `full`: Takes up remaining space (flex-1)
* @default 'full'
*/
size?: 'small' | 'full';
/**
* If true, the column will stick to the top when scrolling on desktop.
* @default false
*/
sticky?: boolean;
/**
* Optional additional class names.
*/
className?: string;
/**
* The HTML element to use for the root of the component.
* @default 'div'
*/
as?: React.ElementType;
}
const pageColumnVariants = cva('w-full transition-all duration-200', {
variants: {
size: {
small: 'lg:w-[var(--rb-page-column-small,300px)] lg:flex-none',
full: 'flex-1',
},
sticky: {
true: 'lg:sticky lg:top-24 self-start',
false: '',
},
},
defaultVariants: {
size: 'full',
sticky: false,
},
});
/**
/**
* A single column unit within the PageColumns host.
*
* @param props - The component props.
* @returns A PageColumn component.
* @example
* ```tsx
* <PageColumn size="small">Sidebar</PageColumn>
* ```
*/
export const PageColumn = ({
children,
size = 'full',
sticky = false,
className,
as: Component = 'div',
}: PageColumnProps) => {
return (
<Component className={cn(pageColumnVariants({ size, sticky }), className)}>{children}</Component>
);
};
@@ -1,137 +0,0 @@
import React from 'react';
import { PageColumns } from './PageColumns';
import { PageColumn } from '../PageColumn/PageColumn';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageColumns> = {
title: 'components/Page/PageColumns',
component: PageColumns,
parameters: {
layout: 'padded',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof PageColumns>;
const Box = ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div
className={`p-6 bg-slate-100 dark:bg-slate-900 border-2 border-slate-300 dark:border-slate-700 rounded-xl h-full flex items-center justify-center font-bold text-center ${className}`}
>
{children}
</div>
);
/**
* A standard two-column layout: small sidebar and full main area.
*/
export const TwoColumnsSidebarLeft: Story = {
args: {
gap: 8,
children: (
<>
<PageColumn size="small">
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
Small Sidebar
</Box>
</PageColumn>
<PageColumn size="full">
<Box className="min-h-[200px]">Main Full Content</Box>
</PageColumn>
</>
),
},
};
/**
* A standard two-column layout with sidebar on the right.
*/
export const TwoColumnsSidebarRight: Story = {
args: {
gap: 8,
children: (
<>
<PageColumn size="full">
<Box className="min-h-[200px]">Main Full Content</Box>
</PageColumn>
<PageColumn size="small">
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
Small Sidebar
</Box>
</PageColumn>
</>
),
},
};
/**
* Three columns: small sidebar on both sides.
*/
export const ThreeColumns: Story = {
args: {
gap: 6,
children: (
<>
<PageColumn size="small">
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200">Left Sidebar</Box>
</PageColumn>
<PageColumn size="full">
<Box className="min-h-[200px]">Primary Content</Box>
</PageColumn>
<PageColumn size="small">
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200">Right Sidebar</Box>
</PageColumn>
</>
),
},
};
/**
* Two full-width columns sharing space equally.
*/
export const TwoFullColumns: Story = {
args: {
gap: 8,
children: (
<>
<PageColumn size="full">
<Box className="min-h-[200px]">Column A (full)</Box>
</PageColumn>
<PageColumn size="full">
<Box className="min-h-[200px]">Column B (full)</Box>
</PageColumn>
</>
),
},
};
/**
* A sidebar that sticks to the top when scrolling.
*/
export const StickySidebar: Story = {
render: (args) => (
<div className="h-[800px] overflow-y-auto border-2 border-dashed p-4 rounded-lg">
<p className="mb-4 text-center italic text-slate-500">Scroll down to see the sidebar stick</p>
<PageColumns {...args} gap={8}>
<PageColumn size="full">
<div className="flex flex-col gap-4">
{Array.from({ length: 15 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Box key={i} className="min-h-[100px]">
Main Content Block {i + 1}
</Box>
))}
</div>
</PageColumn>
<PageColumn sticky size="small">
<Box className="bg-yellow-50 dark:bg-yellow-950 border-yellow-200 min-h-[300px]">
I am a Sticky Sidebar!
</Box>
</PageColumn>
</PageColumns>
</div>
),
};
@@ -1,31 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { PageColumns } from './PageColumns';
describe('PageColumns', () => {
it('renders children correctly', () => {
render(
<PageColumns>
<div data-testid="child">Test Content</div>
</PageColumns>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('applies gap class', () => {
const { container: gap8 } = render(<PageColumns gap={8}>Content</PageColumns>);
expect(gap8.firstChild).toHaveClass('gap-8');
const { container: gapCustom } = render(<PageColumns gap="gap-[20px]">Content</PageColumns>);
expect(gapCustom.firstChild).toHaveClass('gap-[20px]');
});
it('has base responsive classes', () => {
const { container } = render(<PageColumns>Content</PageColumns>);
expect(container.firstChild).toHaveClass('flex');
expect(container.firstChild).toHaveClass('flex-col');
expect(container.firstChild).toHaveClass('lg:flex-row');
});
});
@@ -1,48 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
/**
* Props for the PageColumns component.
*/
export interface PageColumnsProps {
/** The columns to be displayed within the grid. */
children: React.ReactNode;
/**
* Spacing between columns.
* @default 6
*/
gap?: number | string;
/**
* Optional additional class names.
*/
className?: string;
}
const pageColumnsVariants = cva('flex flex-col lg:flex-row w-full');
/**
/**
* Host component for a responsive column system.
* It handles the flex/grid layout for page columns.
*
* @param props - The component props.
* @returns A PageColumns component.
* @example
* ```tsx
* <PageColumns gap={8}>
* <PageColumn size="small">Sidebar</PageColumn>
* <PageColumn size="full">Main Content</PageColumn>
* </PageColumns>
* ```
*/
export const PageColumns = ({ children, gap = 6, className }: PageColumnsProps) => {
// `gap` accepts either a Tailwind spacing-scale number (`gap-N`) or an
// arbitrary class string, so the value space is not enumerable as a cva
// variant. Compute the numeric class here and pass strings through verbatim.
const gapClass = typeof gap === 'number' ? `gap-${gap}` : gap;
return (
<div className={cn(pageColumnsVariants(), gapClass, className)}>{children}</div>
);
};
@@ -1,120 +0,0 @@
import React from 'react';
import { PageContainer } from './PageContainer';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageContainer> = {
title: 'components/Page/PageContainer',
component: PageContainer,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
argTypes: {
width: {
control: 'select',
options: ['slim', 'default', 'wide', 'full'],
},
paddingX: {
control: 'select',
options: ['none', 'small', 'medium', 'large'],
},
paddingY: {
control: 'select',
options: ['none', 'small', 'medium', 'large'],
},
centerVertically: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof PageContainer>;
const Box = ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div
className={`p-4 bg-slate-200 dark:bg-slate-800 border-2 border-slate-400 dark:border-slate-600 rounded-lg ${className}`}
>
{children}
</div>
);
/**
* Standard container with default width and padding.
*/
export const Default: Story = {
args: {
children: <Box>Default Container Content</Box>,
width: 'default',
paddingY: 'medium',
},
};
/**
* Narrower slim container, ideal for articles.
*/
export const Slim: Story = {
args: {
children: <Box>Slim Container Content (1100px Max-Width)</Box>,
width: 'slim',
paddingY: 'medium',
},
};
/**
* Wider wide container, useful for dashboards.
*/
export const Wide: Story = {
args: {
children: <Box>Wide Container Content (1920px Max-Width)</Box>,
width: 'wide',
paddingY: 'medium',
},
};
/**
* Vertically centered container.
*/
export const VerticallyCentered: Story = {
render: (args) => (
<div className="h-[400px] border-2 border-dashed border-slate-300 dark:border-slate-700 m-4 flex flex-col">
<PageContainer {...args} centerVertically className="flex-1">
<Box className="text-center font-bold">I am vertically centered!</Box>
</PageContainer>
</div>
),
};
/**
* Demonstration of different horizontal padding scales.
*/
export const PaddingScales: Story = {
render: () => (
<div className="flex flex-col gap-8 py-8">
<div>
<h3 className="mb-2 font-bold px-4">paddingX: small</h3>
<PageContainer paddingX="small" width="full">
<Box className="bg-blue-100 dark:bg-blue-900 border-blue-300">Small Padding Content</Box>
</PageContainer>
</div>
<div>
<h3 className="mb-2 font-bold px-4">paddingX: medium (Default)</h3>
<PageContainer paddingX="medium" width="full">
<Box className="bg-green-100 dark:bg-green-900 border-green-300">
Medium Padding Content
</Box>
</PageContainer>
</div>
<div>
<h3 className="mb-2 font-bold px-4">paddingX: large</h3>
<PageContainer paddingX="large" width="full">
<Box className="bg-purple-100 dark:bg-purple-900 border-purple-300">
Large Padding Content
</Box>
</PageContainer>
</div>
</div>
),
};
@@ -1,60 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { PageContainer } from './PageContainer';
describe('PageContainer', () => {
it('renders children correctly', () => {
render(
<PageContainer>
<div data-testid="child">Test Content</div>
</PageContainer>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('applies correct width classes', () => {
const { container: slim } = render(<PageContainer width="slim">Content</PageContainer>);
expect(slim.firstChild).toHaveClass('max-w-[var(--rb-page-width-slim,1100px)]');
const { container: def } = render(<PageContainer width="default">Content</PageContainer>);
expect(def.firstChild).toHaveClass('max-w-[var(--rb-page-width-default,1600px)]');
const { container: wide } = render(<PageContainer width="wide">Content</PageContainer>);
expect(wide.firstChild).toHaveClass('max-w-[var(--rb-page-width-wide,1920px)]');
const { container: full } = render(<PageContainer width="full">Content</PageContainer>);
expect(full.firstChild).toHaveClass('max-w-full');
});
it('applies padding classes', () => {
const { container: small } = render(
<PageContainer paddingX="small" paddingY="small">
Content
</PageContainer>
);
expect(small.firstChild).toHaveClass('px-2');
expect(small.firstChild).toHaveClass('py-4');
const { container: large } = render(
<PageContainer paddingX="large" paddingY="large">
Content
</PageContainer>
);
expect(large.firstChild).toHaveClass('px-6');
expect(large.firstChild).toHaveClass('py-12');
});
it('handles centerVertically prop', () => {
const { container } = render(<PageContainer centerVertically>Content</PageContainer>);
expect(container.firstChild).toHaveClass('flex-1');
expect(container.firstChild).toHaveClass('flex-col');
expect(container.firstChild).toHaveClass('justify-center');
});
it('renders as different element', () => {
render(<PageContainer as="main">Content</PageContainer>);
expect(screen.getByRole('main')).toBeInTheDocument();
});
});
@@ -1,126 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
/**
* Supported width configurations for the PageContainer.
* - `slim`: 1100px
* - `default`: 1600px
* - `wide`: 1920px
* - `full`: 100%
*/
export type PageWidth = 'slim' | 'default' | 'wide' | 'full';
/**
* Supported padding scales for the PageContainer.
* - `none`: No padding.
* - `small`: Standard small padding.
* - `medium`: Standard medium padding.
* - `large`: Standard large padding.
*/
export type PagePadding = 'none' | 'small' | 'medium' | 'large';
/**
* Props for the PageContainer.
*/
export interface PageContainerProps {
/** The content of the container. */
children: React.ReactNode;
/**
* The maximum width of the container on desktop.
* @default 'default'
*/
width?: PageWidth;
/**
* Horizontal padding scale.
* @default 'medium'
*/
paddingX?: PagePadding;
/**
* Vertical padding scale.
* @default 'none'
*/
paddingY?: PagePadding;
/**
* If true, vertically centers the content on the page.
* Has no effect if the content is taller than the viewport.
* @default false
*/
centerVertically?: boolean;
/**
* Optional additional class names.
*/
className?: string;
/**
* The HTML element to use for the root of the component.
* @default 'div'
*/
as?: React.ElementType;
}
const pageContainerVariants = cva('w-full mx-auto', {
variants: {
width: {
slim: 'max-w-[var(--rb-page-width-slim,1100px)]',
default: 'max-w-[var(--rb-page-width-default,1600px)]',
wide: 'max-w-[var(--rb-page-width-wide,1920px)]',
full: 'max-w-full',
},
paddingX: {
none: 'px-0',
small: 'px-2 sm:px-4',
medium: 'px-4 sm:px-6 lg:px-8',
large: 'px-6 sm:px-8 lg:px-12',
},
paddingY: {
none: 'py-0',
small: 'py-4 sm:py-6',
medium: 'py-8 sm:py-12',
large: 'py-12 sm:py-24',
},
centerVertically: {
true: 'flex-1 flex flex-col justify-center',
false: '',
},
},
defaultVariants: {
width: 'default',
paddingX: 'medium',
paddingY: 'none',
centerVertically: false,
},
});
/**
/**
* Manages the horizontal and vertical constraints for page content.
*
* @param props - The component props.
* @returns A PageContainer component.
* @example
* ```tsx
* <PageContainer width="slim" paddingY="medium">
* Main Content
* </PageContainer>
* ```
*/
export const PageContainer = ({
children,
width = 'default',
paddingX = 'medium',
paddingY = 'none',
centerVertically = false,
className,
as: Component = 'div',
}: PageContainerProps) => {
return (
<Component
className={cn(
pageContainerVariants({ width, paddingX, paddingY, centerVertically }),
className
)}
>
{children}
</Component>
);
};
@@ -1,102 +0,0 @@
import React from 'react';
import { PageHead } from './PageHead';
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
import { Button } from '../../Button/Button';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageHead> = {
title: 'Components/PageHead',
component: PageHead,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof PageHead>;
const dummyParagraphs = Array.from({ length: 50 }).map((_, i) => `paragraph-${i}`);
const DummyContent = () => (
<div className="p-8">
{dummyParagraphs.map((key) => (
<p key={key}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
))}
</div>
);
export const Default: Story = {
args: {
heading: 'Page Title',
caption: 'A short and inspiring caption for the page.',
breadcrumbs: (
<Breadcrumbs>
<Breadcrumbs.Item>
<a href="/">Home</a>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>Components</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>PageHead</Breadcrumbs.Item>
</Breadcrumbs>
),
addonLeft: <Button variant="outline">Back</Button>,
addonRight: <Button>Primary Action</Button>,
},
render: (args) => (
<div>
<PageHead {...args} />
<DummyContent />
</div>
),
};
export const CollapsibleSticky: Story = {
...Default,
args: {
...Default.args,
strategy: 'collapsible-sticky',
},
};
export const AlwaysExpandedFixed: Story = {
...Default,
args: {
...Default.args,
strategy: 'always-expanded-fixed',
},
};
export const AlwaysCollapsedSticky: Story = {
...Default,
args: {
...Default.args,
strategy: 'always-collapsed-sticky',
},
};
export const WithoutAddons: Story = {
...Default,
args: {
...Default.args,
addonLeft: undefined,
addonRight: undefined,
},
};
export const WithoutBreadcrumbs: Story = {
...Default,
args: {
...Default.args,
breadcrumbs: undefined,
},
};
@@ -1,52 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { PageHead } from './PageHead';
describe('PageHead', () => {
it('renders the heading and caption', () => {
render(<PageHead caption="Test Caption" heading="Test Heading" />);
// Both layouts are rendered (one hidden), so we use getAllByText
expect(screen.getAllByText('Test Heading')[0]).toBeInTheDocument();
expect(screen.getByText('Test Caption')).toBeInTheDocument();
});
it('renders breadcrumbs when provided', () => {
render(<PageHead breadcrumbs={<div>My Breadcrumbs</div>} heading="Test" />);
expect(screen.getByText('My Breadcrumbs')).toBeInTheDocument();
});
it('renders addons when provided', () => {
render(
<PageHead
addonLeft={<div>Left Addon</div>}
addonRight={<div>Right Addon</div>}
heading="Test"
/>
);
// Addons are rendered in both layouts
expect(screen.getAllByText('Left Addon')[0]).toBeInTheDocument();
expect(screen.getAllByText('Right Addon')[0]).toBeInTheDocument();
});
it('applies the correct classes for the strategy', () => {
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
const header = screen.getByRole('banner');
expect(header).toHaveClass('fixed');
expect(header).not.toHaveClass('sticky');
});
it('reserves flow space below the out-of-flow bar for always-collapsed strategies', () => {
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
const spacer = screen.getByRole('banner').nextElementSibling;
expect(spacer).not.toBeNull();
// The spacer is an invisible copy of the bar so it reserves the exact height.
expect(spacer).toHaveClass('invisible');
expect(spacer).toHaveAttribute('aria-hidden', 'true');
});
it('does not add a spacer for in-flow strategies', () => {
render(<PageHead heading="Test" strategy="collapsible-sticky" />);
expect(screen.getByRole('banner').nextElementSibling).toBeNull();
});
});
@@ -1,197 +0,0 @@
import { cn, useScroll } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { type ReactNode, useEffect, useState } from 'react';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
import { Text } from '../../Text/Text';
import { VStack } from '../../VStack/VStack';
import { PageContainer } from '../PageContainer/PageContainer';
/**
* Defines the positioning and behavior of the PageHead component.
* - `always-collapsed-fixed`: Always collapsed and fixed to the top.
* - `always-collapsed-sticky`: Always collapsed and sticks to the top when scrolling.
* - `always-expanded-fixed`: Always expanded and fixed to the top.
* - `always-expanded-sticky`: Always expanded and sticks to the top when scrolling.
* - `collapsible-sticky`: Starts expanded and collapses when scrolling down, sticking to the top.
*/
export type PageHeadStrategy =
| 'always-collapsed-fixed'
| 'always-collapsed-sticky'
| 'always-expanded-fixed'
| 'always-expanded-sticky'
| 'collapsible-sticky';
/**
* Props for the PageHead component.
*/
export interface PageHeadProps {
/** The main heading text. */
heading: string;
/** The caption text displayed below the heading. */
caption?: ReactNode;
/** The breadcrumbs navigation component. */
breadcrumbs?: ReactNode;
/** A component to be displayed on the left side of the header. */
addonLeft?: ReactNode;
/** A component to be displayed on the right side of the header. */
addonRight?: ReactNode;
/** The positioning and behavior strategy for the header. */
strategy?: PageHeadStrategy;
/** Additional CSS classes for the component. */
className?: string;
}
// Root positioning is fully determined by `strategy`:
// - `collapsible-sticky` stays in-flow so expanded content scrolls away.
// - `*-sticky` / `*-fixed` strategies pin the root to the top.
const pageHeadRootVariants = cva(
'page-head w-full bg-[var(--rb-background)] border-b border-[var(--rb-outline)]/10',
{
variants: {
strategy: {
'always-collapsed-fixed': 'fixed top-0 z-10',
'always-collapsed-sticky': 'sticky top-0 z-10',
'always-expanded-fixed': 'fixed top-0 z-10',
'always-expanded-sticky': 'sticky top-0 z-10',
'collapsible-sticky': '',
},
},
defaultVariants: {
strategy: 'collapsible-sticky',
},
}
);
/**
* A universal page head component with multiple layout and behavior strategies.
*
* @param props - The props for the component.
* @returns A PageHead component.
*
* @example
* ```tsx
* <PageHead
* heading="Page Title"
* caption="A short and inspiring caption"
* strategy="collapsible-sticky"
* />
* ```
*/
export const PageHead = ({
heading,
caption,
breadcrumbs,
addonLeft,
addonRight,
strategy = 'collapsible-sticky',
className,
}: PageHeadProps) => {
const { y: scrollY } = useScroll();
const isCollapsible = strategy === 'collapsible-sticky';
const isAlwaysCollapsed = strategy.startsWith('always-collapsed');
const [isCollapsed, setIsCollapsed] = useState(isAlwaysCollapsed);
useEffect(() => {
if (isCollapsible) {
// Trigger collapse once we've scrolled past the expanded header's height.
// Usually around 150-200px depending on caption length.
// 200px ensures it won't snap until it's mostly gone.
setIsCollapsed(scrollY > 200);
}
}, [scrollY, isCollapsible]);
const rootClasses = cn(pageHeadRootVariants({ strategy }), className);
const expandedContent = (
<PageContainer paddingY="medium">
<VStack align="stretch" gap={6}>
<HStack align="center" justify="between">
<HStack align="center" gap={4}>
{addonLeft && <div>{addonLeft}</div>}
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
</HStack>
{addonRight && <div>{addonRight}</div>}
</HStack>
<VStack gap={4}>
<Header level={1}>{heading}</Header>
{typeof caption === 'string' ? (
<Text color="muted" size="l" type="body">
{caption}
</Text>
) : (
caption
)}
</VStack>
</VStack>
</PageContainer>
);
const collapsedContent = (
<PageContainer paddingY="none">
{/*
* A single row on wide screens (title left, menu right). When the title
* and menu can't fit together, the row wraps the menu onto its own line
* beneath the title instead of overlapping. `whitespace-nowrap` keeps the
* title a single unit so it's the menu that wraps, not the title that
* shrinks. The invisible spacer copy below reserves whichever height this
* ends up being, so page content always starts clear of the bar.
*/}
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
<HStack align="center" className="flex-1" gap={4}>
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
<Header className="min-w-0" level={4} overflow="ellipsis">
{heading}
</Header>
</HStack>
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
</HStack>
</PageContainer>
);
return (
<>
<header className={rootClasses}>
{/* Expanded Content: In-flow for collapsible, or sticky/fixed for others */}
{!isAlwaysCollapsed && (
<div
className={cn(
'transition-opacity duration-300',
isCollapsible && isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
>
{expandedContent}
</div>
)}
{/* Collapsed Content: Fixed overlay for collapsible, or absolute/relative within sticky/fixed root */}
<div
className={cn(
'z-20 transition-all duration-300 transform bg-[var(--rb-background)]/90 backdrop-blur-md border-b border-[var(--rb-outline)]/10 shadow-md',
isAlwaysCollapsed || (isCollapsible && isCollapsed)
? 'translate-y-0 opacity-100'
: '-translate-y-full opacity-0 pointer-events-none',
isCollapsible ? 'fixed top-0 left-0 right-0' : 'absolute top-0 left-0 right-0'
)}
>
{collapsedContent}
</div>
</header>
{/*
* Always-collapsed strategies show only the collapsed bar, which is taken
* out of normal flow (the root is fixed, and its collapsed bar is absolute),
* so the header reserves no height. Without this spacer, page content would
* start at the top of the viewport, underneath the bar. Rendering an
* invisible copy of the bar reserves exactly its height at every viewport —
* including when it wraps to two rows — with no measurement or layout shift.
*/}
{isAlwaysCollapsed && (
<div aria-hidden="true" className="invisible shrink-0">
{collapsedContent}
</div>
)}
</>
);
};
@@ -1,51 +0,0 @@
import React from 'react';
import { PageRoot } from './PageRoot';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageRoot> = {
title: 'components/Page/PageRoot',
component: PageRoot,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof PageRoot>;
/**
* The default PageRoot story showing the base technical container.
*/
export const Default: Story = {
args: {
children: (
<div className="p-8">
<h1 className="text-2xl font-bold">Page Root Content</h1>
<p className="mt-4">
This is the base technical container that handles theme initialization and core
background/text colors.
</p>
</div>
),
},
};
/**
* PageRoot with custom background classes.
*/
export const CustomStyling: Story = {
args: {
className: 'bg-slate-100 dark:bg-slate-900',
children: (
<div className="p-8">
<h1 className="text-2xl font-bold">Custom Styled Root</h1>
<p className="mt-4 text-slate-600 dark:text-slate-400">
PageRoot accepts a className prop to override or extend base styles.
</p>
</div>
),
},
};
@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { PageRoot } from './PageRoot';
describe('PageRoot', () => {
it('renders children correctly', () => {
render(
<PageRoot>
<div data-testid="child">Test Content</div>
</PageRoot>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(
<PageRoot className="custom-class">
<div>Content</div>
</PageRoot>
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('has base technical classes', () => {
const { container } = render(
<PageRoot>
<div>Content</div>
</PageRoot>
);
expect(container.firstChild).toHaveClass('w-full');
});
});
@@ -1,34 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { useTheme } from '../../../lib/theme';
/**
* Props for the PageRoot component.
*/
export interface PageRootProps {
/** The main content slot. */
children: React.ReactNode;
/** Additional CSS classes for the root container. */
className?: string;
}
/**
/**
* The technical bootstrap layer for all layouts.
* It handles theme initialization and base background/text colors.
*
* @param props - The component props.
* @returns A PageRoot component.
* @example
* ```tsx
* <PageRoot>
* <PageContainer>Content</PageContainer>
* </PageRoot>
* ```
*/
export const PageRoot = ({ children, className }: PageRootProps) => {
useTheme();
return <div className={cn('w-full transition-colors duration-200', className)}>{children}</div>;
};
-5
View File
@@ -1,5 +0,0 @@
export * from './PageRoot/PageRoot';
export * from './PageContainer/PageContainer';
export * from './PageColumns/PageColumns';
export * from './PageColumn/PageColumn';
export * from './PageHead/PageHead';
+3 -3
View File
@@ -65,9 +65,9 @@ export interface TextProps
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
{
children,
type = 'body',
size = 'm',
color = 'default',
type,
size,
color,
bold = false,
italic = false,
underline = false,
@@ -1,14 +0,0 @@
import { ThemeSwitch } from './ThemeSwitch';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof ThemeSwitch> = {
title: 'Components/ThemeSwitch',
component: ThemeSwitch,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof ThemeSwitch>;
export const Default: Story = {};
@@ -1,52 +0,0 @@
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { ThemeSwitch } from './ThemeSwitch';
import { render, screen } from '../../lib/testing';
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
describe('components/ThemeSwitch', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('renders correctly', () => {
render(<ThemeSwitch />);
expect(screen.getByLabelText('Toggle dark mode')).toBeInTheDocument();
expect(screen.getByLabelText('Follow system theme')).toBeInTheDocument();
});
it('toggles theme when switch is clicked', async () => {
const user = userEvent.setup();
render(<ThemeSwitch />);
const toggle = screen.getByRole('switch');
await user.click(toggle);
expect(window.localStorage.getItem('ermnvldmr/ui/lib/theme')).toContain('dark');
});
it('sets system theme when auto is clicked', async () => {
const user = userEvent.setup();
render(<ThemeSwitch />);
const autoButton = screen.getByLabelText('Follow system theme');
await user.click(autoButton);
expect(window.localStorage.getItem('ermnvldmr/ui/lib/theme')).toContain('system');
});
});
@@ -1,53 +0,0 @@
import { Monitor, Moon, Sun } from 'lucide-react';
import React from 'react';
import { useTheme } from '../../lib/theme';
import { Button } from '../Button/Button';
import { HStack } from '../HStack/HStack';
import { Separator } from '../Separator/Separator';
import { Switch } from '../Switch/Switch';
import { Text } from '../Text/Text';
/**
* A composite component for managing theme preferences.
* Includes a toggle for Light/Dark and a button for System mode.
*
* @returns A React element representing the theme switcher.
* @example
* ```tsx
* <ThemeSwitch />
* ```
*/
export function ThemeSwitch(): React.JSX.Element {
const { preference, setPreference, resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
return (
<HStack align="center" gap={4}>
<HStack align="center" gap={2}>
<Sun className="w-4 h-4 text-[var(--rb-text)] opacity-60" />
<Switch
aria-label="Toggle dark mode"
isSelected={isDark}
variant="outline"
onChange={(isSelected) => setPreference(isSelected ? 'dark' : 'light')}
/>
<Moon className="w-4 h-4 text-[var(--rb-text)] opacity-60" />
</HStack>
<Separator direction="vertical" thinned="none" />
<Button
aria-label="Follow system theme"
color={preference === 'system' ? 'primary' : 'neutral'}
renderStartIcon={(className) => <Monitor className={className} />}
size="s"
variant={preference === 'system' ? 'solid' : 'ghost'}
onPress={() => setPreference('system')}
>
<Text bold={preference === 'system'} size="s" type="label">
Follow system theme
</Text>
</Button>
</HStack>
);
}
@@ -1,63 +0,0 @@
import React from 'react';
import { Time } from './Time';
import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Time> = {
title: 'components/Time',
component: Time,
tags: ['autodocs'],
argTypes: {
color: {
control: 'select',
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
},
size: {
control: 'select',
options: ['s', 'm', 'l'],
},
type: {
control: 'select',
options: ['display', 'headline', 'title', 'body', 'label'],
},
},
};
export default meta;
type Story = StoryObj<typeof Time>;
export const Default: Story = {
args: {
date: new Date(),
},
};
export const CustomFormatting: Story = {
render: () => (
<VStack gap={4}>
<Time date={new Date()} formatOptions={{ dateStyle: 'full' }} />
<Time date={new Date()} formatOptions={{ dateStyle: 'long' }} />
<Time date={new Date()} formatOptions={{ dateStyle: 'medium' }} />
<Time date={new Date()} formatOptions={{ dateStyle: 'short' }} />
</VStack>
),
};
export const Styled: Story = {
render: () => (
<VStack gap={4}>
<Time bold color="primary" date={new Date()} size="l" type="headline" />
<Time italic color="muted" date={new Date()} size="s" type="label" />
</VStack>
),
};
export const CustomChildren: Story = {
args: {
date: new Date(),
children: 'Today',
},
};
@@ -1,53 +0,0 @@
import React from 'react';
import { Time } from './Time';
import { render, screen } from '../../lib/testing';
describe('components/Time', () => {
it('renders correctly with a Date object', () => {
const date = new Date('2026-02-10T00:00:00Z');
render(<Time date={date} />);
const timeElement = screen.getByText(/February 10, 2026/i);
expect(timeElement.tagName).toBe('TIME');
expect(timeElement).toHaveAttribute('datetime', date.toISOString());
});
it('renders correctly with an ISO string', () => {
const dateString = '2026-02-10T00:00:00Z';
render(<Time date={dateString} />);
const timeElement = screen.getByText(/February 10, 2026/i);
expect(timeElement.tagName).toBe('TIME');
expect(timeElement).toHaveAttribute('datetime', new Date(dateString).toISOString());
});
it('respects custom children', () => {
const date = new Date('2026-02-10T00:00:00Z');
render(<Time date={date}>Custom Date Display</Time>);
const timeElement = screen.getByText('Custom Date Display');
expect(timeElement).toHaveAttribute('datetime', date.toISOString());
});
it('applies custom formatting options', () => {
const date = new Date('2026-02-10T00:00:00Z');
render(
<Time date={date} formatOptions={{ year: '2-digit', month: 'short', day: 'numeric' }} />
);
// Depending on locale, "Feb 10, 26" or similar
expect(screen.getByText(/Feb/i)).toBeInTheDocument();
expect(screen.getByText(/10/i)).toBeInTheDocument();
expect(screen.getByText(/26/i)).toBeInTheDocument();
});
it('passes text props correctly', () => {
const date = new Date('2026-02-10T00:00:00Z');
render(<Time bold color="primary" date={date} size="l" />);
const timeElement = screen.getByText(/February 10, 2026/i);
expect(timeElement).toHaveClass('font-bold');
expect(timeElement).toHaveClass('text-primary-text');
});
});
-63
View File
@@ -1,63 +0,0 @@
import { castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef, useMemo } from 'react';
import { Text } from '../Text/Text';
import type { TextProps } from '../Text/Text';
/**
* Props for the Time component.
*/
export interface TimeProps extends Omit<TextProps, 'as' | 'children'> {
/** The date to display */
date: Date | string;
/** Intl.DateTimeFormatOptions for formatting */
formatOptions?: Intl.DateTimeFormatOptions;
/** Locale for formatting (defaults to system/en-US) */
locale?: string;
/** Content to be rendered instead of formatted date */
children?: React.ReactNode;
/** Semantic ISO date string for the HTML time element */
dateTime?: string;
}
/**
* A semantic time component that wraps the Text component.
*
* Time provides a consistent way to display formatted dates while
* maintaining semantic integrity using the HTML <time> element.
*/
export const Time = genericMemo(
forwardRef<HTMLElement, TimeProps>(function Time(
{ date, formatOptions, locale = 'en-US', children, dateTime, ...props },
ref
) {
const dateObj = useMemo(() => (typeof date === 'string' ? new Date(date) : date), [date]);
const isoString = useMemo(() => dateTime ?? dateObj.toISOString(), [dateTime, dateObj]);
const formattedDate = useMemo(() => {
if (children) return children;
// If formatOptions contains dateStyle or timeStyle, we must not provide
// individual components like year/month/day as it will throw an error.
const hasStyle =
formatOptions && ('dateStyle' in formatOptions || 'timeStyle' in formatOptions);
const options: Intl.DateTimeFormatOptions = hasStyle
? { ...formatOptions }
: {
year: 'numeric',
month: 'long',
day: 'numeric',
...formatOptions,
};
return dateObj.toLocaleDateString(locale, options);
}, [children, dateObj, locale, formatOptions]);
return (
<Text ref={castRef<HTMLElement>(ref)} as="time" dateTime={isoString} {...props}>
{formattedDate}
</Text>
);
})
);
+1 -6
View File
@@ -1,6 +1,4 @@
export * from './Accordion/index';
export * from './Article/Article';
export * from './BentoGrid/index';
export * from './Blockquote/Blockquote';
export * from './Breadcrumbs/Breadcrumbs';
export * from './Button/Button';
@@ -8,18 +6,15 @@ export * from './Code/index';
export * from './Container/Container';
export * from './Header/Header';
export * from './HStack/HStack';
export * from './Icon/index';
export * from './Image/Image';
export * from './Link/Link';
export * from './List/List';
export * from './Markdown/Markdown';
export * from './Paragraph/Paragraph';
export * from './Page/index';
export * from './Separator/Separator';
export * from './Stack/Stack';
export * from './Stub/Stub';
export * from './Switch/Switch';
export * from './Table/index';
export * from './Text/Text';
export * from './ThemeSwitch/ThemeSwitch';
export * from './Time/Time';
export * from './VStack/VStack';