feat: Add PageHead component
This commit is contained in:
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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user