fix, feat, doc: massive build system refactorion, bug fixes and apex documentation (#19)

This commit is contained in:
deytenit authored and GitHub committed 2026-07-12 01:57:40 +03:00
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');
});
});
+106 -50
View File
@@ -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
+43 -52
View File
@@ -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}
+38 -28
View File
@@ -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"
+28 -19
View File
@@ -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);
+100 -129
View File
@@ -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>
);
+22 -14
View File
@@ -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');
});
});
+165 -80
View File
@@ -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 && (
+30 -11
View File
@@ -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');
});
});
+1
View File
@@ -1,2 +1,3 @@
export * from './scale';
export * from './theme';
export * from './typography';
+1
View File
@@ -0,0 +1 @@
export * from './scale';
+42
View File
@@ -0,0 +1,42 @@
/**
* Tailwind spacing scale steps.
*
* Shared across layout components (e.g. `Stack` gaps, `Container` padding) so
* the design system exposes a single, consistent set of spacing values. Values
* mirror Tailwind's default spacing scale.
*/
export type SpacingScale =
| 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;
@@ -2,20 +2,20 @@ import { getTypographyClassNames } from './getTypographyClassNames';
describe('lib/typography/getTypographyClassNames', () => {
describe('type + size → @theme token class', () => {
it('returns text-rb-body-m by default', () => {
expect(getTypographyClassNames()).toContain('text-rb-body-m');
it('returns text-body-m by default', () => {
expect(getTypographyClassNames()).toContain('text-body-m');
});
it('returns correct class for each type/size combination', () => {
expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain(
'text-rb-display-l'
'text-display-l'
);
expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain(
'text-rb-headline-m'
'text-headline-m'
);
expect(getTypographyClassNames({ type: 'title', size: 's' })).toContain('text-rb-title-s');
expect(getTypographyClassNames({ type: 'body', size: 'l' })).toContain('text-rb-body-l');
expect(getTypographyClassNames({ type: 'label', size: 'm' })).toContain('text-rb-label-m');
expect(getTypographyClassNames({ type: 'title', size: 's' })).toContain('text-title-s');
expect(getTypographyClassNames({ type: 'body', size: 'l' })).toContain('text-body-l');
expect(getTypographyClassNames({ type: 'label', size: 'm' })).toContain('text-label-m');
});
});
@@ -53,15 +53,15 @@ describe('lib/typography/getTypographyClassNames', () => {
describe('color', () => {
it('applies default color class', () => {
expect(getTypographyClassNames()).toContain('text-[var(--rb-text)]');
expect(getTypographyClassNames()).toContain('text-text');
});
it('applies each color variant', () => {
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-[var(--rb-muted-text)]');
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-muted-text');
expect(getTypographyClassNames({ color: 'primary' })).toContain(
'text-[var(--rb-primary-text)]'
'text-primary-text'
);
expect(getTypographyClassNames({ color: 'error' })).toContain('text-[var(--rb-error-text)]');
expect(getTypographyClassNames({ color: 'error' })).toContain('text-error-text');
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
});
});
@@ -1,3 +1,5 @@
import { cva } from 'class-variance-authority';
/**
* Typography type roles.
* Determines font family and base visual treatment.
@@ -65,35 +67,92 @@ export interface TypographyOptions {
maxLines?: number;
}
const fontFamilyClasses: Record<TextType, string> = {
display: 'font-serif',
headline: 'font-serif',
title: 'font-sans',
body: 'font-sans',
label: 'font-sans',
};
const typographyVariants = cva('', {
variants: {
type: {
display: 'font-serif',
headline: 'font-serif',
title: 'font-sans',
body: 'font-sans',
label: 'font-sans font-medium',
},
size: {
s: '',
m: '',
l: '',
},
color: {
default: 'text-text',
primary: 'text-primary-text',
secondary: 'text-secondary-text',
tertiary: 'text-tertiary-text',
error: 'text-error-text',
muted: 'text-muted-text',
inherit: 'text-inherit',
},
bold: {
true: 'font-bold',
false: '',
},
italic: {
true: 'italic',
false: '',
},
underline: {
true: 'underline',
false: '',
},
strike: {
true: 'line-through',
false: '',
},
align: {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
},
wrap: {
nowrap: 'whitespace-nowrap',
balance: 'text-balance',
pretty: 'text-pretty',
},
overflow: {
ellipsis: 'truncate',
clip: 'overflow-clip',
},
},
compoundVariants: [
{ type: 'display', size: 's', className: 'text-display-s' },
{ type: 'display', size: 'm', className: 'text-display-m' },
{ type: 'display', size: 'l', className: 'text-display-l' },
{ type: 'headline', size: 's', className: 'text-headline-s' },
{ type: 'headline', size: 'm', className: 'text-headline-m' },
{ type: 'headline', size: 'l', className: 'text-headline-l' },
{ type: 'title', size: 's', className: 'text-title-s' },
{ type: 'title', size: 'm', className: 'text-title-m' },
{ type: 'title', size: 'l', className: 'text-title-l' },
{ type: 'body', size: 's', className: 'text-body-s' },
{ type: 'body', size: 'm', className: 'text-body-m' },
{ type: 'body', size: 'l', className: 'text-body-l' },
{ type: 'label', size: 's', className: 'text-label-s' },
{ type: 'label', size: 'm', className: 'text-label-m' },
{ type: 'label', size: 'l', className: 'text-label-l' },
],
defaultVariants: {
type: 'body',
size: 'm',
color: 'default',
},
});
const colorClasses: Record<TextColor, string> = {
default: 'text-[var(--rb-text)]',
primary: 'text-[var(--rb-primary-text)]',
secondary: 'text-[var(--rb-secondary-text)]',
tertiary: 'text-[var(--rb-tertiary-text)]',
error: 'text-[var(--rb-error-text)]',
muted: 'text-[var(--rb-muted-text)]',
inherit: 'text-inherit',
};
const alignClasses: Record<TextAlign, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
const wrapClasses: Record<TextWrap, string> = {
nowrap: 'whitespace-nowrap',
balance: 'text-balance',
pretty: 'text-pretty',
const lineClampMap: Record<number, string> = {
1: 'line-clamp-1',
2: 'line-clamp-2',
3: 'line-clamp-3',
4: 'line-clamp-4',
5: 'line-clamp-5',
6: 'line-clamp-6',
};
/**
@@ -102,7 +161,7 @@ const wrapClasses: Record<TextWrap, string> = {
*
* The returned string is safe to pass directly to `cn()` alongside other
* classes — it contains no internal conflicts. Tailwind-merge is configured
* (via the stl `cn` utility) to treat `text-rb-{type}-{size}` as a
* (via the stl `cn` utility) to treat `text-{type}-{size}` as a
* font-size group so it never conflicts with text-color classes.
*
* @example
@@ -133,26 +192,21 @@ export function getTypographyClassNames(options: TypographyOptions = {}): string
let truncationClass = '';
if (maxLines) {
truncationClass = `line-clamp-${maxLines}`;
} else if (overflow === 'ellipsis') {
truncationClass = 'truncate';
} else if (overflow === 'clip') {
truncationClass = 'overflow-clip';
truncationClass = lineClampMap[maxLines] ?? `line-clamp-[${maxLines}]`;
}
return [
`text-rb-${type}-${size}`,
fontFamilyClasses[type],
type === 'label' && 'font-medium',
colorClasses[color],
bold && 'font-bold',
italic && 'italic',
underline && 'underline',
strike && 'line-through',
align && alignClasses[align],
wrap && wrapClasses[wrap],
truncationClass,
]
.filter(Boolean)
.join(' ');
const classNames = typographyVariants({
type,
size,
color,
bold,
italic,
underline,
strike,
align,
wrap,
overflow: maxLines ? undefined : overflow,
});
return [classNames, truncationClass].filter(Boolean).join(' ');
}
+73 -4
View File
@@ -76,10 +76,7 @@
@import 'tailwindcss';
@import 'tw-animate-css';
/* Safelist: text-rb-{type}-{size} utilities are assembled via template literals
in getTypographyClassNames and cannot be detected by Tailwind's static scanner.
@source inline forces inclusion regardless of source scanning. */
@source inline("text-rb-display-l text-rb-display-m text-rb-display-s text-rb-headline-l text-rb-headline-m text-rb-headline-s text-rb-title-l text-rb-title-m text-rb-title-s text-rb-body-l text-rb-body-m text-rb-body-s text-rb-label-l text-rb-label-m text-rb-label-s");
@custom-variant dark (&:where(.dark, .dark *));
@@ -257,6 +254,7 @@
--rb-input: var(--rb-color-neutral-800);
--rb-text: var(--rb-color-neutral-950);
--rb-ring: var(--rb-color-cyan-700);
--rb-radius-md: 0.375rem;
/* Container */
--rb-container-base: var(--rb-color-neutral-50);
@@ -419,3 +417,74 @@
color: var(--shiki-dark) !important;
}
}
@theme {
/* Colors */
--color-primary: var(--rb-primary-base);
--color-primary-text: var(--rb-primary-text);
--color-secondary: var(--rb-secondary-base);
--color-secondary-text: var(--rb-secondary-text);
--color-tertiary: var(--rb-tertiary-base);
--color-tertiary-text: var(--rb-tertiary-text);
--color-error: var(--rb-error-base);
--color-error-text: var(--rb-error-text);
--color-muted: var(--rb-muted-base);
--color-muted-text: var(--rb-muted-text);
--color-base: var(--rb-base);
--color-outline: var(--rb-outline);
--color-text: var(--rb-text);
/* Typography */
--text-display-l: var(--text-rb-display-l);
--text-display-l--line-height: var(--text-rb-display-l--line-height);
--text-display-l--letter-spacing: var(--text-rb-display-l--letter-spacing);
--text-display-m: var(--text-rb-display-m);
--text-display-m--line-height: var(--text-rb-display-m--line-height);
--text-display-m--letter-spacing: var(--text-rb-display-m--letter-spacing);
--text-display-s: var(--text-rb-display-s);
--text-display-s--line-height: var(--text-rb-display-s--line-height);
--text-display-s--letter-spacing: var(--text-rb-display-s--letter-spacing);
--text-headline-l: var(--text-rb-headline-l);
--text-headline-l--line-height: var(--text-rb-headline-l--line-height);
--text-headline-l--letter-spacing: var(--text-rb-headline-l--letter-spacing);
--text-headline-m: var(--text-rb-headline-m);
--text-headline-m--line-height: var(--text-rb-headline-m--line-height);
--text-headline-m--letter-spacing: var(--text-rb-headline-m--letter-spacing);
--text-headline-s: var(--text-rb-headline-s);
--text-headline-s--line-height: var(--text-rb-headline-s--line-height);
--text-headline-s--letter-spacing: var(--text-rb-headline-s--letter-spacing);
--text-title-l: var(--text-rb-title-l);
--text-title-l--line-height: var(--text-rb-title-l--line-height);
--text-title-l--letter-spacing: var(--text-rb-title-l--letter-spacing);
--text-title-m: var(--text-rb-title-m);
--text-title-m--line-height: var(--text-rb-title-m--line-height);
--text-title-m--letter-spacing: var(--text-rb-title-m--letter-spacing);
--text-title-s: var(--text-rb-title-s);
--text-title-s--line-height: var(--text-rb-title-s--line-height);
--text-title-s--letter-spacing: var(--text-rb-title-s--letter-spacing);
--text-body-l: var(--text-rb-body-l);
--text-body-l--line-height: var(--text-rb-body-l--line-height);
--text-body-l--letter-spacing: var(--text-rb-body-l--letter-spacing);
--text-body-m: var(--text-rb-body-m);
--text-body-m--line-height: var(--text-rb-body-m--line-height);
--text-body-m--letter-spacing: var(--text-rb-body-m--letter-spacing);
--text-body-s: var(--text-rb-body-s);
--text-body-s--line-height: var(--text-rb-body-s--line-height);
--text-body-s--letter-spacing: var(--text-rb-body-s--letter-spacing);
--text-label-l: var(--text-rb-label-l);
--text-label-l--line-height: var(--text-rb-label-l--line-height);
--text-label-l--letter-spacing: var(--text-rb-label-l--letter-spacing);
--text-label-m: var(--text-rb-label-m);
--text-label-m--line-height: var(--text-rb-label-m--line-height);
--text-label-m--letter-spacing: var(--text-rb-label-m--letter-spacing);
--text-label-s: var(--text-rb-label-s);
--text-label-s--line-height: var(--text-rb-label-s--line-height);
--text-label-s--letter-spacing: var(--text-rb-label-s--letter-spacing);
/* Utilities */
--radius-md: var(--rb-radius-md);
}