feat(ui): add product-safe primitive variants

This commit is contained in:
Saravana Achu Mac 2026-05-06 11:20:33 -07:00
parent 9d5a31d559
commit f37fd480fe
6 changed files with 115 additions and 26 deletions

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { clsx } from 'clsx';
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
variant?: 'success' | 'warning' | 'error' | 'danger' | 'info' | 'neutral' | 'accent';
size?: 'sm' | 'md';
dot?: boolean;
}
@ -14,17 +14,23 @@ const variantStyles: Record<string, string> = {
'bg-[var(--bl-warning-muted,var(--bl-surface-muted))] text-[var(--bl-warning)] border-[var(--bl-warning-border,var(--bl-warning))]',
error:
'bg-[var(--bl-danger-muted,var(--bl-surface-muted))] text-[var(--bl-danger)] border-[var(--bl-danger-border,var(--bl-danger))]',
danger:
'bg-[var(--bl-danger-muted,var(--bl-surface-muted))] text-[var(--bl-danger)] border-[var(--bl-danger-border,var(--bl-danger))]',
info: 'bg-[var(--bl-info-muted,var(--bl-surface-muted))] text-[var(--bl-info,var(--bl-accent))] border-[var(--bl-info-border,var(--bl-info,var(--bl-accent)))]',
neutral:
'bg-[var(--bl-surface-muted,#252540)] text-[var(--bl-text-secondary,#a0a0b0)] border-[var(--bl-border,#2a2a4a)]',
accent:
'bg-[var(--bl-accent-muted,var(--bl-surface-muted))] text-[var(--bl-text-primary)] border-[var(--bl-accent)]',
};
const dotColors: Record<string, string> = {
success: 'bg-[var(--bl-success)]',
error: 'bg-[var(--bl-danger)]',
danger: 'bg-[var(--bl-danger)]',
warning: 'bg-[var(--bl-warning)]',
info: 'bg-[var(--bl-info,var(--bl-accent))]',
neutral: 'bg-[var(--bl-text-tertiary,var(--bl-text-secondary))]',
accent: 'bg-[var(--bl-accent)]',
};
export function Badge({

View File

@ -4,7 +4,7 @@ import { clsx } from 'clsx';
import { Loader2 } from 'lucide-react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline' | 'subtle' | 'link';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
asChild?: boolean;
@ -21,14 +21,19 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
'inline-flex items-center justify-center font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50';
const variants: Record<string, string> = {
primary: 'bg-[var(--bl-accent,#5A8CFF)] text-white hover:opacity-90',
primary:
'bg-[var(--bl-accent,#5A8CFF)] text-[var(--bl-accent-foreground,var(--bl-bg-canvas,#0b0f17))] hover:opacity-90',
secondary:
'bg-[var(--bl-surface-card,#1a1a2e)] text-[var(--bl-text-primary,#fff)] border border-[var(--bl-border,#2a2a4a)] hover:bg-[var(--bl-surface-muted,#252540)]',
ghost:
'text-[var(--bl-text-secondary,#a0a0b0)] hover:bg-[var(--bl-surface-muted,#252540)] hover:text-[var(--bl-text-primary,#fff)]',
destructive: 'bg-red-600 text-white hover:bg-red-700',
destructive:
'bg-[var(--bl-danger)] text-[var(--bl-danger-foreground,var(--bl-bg-canvas,#0b0f17))] hover:opacity-90',
outline:
'border border-[var(--bl-border,#2a2a4a)] text-[var(--bl-text-primary,#fff)] hover:bg-[var(--bl-surface-muted,#252540)]',
subtle:
'bg-[var(--bl-surface-muted,#252540)] text-[var(--bl-text-primary,#fff)] hover:bg-[var(--bl-surface-card,#1a1a2e)]',
link: 'h-auto rounded-none p-0 text-[var(--bl-accent,#5A8CFF)] underline-offset-4 hover:underline',
};
const sizes: Record<string, string> = {

View File

@ -3,6 +3,7 @@ import { clsx } from 'clsx';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg';
variant?: 'default' | 'muted' | 'elevated' | 'outline';
hover?: boolean;
}
@ -13,12 +14,26 @@ const paddings: Record<string, string> = {
lg: 'p-6',
};
export function Card({ padding = 'md', hover, className, children, ...props }: CardProps) {
export function Card({
padding = 'md',
variant = 'default',
hover,
className,
children,
...props
}: CardProps) {
const variants: Record<NonNullable<CardProps['variant']>, string> = {
default: 'bg-[var(--bl-surface-card,#1a1a2e)] border-[var(--bl-border,#2a2a4a)]',
muted: 'bg-[var(--bl-surface-muted,#252540)] border-[var(--bl-border,#2a2a4a)]',
elevated: 'bg-[var(--bl-bg-elevated,#12151c)] border-[var(--bl-border,#2a2a4a)] shadow-sm',
outline: 'bg-transparent border-[var(--bl-border,#2a2a4a)]',
};
return (
<div
className={clsx(
'rounded-xl border',
'bg-[var(--bl-surface-card,#1a1a2e)] border-[var(--bl-border,#2a2a4a)]',
variants[variant],
hover && 'transition-colors hover:border-[var(--bl-accent,#5A8CFF)]/40',
paddings[padding],
className

View File

@ -5,11 +5,27 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
label?: string;
error?: string;
hint?: string;
controlSize?: 'sm' | 'md' | 'lg';
variant?: 'surface' | 'muted' | 'ghost';
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, hint, className, id, ...props }, ref) => {
const inputId = id ?? (label ? `input-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined);
(
{ label, error, hint, controlSize = 'md', variant = 'surface', className, id, ...props },
ref
) => {
const generatedId = React.useId();
const inputId = id ?? (label || error || hint ? `input-${generatedId}` : undefined);
const sizes: Record<NonNullable<InputProps['controlSize']>, string> = {
sm: 'h-8 px-2.5 text-xs',
md: 'h-10 px-3 text-sm',
lg: 'h-12 px-4 text-base',
};
const variants: Record<NonNullable<InputProps['variant']>, string> = {
surface: 'bg-[var(--bl-surface-card,#1a1a2e)]',
muted: 'bg-[var(--bl-surface-muted,#252540)]',
ghost: 'bg-transparent',
};
return (
<div className="space-y-1">
@ -25,11 +41,13 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={ref}
id={inputId}
className={clsx(
'w-full rounded-md border px-3 py-2 text-sm outline-none transition-colors',
'bg-[var(--bl-surface-card,#1a1a2e)] text-[var(--bl-text-primary,#fff)]',
'w-full rounded-md border outline-none transition-colors',
variants[variant],
sizes[controlSize],
'text-[var(--bl-text-primary,#fff)]',
'placeholder:text-[var(--bl-text-tertiary,#555)]',
'focus:ring-2 focus:ring-[var(--bl-accent,#5A8CFF)] focus:ring-offset-0',
error ? 'border-red-500' : 'border-[var(--bl-border,#2a2a4a)]',
error ? 'border-[var(--bl-danger)]' : 'border-[var(--bl-border,#2a2a4a)]',
className
)}
aria-invalid={error ? 'true' : undefined}
@ -37,7 +55,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="text-xs text-red-400" role="alert">
<p id={`${inputId}-error`} className="text-xs text-[var(--bl-danger)]" role="alert">
{error}
</p>
)}

View File

@ -7,12 +7,37 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
error?: string;
options: Array<{ value: string; label: string; disabled?: boolean }>;
placeholder?: string;
controlSize?: 'sm' | 'md' | 'lg';
variant?: 'surface' | 'muted' | 'ghost';
}
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ label, error, options, placeholder, className, id, ...props }, ref) => {
const selectId =
id ?? (label ? `select-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined);
(
{
label,
error,
options,
placeholder,
controlSize = 'md',
variant = 'surface',
className,
id,
...props
},
ref
) => {
const generatedId = React.useId();
const selectId = id ?? (label || error ? `select-${generatedId}` : undefined);
const sizes: Record<NonNullable<SelectProps['controlSize']>, string> = {
sm: 'h-8 px-2.5 pr-8 text-xs',
md: 'h-10 px-3 pr-8 text-sm',
lg: 'h-12 px-4 pr-9 text-base',
};
const variants: Record<NonNullable<SelectProps['variant']>, string> = {
surface: 'bg-[var(--bl-surface-card,#1a1a2e)]',
muted: 'bg-[var(--bl-surface-muted,#252540)]',
ghost: 'bg-transparent',
};
return (
<div className="space-y-1">
@ -29,13 +54,16 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
ref={ref}
id={selectId}
className={clsx(
'w-full appearance-none rounded-md border px-3 py-2 pr-8 text-sm outline-none transition-colors',
'bg-[var(--bl-surface-card,#1a1a2e)] text-[var(--bl-text-primary,#fff)]',
'w-full appearance-none rounded-md border outline-none transition-colors',
variants[variant],
sizes[controlSize],
'text-[var(--bl-text-primary,#fff)]',
'focus:ring-2 focus:ring-[var(--bl-accent,#5A8CFF)] focus:ring-offset-0',
error ? 'border-red-500' : 'border-[var(--bl-border,#2a2a4a)]',
error ? 'border-[var(--bl-danger)]' : 'border-[var(--bl-border,#2a2a4a)]',
className
)}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${selectId}-error` : undefined}
{...props}
>
{placeholder && (
@ -55,7 +83,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
/>
</div>
{error && (
<p className="text-xs text-red-400" role="alert">
<p id={`${selectId}-error`} className="text-xs text-[var(--bl-danger)]" role="alert">
{error}
</p>
)}

View File

@ -5,12 +5,27 @@ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextArea
label?: string;
error?: string;
hint?: string;
controlSize?: 'sm' | 'md' | 'lg';
variant?: 'surface' | 'muted' | 'ghost';
}
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, hint, className, id, ...props }, ref) => {
const textareaId =
id ?? (label ? `textarea-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined);
(
{ label, error, hint, controlSize = 'md', variant = 'surface', className, id, ...props },
ref
) => {
const generatedId = React.useId();
const textareaId = id ?? (label || error || hint ? `textarea-${generatedId}` : undefined);
const sizes: Record<NonNullable<TextareaProps['controlSize']>, string> = {
sm: 'min-h-20 px-2.5 py-2 text-xs',
md: 'min-h-24 px-3 py-2 text-sm',
lg: 'min-h-32 px-4 py-3 text-base',
};
const variants: Record<NonNullable<TextareaProps['variant']>, string> = {
surface: 'bg-[var(--bl-surface-card,#1a1a2e)]',
muted: 'bg-[var(--bl-surface-muted,#252540)]',
ghost: 'bg-transparent',
};
return (
<div className="space-y-1">
@ -26,11 +41,13 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
ref={ref}
id={textareaId}
className={clsx(
'w-full rounded-md border px-3 py-2 text-sm outline-none transition-colors resize-y',
'bg-[var(--bl-surface-card,#1a1a2e)] text-[var(--bl-text-primary,#fff)]',
'w-full resize-y rounded-md border outline-none transition-colors',
variants[variant],
sizes[controlSize],
'text-[var(--bl-text-primary,#fff)]',
'placeholder:text-[var(--bl-text-tertiary,#555)]',
'focus:ring-2 focus:ring-[var(--bl-accent,#5A8CFF)] focus:ring-offset-0',
error ? 'border-red-500' : 'border-[var(--bl-border,#2a2a4a)]',
error ? 'border-[var(--bl-danger)]' : 'border-[var(--bl-border,#2a2a4a)]',
className
)}
aria-invalid={error ? 'true' : undefined}
@ -38,7 +55,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
{...props}
/>
{error && (
<p id={`${textareaId}-error`} className="text-xs text-red-400" role="alert">
<p id={`${textareaId}-error`} className="text-xs text-[var(--bl-danger)]" role="alert">
{error}
</p>
)}