feat: Add PageHead component

This commit is contained in:
deytenit committed 2026-03-06 09:50:38 +03:00
1 parent 30fd52b8e9
commit 8d57d6ba1b
30 files changed
+600 -114

No files matched your search

@@ -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,134 @@
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';
/**
* 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?: string;
/** 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',
{
'px-6 py-4': !isCollapsed,
'px-4 py-2': isCollapsed,
'shadow-md': isCollapsed,
},
className
);
return (
<>
<header ref={headerRef} className={rootClasses}>
<div
className={cn('transition-opacity duration-200', {
'opacity-0 hidden': isCollapsed,
'opacity-100': !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>
</div>
<div
className={cn('transition-opacity duration-200', {
'opacity-100': isCollapsed,
'opacity-0 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>
</div>
</header>
<div style={{ height }} />
</>
);
};
@@ -17,12 +17,16 @@ describe('components/Switch', () => {
const checkbox = screen.getByRole('switch');
await user.click(checkbox);
expect(handleChange).toHaveBeenCalledWith(true);
});
it('respects isSelected prop', () => {
render(<Switch isSelected onChange={() => {}}>Switch</Switch>);
render(
<Switch isSelected onChange={() => {}}>
Switch
</Switch>
);
const checkbox = screen.getByRole('switch');
expect(checkbox).toBeChecked();
});
@@ -30,11 +34,15 @@ describe('components/Switch', () => {
it('respects isDisabled prop', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<Switch isDisabled onChange={handleChange}>Switch</Switch>);
render(
<Switch isDisabled onChange={handleChange}>
Switch
</Switch>
);
const checkbox = screen.getByRole('switch');
expect(checkbox).toBeDisabled();
await user.click(checkbox);
expect(handleChange).not.toHaveBeenCalled();
});
+17 -3
View File
@@ -135,7 +135,12 @@ export function Switch(props: SwitchProps): React.JSX.Element {
aria-hidden="true"
className={cn(
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
!state.isSelected && (variant === 'solid' ? 'bg-[var(--rb-muted-base)]' : variant === 'outline' ? 'bg-transparent border-[var(--rb-muted-base)] border-2' : 'bg-transparent border-transparent'),
!state.isSelected &&
(variant === 'solid'
? 'bg-[var(--rb-muted-base)]'
: variant === 'outline'
? 'bg-transparent border-[var(--rb-muted-base)] border-2'
: 'bg-transparent border-transparent'),
state.isSelected && variant === 'outline' && 'bg-transparent border-2',
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
props.isDisabled && 'grayscale-[0.5]'
@@ -147,13 +152,22 @@ export function Switch(props: SwitchProps): React.JSX.Element {
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm',
!state.isSelected && 'bg-[var(--rb-text)]',
variant === 'outline' ? 'ml-0.5' : 'ml-1',
state.isSelected ? (variant === 'outline' ? 'translate-x-4.5' : 'translate-x-5') : 'translate-x-0'
state.isSelected
? variant === 'outline'
? 'translate-x-4.5'
: 'translate-x-5'
: 'translate-x-0'
)}
style={getThumbStyles()}
/>
</div>
{children && (
<Text bold={state.isSelected} color={state.isSelected ? 'default' : 'muted'} size="s" type="label">
<Text
bold={state.isSelected}
color={state.isSelected ? 'default' : 'muted'}
size="s"
type="label"
>
{children}
</Text>
)}
@@ -7,7 +7,7 @@ import { render, screen } from '../../lib/testing';
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
@@ -33,20 +33,20 @@ describe('components/ThemeSwitch', () => {
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
View File
@@ -11,6 +11,7 @@ export * from './Link/Link';
export * from './List/List';
export * from './Markdown/Markdown';
export * from './Paragraph/Paragraph';
export * from './PageHead/PageHead';
export * from './Separator/Separator';
export * from './Stack/Stack';
export * from './Stub/Stub';