fix, feat, doc: massive build system refactorion, bug fixes and apex documentation (#19)
This commit is contained in:
1 parent
e8b42c60c2
commit
91f5245823
165 files changed
+8598
-1205
No files matched your search
@@ -1,10 +1,23 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
|
||||
|
||||
import type { AccordionContentProps } from '../types';
|
||||
|
||||
const contentVariants = cva('grid transition-[grid-template-rows] duration-200 ease-out', {
|
||||
variants: {
|
||||
isOpen: {
|
||||
true: 'grid-rows-[1fr]',
|
||||
false: 'grid-rows-[0fr]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isOpen: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The collapsible content of an Accordion item.
|
||||
*
|
||||
@@ -18,11 +31,7 @@ export const AccordionContent = ({ children, className }: AccordionContentProps)
|
||||
<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
|
||||
)}
|
||||
className={cn(contentVariants({ isOpen }), className)}
|
||||
id={`accordion-content-${value}`}
|
||||
inert={!isOpen ? true : undefined}
|
||||
role="region"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useButton } from 'react-aria';
|
||||
@@ -10,6 +11,36 @@ import { useAccordionItemContext } from '../contexts/AccordionItemContext/Accord
|
||||
|
||||
import type { AccordionTriggerProps } from '../types';
|
||||
|
||||
const triggerVariants = cva(
|
||||
'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',
|
||||
{
|
||||
variants: {
|
||||
isDisabled: {
|
||||
true: 'cursor-not-allowed opacity-50',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isDisabled: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const iconVariants = cva(
|
||||
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
|
||||
{
|
||||
variants: {
|
||||
isOpen: {
|
||||
true: 'rotate-90',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isOpen: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* The interactive trigger for an Accordion item.
|
||||
*
|
||||
@@ -52,19 +83,10 @@ export const AccordionTrigger = ({
|
||||
<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
|
||||
)}
|
||||
className={cn(triggerVariants({ isDisabled }), 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'
|
||||
)}
|
||||
/>
|
||||
<ChevronRight className={cn(iconVariants({ isOpen }))} />
|
||||
<span className="flex-1">{children}</span>
|
||||
</HStack>
|
||||
</button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const Showcase: Story = {
|
||||
/>
|
||||
<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} />}
|
||||
icon={<Zap className="text-orange-500" size={24} />}
|
||||
title="Lightning Fast"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
@@ -46,7 +46,7 @@ export const Showcase: Story = {
|
||||
<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} />}
|
||||
icon={<Shield className="text-green-500" size={24} />}
|
||||
title="Type Safe"
|
||||
/>
|
||||
<BentoGrid.ImageCard
|
||||
@@ -57,7 +57,7 @@ export const Showcase: Story = {
|
||||
/>
|
||||
<BentoGrid.InfoCard
|
||||
description="Easily extend and customize the design system with our modular component library."
|
||||
icon={<Cpu className="text-rb-blue-500" size={24} />}
|
||||
icon={<Cpu className="text-blue-500" size={24} />}
|
||||
title="Modular Architecture"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
@@ -66,18 +66,18 @@ export const Showcase: Story = {
|
||||
href="#"
|
||||
title="Documentation"
|
||||
/>
|
||||
<BentoGrid.BaseCard className="bg-rb-tertiary-base/10 border-dashed" colSpan={4}>
|
||||
<BentoGrid.BaseCard className="bg-tertiary/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">
|
||||
<div className="p-3 bg-tertiary rounded-full text-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>
|
||||
<h4 className="font-bold text-text">Subscribe to Newsletter</h4>
|
||||
<p className="text-sm text-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>
|
||||
<button className="px-6 py-2 bg-text text-[var(--rb-base)] rounded-md font-medium">Subscribe</button>
|
||||
</div>
|
||||
</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
@@ -91,31 +91,31 @@ export const LayoutPatterns: Story = {
|
||||
<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.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-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.BaseCard className="flex items-center justify-center bg-primary/20 border-primary" colSpan={2} rowSpan={2}>2x2 Hero</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-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.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-secondary/20 border-secondary" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-tertiary/20 border-tertiary" rowSpan={2}>1x2 Tall</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
</section>
|
||||
</div>
|
||||
@@ -155,20 +155,20 @@ export const AllCardTypes: Story = {
|
||||
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>}
|
||||
icon={<div className="p-2 bg-blue-100 text-blue-700 rounded-lg"><Star size={20} /></div>}
|
||||
sections={[
|
||||
{
|
||||
value: 'features',
|
||||
label: 'Features',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-green-600',
|
||||
labelClassName: 'text-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">
|
||||
<div className="p-2 bg-yellow-100 text-yellow-900 rounded-md w-fit">
|
||||
<Code size={20} />
|
||||
</div>
|
||||
<h4 className="font-bold">Base Card (Custom)</h4>
|
||||
@@ -184,7 +184,7 @@ export const WishlistVariant: Story = {
|
||||
<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>
|
||||
<Text className="text-muted-text" size="l">Things I want and things I already have, organized by category.</Text>
|
||||
</div>
|
||||
|
||||
<BentoGrid>
|
||||
@@ -193,7 +193,7 @@ export const WishlistVariant: Story = {
|
||||
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">
|
||||
<div className="p-3 bg-blue-100 text-blue-700 rounded-xl">
|
||||
<Gamepad2 size={24} />
|
||||
</div>
|
||||
}
|
||||
@@ -203,7 +203,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'wanted',
|
||||
label: 'Wanted',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
labelClassName: 'text-orange-600',
|
||||
items: [
|
||||
'Steam Deck OLED 1TB',
|
||||
'Sony A7C II with 35mm lens',
|
||||
@@ -214,7 +214,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'owned',
|
||||
label: 'Already Owned',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-green-600',
|
||||
labelClassName: 'text-green-600',
|
||||
items: [
|
||||
'MacBook Pro M3 Max',
|
||||
'Sony WH-1000XM5 Headphones',
|
||||
@@ -229,7 +229,7 @@ export const WishlistVariant: Story = {
|
||||
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">
|
||||
<div className="p-3 bg-green-100 text-green-700 rounded-xl">
|
||||
<Plane size={24} />
|
||||
</div>
|
||||
}
|
||||
@@ -238,7 +238,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'wanted',
|
||||
label: 'Bucket List',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
labelClassName: 'text-orange-600',
|
||||
items: [
|
||||
'Kyoto, Japan (Cherry Blossom Season)',
|
||||
'Reykjavik, Iceland',
|
||||
@@ -249,7 +249,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'owned',
|
||||
label: 'Visited',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-green-600',
|
||||
labelClassName: 'text-green-600',
|
||||
items: [
|
||||
'Paris, France',
|
||||
'Rome, Italy',
|
||||
@@ -265,7 +265,7 @@ export const WishlistVariant: Story = {
|
||||
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">
|
||||
<div className="p-3 bg-violet-100 text-violet-700 rounded-xl">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
}
|
||||
@@ -274,7 +274,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'wanted',
|
||||
label: 'Reading List',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
labelClassName: 'text-orange-600',
|
||||
items: [
|
||||
'Designing Data-Intensive Applications',
|
||||
'The Pragmatic Programmer',
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('BentoGrid', () => {
|
||||
);
|
||||
|
||||
const title = screen.getByText('Primary CTA');
|
||||
const card = title.closest('.bg-rb-primary-base');
|
||||
const card = title.closest('.bg-primary');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,31 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
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]);
|
||||
};
|
||||
const baseCardVariants = cva(
|
||||
'relative overflow-hidden rounded-xl border border-outline bg-base p-6 shadow-sm transition-all hover:shadow-md',
|
||||
{
|
||||
variants: {
|
||||
colSpan: {
|
||||
1: 'md:col-span-1',
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-3',
|
||||
4: 'md:col-span-4',
|
||||
},
|
||||
rowSpan: {
|
||||
1: 'md:row-span-1',
|
||||
2: 'md:row-span-2',
|
||||
3: 'md:row-span-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
colSpan: 1,
|
||||
rowSpan: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base card component that provides the foundational styling for all Bento cards.
|
||||
@@ -36,18 +36,20 @@ export const BentoGridBaseCard = ({
|
||||
className,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
href,
|
||||
...props
|
||||
}: BentoCardBaseProps) => {
|
||||
// Render as an anchor when `href` is provided so the whole card is a link,
|
||||
// otherwise a plain div. Styling and span variants are identical either way.
|
||||
const Component: React.ElementType = href != null ? 'a' : 'div';
|
||||
|
||||
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
|
||||
)}
|
||||
<Component
|
||||
className={cn(baseCardVariants({ colSpan, rowSpan }), href != null && 'block', className)}
|
||||
href={href}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
import { BentoGridBaseCard } from './BentoGridBaseCard';
|
||||
@@ -9,6 +10,42 @@ import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { BentoCardBaseProps } from './types';
|
||||
|
||||
const cardVariants = cva('', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
primary: 'bg-primary border-primary',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const headerVariants = cva('', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
primary: 'text-primary-text',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const descriptionVariants = cva('', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-muted-text',
|
||||
primary: 'text-primary-text/80',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@@ -45,18 +82,15 @@ export const BentoGridCTACard = ({
|
||||
return (
|
||||
<BentoGridBaseCard
|
||||
{...baseProps}
|
||||
className={cn(
|
||||
variant === 'primary' && 'bg-rb-primary-base border-rb-primary-base',
|
||||
className
|
||||
)}
|
||||
className={cn(cardVariants({ variant }), className)}
|
||||
>
|
||||
<VStack className="h-full justify-between" gap={4}>
|
||||
<VStack gap={2}>
|
||||
<Header className={cn(variant === 'primary' && 'text-rb-primary-text')} level={3}>
|
||||
<Header className={cn(headerVariants({ variant }))} level={3}>
|
||||
{title}
|
||||
</Header>
|
||||
<Text
|
||||
className={cn(variant === 'primary' ? 'text-rb-primary-text/80' : 'text-rb-muted-text')}
|
||||
className={cn(descriptionVariants({ variant }))}
|
||||
size="m"
|
||||
>
|
||||
{description}
|
||||
|
||||
@@ -36,11 +36,11 @@ export const BentoGridInfoCard = ({
|
||||
<BentoGridBaseCard {...baseProps}>
|
||||
<VStack className="h-full justify-between" gap={4}>
|
||||
<VStack gap={2}>
|
||||
{icon && <div className="text-rb-primary-text">{icon}</div>}
|
||||
{icon && <div className="text-primary-text">{icon}</div>}
|
||||
<Header level={3}>{title}</Header>
|
||||
{description && <Text className="text-rb-muted-text" size="m">{description}</Text>}
|
||||
{description && <Text className="text-muted-text" size="m">{description}</Text>}
|
||||
</VStack>
|
||||
{footer && <div className="mt-auto">{footer}</div>}
|
||||
{footer && <div className="mt-auto w-full">{footer}</div>}
|
||||
</VStack>
|
||||
</BentoGridBaseCard>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ export const BentoGridListCard = ({
|
||||
</HStack>
|
||||
|
||||
{description && (
|
||||
<Text className="text-rb-muted-text">
|
||||
<Text className="text-muted-text">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,11 @@ import type React from 'react';
|
||||
|
||||
/**
|
||||
* Common props for all BentoGrid card variations.
|
||||
*
|
||||
* Typed against `HTMLElement` since a card can render as either a `div` or,
|
||||
* when `href` is provided, an `a`.
|
||||
*/
|
||||
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLElement> {
|
||||
/**
|
||||
* Number of columns to span on medium screens and up.
|
||||
* @default 1
|
||||
@@ -22,6 +25,11 @@ export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLDivElement>
|
||||
* The content of the card.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* If provided, the card renders as an anchor (`<a>`) so the entire card is a
|
||||
* navigable link. Card styling and colSpan/rowSpan are preserved.
|
||||
*/
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
@@ -18,6 +19,28 @@ export interface BlockquoteProps
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const blockquoteVariants = cva('border-l-4 border-[var(--rb-primary)]', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
inset: 'bg-[var(--rb-surface-variant)] p-6 rounded-r-lg',
|
||||
},
|
||||
indent: {
|
||||
true: 'pl-6',
|
||||
false: '',
|
||||
},
|
||||
gutterBottom: {
|
||||
true: 'mb-6',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
indent: true,
|
||||
gutterBottom: false,
|
||||
},
|
||||
});
|
||||
|
||||
const BlockquoteComponent = forwardRef<HTMLQuoteElement, BlockquoteProps>(function Blockquote(
|
||||
{
|
||||
children,
|
||||
@@ -34,13 +57,7 @@ const BlockquoteComponent = forwardRef<HTMLQuoteElement, BlockquoteProps>(functi
|
||||
<blockquote
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-l-4 border-[var(--rb-primary)]',
|
||||
indent && 'pl-6',
|
||||
gutterBottom && 'mb-6',
|
||||
variant === 'inset' && 'bg-[var(--rb-surface-variant)] p-6 rounded-r-lg',
|
||||
className
|
||||
)}
|
||||
className={cn(blockquoteVariants({ variant, indent, gutterBottom }), className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
@@ -46,6 +47,36 @@ export interface BreadcrumbsItemProps extends TestIdProps {
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
const itemVariants = cva('group flex items-center transition-all duration-200', {
|
||||
variants: {
|
||||
isCurrent: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
isClickable: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [{ isCurrent: false, isClickable: true, className: 'cursor-pointer' }],
|
||||
defaultVariants: {
|
||||
isCurrent: false,
|
||||
isClickable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const itemTextVariants = cva('transition-all duration-200', {
|
||||
variants: {
|
||||
isCurrent: {
|
||||
true: '',
|
||||
false: 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isCurrent: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Individual step in the breadcrumb trail.
|
||||
*/
|
||||
@@ -63,19 +94,12 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
return (
|
||||
<li
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex items-center transition-all duration-200',
|
||||
!isCurrent && isClickable && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
className={cn(itemVariants({ isCurrent, isClickable }), className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Text
|
||||
as={resolvedHref ? 'a' : onClick ? 'button' : 'span'}
|
||||
className={cn(
|
||||
'transition-all duration-200',
|
||||
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
|
||||
)}
|
||||
className={cn(itemTextVariants({ isCurrent }))}
|
||||
color={isCurrent ? 'default' : 'muted'}
|
||||
href={resolvedHref}
|
||||
size="m"
|
||||
|
||||
@@ -79,4 +79,16 @@ describe('components/Button', () => {
|
||||
render(<Button fullWidth>Full Width</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('applies correct variant and color classes', () => {
|
||||
const { rerender } = render(<Button color="primary" variant="solid">Primary Solid</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-primary');
|
||||
expect(screen.getByRole('button')).toHaveClass('text-primary-text');
|
||||
|
||||
rerender(<Button color="secondary" variant="outline">Secondary Outline</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||
expect(screen.getByRole('button')).toHaveClass('border-secondary');
|
||||
expect(screen.getByRole('button')).toHaveClass('text-secondary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
import { useButton, useLink, useFocusRing, useHover } from 'react-aria';
|
||||
|
||||
@@ -77,6 +78,109 @@ interface LinkButtonProps
|
||||
*/
|
||||
export type ButtonProps = ActionButtonProps | LinkButtonProps;
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer active:scale-[0.98]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: '',
|
||||
outline: '',
|
||||
ghost: '',
|
||||
link: '',
|
||||
},
|
||||
color: {
|
||||
primary: '',
|
||||
'primary-negative': '',
|
||||
secondary: '',
|
||||
'secondary-negative': '',
|
||||
tertiary: '',
|
||||
'tertiary-negative': '',
|
||||
error: '',
|
||||
'error-negative': '',
|
||||
neutral: '',
|
||||
'neutral-negative': '',
|
||||
},
|
||||
size: {
|
||||
s: 'h-8 px-3 text-sm gap-1.5',
|
||||
m: 'h-10 px-4 text-base gap-2',
|
||||
l: 'h-12 px-6 text-lg gap-3',
|
||||
},
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded-md',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
false: '',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Primary
|
||||
{ variant: 'solid', color: 'primary', className: 'bg-primary text-primary-text' },
|
||||
{ variant: 'solid', color: 'primary-negative', className: 'bg-primary-text text-primary' },
|
||||
{ variant: 'outline', color: 'primary', className: 'bg-transparent border-2 text-primary border-primary hover:bg-primary/10' },
|
||||
{ variant: 'outline', color: 'primary-negative', className: 'bg-transparent border-2 text-primary-text border-primary-text hover:bg-primary-text/10' },
|
||||
{ variant: 'ghost', color: 'primary', className: 'bg-transparent border-transparent text-primary hover:bg-primary/10' },
|
||||
{ variant: 'ghost', color: 'primary-negative', className: 'bg-transparent border-transparent text-primary-text hover:bg-primary-text/10' },
|
||||
{ variant: 'link', color: 'primary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-primary' },
|
||||
{ variant: 'link', color: 'primary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-primary-text' },
|
||||
|
||||
// Secondary
|
||||
{ variant: 'solid', color: 'secondary', className: 'bg-secondary text-secondary-text' },
|
||||
{ variant: 'solid', color: 'secondary-negative', className: 'bg-secondary-text text-secondary' },
|
||||
{ variant: 'outline', color: 'secondary', className: 'bg-transparent border-2 text-secondary border-secondary hover:bg-secondary/10' },
|
||||
{ variant: 'outline', color: 'secondary-negative', className: 'bg-transparent border-2 text-secondary-text border-secondary-text hover:bg-secondary-text/10' },
|
||||
{ variant: 'ghost', color: 'secondary', className: 'bg-transparent border-transparent text-secondary hover:bg-secondary/10' },
|
||||
{ variant: 'ghost', color: 'secondary-negative', className: 'bg-transparent border-transparent text-secondary-text hover:bg-secondary-text/10' },
|
||||
{ variant: 'link', color: 'secondary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-secondary' },
|
||||
{ variant: 'link', color: 'secondary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-secondary-text' },
|
||||
|
||||
// Tertiary
|
||||
{ variant: 'solid', color: 'tertiary', className: 'bg-tertiary text-tertiary-text' },
|
||||
{ variant: 'solid', color: 'tertiary-negative', className: 'bg-tertiary-text text-tertiary' },
|
||||
{ variant: 'outline', color: 'tertiary', className: 'bg-transparent border-2 text-tertiary border-tertiary hover:bg-tertiary/10' },
|
||||
{ variant: 'outline', color: 'tertiary-negative', className: 'bg-transparent border-2 text-tertiary-text border-tertiary-text hover:bg-tertiary-text/10' },
|
||||
{ variant: 'ghost', color: 'tertiary', className: 'bg-transparent border-transparent text-tertiary hover:bg-tertiary/10' },
|
||||
{ variant: 'ghost', color: 'tertiary-negative', className: 'bg-transparent border-transparent text-tertiary-text hover:bg-tertiary-text/10' },
|
||||
{ variant: 'link', color: 'tertiary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-tertiary' },
|
||||
{ variant: 'link', color: 'tertiary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-tertiary-text' },
|
||||
|
||||
// Error
|
||||
{ variant: 'solid', color: 'error', className: 'bg-error text-error-text' },
|
||||
{ variant: 'solid', color: 'error-negative', className: 'bg-error-text text-error' },
|
||||
{ variant: 'outline', color: 'error', className: 'bg-transparent border-2 text-error border-error hover:bg-error/10' },
|
||||
{ variant: 'outline', color: 'error-negative', className: 'bg-transparent border-2 text-error-text border-error-text hover:bg-error-text/10' },
|
||||
{ variant: 'ghost', color: 'error', className: 'bg-transparent border-transparent text-error hover:bg-error/10' },
|
||||
{ variant: 'ghost', color: 'error-negative', className: 'bg-transparent border-transparent text-error-text hover:bg-error-text/10' },
|
||||
{ variant: 'link', color: 'error', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-error' },
|
||||
{ variant: 'link', color: 'error-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-error-text' },
|
||||
|
||||
// Neutral
|
||||
{ variant: 'solid', color: 'neutral', className: 'bg-muted text-[var(--rb-text)]' },
|
||||
{ variant: 'solid', color: 'neutral-negative', className: 'bg-[var(--rb-text)] text-muted' },
|
||||
{ variant: 'outline', color: 'neutral', className: 'bg-transparent border-2 text-muted border-muted hover:bg-muted/10' },
|
||||
{ variant: 'outline', color: 'neutral-negative', className: 'bg-transparent border-2 text-[var(--rb-text)] border-[var(--rb-text)] hover:bg-[var(--rb-text)]/10' },
|
||||
{ variant: 'ghost', color: 'neutral', className: 'bg-transparent border-transparent text-muted hover:bg-muted/10' },
|
||||
{ variant: 'ghost', color: 'neutral-negative', className: 'bg-transparent border-transparent text-[var(--rb-text)] hover:bg-[var(--rb-text)]/10' },
|
||||
{ variant: 'link', color: 'neutral', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-muted' },
|
||||
{ variant: 'link', color: 'neutral-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-[var(--rb-text)]' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
size: 'm',
|
||||
rounded: 'md',
|
||||
fullWidth: false,
|
||||
isDisabled: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A highly extensible, polymorphic button component.
|
||||
*
|
||||
@@ -95,7 +199,7 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
|
||||
rounded = 'md',
|
||||
className,
|
||||
'data-testid': testId,
|
||||
isDisabled,
|
||||
isDisabled = false,
|
||||
href,
|
||||
} = props;
|
||||
|
||||
@@ -124,62 +228,14 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
|
||||
const { hoverProps, isHovered } = useHover({ isDisabled });
|
||||
|
||||
// Styles mapping
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
s: 'h-8 px-3 text-sm gap-1.5',
|
||||
m: 'h-10 px-4 text-base gap-2',
|
||||
l: 'h-12 px-6 text-lg gap-3',
|
||||
};
|
||||
|
||||
const iconClasses: Record<ButtonSize, string> = {
|
||||
s: 'w-4 h-4',
|
||||
m: 'w-5 h-5',
|
||||
l: 'w-6 h-6',
|
||||
};
|
||||
|
||||
const roundedClasses: Record<string, string> = {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded-md',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
const getVariantClasses = (): string => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
const colors: Record<string, { base: string; text: string }> = {
|
||||
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
|
||||
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
|
||||
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
|
||||
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
|
||||
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const colorValues = colors[baseColor];
|
||||
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
|
||||
const foregroundColor = isNegative ? colorValues.base : colorValues.text;
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return `bg-[${backgroundColor}] text-[${foregroundColor}] border-transparent`;
|
||||
case 'outline':
|
||||
return `bg-transparent text-[${backgroundColor}] border-[${backgroundColor}] border-2`;
|
||||
case 'ghost':
|
||||
return `bg-transparent text-[${backgroundColor}] border-transparent hover:bg-[${backgroundColor}]/10`;
|
||||
case 'link':
|
||||
return `bg-transparent text-[${backgroundColor}] border-transparent underline-offset-4 hover:underline px-0 h-auto`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const sharedClasses = cn(
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer',
|
||||
'active:scale-[0.98]',
|
||||
sizeClasses[size],
|
||||
roundedClasses[rounded],
|
||||
getVariantClasses(),
|
||||
fullWidth && 'w-full',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
|
||||
buttonVariants({ variant, color, size, rounded, fullWidth, isDisabled }),
|
||||
isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2',
|
||||
isHovered && !isDisabled && 'brightness-90 dark:brightness-110',
|
||||
className
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePress } from 'react-aria';
|
||||
|
||||
@@ -10,6 +11,45 @@ import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
export type { CodeLanguage };
|
||||
|
||||
const highlightedVariants = cva('block min-w-full font-mono text-sm [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0', {
|
||||
variants: {
|
||||
showLineNumbers: {
|
||||
true: '[&>pre>code]:[counter-reset:line] [&>pre>code>.line]:relative [&>pre>code>.line]:pl-10 [&>pre>code>.line]:[counter-increment:line] [&>pre>code>.line]:before:content-[counter(line)] [&>pre>code>.line]:before:absolute [&>pre>code>.line]:before:left-0 [&>pre>code>.line]:before:top-0 [&>pre>code>.line]:before:inline-block [&>pre>code>.line]:before:w-8 [&>pre>code>.line]:before:text-right [&>pre>code>.line]:before:text-[var(--rb-muted-text)]/50 [&>pre>code>.line]:before:select-none [&>pre>code>.line]:before:border-r [&>pre>code>.line]:before:border-[var(--rb-outline)]/10 [&>pre>code>.line]:before:pr-3 [&>pre>code>.line]:before:font-mono [&>pre>code>.line]:before:text-[10px]',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
showLineNumbers: false,
|
||||
},
|
||||
});
|
||||
|
||||
const blockVariants = cva('block min-w-full font-mono whitespace-pre', {
|
||||
variants: {
|
||||
showLineNumbers: {
|
||||
true: '[counter-reset:line] [&>span]:relative [&>span]:pl-10 [&>span]:[counter-increment:line] [&>span]:before:content-[counter(line)] [&>span]:before:absolute [&>span]:before:left-0 [&>span]:before:top-[4px] [&>span]:before:inline-block [&>span]:before:w-8 [&>span]:before:text-right [&>span]:before:text-[var(--rb-muted-text)]/50 [&>span]:before:select-none [&>span]:before:border-r [&>span]:before:border-[var(--rb-outline)]/10 [&>span]:before:pr-3 [&>span]:before:font-mono [&>span]:before:text-[10px]',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
showLineNumbers: false,
|
||||
},
|
||||
});
|
||||
|
||||
const inlineVariants = cva(
|
||||
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)] cursor-pointer transition-all duration-200 hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10 active:scale-95',
|
||||
{
|
||||
variants: {
|
||||
isCopied: {
|
||||
true: 'bg-[var(--rb-color-red-500)]/20 ring-1 ring-[var(--rb-color-red-500)]/40',
|
||||
false: 'bg-transparent ring-1 ring-transparent',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isCopied: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for the Code component.
|
||||
*/
|
||||
@@ -116,27 +156,7 @@ export const Code = memo(function Code({
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
ref={divRef}
|
||||
className={cn(
|
||||
'block min-w-full font-mono text-sm',
|
||||
'[&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0',
|
||||
showLineNumbers && [
|
||||
'[&>pre>code]:[counter-reset:line]',
|
||||
'[&>pre>code>.line]:relative [&>pre>code>.line]:pl-10',
|
||||
'[&>pre>code>.line]:[counter-increment:line]',
|
||||
'[&>pre>code>.line]:before:content-[counter(line)]',
|
||||
'[&>pre>code>.line]:before:absolute [&>pre>code>.line]:before:left-0 [&>pre>code>.line]:before:top-0',
|
||||
'[&>pre>code>.line]:before:inline-block',
|
||||
'[&>pre>code>.line]:before:w-8',
|
||||
'[&>pre>code>.line]:before:text-right',
|
||||
'[&>pre>code>.line]:before:text-[var(--rb-muted-text)]/50',
|
||||
'[&>pre>code>.line]:before:select-none',
|
||||
'[&>pre>code>.line]:before:border-r',
|
||||
'[&>pre>code>.line]:before:border-[var(--rb-outline)]/10',
|
||||
'[&>pre>code>.line]:before:pr-3',
|
||||
'[&>pre>code>.line]:before:font-mono [&>pre>code>.line]:before:text-[10px]',
|
||||
],
|
||||
className
|
||||
)}
|
||||
className={cn(highlightedVariants({ showLineNumbers }), className)}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
@@ -157,26 +177,7 @@ export const Code = memo(function Code({
|
||||
return (
|
||||
<code
|
||||
ref={codeRef}
|
||||
className={cn(
|
||||
'block min-w-full font-mono whitespace-pre',
|
||||
showLineNumbers && [
|
||||
'[counter-reset:line]',
|
||||
'[&>span]:relative [&>span]:pl-10',
|
||||
'[&>span]:[counter-increment:line]',
|
||||
'[&>span]:before:content-[counter(line)]',
|
||||
'[&>span]:before:absolute [&>span]:before:left-0 [&>span]:before:top-[4px]',
|
||||
'[&>span]:before:inline-block',
|
||||
'[&>span]:before:w-8',
|
||||
'[&>span]:before:text-right',
|
||||
'[&>span]:before:text-[var(--rb-muted-text)]/50',
|
||||
'[&>span]:before:select-none',
|
||||
'[&>span]:before:border-r',
|
||||
'[&>span]:before:border-[var(--rb-outline)]/10',
|
||||
'[&>span]:before:pr-3',
|
||||
'[&>span]:before:font-mono [&>span]:before:text-[10px]',
|
||||
],
|
||||
className
|
||||
)}
|
||||
className={cn(blockVariants({ showLineNumbers }), className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{content}
|
||||
@@ -189,17 +190,7 @@ export const Code = memo(function Code({
|
||||
{...pressProps}
|
||||
ref={codeRef}
|
||||
aria-label="Copy code snippet"
|
||||
className={cn(
|
||||
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
|
||||
'text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)]',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10',
|
||||
'active:scale-95',
|
||||
isCopied
|
||||
? 'bg-[var(--rb-color-red-500)]/20 ring-1 ring-[var(--rb-color-red-500)]/40'
|
||||
: 'bg-transparent ring-1 ring-transparent',
|
||||
className
|
||||
)}
|
||||
className={cn(inlineVariants({ isCopied }), className)}
|
||||
data-testid={testId}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
|
||||
@@ -9,6 +10,36 @@ import { CodeContext, type CodeContextValue } from '../contexts/CodeContext/Code
|
||||
import type { CodeLanguage } from '../lib/highlighter/highlighter';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
const copyButtonVariants = cva(
|
||||
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
|
||||
{
|
||||
variants: {
|
||||
isCopied: {
|
||||
true: '',
|
||||
false: 'opacity-0 group-hover:opacity-100 focus:opacity-100',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isCopied: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const preVariants = cva(
|
||||
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text whitespace-pre',
|
||||
{
|
||||
variants: {
|
||||
hasLabel: {
|
||||
true: 'pt-8',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
hasLabel: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for the CodeBlock component.
|
||||
*/
|
||||
@@ -122,10 +153,7 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||
<Button
|
||||
aria-label="Copy code"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
|
||||
!isCopied && 'opacity-0 group-hover:opacity-100 focus:opacity-100'
|
||||
)}
|
||||
className={cn(copyButtonVariants({ isCopied }))}
|
||||
color="neutral"
|
||||
size="s"
|
||||
variant="ghost"
|
||||
@@ -143,10 +171,7 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
{/* Code Area */}
|
||||
<pre
|
||||
ref={preRef}
|
||||
className={cn(
|
||||
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text whitespace-pre',
|
||||
label && 'pt-8'
|
||||
)}
|
||||
className={cn(preVariants({ hasLabel: !!label }))}
|
||||
style={{ maxHeight: maxHeight }}
|
||||
>
|
||||
<CodeContext.Provider value={contextValue}>{children}</CodeContext.Provider>
|
||||
|
||||
@@ -1,49 +1,12 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { memo } from 'react';
|
||||
import { usePress } from 'react-aria';
|
||||
|
||||
import type { SpacingScale } from '../../lib/scale';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { PressEvents } from 'react-aria';
|
||||
|
||||
/**
|
||||
* Tailwind gap spacing scale values.
|
||||
*/
|
||||
type GapScale =
|
||||
| 0
|
||||
| 0.5
|
||||
| 1
|
||||
| 1.5
|
||||
| 2
|
||||
| 2.5
|
||||
| 3
|
||||
| 3.5
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 14
|
||||
| 16
|
||||
| 20
|
||||
| 24
|
||||
| 28
|
||||
| 32
|
||||
| 36
|
||||
| 40
|
||||
| 44
|
||||
| 48
|
||||
| 52
|
||||
| 56
|
||||
| 60
|
||||
| 64
|
||||
| 72
|
||||
| 80
|
||||
| 96;
|
||||
|
||||
/**
|
||||
* Background variants for the Container.
|
||||
*/
|
||||
@@ -75,7 +38,7 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
|
||||
/** Background color variant */
|
||||
bg?: ContainerBackground;
|
||||
/** Padding using Tailwind spacing scale */
|
||||
padding?: GapScale;
|
||||
padding?: SpacingScale;
|
||||
/** Maximum width constraint */
|
||||
maxWidth?: ContainerMaxWidth;
|
||||
/** Border radius variant */
|
||||
@@ -98,6 +61,91 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const containerVariants = cva('overflow-hidden', {
|
||||
variants: {
|
||||
bg: {
|
||||
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
|
||||
primary: 'bg-[var(--rb-primary-base)] text-[var(--rb-primary-text)]',
|
||||
secondary: 'bg-[var(--rb-secondary-base)] text-[var(--rb-secondary-text)]',
|
||||
tertiary: 'bg-[var(--rb-tertiary-base)] text-[var(--rb-tertiary-text)]',
|
||||
error: 'bg-[var(--rb-error-base)] text-[var(--rb-error-text)]',
|
||||
muted: 'bg-[var(--rb-muted-base)] text-[var(--rb-muted-text)]',
|
||||
transparent: 'bg-transparent',
|
||||
},
|
||||
maxWidth: {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
none: 'max-w-none',
|
||||
},
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
padding: {
|
||||
0: 'p-0',
|
||||
0.5: 'p-0.5',
|
||||
1: 'p-1',
|
||||
1.5: 'p-1.5',
|
||||
2: 'p-2',
|
||||
2.5: 'p-2.5',
|
||||
3: 'p-3',
|
||||
3.5: 'p-3.5',
|
||||
4: 'p-4',
|
||||
5: 'p-5',
|
||||
6: 'p-6',
|
||||
7: 'p-7',
|
||||
8: 'p-8',
|
||||
9: 'p-9',
|
||||
10: 'p-10',
|
||||
11: 'p-11',
|
||||
12: 'p-12',
|
||||
14: 'p-14',
|
||||
16: 'p-16',
|
||||
20: 'p-20',
|
||||
24: 'p-24',
|
||||
28: 'p-28',
|
||||
32: 'p-32',
|
||||
36: 'p-36',
|
||||
40: 'p-40',
|
||||
44: 'p-44',
|
||||
48: 'p-48',
|
||||
52: 'p-52',
|
||||
56: 'p-56',
|
||||
60: 'p-60',
|
||||
64: 'p-64',
|
||||
72: 'p-72',
|
||||
80: 'p-80',
|
||||
96: 'p-96',
|
||||
},
|
||||
shadow: {
|
||||
true: 'shadow-md',
|
||||
false: '',
|
||||
},
|
||||
border: {
|
||||
true: 'border border-[var(--rb-outline)]',
|
||||
false: '',
|
||||
},
|
||||
interactive: {
|
||||
true: 'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
bg: 'base',
|
||||
maxWidth: 'none',
|
||||
rounded: 'none',
|
||||
padding: 0,
|
||||
shadow: false,
|
||||
border: false,
|
||||
interactive: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A generic surface component for creating structural layouts.
|
||||
*
|
||||
@@ -134,83 +182,20 @@ export const Container = memo(function Container({
|
||||
const interactionProps = isPressable ? pressProps : {};
|
||||
const FinalComponent = isLink ? 'a' : Component;
|
||||
|
||||
const bgClasses: Record<ContainerBackground, string> = {
|
||||
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
|
||||
primary: 'bg-[var(--rb-primary-base)] text-[var(--rb-primary-text)]',
|
||||
secondary: 'bg-[var(--rb-secondary-base)] text-[var(--rb-secondary-text)]',
|
||||
tertiary: 'bg-[var(--rb-tertiary-base)] text-[var(--rb-tertiary-text)]',
|
||||
error: 'bg-[var(--rb-error-base)] text-[var(--rb-error-text)]',
|
||||
muted: 'bg-[var(--rb-muted-base)] text-[var(--rb-muted-text)]',
|
||||
transparent: 'bg-transparent',
|
||||
};
|
||||
|
||||
const maxWidthClasses: Record<ContainerMaxWidth, string> = {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
none: 'max-w-none',
|
||||
};
|
||||
|
||||
const roundedClasses: Record<ContainerRounded, string> = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
const paddingClasses: Record<GapScale, string> = {
|
||||
0: 'p-0',
|
||||
0.5: 'p-0.5',
|
||||
1: 'p-1',
|
||||
1.5: 'p-1.5',
|
||||
2: 'p-2',
|
||||
2.5: 'p-2.5',
|
||||
3: 'p-3',
|
||||
3.5: 'p-3.5',
|
||||
4: 'p-4',
|
||||
5: 'p-5',
|
||||
6: 'p-6',
|
||||
7: 'p-7',
|
||||
8: 'p-8',
|
||||
9: 'p-9',
|
||||
10: 'p-10',
|
||||
11: 'p-11',
|
||||
12: 'p-12',
|
||||
14: 'p-14',
|
||||
16: 'p-16',
|
||||
20: 'p-20',
|
||||
24: 'p-24',
|
||||
28: 'p-28',
|
||||
32: 'p-32',
|
||||
36: 'p-36',
|
||||
40: 'p-40',
|
||||
44: 'p-44',
|
||||
48: 'p-48',
|
||||
52: 'p-52',
|
||||
56: 'p-56',
|
||||
60: 'p-60',
|
||||
64: 'p-64',
|
||||
72: 'p-72',
|
||||
80: 'p-80',
|
||||
96: 'p-96',
|
||||
};
|
||||
|
||||
return (
|
||||
<FinalComponent
|
||||
{...interactionProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden',
|
||||
bgClasses[bg],
|
||||
maxWidthClasses[maxWidth],
|
||||
roundedClasses[rounded],
|
||||
paddingClasses[padding],
|
||||
shadow && 'shadow-md',
|
||||
border && 'border border-[var(--rb-outline)]',
|
||||
(isPressable || isLink) &&
|
||||
'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
|
||||
containerVariants({
|
||||
bg,
|
||||
maxWidth,
|
||||
rounded,
|
||||
padding,
|
||||
shadow,
|
||||
border,
|
||||
interactive: isPressable || isLink,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Stub } from '../Stub/Stub';
|
||||
@@ -38,6 +39,41 @@ interface ImageBaseProps {
|
||||
*/
|
||||
export type ImageProps = ImageBaseProps & ImageDimensions;
|
||||
|
||||
const imageWrapperVariants = cva('relative overflow-hidden', {
|
||||
variants: {
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
shadow: {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
rounded: 'none',
|
||||
shadow: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
const imageVariants = cva('w-full h-full transition-opacity duration-300', {
|
||||
variants: {
|
||||
objectFit: {
|
||||
cover: 'object-cover',
|
||||
contain: 'object-contain',
|
||||
fill: 'object-fill',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
objectFit: 'cover',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A performance-optimized Image component with built-in CLS prevention,
|
||||
* loading states, and Rainby design system styles.
|
||||
@@ -52,21 +88,6 @@ export const Image: React.FC<ImageProps> = (props) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
const shadowClasses = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
};
|
||||
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
width: 'width' in props ? props.width : undefined,
|
||||
height: 'height' in props ? props.height : undefined,
|
||||
@@ -75,12 +96,7 @@ export const Image: React.FC<ImageProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden',
|
||||
roundedClasses[rounded],
|
||||
shadowClasses[shadow],
|
||||
className
|
||||
)}
|
||||
className={cn(imageWrapperVariants({ rounded, shadow }), className)}
|
||||
data-testid="image-wrapper"
|
||||
style={wrapperStyle}
|
||||
>
|
||||
@@ -89,13 +105,7 @@ export const Image: React.FC<ImageProps> = (props) => {
|
||||
)}
|
||||
<img
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity duration-300',
|
||||
objectFit === 'cover' && 'object-cover',
|
||||
objectFit === 'contain' && 'object-contain',
|
||||
objectFit === 'fill' && 'object-fill',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
className={cn(imageVariants({ objectFit }), isLoaded ? 'opacity-100' : 'opacity-0')}
|
||||
data-testid="image-element"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
@@ -29,18 +30,32 @@ export interface ListProps extends ClassNameProps, TestIdProps, React.HTMLAttrib
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const spacingStyles: Record<ListSpacing, string> = {
|
||||
none: 'gap-0',
|
||||
s: 'gap-1',
|
||||
m: 'gap-2',
|
||||
l: 'gap-4',
|
||||
};
|
||||
|
||||
const variantStyles: Record<ListVariant, string> = {
|
||||
unordered: 'list-disc pl-5',
|
||||
ordered: 'list-decimal pl-5',
|
||||
plain: 'list-none pl-0',
|
||||
};
|
||||
const listVariants = cva('m-0 flex flex-col', {
|
||||
variants: {
|
||||
variant: {
|
||||
unordered: 'list-disc pl-5',
|
||||
ordered: 'list-decimal pl-5',
|
||||
plain: 'list-none pl-0',
|
||||
},
|
||||
spacing: {
|
||||
none: 'gap-0',
|
||||
s: 'gap-1',
|
||||
m: 'gap-2',
|
||||
l: 'gap-4',
|
||||
},
|
||||
// Hide default markers if a global custom marker is provided.
|
||||
hasCustomMarker: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [{ hasCustomMarker: true, className: 'list-none pl-0' }],
|
||||
defaultVariants: {
|
||||
variant: 'unordered',
|
||||
spacing: 'none',
|
||||
hasCustomMarker: false,
|
||||
},
|
||||
});
|
||||
|
||||
const ListContext = React.createContext<{ marker?: React.ReactNode }>({});
|
||||
|
||||
@@ -65,13 +80,7 @@ const ListComponent = forwardRef<HTMLElement, ListProps>(function List(
|
||||
<Component
|
||||
{...props}
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
className={cn(
|
||||
'm-0 flex flex-col',
|
||||
// Hide default markers if a global custom marker is provided
|
||||
hasCustomMarker ? 'list-none pl-0' : variantStyles[variant],
|
||||
spacingStyles[spacing],
|
||||
className
|
||||
)}
|
||||
className={cn(listVariants({ variant, spacing, hasCustomMarker }), className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,23 @@ export interface PageColumnProps {
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const pageColumnVariants = cva('w-full transition-all duration-200', {
|
||||
variants: {
|
||||
size: {
|
||||
small: 'lg:w-[var(--rb-page-column-small,300px)] lg:flex-none',
|
||||
full: 'flex-1',
|
||||
},
|
||||
sticky: {
|
||||
true: 'lg:sticky lg:top-24 self-start',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'full',
|
||||
sticky: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
/**
|
||||
* A single column unit within the PageColumns host.
|
||||
@@ -49,18 +67,6 @@ export const PageColumn = ({
|
||||
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>
|
||||
<Component className={cn(pageColumnVariants({ size, sticky }), className)}>{children}</Component>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,8 @@ export interface PageColumnsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const pageColumnsVariants = cva('flex flex-col lg:flex-row w-full');
|
||||
|
||||
/**
|
||||
/**
|
||||
* Host component for a responsive column system.
|
||||
@@ -34,18 +37,12 @@ export interface PageColumnsProps {
|
||||
* ```
|
||||
*/
|
||||
export const PageColumns = ({ children, gap = 6, className }: PageColumnsProps) => {
|
||||
// `gap` accepts either a Tailwind spacing-scale number (`gap-N`) or an
|
||||
// arbitrary class string, so the value space is not enumerable as a cva
|
||||
// variant. Compute the numeric class here and pass strings through verbatim.
|
||||
const gapClass = typeof gap === 'number' ? `gap-${gap}` : gap;
|
||||
|
||||
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>
|
||||
<div className={cn(pageColumnsVariants(), gapClass, className)}>{children}</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -57,6 +58,39 @@ export interface PageContainerProps {
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const pageContainerVariants = cva('w-full mx-auto', {
|
||||
variants: {
|
||||
width: {
|
||||
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',
|
||||
},
|
||||
paddingX: {
|
||||
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',
|
||||
},
|
||||
paddingY: {
|
||||
none: 'py-0',
|
||||
small: 'py-4 sm:py-6',
|
||||
medium: 'py-8 sm:py-12',
|
||||
large: 'py-12 sm:py-24',
|
||||
},
|
||||
centerVertically: {
|
||||
true: 'flex-1 flex flex-col justify-center',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
width: 'default',
|
||||
paddingX: 'medium',
|
||||
paddingY: 'none',
|
||||
centerVertically: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
/**
|
||||
* Manages the horizontal and vertical constraints for page content.
|
||||
@@ -79,37 +113,10 @@ export const PageContainer = ({
|
||||
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,
|
||||
},
|
||||
pageContainerVariants({ width, paddingX, paddingY, centerVertically }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -35,4 +35,18 @@ describe('PageHead', () => {
|
||||
expect(header).toHaveClass('fixed');
|
||||
expect(header).not.toHaveClass('sticky');
|
||||
});
|
||||
|
||||
it('reserves flow space below the out-of-flow bar for always-collapsed strategies', () => {
|
||||
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
|
||||
const spacer = screen.getByRole('banner').nextElementSibling;
|
||||
expect(spacer).not.toBeNull();
|
||||
// The spacer is an invisible copy of the bar so it reserves the exact height.
|
||||
expect(spacer).toHaveClass('invisible');
|
||||
expect(spacer).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('does not add a spacer for in-flow strategies', () => {
|
||||
render(<PageHead heading="Test" strategy="collapsible-sticky" />);
|
||||
expect(screen.getByRole('banner').nextElementSibling).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, useScroll } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { Header } from '../../Header/Header';
|
||||
@@ -42,6 +43,27 @@ export interface PageHeadProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Root positioning is fully determined by `strategy`:
|
||||
// - `collapsible-sticky` stays in-flow so expanded content scrolls away.
|
||||
// - `*-sticky` / `*-fixed` strategies pin the root to the top.
|
||||
const pageHeadRootVariants = cva(
|
||||
'page-head w-full bg-[var(--rb-background)] border-b border-[var(--rb-outline)]/10',
|
||||
{
|
||||
variants: {
|
||||
strategy: {
|
||||
'always-collapsed-fixed': 'fixed top-0 z-10',
|
||||
'always-collapsed-sticky': 'sticky top-0 z-10',
|
||||
'always-expanded-fixed': 'fixed top-0 z-10',
|
||||
'always-expanded-sticky': 'sticky top-0 z-10',
|
||||
'collapsible-sticky': '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
strategy: 'collapsible-sticky',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A universal page head component with multiple layout and behavior strategies.
|
||||
*
|
||||
@@ -80,18 +102,7 @@ export const PageHead = ({
|
||||
}
|
||||
}, [scrollY, isCollapsible]);
|
||||
|
||||
const rootClasses = cn(
|
||||
'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
|
||||
);
|
||||
const rootClasses = cn(pageHeadRootVariants({ strategy }), className);
|
||||
|
||||
const expandedContent = (
|
||||
<PageContainer paddingY="medium">
|
||||
@@ -119,44 +130,68 @@ export const PageHead = ({
|
||||
|
||||
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">
|
||||
{/*
|
||||
* A single row on wide screens (title left, menu right). When the title
|
||||
* and menu can't fit together, the row wraps the menu onto its own line
|
||||
* beneath the title instead of overlapping. `whitespace-nowrap` keeps the
|
||||
* title a single unit so it's the menu that wraps, not the title that
|
||||
* shrinks. The invisible spacer copy below reserves whichever height this
|
||||
* ends up being, so page content always starts clear of the bar.
|
||||
*/}
|
||||
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
|
||||
<HStack align="center" className="flex-1" gap={4}>
|
||||
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
|
||||
<Header className="min-w-0" level={4} overflow="ellipsis">
|
||||
{heading}
|
||||
</Header>
|
||||
</HStack>
|
||||
{addonRight && <div className="flex-shrink-0 ml-4">{addonRight}</div>}
|
||||
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
|
||||
</HStack>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className={rootClasses}>
|
||||
{/* Expanded Content: In-flow for collapsible, or sticky/fixed for others */}
|
||||
{!isAlwaysCollapsed && (
|
||||
<>
|
||||
<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(
|
||||
'transition-opacity duration-300',
|
||||
isCollapsible && isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{expandedContent}
|
||||
{collapsedContent}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
* Always-collapsed strategies show only the collapsed bar, which is taken
|
||||
* out of normal flow (the root is fixed, and its collapsed bar is absolute),
|
||||
* so the header reserves no height. Without this spacer, page content would
|
||||
* start at the top of the viewport, underneath the bar. Rendering an
|
||||
* invisible copy of the bar reserves exactly its height at every viewport —
|
||||
* including when it wraps to two rows — with no measurement or layout shift.
|
||||
*/}
|
||||
{isAlwaysCollapsed && (
|
||||
<div aria-hidden="true" className="invisible shrink-0">
|
||||
{collapsedContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed Content: Fixed overlay for collapsible, or absolute/relative within sticky/fixed root */}
|
||||
<div
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{collapsedContent}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
@@ -17,6 +18,28 @@ export interface ParagraphProps extends Omit<TextProps, 'as'> {
|
||||
dropCap?: boolean;
|
||||
}
|
||||
|
||||
const paragraphVariants = cva('', {
|
||||
variants: {
|
||||
gutterBottom: {
|
||||
true: 'mb-4',
|
||||
false: '',
|
||||
},
|
||||
indent: {
|
||||
true: 'indent-8',
|
||||
false: '',
|
||||
},
|
||||
dropCap: {
|
||||
true: 'first-letter:text-7xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:leading-none',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
gutterBottom: false,
|
||||
indent: false,
|
||||
dropCap: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A semantic paragraph component that wraps the Text component.
|
||||
*
|
||||
@@ -32,13 +55,7 @@ const ParagraphComponent = forwardRef<HTMLElement, ParagraphProps>(function Para
|
||||
<Text
|
||||
ref={ref}
|
||||
as="p"
|
||||
className={cn(
|
||||
gutterBottom && 'mb-4',
|
||||
indent && 'indent-8',
|
||||
dropCap &&
|
||||
'first-letter:text-7xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:leading-none',
|
||||
className
|
||||
)}
|
||||
className={cn(paragraphVariants({ gutterBottom, indent, dropCap }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Separator } from './Separator';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
describe('Separator', () => {
|
||||
it('renders a single horizontal separator by default', () => {
|
||||
render(<Separator />);
|
||||
const separator = screen.getByRole('separator');
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
|
||||
expect(separator).toHaveClass('border-t');
|
||||
});
|
||||
|
||||
it('renders a single vertical separator', () => {
|
||||
render(<Separator direction="vertical" />);
|
||||
const separator = screen.getByRole('separator');
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
|
||||
expect(separator).toHaveClass('border-l');
|
||||
});
|
||||
|
||||
it('applies an explicit color override', () => {
|
||||
render(<Separator color="black" />);
|
||||
expect(screen.getByRole('separator')).toHaveClass('border-foreground');
|
||||
});
|
||||
|
||||
it('applies the thinned gradient mask by default and omits it when disabled', () => {
|
||||
const { rerender } = render(<Separator data-testid="sep" />);
|
||||
expect(screen.getByTestId('sep').className).toContain('mask-image');
|
||||
|
||||
rerender(<Separator data-testid="sep" thinned="none" />);
|
||||
expect(screen.getByTestId('sep').className).not.toContain('mask-image');
|
||||
});
|
||||
|
||||
it('renders a double horizontal separator with two lines', () => {
|
||||
const { container } = render(<Separator type="double" />);
|
||||
const separator = screen.getByRole('separator');
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
|
||||
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders a double vertical separator laid out in a row', () => {
|
||||
const { container } = render(<Separator direction="vertical" type="double" />);
|
||||
const separator = screen.getByRole('separator');
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
|
||||
expect(separator).toHaveClass('flex-row');
|
||||
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forwards data-testid and merges a custom className', () => {
|
||||
render(<Separator className="my-custom-class" data-testid="sep" />);
|
||||
expect(screen.getByTestId('sep')).toHaveClass('my-custom-class');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { VStack } from '../VStack/VStack';
|
||||
@@ -30,21 +31,61 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
|
||||
thinned?: SeparatorThinnedType;
|
||||
}
|
||||
|
||||
const THINNED_TYPE_CLASSES: Record<
|
||||
'horizontal' | 'vertical',
|
||||
Record<Exclude<SeparatorThinnedType, 'none'>, string>
|
||||
> = {
|
||||
horizontal: {
|
||||
thinned: '[mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]',
|
||||
'thinned-start': '[mask-image:linear-gradient(to_right,transparent,black_20%,black)]',
|
||||
'thinned-end': '[mask-image:linear-gradient(to_right,black,black_80%,transparent)]',
|
||||
const separatorLineVariants = cva('border-0 border-solid', {
|
||||
variants: {
|
||||
color: {
|
||||
black: 'border-foreground',
|
||||
outline: 'border-[var(--rb-outline)]',
|
||||
'outline-light': 'border-[var(--rb-outline)]/40',
|
||||
},
|
||||
direction: {
|
||||
// 1px stroke for subtler appearance, plus size classes to fill the
|
||||
// container (the border provides the visual line).
|
||||
horizontal: 'border-t w-full h-0',
|
||||
vertical: 'border-l h-full w-0',
|
||||
},
|
||||
thinned: {
|
||||
thinned: '',
|
||||
'thinned-start': '',
|
||||
'thinned-end': '',
|
||||
none: '',
|
||||
},
|
||||
},
|
||||
vertical: {
|
||||
thinned: '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]',
|
||||
'thinned-start': '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black)]',
|
||||
'thinned-end': '[mask-image:linear-gradient(to_bottom,black,black_80%,transparent)]',
|
||||
},
|
||||
};
|
||||
// Thinned effect using CSS mask; the gradient axis depends on direction.
|
||||
compoundVariants: [
|
||||
{
|
||||
direction: 'horizontal',
|
||||
thinned: 'thinned',
|
||||
className: '[mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]',
|
||||
},
|
||||
{
|
||||
direction: 'horizontal',
|
||||
thinned: 'thinned-start',
|
||||
className: '[mask-image:linear-gradient(to_right,transparent,black_20%,black)]',
|
||||
},
|
||||
{
|
||||
direction: 'horizontal',
|
||||
thinned: 'thinned-end',
|
||||
className: '[mask-image:linear-gradient(to_right,black,black_80%,transparent)]',
|
||||
},
|
||||
{
|
||||
direction: 'vertical',
|
||||
thinned: 'thinned',
|
||||
className:
|
||||
'[mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]',
|
||||
},
|
||||
{
|
||||
direction: 'vertical',
|
||||
thinned: 'thinned-start',
|
||||
className: '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black)]',
|
||||
},
|
||||
{
|
||||
direction: 'vertical',
|
||||
thinned: 'thinned-end',
|
||||
className: '[mask-image:linear-gradient(to_bottom,black,black_80%,transparent)]',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* A separator component for dividing content sections.
|
||||
@@ -72,34 +113,12 @@ export const Separator = memo(function Separator({
|
||||
const defaultColor = type === 'single' ? 'outline-light' : 'outline';
|
||||
const finalColor = color ?? defaultColor;
|
||||
|
||||
// Color mapping to CSS custom properties
|
||||
const colorClasses = {
|
||||
black: 'border-foreground',
|
||||
outline: 'border-[var(--rb-outline)]',
|
||||
'outline-light': 'border-[var(--rb-outline)]/40',
|
||||
};
|
||||
|
||||
// Base line styles
|
||||
const lineBaseClasses = [
|
||||
'border-0',
|
||||
'border-solid',
|
||||
colorClasses[finalColor],
|
||||
// 1px stroke for subtler appearance
|
||||
direction === 'horizontal' ? 'border-t' : 'border-l',
|
||||
];
|
||||
|
||||
// Thinned effect using CSS mask
|
||||
const thinnedClasses = thinned !== 'none' ? [THINNED_TYPE_CLASSES[direction][thinned]] : [];
|
||||
|
||||
// Size classes - fill container by default
|
||||
const sizeClasses =
|
||||
direction === 'horizontal'
|
||||
? ['w-full', 'h-0'] // Full width, no height (border provides the visual)
|
||||
: ['h-full', 'w-0']; // Full height, no width (border provides the visual)
|
||||
|
||||
// Single line implementation
|
||||
if (type === 'single') {
|
||||
const lineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses, className);
|
||||
const lineClasses = cn(
|
||||
separatorLineVariants({ color: finalColor, direction, thinned }),
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,7 +131,7 @@ export const Separator = memo(function Separator({
|
||||
}
|
||||
|
||||
// Double line implementation
|
||||
const singleLineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses);
|
||||
const singleLineClasses = separatorLineVariants({ color: finalColor, direction, thinned });
|
||||
|
||||
const containerClasses = cn(direction === 'horizontal' ? 'w-full' : 'h-full', className);
|
||||
|
||||
|
||||
@@ -1,47 +1,10 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import type { SpacingScale } from '../../lib/scale';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Tailwind gap spacing scale values.
|
||||
*/
|
||||
type GapScale =
|
||||
| 0
|
||||
| 0.5
|
||||
| 1
|
||||
| 1.5
|
||||
| 2
|
||||
| 2.5
|
||||
| 3
|
||||
| 3.5
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 14
|
||||
| 16
|
||||
| 20
|
||||
| 24
|
||||
| 28
|
||||
| 32
|
||||
| 36
|
||||
| 40
|
||||
| 44
|
||||
| 48
|
||||
| 52
|
||||
| 56
|
||||
| 60
|
||||
| 64
|
||||
| 72
|
||||
| 80
|
||||
| 96;
|
||||
|
||||
/**
|
||||
* Flexbox direction values.
|
||||
*/
|
||||
@@ -77,13 +40,102 @@ export interface StackProps extends ClassNameProps, TestIdProps {
|
||||
/** Whether items should wrap to new lines/columns */
|
||||
wrap?: FlexWrap;
|
||||
/** Spacing between items using Tailwind's spacing scale */
|
||||
gap?: GapScale;
|
||||
gap?: SpacingScale;
|
||||
/** Whether to enable scrolling when content overflows */
|
||||
scrollable?: boolean;
|
||||
/** The HTML element to use for rendering */
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const stackVariants = cva('flex', {
|
||||
variants: {
|
||||
direction: {
|
||||
row: 'flex-row',
|
||||
col: 'flex-col',
|
||||
'row-reverse': 'flex-row-reverse',
|
||||
'col-reverse': 'flex-col-reverse',
|
||||
},
|
||||
justify: {
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
center: 'justify-center',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
},
|
||||
align: {
|
||||
start: 'items-start',
|
||||
end: 'items-end',
|
||||
center: 'items-center',
|
||||
baseline: 'items-baseline',
|
||||
stretch: 'items-stretch',
|
||||
},
|
||||
wrap: {
|
||||
nowrap: 'flex-nowrap',
|
||||
wrap: 'flex-wrap',
|
||||
'wrap-reverse': 'flex-wrap-reverse',
|
||||
},
|
||||
// Convert gap scale to Tailwind class using explicit mapping
|
||||
// This ensures all gap classes are detected by Tailwind's static analysis
|
||||
gap: {
|
||||
0: '',
|
||||
0.5: 'gap-0.5',
|
||||
1: 'gap-1',
|
||||
1.5: 'gap-1.5',
|
||||
2: 'gap-2',
|
||||
2.5: 'gap-2.5',
|
||||
3: 'gap-3',
|
||||
3.5: 'gap-3.5',
|
||||
4: 'gap-4',
|
||||
5: 'gap-5',
|
||||
6: 'gap-6',
|
||||
7: 'gap-7',
|
||||
8: 'gap-8',
|
||||
9: 'gap-9',
|
||||
10: 'gap-10',
|
||||
11: 'gap-11',
|
||||
12: 'gap-12',
|
||||
14: 'gap-14',
|
||||
16: 'gap-16',
|
||||
20: 'gap-20',
|
||||
24: 'gap-24',
|
||||
28: 'gap-28',
|
||||
32: 'gap-32',
|
||||
36: 'gap-36',
|
||||
40: 'gap-40',
|
||||
44: 'gap-44',
|
||||
48: 'gap-48',
|
||||
52: 'gap-52',
|
||||
56: 'gap-56',
|
||||
60: 'gap-60',
|
||||
64: 'gap-64',
|
||||
72: 'gap-72',
|
||||
80: 'gap-80',
|
||||
96: 'gap-96',
|
||||
},
|
||||
scrollable: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
// Scroll axis depends on both scrollable and direction:
|
||||
// row directions overflow on the x-axis, column directions on the y-axis.
|
||||
compoundVariants: [
|
||||
{ scrollable: true, direction: 'row', className: 'overflow-x-auto' },
|
||||
{ scrollable: true, direction: 'row-reverse', className: 'overflow-x-auto' },
|
||||
{ scrollable: true, direction: 'col', className: 'overflow-y-auto' },
|
||||
{ scrollable: true, direction: 'col-reverse', className: 'overflow-y-auto' },
|
||||
],
|
||||
defaultVariants: {
|
||||
direction: 'row',
|
||||
justify: 'start',
|
||||
align: 'start',
|
||||
wrap: 'nowrap',
|
||||
gap: 0,
|
||||
scrollable: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A flexible layout component that arranges children using CSS Flexbox.
|
||||
*
|
||||
@@ -127,96 +179,15 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const directionClasses = {
|
||||
row: 'flex-row',
|
||||
col: 'flex-col',
|
||||
'row-reverse': 'flex-row-reverse',
|
||||
'col-reverse': 'flex-col-reverse',
|
||||
} as const;
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
center: 'justify-center',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
} as const;
|
||||
|
||||
const alignClasses = {
|
||||
start: 'items-start',
|
||||
end: 'items-end',
|
||||
center: 'items-center',
|
||||
baseline: 'items-baseline',
|
||||
stretch: 'items-stretch',
|
||||
} as const;
|
||||
|
||||
const wrapClasses = {
|
||||
nowrap: 'flex-nowrap',
|
||||
wrap: 'flex-wrap',
|
||||
'wrap-reverse': 'flex-wrap-reverse',
|
||||
} as const;
|
||||
|
||||
// Convert gap scale to Tailwind class using explicit mapping
|
||||
// This ensures all gap classes are detected by Tailwind's static analysis
|
||||
const gapClasses = {
|
||||
0: '',
|
||||
0.5: 'gap-0.5',
|
||||
1: 'gap-1',
|
||||
1.5: 'gap-1.5',
|
||||
2: 'gap-2',
|
||||
2.5: 'gap-2.5',
|
||||
3: 'gap-3',
|
||||
3.5: 'gap-3.5',
|
||||
4: 'gap-4',
|
||||
5: 'gap-5',
|
||||
6: 'gap-6',
|
||||
7: 'gap-7',
|
||||
8: 'gap-8',
|
||||
9: 'gap-9',
|
||||
10: 'gap-10',
|
||||
11: 'gap-10',
|
||||
12: 'gap-12',
|
||||
14: 'gap-14',
|
||||
16: 'gap-16',
|
||||
20: 'gap-20',
|
||||
24: 'gap-24',
|
||||
28: 'gap-28',
|
||||
32: 'gap-32',
|
||||
36: 'gap-36',
|
||||
40: 'gap-40',
|
||||
44: 'gap-44',
|
||||
48: 'gap-48',
|
||||
52: 'gap-52',
|
||||
56: 'gap-56',
|
||||
60: 'gap-60',
|
||||
64: 'gap-64',
|
||||
72: 'gap-72',
|
||||
80: 'gap-80',
|
||||
96: 'gap-96',
|
||||
} as const;
|
||||
|
||||
const gapClass = gapClasses[gap];
|
||||
|
||||
const scrollClasses = scrollable
|
||||
? direction.includes('row')
|
||||
? 'overflow-x-auto'
|
||||
: 'overflow-y-auto'
|
||||
: '';
|
||||
|
||||
const stackClasses = cn(
|
||||
'flex',
|
||||
directionClasses[direction],
|
||||
justifyClasses[justify],
|
||||
alignClasses[align],
|
||||
wrapClasses[wrap],
|
||||
gapClass,
|
||||
scrollClasses,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<Component ref={castRef<HTMLElement>(ref)} className={stackClasses} data-testid={testId}>
|
||||
<Component
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
className={cn(
|
||||
stackVariants({ direction, justify, align, wrap, gap, scrollable }),
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,26 @@ export interface StubProps {
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
const stubVariants = cva('bg-[var(--rb-color-neutral-100)] dark:bg-[var(--rb-color-neutral-800)]', {
|
||||
variants: {
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
animate: {
|
||||
true: 'animate-shimmer',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
rounded: 'md',
|
||||
animate: true,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A skeleton placeholder component with a shimmering animation.
|
||||
* Used to indicate loading states while maintaining layout stability.
|
||||
@@ -38,22 +59,9 @@ export const Stub: React.FC<StubProps> = ({
|
||||
className,
|
||||
animate = true,
|
||||
}) => {
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-[var(--rb-color-neutral-100)] dark:bg-[var(--rb-color-neutral-800)]',
|
||||
animate && 'animate-shimmer',
|
||||
roundedClasses[rounded],
|
||||
className
|
||||
)}
|
||||
className={cn(stubVariants({ rounded, animate }), className)}
|
||||
data-testid="stub"
|
||||
style={{ width, height }}
|
||||
/>
|
||||
|
||||
@@ -46,4 +46,25 @@ describe('components/Switch', () => {
|
||||
await user.click(checkbox);
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies correct Tailwind classes to track and thumb when selected/unselected', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Switch color="primary" variant="solid">Switch</Switch>);
|
||||
|
||||
const track = container.querySelector('[aria-hidden="true"]');
|
||||
const thumb = track?.firstElementChild;
|
||||
|
||||
// Unselected state
|
||||
expect(track).toHaveClass('data-[selected=false]:bg-muted');
|
||||
expect(thumb).toHaveClass('data-[selected=false]:bg-[var(--rb-text)]');
|
||||
|
||||
// Toggle
|
||||
const checkbox = screen.getByRole('switch');
|
||||
await user.click(checkbox);
|
||||
|
||||
// Selected state
|
||||
expect(track).toHaveClass('data-[selected=true]:bg-primary');
|
||||
expect(thumb).toHaveClass('data-[selected=true]:bg-primary-text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { useRef } from 'react';
|
||||
import { useSwitch, useFocusRing, VisuallyHidden } from 'react-aria';
|
||||
import { useToggleState } from 'react-stately';
|
||||
@@ -41,6 +42,166 @@ export interface SwitchProps extends AriaSwitchProps {
|
||||
color?: SwitchColor;
|
||||
}
|
||||
|
||||
const trackVariants = cva(
|
||||
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'data-[selected=false]:bg-muted',
|
||||
outline: 'border-2 data-[selected=false]:bg-transparent data-[selected=false]:border-muted',
|
||||
ghost: 'data-[selected=false]:bg-transparent data-[selected=false]:border-transparent',
|
||||
},
|
||||
color: {
|
||||
primary: '',
|
||||
'primary-negative': '',
|
||||
secondary: '',
|
||||
'secondary-negative': '',
|
||||
tertiary: '',
|
||||
'tertiary-negative': '',
|
||||
error: '',
|
||||
'error-negative': '',
|
||||
neutral: '',
|
||||
'neutral-negative': '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// primary
|
||||
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary data-[selected=true]:border-primary' },
|
||||
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary data-[selected=true]:border-primary' },
|
||||
{ variant: 'ghost', color: 'primary', className: 'data-[selected=true]:bg-primary/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// primary-negative
|
||||
{ variant: 'solid', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text data-[selected=true]:border-primary-text' },
|
||||
{ variant: 'outline', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text data-[selected=true]:border-primary-text' },
|
||||
{ variant: 'ghost', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// secondary
|
||||
{ variant: 'solid', color: 'secondary', className: 'data-[selected=true]:bg-secondary data-[selected=true]:border-secondary' },
|
||||
{ variant: 'outline', color: 'secondary', className: 'data-[selected=true]:bg-secondary data-[selected=true]:border-secondary' },
|
||||
{ variant: 'ghost', color: 'secondary', className: 'data-[selected=true]:bg-secondary/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// secondary-negative
|
||||
{ variant: 'solid', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text data-[selected=true]:border-secondary-text' },
|
||||
{ variant: 'outline', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text data-[selected=true]:border-secondary-text' },
|
||||
{ variant: 'ghost', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// tertiary
|
||||
{ variant: 'solid', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary data-[selected=true]:border-tertiary' },
|
||||
{ variant: 'outline', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary data-[selected=true]:border-tertiary' },
|
||||
{ variant: 'ghost', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// tertiary-negative
|
||||
{ variant: 'solid', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text data-[selected=true]:border-tertiary-text' },
|
||||
{ variant: 'outline', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text data-[selected=true]:border-tertiary-text' },
|
||||
{ variant: 'ghost', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// error
|
||||
{ variant: 'solid', color: 'error', className: 'data-[selected=true]:bg-error data-[selected=true]:border-error' },
|
||||
{ variant: 'outline', color: 'error', className: 'data-[selected=true]:bg-error data-[selected=true]:border-error' },
|
||||
{ variant: 'ghost', color: 'error', className: 'data-[selected=true]:bg-error/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// error-negative
|
||||
{ variant: 'solid', color: 'error-negative', className: 'data-[selected=true]:bg-error-text data-[selected=true]:border-error-text' },
|
||||
{ variant: 'outline', color: 'error-negative', className: 'data-[selected=true]:bg-error-text data-[selected=true]:border-error-text' },
|
||||
{ variant: 'ghost', color: 'error-negative', className: 'data-[selected=true]:bg-error-text/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// neutral
|
||||
{ variant: 'solid', color: 'neutral', className: 'data-[selected=true]:bg-muted data-[selected=true]:border-muted' },
|
||||
{ variant: 'outline', color: 'neutral', className: 'data-[selected=true]:bg-muted data-[selected=true]:border-muted' },
|
||||
{ variant: 'ghost', color: 'neutral', className: 'data-[selected=true]:bg-muted/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// neutral-negative
|
||||
{ variant: 'solid', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)] data-[selected=true]:border-[var(--rb-text)]' },
|
||||
{ variant: 'outline', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)] data-[selected=true]:border-[var(--rb-text)]' },
|
||||
{ variant: 'ghost', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]/20 data-[selected=true]:border-transparent' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const thumbVariants = cva(
|
||||
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm data-[selected=false]:bg-[var(--rb-text)] data-[selected=false]:translate-x-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'ml-1 data-[selected=true]:translate-x-5',
|
||||
outline: 'ml-0.5 data-[selected=true]:translate-x-4.5',
|
||||
ghost: 'ml-1 data-[selected=true]:translate-x-5',
|
||||
},
|
||||
color: {
|
||||
primary: '',
|
||||
'primary-negative': '',
|
||||
secondary: '',
|
||||
'secondary-negative': '',
|
||||
tertiary: '',
|
||||
'tertiary-negative': '',
|
||||
error: '',
|
||||
'error-negative': '',
|
||||
neutral: '',
|
||||
'neutral-negative': '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// primary
|
||||
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
|
||||
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
|
||||
{ variant: 'ghost', color: 'primary', className: 'data-[selected=true]:bg-primary' },
|
||||
|
||||
// primary-negative
|
||||
{ variant: 'solid', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
|
||||
// secondary
|
||||
{ variant: 'solid', color: 'secondary', className: 'data-[selected=true]:bg-secondary-text' },
|
||||
{ variant: 'outline', color: 'secondary', className: 'data-[selected=true]:bg-secondary-text' },
|
||||
{ variant: 'ghost', color: 'secondary', className: 'data-[selected=true]:bg-secondary' },
|
||||
|
||||
// secondary-negative
|
||||
{ variant: 'solid', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
|
||||
// tertiary
|
||||
{ variant: 'solid', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary-text' },
|
||||
{ variant: 'outline', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary-text' },
|
||||
{ variant: 'ghost', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary' },
|
||||
|
||||
// tertiary-negative
|
||||
{ variant: 'solid', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
|
||||
// error
|
||||
{ variant: 'solid', color: 'error', className: 'data-[selected=true]:bg-error-text' },
|
||||
{ variant: 'outline', color: 'error', className: 'data-[selected=true]:bg-error-text' },
|
||||
{ variant: 'ghost', color: 'error', className: 'data-[selected=true]:bg-error' },
|
||||
|
||||
// error-negative
|
||||
{ variant: 'solid', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
|
||||
// neutral
|
||||
{ variant: 'solid', color: 'neutral', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
{ variant: 'outline', color: 'neutral', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
{ variant: 'ghost', color: 'neutral', className: 'data-[selected=true]:bg-muted' },
|
||||
|
||||
// neutral-negative
|
||||
{ variant: 'solid', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A highly accessible toggle switch component.
|
||||
*
|
||||
@@ -60,66 +221,6 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
const { inputProps } = useSwitch(props, state, ref);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
const getVariantStyles = (): React.CSSProperties => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
const colors: Record<string, { base: string; text: string }> = {
|
||||
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
|
||||
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
|
||||
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
|
||||
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
|
||||
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const colorValues = colors[baseColor];
|
||||
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
|
||||
|
||||
if (!state.isSelected) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return { backgroundColor };
|
||||
case 'outline':
|
||||
return { borderColor: backgroundColor };
|
||||
case 'ghost':
|
||||
return { backgroundColor: `${backgroundColor}33`, borderColor: 'transparent' }; // 33 is 20% opacity in hex
|
||||
default:
|
||||
return { backgroundColor };
|
||||
}
|
||||
};
|
||||
|
||||
const getThumbStyles = (): React.CSSProperties => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
const colors: Record<string, { text: string }> = {
|
||||
primary: { text: 'var(--rb-primary-text)' },
|
||||
secondary: { text: 'var(--rb-secondary-text)' },
|
||||
tertiary: { text: 'var(--rb-tertiary-text)' },
|
||||
error: { text: 'var(--rb-error-text)' },
|
||||
neutral: { text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const thumbColor = isNegative ? 'var(--rb-base)' : colors[baseColor].text;
|
||||
|
||||
if (!state.isSelected) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return { backgroundColor: thumbColor };
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return { backgroundColor: isNegative ? 'var(--rb-text)' : `var(--rb-${baseColor}-base)` };
|
||||
default:
|
||||
return { backgroundColor: thumbColor };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
@@ -134,31 +235,15 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
<div
|
||||
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 === 'outline' && 'bg-transparent border-2',
|
||||
trackVariants({ variant, color }),
|
||||
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
|
||||
props.isDisabled && 'grayscale-[0.5]'
|
||||
)}
|
||||
style={getVariantStyles()}
|
||||
data-selected={state.isSelected}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
style={getThumbStyles()}
|
||||
className={thumbVariants({ variant, color })}
|
||||
data-selected={state.isSelected}
|
||||
/>
|
||||
</div>
|
||||
{children && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { TableContext } from './contexts/TableContext/TableContext';
|
||||
@@ -26,6 +27,34 @@ export interface TableProps
|
||||
stickyHeader?: boolean;
|
||||
}
|
||||
|
||||
const tableVariants = cva(
|
||||
cn(
|
||||
'w-full border-collapse caption-bottom text-sm',
|
||||
// Mask borders at the edges to prevent doubling with container borders
|
||||
'[&_td:first-child]:border-l-0 [&_th:first-child]:border-l-0',
|
||||
'[&_td:last-child]:border-r-0 [&_th:last-child]:border-r-0',
|
||||
'[&_tr:first-child_td]:border-t-0 [&_tr:first-child_th]:border-t-0',
|
||||
'[&_tr:last-child_td]:border-b-0'
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
surface: '',
|
||||
outline: '',
|
||||
ghost: 'border-none',
|
||||
},
|
||||
stickyHeader: {
|
||||
true: 'relative',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'surface',
|
||||
stickyHeader: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const TableComponent = forwardRef<HTMLTableElement, TableProps>(function Table(
|
||||
{
|
||||
className,
|
||||
@@ -47,17 +76,7 @@ const TableComponent = forwardRef<HTMLTableElement, TableProps>(function Table(
|
||||
<table
|
||||
{...props}
|
||||
ref={castRef<HTMLTableElement>(ref)}
|
||||
className={cn(
|
||||
'w-full border-collapse caption-bottom text-sm',
|
||||
// Mask borders at the edges to prevent doubling with container borders
|
||||
'[&_td:first-child]:border-l-0 [&_th:first-child]:border-l-0',
|
||||
'[&_td:last-child]:border-r-0 [&_th:last-child]:border-r-0',
|
||||
'[&_tr:first-child_td]:border-t-0 [&_tr:first-child_th]:border-t-0',
|
||||
'[&_tr:last-child_td]:border-b-0',
|
||||
variant === 'ghost' && 'border-none',
|
||||
stickyHeader && 'relative',
|
||||
className
|
||||
)}
|
||||
className={cn(tableVariants({ variant, stickyHeader }), className)}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { paddingClasses, borderClasses } from '../constants';
|
||||
@@ -23,6 +24,18 @@ export interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElem
|
||||
border?: TableCellBorder;
|
||||
}
|
||||
|
||||
const cellVariants = cva('align-middle', {
|
||||
variants: {
|
||||
padding: paddingClasses,
|
||||
border: borderClasses,
|
||||
align: {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A standard table data cell (td) with density and alignment control.
|
||||
*
|
||||
@@ -65,12 +78,7 @@ export const TableCell = genericMemo(
|
||||
{...props}
|
||||
ref={castRef<HTMLTableCellElement>(ref)}
|
||||
className={cn(
|
||||
'align-middle',
|
||||
paddingClasses[finalPadding],
|
||||
borderClasses[resolvedBorder],
|
||||
align === 'left' && 'text-left',
|
||||
align === 'center' && 'text-center',
|
||||
align === 'right' && 'text-right',
|
||||
cellVariants({ padding: finalPadding, border: resolvedBorder, align }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
@@ -36,18 +37,49 @@ export interface TableContentProps extends React.HTMLAttributes<HTMLDivElement>
|
||||
color?: 'default' | 'muted' | 'error' | 'success';
|
||||
}
|
||||
|
||||
const gapClasses: Record<string, string> = {
|
||||
s: 'gap-1.5',
|
||||
m: 'gap-3',
|
||||
l: 'gap-4',
|
||||
};
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
default: 'text-[var(--rb-text)]',
|
||||
muted: 'text-[var(--rb-muted-text)]',
|
||||
error: 'text-[var(--rb-error-text)]',
|
||||
success: 'text-[var(--rb-success-text)]',
|
||||
};
|
||||
const contentVariants = cva('flex', {
|
||||
variants: {
|
||||
layout: {
|
||||
flex: 'flex-row items-center',
|
||||
stack: 'flex-col',
|
||||
},
|
||||
align: {
|
||||
start: '',
|
||||
center: '',
|
||||
end: '',
|
||||
between: '',
|
||||
},
|
||||
gap: {
|
||||
s: 'gap-1.5',
|
||||
m: 'gap-3',
|
||||
l: 'gap-4',
|
||||
},
|
||||
color: {
|
||||
default: 'text-[var(--rb-text)]',
|
||||
muted: 'text-[var(--rb-muted-text)]',
|
||||
error: 'text-[var(--rb-error-text)]',
|
||||
success: 'text-[var(--rb-success-text)]',
|
||||
},
|
||||
variant: {
|
||||
text: '',
|
||||
numeric: 'tabular-nums',
|
||||
icon: '',
|
||||
checkbox: 'w-min mx-auto',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Flex layout maps alignment to justify-*
|
||||
{ layout: 'flex', align: 'start', className: 'justify-start' },
|
||||
{ layout: 'flex', align: 'center', className: 'justify-center' },
|
||||
{ layout: 'flex', align: 'end', className: 'justify-end' },
|
||||
{ layout: 'flex', align: 'between', className: 'justify-between' },
|
||||
// Stack layout maps alignment to items-*
|
||||
{ layout: 'stack', align: 'start', className: 'items-start' },
|
||||
{ layout: 'stack', align: 'center', className: 'items-center' },
|
||||
{ layout: 'stack', align: 'end', className: 'items-end' },
|
||||
{ layout: 'stack', align: 'between', className: 'items-stretch' },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* A polymorphic layout primitive for content within table cells.
|
||||
@@ -85,20 +117,12 @@ export const TableContent = genericMemo(
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const alignClasses = {
|
||||
start: layout === 'flex' ? 'justify-start' : 'items-start',
|
||||
center: layout === 'flex' ? 'justify-center' : 'items-center',
|
||||
end: layout === 'flex' ? 'justify-end' : 'items-end',
|
||||
between: layout === 'flex' ? 'justify-between' : 'items-stretch',
|
||||
};
|
||||
|
||||
// Determine default alignment based on variant if not explicitly provided
|
||||
let defaultAlign: keyof typeof alignClasses = 'start';
|
||||
let defaultAlign: NonNullable<TableContentProps['align']> = 'start';
|
||||
if (variant === 'numeric') defaultAlign = 'end';
|
||||
else if (variant === 'icon' || variant === 'checkbox') defaultAlign = 'center';
|
||||
|
||||
const finalAlign = align ?? defaultAlign;
|
||||
const alignmentClass = alignClasses[finalAlign];
|
||||
|
||||
// Truncation logic
|
||||
const isTruncated = truncate || maxLines !== undefined;
|
||||
@@ -109,16 +133,10 @@ export const TableContent = genericMemo(
|
||||
{...props}
|
||||
ref={castRef<HTMLDivElement>(ref)}
|
||||
className={cn(
|
||||
'flex',
|
||||
layout === 'flex' ? 'flex-row items-center' : 'flex-col',
|
||||
alignmentClass,
|
||||
gap && gapClasses[gap],
|
||||
colorClasses[color],
|
||||
contentVariants({ layout, align: finalAlign, gap, color, variant }),
|
||||
// When truncating, we need to handle block layout and min-width
|
||||
isTruncated && (lineCount === 1 ? 'truncate block' : 'line-clamp'),
|
||||
isTruncated && 'min-w-0',
|
||||
variant === 'numeric' && 'tabular-nums',
|
||||
variant === 'checkbox' && 'w-min mx-auto',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { paddingClasses, borderClasses } from '../constants';
|
||||
@@ -23,6 +24,18 @@ export interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElem
|
||||
border?: TableCellBorder;
|
||||
}
|
||||
|
||||
const headVariants = cva('align-middle font-medium text-[var(--rb-muted-text)]', {
|
||||
variants: {
|
||||
padding: paddingClasses,
|
||||
border: borderClasses,
|
||||
align: {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A table header cell (th) with density and alignment control.
|
||||
*
|
||||
@@ -60,12 +73,7 @@ export const TableHead = genericMemo(
|
||||
{...props}
|
||||
ref={castRef<HTMLTableCellElement>(ref)}
|
||||
className={cn(
|
||||
'align-middle font-medium text-[var(--rb-muted-text)]',
|
||||
paddingClasses[finalPadding],
|
||||
borderClasses[resolvedBorder],
|
||||
align === 'left' && 'text-left',
|
||||
align === 'center' && 'text-center',
|
||||
align === 'right' && 'text-right',
|
||||
headVariants({ padding: finalPadding, border: resolvedBorder, align }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { useTableContext } from '../contexts/TableContext/TableContext';
|
||||
@@ -25,6 +26,28 @@ export interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement>
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const rowVariants = cva('transition-colors', {
|
||||
variants: {
|
||||
hoverable: {
|
||||
true: 'hover:bg-[var(--rb-muted-base)]/50',
|
||||
false: '',
|
||||
},
|
||||
striped: {
|
||||
true: 'even:bg-[var(--rb-muted-base)]/20',
|
||||
false: '',
|
||||
},
|
||||
selected: {
|
||||
true: 'bg-[var(--rb-muted-base)]',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
hoverable: false,
|
||||
striped: false,
|
||||
selected: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A table row component (tr) with hover, stripe, and border controls.
|
||||
*
|
||||
@@ -53,10 +76,7 @@ export const TableRow = genericMemo(
|
||||
{...props}
|
||||
ref={castRef<HTMLTableRowElement>(ref)}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
isHoverable && 'hover:bg-[var(--rb-muted-base)]/50',
|
||||
context.striped && 'even:bg-[var(--rb-muted-base)]/20',
|
||||
selected && 'bg-[var(--rb-muted-base)]',
|
||||
rowVariants({ hoverable: isHoverable, striped: context.striped, selected }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -131,13 +131,13 @@ describe('components/Text', () => {
|
||||
});
|
||||
|
||||
// TDD: fails before implementation (old code uses text-[14px], not text-rb-body-m)
|
||||
it('applies text-rb-{type}-{size} class', () => {
|
||||
it('applies text-body-m class', () => {
|
||||
render(
|
||||
<Text size="m" type="body">
|
||||
Content
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByText('Content')).toHaveClass('text-rb-body-m');
|
||||
expect(screen.getByText('Content')).toHaveClass('text-body-m');
|
||||
});
|
||||
|
||||
it('does not apply legacy px-based font-size classes', () => {
|
||||
|
||||
@@ -48,6 +48,6 @@ describe('components/Time', () => {
|
||||
|
||||
const timeElement = screen.getByText(/February 10, 2026/i);
|
||||
expect(timeElement).toHaveClass('font-bold');
|
||||
expect(timeElement).toHaveClass('text-[var(--rb-primary-text)]');
|
||||
expect(timeElement).toHaveClass('text-primary-text');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user