fix: Fix containing various changes that stacked up in a period of time (#22)
This commit is contained in:
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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)} />;
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Icon';
|
||||
@@ -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>
|
||||
"Design is not just what it looks like and feels like. Design is how it works."
|
||||
<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>;
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './PageRoot/PageRoot';
|
||||
export * from './PageContainer/PageContainer';
|
||||
export * from './PageColumns/PageColumns';
|
||||
export * from './PageColumn/PageColumn';
|
||||
export * from './PageHead/PageHead';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 +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';
|
||||
Reference in new issue
Block a user