feat: Refactor layouts in www

This commit is contained in:
deytenit committed 2026-03-07 20:59:03 +03:00
1 parent 6aa3a03319
commit f3a3a52aa8
66 files changed
+1805 -352

No files matched your search

@@ -0,0 +1,35 @@
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();
});
});
@@ -0,0 +1,66 @@
import { cn } from '@ermnvldmr/stl';
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;
}
/**
/**
* 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(
'w-full transition-all duration-200',
{
'lg:w-[var(--rb-page-column-small,300px)] lg:flex-none': size === 'small',
'flex-1': size === 'full',
'lg:sticky lg:top-24 self-start': sticky,
},
className
)}
>
{children}
</Component>
);
};
@@ -0,0 +1,137 @@
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>
),
};
@@ -0,0 +1,31 @@
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');
});
});
@@ -0,0 +1,51 @@
import { cn } from '@ermnvldmr/stl';
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;
}
/**
/**
* 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) => {
return (
<div
className={cn(
'flex flex-col lg:flex-row w-full',
{
[`gap-${gap}`]: typeof gap === 'number',
[String(gap)]: typeof gap === 'string',
},
className
)}
>
{children}
</div>
);
};
@@ -0,0 +1,120 @@
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>
),
};
@@ -0,0 +1,60 @@
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();
});
});
@@ -0,0 +1,119 @@
import { cn } from '@ermnvldmr/stl';
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;
}
/**
/**
* 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) => {
const widthClasses = {
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',
};
const pxClasses = {
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',
};
const pyClasses = {
none: 'py-0',
small: 'py-4 sm:py-6',
medium: 'py-8 sm:py-12',
large: 'py-12 sm:py-24',
};
return (
<Component
className={cn(
'w-full mx-auto',
widthClasses[width],
pxClasses[paddingX],
pyClasses[paddingY],
{
'flex-1 flex flex-col justify-center': centerVertically,
},
className
)}
>
{children}
</Component>
);
};
@@ -0,0 +1,102 @@
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,
},
};
@@ -0,0 +1,38 @@
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).toHaveClass('shadow-md');
});
});
@@ -0,0 +1,149 @@
import { cn, useScroll, useResizeObserver } from '@ermnvldmr/stl';
import React, { type ReactNode, useRef, 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;
}
/**
* 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 [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
const headerRef = useRef<HTMLDivElement>(null);
const { height } = useResizeObserver(headerRef);
useEffect(() => {
if (strategy === 'collapsible-sticky') {
setIsCollapsed(scrollY > 10);
}
}, [scrollY, strategy]);
const rootClasses = cn(
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
{
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
},
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={2}>
<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">
<HStack align="center" className="h-14" justify="between">
<HStack align="center" className="min-w-0 flex-1" gap={4}>
{addonLeft && <div className="flex-shrink-0">{addonLeft}</div>}
<Header level={4} overflow="ellipsis">
{heading}
</Header>
</HStack>
{addonRight && <div className="flex-shrink-0 ml-4">{addonRight}</div>}
</HStack>
</PageContainer>
);
return (
<>
<header ref={headerRef} className={rootClasses}>
<div
className={cn('transition-all duration-300', {
'opacity-0 invisible h-0 overflow-hidden': isCollapsed,
'opacity-100 visible': !isCollapsed,
})}
>
{expandedContent}
</div>
<div
className={cn('transition-all duration-300', {
'opacity-100 visible': isCollapsed,
'opacity-0 invisible h-0 overflow-hidden': !isCollapsed,
})}
>
{collapsedContent}
</div>
</header>
<div style={{ height }} />
</>
);
};
@@ -0,0 +1,51 @@
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>
),
},
};
@@ -0,0 +1,35 @@
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');
});
});
@@ -0,0 +1,34 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { useTheme } from '../../../lib/theme';
/**
* Props for the PageRoot component.
*/
export interface PageRootProps {
/** The main content slot. */
children: React.ReactNode;
/** Additional CSS classes for the root container. */
className?: string;
}
/**
/**
* The technical bootstrap layer for all layouts.
* It handles theme initialization and base background/text colors.
*
* @param props - The component props.
* @returns A PageRoot component.
* @example
* ```tsx
* <PageRoot>
* <PageContainer>Content</PageContainer>
* </PageRoot>
* ```
*/
export const PageRoot = ({ children, className }: PageRootProps) => {
useTheme();
return <div className={cn('w-full transition-colors duration-200', className)}>{children}</div>;
};
+5
View File
@@ -0,0 +1,5 @@
export * from './PageRoot/PageRoot';
export * from './PageContainer/PageContainer';
export * from './PageColumns/PageColumns';
export * from './PageColumn/PageColumn';
export * from './PageHead/PageHead';