feat (www): Add Wanted (wishlist) page (#15)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
deytenitandcopilot-swe-agent[bot] authored and GitHub committed 2026-05-04 18:58:46 +03:00
1 parent 07b16d404c
commit ef6ca40c72
29 files changed
+2232 -225

No files matched your search

@@ -0,0 +1,112 @@
import React from 'react';
import { Accordion } from './Accordion';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Accordion> = {
title: 'Components/Accordion',
component: Accordion,
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['single', 'multiple'],
},
collapsible: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof Accordion>;
export const Default: Story = {
args: {
type: 'single',
collapsible: true,
},
render: (args) => (
<Accordion {...args}>
<Accordion.Item value="item-1">
<Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
<Accordion.Content>
Yes. It adheres to the WAI-ARIA design pattern.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger level={2}>Is it styled?</Accordion.Trigger>
<Accordion.Content>
Yes. It comes with default styles that matches the design system.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-3">
<Accordion.Trigger level={2}>Is it animated?</Accordion.Trigger>
<Accordion.Content>
Yes. It uses CSS Grid transitions for smooth height animation.
</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const Multiple: Story = {
args: {
type: 'multiple',
},
render: (args) => (
<Accordion {...args}>
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Section One</Accordion.Trigger>
<Accordion.Content>
This section can be open alongside others.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger level={3}>Section Two</Accordion.Trigger>
<Accordion.Content>
You can open as many sections as you want in multiple mode.
</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const DifferentHeaderLevels: Story = {
render: () => (
<Accordion type="single">
<Accordion.Item value="h1">
<Accordion.Trigger level={1}>Header Level 1</Accordion.Trigger>
<Accordion.Content>Content for H1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h2">
<Accordion.Trigger level={2}>Header Level 2</Accordion.Trigger>
<Accordion.Content>Content for H2</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h3">
<Accordion.Trigger level={3}>Header Level 3</Accordion.Trigger>
<Accordion.Content>Content for H3</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h4">
<Accordion.Trigger level={4}>Header Level 4</Accordion.Trigger>
<Accordion.Content>Content for H4</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const Disabled: Story = {
render: () => (
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Enabled Item</Accordion.Trigger>
<Accordion.Content>This one works fine.</Accordion.Content>
</Accordion.Item>
<Accordion.Item disabled value="item-2">
<Accordion.Trigger level={3}>Disabled Item</Accordion.Trigger>
<Accordion.Content>You should not see this.</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
@@ -0,0 +1,221 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { vi } from 'vitest';
import { Accordion } from './Accordion';
describe('Accordion', () => {
it('renders correctly with all sub-components', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={2}>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
expect(screen.getByText('Content 1')).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
});
it('toggles content on click in single mode', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger1 = screen.getByText('Trigger 1');
const trigger2 = screen.getByText('Trigger 2');
await user.click(trigger1);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'false');
await user.click(trigger2);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('allows multiple items to be open in multiple mode', async () => {
const user = userEvent.setup();
render(
<Accordion type="multiple">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByText('Trigger 1'));
await user.click(screen.getByText('Trigger 2'));
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('respects the collapsible prop in single mode', async () => {
const user = userEvent.setup();
render(
<Accordion collapsible={false} type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger = screen.getByText('Trigger 1');
// Open it
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
// Try to close it (should stay open because collapsible={false})
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
});
it('does not toggle when disabled', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item disabled value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger = screen.getByText('Trigger 1');
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button')).toBeDisabled();
});
it('works as a controlled component', async () => {
const user = userEvent.setup();
const TestComponent = () => {
const [value, setValue] = React.useState<string[]>(['item-1']);
return (
<Accordion type="single" value={value} onValueChange={setValue}>
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
};
render(<TestComponent />);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
await user.click(screen.getByText('Trigger 2'));
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('works as an uncontrolled component with defaultValue', () => {
render(
<Accordion defaultValue={['item-2']} type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('renders with valid HTML structure (heading containing button)', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const heading = screen.getByRole('heading', { level: 3 });
const button = screen.getByRole('button');
expect(heading).toContainElement(button);
});
it('applies accessibility attributes to content when closed', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const content = screen.getByText('Content 1').closest('[role="region"]');
expect(content).toHaveAttribute('aria-hidden', 'true');
expect(content).toHaveAttribute('inert');
});
it('removes accessibility hiding attributes when open', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByText('Trigger 1'));
const content = screen.getByText('Content 1').closest('[role="region"]');
expect(content).toHaveAttribute('aria-hidden', 'false');
expect(content).not.toHaveAttribute('inert');
});
it('throws error when sub-components are used outside of Accordion', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Accordion.Trigger>Trigger</Accordion.Trigger>)).toThrow(
'Accordion sub-components must be used within an Accordion component.'
);
expect(() => render(<Accordion.Item value="1">Item</Accordion.Item>)).toThrow(
'Accordion sub-components must be used within an Accordion component.'
);
expect(() => render(<Accordion.Content>Content</Accordion.Content>)).toThrow(
'AccordionItem sub-components must be used within an AccordionItem component.'
);
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,95 @@
import { cn } from '@ermnvldmr/stl';
import React, { useState, useCallback, useMemo } from 'react';
import { AccordionContent } from './AccordionContent/AccordionContent';
import { AccordionItem } from './AccordionItem/AccordionItem';
import { AccordionTrigger } from './AccordionTrigger/AccordionTrigger';
import { AccordionContext } from './contexts/AccordionContext/AccordionContext';
import type { AccordionProps } from './types';
/**
* An Accordion component that allows users to reveal and hide content sections.
*
* Built with a compound component pattern for maximum flexibility.
* @example Basic usage:
*/
const AccordionRoot = ({
type = 'single',
value: controlledValue,
defaultValue = [],
onValueChange,
collapsible = true,
children,
className,
'data-testid': testId,
}: AccordionProps) => {
const [internalValue, setInternalValue] = useState<string[]>(defaultValue);
const isControlled = controlledValue !== undefined;
const currentValues = isControlled ? controlledValue : internalValue;
const handleValueChange = useCallback(
(itemValue: string) => {
let nextValues: string[];
if (type === 'single') {
const isCurrentOpen = currentValues.includes(itemValue);
if (isCurrentOpen && collapsible) {
nextValues = [];
} else if (isCurrentOpen && !collapsible) {
nextValues = [itemValue];
} else {
nextValues = [itemValue];
}
} else {
nextValues = currentValues.includes(itemValue)
? currentValues.filter((v) => v !== itemValue)
: [...currentValues, itemValue];
}
if (!isControlled) {
setInternalValue(nextValues);
}
onValueChange?.(nextValues);
},
[type, currentValues, collapsible, isControlled, onValueChange]
);
const contextValue = useMemo(
() => ({
type,
value: currentValues,
onValueChange: handleValueChange,
collapsible,
}),
[type, currentValues, handleValueChange, collapsible]
);
return (
<AccordionContext.Provider value={contextValue}>
<div className={cn('w-full', className)} data-testid={testId}>
{children}
</div>
</AccordionContext.Provider>
);
};
/**
* A collapsible accordion component system.
*
* @example
* ```tsx
* <Accordion type="single" collapsible>
* <Accordion.Item value="item-1">
* <Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
* <Accordion.Content>Yes. It adheres to the WAI-ARIA design pattern.</Accordion.Content>
* </Accordion.Item>
* </Accordion>
* ```
*/
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
@@ -0,0 +1,37 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionContentProps } from '../types';
/**
* The collapsible content of an Accordion item.
*
* Uses a CSS Grid transition for smooth height animation.
* @example Basic usage:
*/
export const AccordionContent = ({ children, className }: AccordionContentProps) => {
const { value, isOpen } = useAccordionItemContext();
return (
<div
aria-hidden={!isOpen}
aria-labelledby={`accordion-trigger-${value}`}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
className
)}
id={`accordion-content-${value}`}
inert={!isOpen ? true : undefined}
role="region"
>
<div className="overflow-hidden">
<div className="pb-4 pt-0 text-sm leading-relaxed">
{children}
</div>
</div>
</div>
);
};
@@ -0,0 +1,40 @@
import { cn } from '@ermnvldmr/stl';
import React, { useMemo } from 'react';
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
import { AccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionItemProps } from '../types';
/**
* An individual item within an Accordion.
*
* Provides context for the Trigger and Content sub-components.
* @example Basic usage:
*/
export const AccordionItem = ({
value,
children,
disabled = false,
className,
}: AccordionItemProps) => {
const { value: openValues } = useAccordionContext();
const isOpen = openValues.includes(value);
const contextValue = useMemo(
() => ({
value,
isOpen,
disabled,
}),
[value, isOpen, disabled]
);
return (
<AccordionItemContext.Provider value={contextValue}>
<div className={cn('border-b border-[var(--rb-outline)]/40', className)}>
{children}
</div>
</AccordionItemContext.Provider>
);
};
@@ -0,0 +1,73 @@
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { useRef } from 'react';
import { useButton } from 'react-aria';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionTriggerProps } from '../types';
/**
* The interactive trigger for an Accordion item.
*
* Wraps the Header component and adds an expansion arrow.
* @example Basic usage:
* ```tsx
* <Accordion.Trigger>Click me</Accordion.Trigger>
* ```
*/
export const AccordionTrigger = ({
children,
className,
level = 3,
...headerProps
}: AccordionTriggerProps) => {
const { onValueChange } = useAccordionContext();
const { value, isOpen, disabled: isDisabled } = useAccordionItemContext();
const ref = useRef<HTMLButtonElement>(null);
const handleToggle = () => {
if (!isDisabled) {
onValueChange(value);
}
};
const { buttonProps } = useButton(
{
onPress: handleToggle,
isDisabled,
'aria-controls': `accordion-content-${value}`,
'aria-expanded': isOpen,
id: `accordion-trigger-${value}`,
type: 'button',
},
ref
);
return (
<Header level={level} {...headerProps}>
<button
{...buttonProps}
ref={ref}
className={cn(
'group flex w-full items-center justify-between py-4 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--rb-ring)] focus-visible:ring-offset-2',
isDisabled && 'cursor-not-allowed opacity-50',
className
)}
>
<HStack align="center" className="w-full" gap={2}>
<ChevronRight
className={cn(
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
isOpen && 'rotate-90'
)}
/>
<span className="flex-1">{children}</span>
</HStack>
</button>
</Header>
);
};
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { AccordionContextValue } from '../../types';
/**
* Context for sharing accordion state with items.
*/
export const AccordionContext = createContext<AccordionContextValue | null>(null);
/**
* Hook to access the Accordion context.
* @example Using the context:
*/
export function useAccordionContext(): AccordionContextValue {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion sub-components must be used within an Accordion component.');
}
return context;
}
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { AccordionItemContextValue } from '../../types';
/**
* Context for sharing specific accordion item state.
*/
export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
/**
* Hook to access the AccordionItem context.
* @example Using the context:
*/
export function useAccordionItemContext(): AccordionItemContextValue {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error('AccordionItem sub-components must be used within an AccordionItem component.');
}
return context;
}
@@ -0,0 +1,9 @@
export * from './Accordion';
export * from './types';
export * from './contexts/AccordionContext/AccordionContext';
export * from './contexts/AccordionItemContext/AccordionItemContext';
// Export sub-components individually for flexibility
export * from './AccordionItem/AccordionItem';
export * from './AccordionTrigger/AccordionTrigger';
export * from './AccordionContent/AccordionContent';
@@ -0,0 +1,88 @@
import type { HeaderProps } from '../Header/Header';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { ReactNode } from 'react';
/**
* Type of accordion behavior.
* - 'single': Only one item can be open at a time.
* - 'multiple': Multiple items can be open at a time.
*/
export type AccordionType = 'single' | 'multiple';
/**
* Context value for the root Accordion component.
*/
export interface AccordionContextValue {
/** Type of accordion behavior */
type: AccordionType;
/** Currently open values */
value: string[];
/** Callback when open values change */
onValueChange: (value: string) => void;
/** Whether the accordion is collapsible in single mode */
collapsible?: boolean;
}
/**
* Context value for an individual Accordion item.
*/
export interface AccordionItemContextValue {
/** Unique value for this item */
value: string;
/** Whether this item is currently open */
isOpen: boolean;
/** Whether this item is disabled */
disabled?: boolean;
}
/**
* Props for the root Accordion component.
*/
export interface AccordionProps extends ClassNameProps, TestIdProps {
/** Content of the accordion */
children: ReactNode;
/** Type of accordion behavior */
type?: AccordionType;
/** Controlled value of open items */
value?: string[];
/** Default value of open items (uncontrolled) */
defaultValue?: string[];
/** Callback when open values change */
onValueChange?: (value: string[]) => void;
/** Whether the accordion is collapsible in single mode */
collapsible?: boolean;
}
/**
* Props for the AccordionItem component.
*/
export interface AccordionItemProps extends ClassNameProps {
/** Unique value for this item */
value: string;
/** Content of the item (Trigger and Content) */
children: ReactNode;
/** Whether the item is disabled */
disabled?: boolean;
}
/**
* Props for the AccordionTrigger component.
*/
export interface AccordionTriggerProps extends ClassNameProps, Omit<HeaderProps, 'children' | 'level'> {
/** Content of the trigger (usually text) */
children: ReactNode;
/**
* The semantic header level (1-6).
* @default 3
*/
level?: 1 | 2 | 3 | 4 | 5 | 6;
}
/**
* Props for the AccordionContent component.
*/
export interface AccordionContentProps extends ClassNameProps {
/** Content to be revealed */
children: ReactNode;
}
@@ -0,0 +1,291 @@
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-rb-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-rb-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-rb-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-rb-tertiary-base/10 border-dashed" colSpan={4}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-rb-tertiary-base rounded-full text-rb-tertiary-text">
<Send size={20} />
</div>
<div>
<h4 className="font-bold text-rb-text">Subscribe to Newsletter</h4>
<p className="text-sm text-rb-muted-text">Stay updated with the latest releases and technical articles.</p>
</div>
</div>
<button className="px-6 py-2 bg-rb-text text-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-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-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-rb-primary-base/20 border-rb-primary-base" colSpan={2} rowSpan={2}>2x2 Hero</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-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-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-secondary-base/20 border-rb-secondary-base" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-tertiary-base/20 border-rb-tertiary-base" rowSpan={2}>1x2 Tall</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-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-rb-blue-100 text-rb-blue-700 rounded-lg"><Star size={20} /></div>}
sections={[
{
value: 'features',
label: 'Features',
labelIcon: <Check size={18} />,
labelClassName: 'text-rb-green-600',
items: ['Responsive', 'Accessible', 'Themeable'],
}
]}
title="List Card"
/>
<BentoGrid.BaseCard className="flex flex-col gap-4">
<div className="p-2 bg-rb-color-yellow-100 text-rb-color-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-rb-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-rb-blue-100 text-rb-blue-700 rounded-xl">
<Gamepad2 size={24} />
</div>
}
rowSpan={2}
sections={[
{
value: 'wanted',
label: 'Wanted',
labelIcon: <Gift size={18} />,
labelClassName: 'text-rb-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-rb-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-rb-green-100 text-rb-green-700 rounded-xl">
<Plane size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Bucket List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-rb-orange-600',
items: [
'Kyoto, Japan (Cherry Blossom Season)',
'Reykjavik, Iceland',
'Swiss Alps'
]
},
{
value: 'owned',
label: 'Visited',
labelIcon: <Check size={18} />,
labelClassName: 'text-rb-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-rb-violet-100 text-rb-violet-700 rounded-xl">
<BookOpen size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Reading List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-rb-orange-600',
items: [
'Designing Data-Intensive Applications',
'The Pragmatic Programmer',
'Dune Messiah'
]
}
]}
title="Books to Read"
type="single"
/>
</BentoGrid>
</div>
),
};
@@ -0,0 +1,121 @@
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-rb-primary-base');
expect(card).toBeInTheDocument();
});
});
@@ -0,0 +1,56 @@
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,
});
@@ -0,0 +1,53 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import type { BentoCardBaseProps } from './types';
/**
* Utility to generate Tailwind grid span classes based on props.
* @example Generating classes:
* ```ts
* const classes = getSpanClasses({ colSpan: 2 });
* ```
*/
const getSpanClasses = ({ colSpan = 1, rowSpan = 1 }: BentoCardBaseProps) => {
const colSpans = {
1: 'md:col-span-1',
2: 'md:col-span-2',
3: 'md:col-span-3',
4: 'md:col-span-4',
};
const rowSpans = {
1: 'md:row-span-1',
2: 'md:row-span-2',
3: 'md:row-span-3',
};
return cn(colSpans[colSpan], rowSpans[rowSpan]);
};
/**
* Base card component that provides the foundational styling for all Bento cards.
* @example Basic usage:
*/
export const BentoGridBaseCard = ({
children,
className,
colSpan,
rowSpan,
...props
}: BentoCardBaseProps) => {
return (
<div
className={cn(
'relative overflow-hidden rounded-xl border border-rb-outline bg-rb-base p-6 shadow-sm transition-all hover:shadow-md',
getSpanClasses({ colSpan, rowSpan }),
className
)}
{...props}
>
{children}
</div>
);
};
@@ -0,0 +1,77 @@
import { cn } from '@ermnvldmr/stl';
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';
/**
*
*/
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(
variant === 'primary' && 'bg-rb-primary-base border-rb-primary-base',
className
)}
>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
<Header className={cn(variant === 'primary' && 'text-rb-primary-text')} level={3}>
{title}
</Header>
<Text
className={cn(variant === 'primary' ? 'text-rb-primary-text/80' : 'text-rb-muted-text')}
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>
);
};
@@ -0,0 +1,70 @@
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>
);
};
@@ -0,0 +1,47 @@
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-rb-primary-text">{icon}</div>}
<Header level={3}>{title}</Header>
{description && <Text className="text-rb-muted-text" size="m">{description}</Text>}
</VStack>
{footer && <div className="mt-auto">{footer}</div>}
</VStack>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,126 @@
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-rb-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>
);
};
@@ -0,0 +1,7 @@
export * from './BentoGrid';
export * from './types';
export * from './BentoGridBaseCard';
export * from './BentoGridImageCard';
export * from './BentoGridInfoCard';
export * from './BentoGridCTACard';
export * from './BentoGridListCard';
@@ -0,0 +1,39 @@
import type React from 'react';
/**
* Common props for all BentoGrid card variations.
*/
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* 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;
}
/**
* Props for the root BentoGrid component.
*/
export interface BentoGridProps {
/**
* The cards to display in the grid.
*/
children: React.ReactNode;
/**
* Additional CSS classes.
*/
className?: string;
}
@@ -32,7 +32,7 @@ describe('PageHead', () => {
it('applies the correct classes for the strategy', () => {
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
const header = screen.getByRole('banner');
expect(header).toHaveClass('sticky');
expect(header).toHaveClass('shadow-md');
expect(header).toHaveClass('fixed');
expect(header).not.toHaveClass('sticky');
});
});
@@ -67,19 +67,29 @@ export const PageHead = ({
className,
}: PageHeadProps) => {
const { y: scrollY } = useScroll();
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
const isCollapsible = strategy === 'collapsible-sticky';
const isAlwaysCollapsed = strategy.startsWith('always-collapsed');
const [isCollapsed, setIsCollapsed] = useState(isAlwaysCollapsed);
useEffect(() => {
if (strategy === 'collapsible-sticky') {
setIsCollapsed(scrollY > 10);
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, strategy]);
}, [scrollY, isCollapsible]);
const rootClasses = cn(
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] sticky top-0 z-10 border-b border-[var(--rb-outline)]/10',
{
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
},
'page-head w-full bg-[var(--rb-background)] border-b border-[var(--rb-outline)]/10',
// Always-collapsed or always-expanded strategies maintain their sticky/fixed behavior.
// Collapsible-sticky strategy keeps the root in-flow so expanded content scrolls away.
!isCollapsible &&
(strategy.includes('sticky')
? 'sticky top-0 z-10'
: strategy.includes('fixed')
? 'fixed top-0 z-10'
: ''),
className
);
@@ -93,7 +103,7 @@ export const PageHead = ({
</HStack>
{addonRight && <div>{addonRight}</div>}
</HStack>
<VStack gap={2}>
<VStack gap={4}>
<Header level={1}>{heading}</Header>
{typeof caption === 'string' ? (
<Text color="muted" size="l" type="body">
@@ -123,23 +133,29 @@ export const PageHead = ({
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(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'
'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'
)}
inert={isCollapsed || undefined}
>
<div className="overflow-hidden">{expandedContent}</div>
</div>
<div
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
inert={!isCollapsed || undefined}
>
<div className="overflow-hidden">{collapsedContent}</div>
{collapsedContent}
</div>
</header>
);
+2
View File
@@ -1,4 +1,6 @@
export * from './Accordion/index';
export * from './Article/Article';
export * from './BentoGrid/index';
export * from './Blockquote/Blockquote';
export * from './Breadcrumbs/Breadcrumbs';
export * from './Button/Button';