feat: Refactor layouts in www
This commit is contained in:
66 files changed
+1805
-352
No files matched your search
@@ -31,9 +31,8 @@ describe('components/Article', () => {
|
||||
</Article>
|
||||
);
|
||||
separators = screen.getAllByRole('separator');
|
||||
expect(separators).toHaveLength(2);
|
||||
expect(separators[0].className).not.toContain('mask-image');
|
||||
expect(separators[1].className).toContain('mask-image');
|
||||
expect(separators).toHaveLength(1);
|
||||
expect(separators[0].className).toContain('mask-image');
|
||||
});
|
||||
|
||||
it('renders as a link when href is provided', () => {
|
||||
|
||||
@@ -56,26 +56,24 @@ export const Article = memo(function Article({
|
||||
return (
|
||||
<Container className={className} href={href} padding={4} onPress={onPress}>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Header level={4}>{headline}</Header>
|
||||
|
||||
<VStack align="stretch" gap={2}>
|
||||
{subHeadline ? (
|
||||
<>
|
||||
<Separator thinned="none" type="single" />
|
||||
<Text italic size="m" type="label">
|
||||
{subHeadline}
|
||||
</Text>
|
||||
<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" />
|
||||
</>
|
||||
) : (
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PageHead } from './PageHead';
|
||||
import { Breadcrumbs } from '../Breadcrumbs/Breadcrumbs';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
|
||||
import { Button } from '../../Button/Button';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
File renamed without changes.
+54
-39
@@ -1,10 +1,11 @@
|
||||
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 { 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.
|
||||
@@ -28,7 +29,7 @@ export interface PageHeadProps {
|
||||
/** The main heading text. */
|
||||
heading: string;
|
||||
/** The caption text displayed below the heading. */
|
||||
caption?: string;
|
||||
caption?: ReactNode;
|
||||
/** The breadcrumbs navigation component. */
|
||||
breadcrumbs?: ReactNode;
|
||||
/** A component to be displayed on the left side of the header. */
|
||||
@@ -77,55 +78,69 @@ export const PageHead = ({
|
||||
}, [scrollY, strategy]);
|
||||
|
||||
const rootClasses = cn(
|
||||
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10',
|
||||
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
|
||||
{
|
||||
'px-6 py-4': !isCollapsed,
|
||||
'px-4 py-2': isCollapsed,
|
||||
'shadow-md': isCollapsed,
|
||||
'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-opacity duration-200', {
|
||||
'opacity-0 hidden': isCollapsed,
|
||||
'opacity-100': !isCollapsed,
|
||||
className={cn('transition-all duration-300', {
|
||||
'opacity-0 invisible h-0 overflow-hidden': isCollapsed,
|
||||
'opacity-100 visible': !isCollapsed,
|
||||
})}
|
||||
>
|
||||
<VStack align="stretch" gap={2}>
|
||||
<HStack align="center" justify="between">
|
||||
<HStack align="center" gap={4}>
|
||||
{addonLeft && <div>{addonLeft}</div>}
|
||||
{breadcrumbs && <div>{breadcrumbs}</div>}
|
||||
</HStack>
|
||||
{addonRight && <div>{addonRight}</div>}
|
||||
</HStack>
|
||||
<VStack gap={2}>
|
||||
<Header level={1}>{heading}</Header>
|
||||
{caption && (
|
||||
<Text color="muted" size="l" type="body">
|
||||
{caption}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
{expandedContent}
|
||||
</div>
|
||||
<div
|
||||
className={cn('transition-opacity duration-200', {
|
||||
'opacity-100': isCollapsed,
|
||||
'opacity-0 hidden': !isCollapsed,
|
||||
className={cn('transition-all duration-300', {
|
||||
'opacity-100 visible': isCollapsed,
|
||||
'opacity-0 invisible h-0 overflow-hidden': !isCollapsed,
|
||||
})}
|
||||
>
|
||||
<HStack align="center" justify="between">
|
||||
<HStack align="center" gap={2}>
|
||||
{addonLeft && <div>{addonLeft}</div>}
|
||||
<Header level={3}>{heading}</Header>
|
||||
</HStack>
|
||||
{addonRight && <div>{addonRight}</div>}
|
||||
</HStack>
|
||||
{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>;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './PageRoot/PageRoot';
|
||||
export * from './PageContainer/PageContainer';
|
||||
export * from './PageColumns/PageColumns';
|
||||
export * from './PageColumn/PageColumn';
|
||||
export * from './PageHead/PageHead';
|
||||
@@ -11,7 +11,7 @@ export * from './Link/Link';
|
||||
export * from './List/List';
|
||||
export * from './Markdown/Markdown';
|
||||
export * from './Paragraph/Paragraph';
|
||||
export * from './PageHead/PageHead';
|
||||
export * from './Page/index';
|
||||
export * from './Separator/Separator';
|
||||
export * from './Stack/Stack';
|
||||
export * from './Stub/Stub';
|
||||
|
||||
Reference in new issue
Block a user