feat(ui): add Input, Textarea, Card, Label, Select, Separator components to @bytelyst/ui

- Input: with label, error, hint, a11y attributes
- Textarea: resizable with label, error, hint
- Card: with CardHeader, CardTitle, CardDescription sub-components
- Label: with required indicator
- Select: native select with chevron icon, label, error
- Separator: horizontal/vertical with ARIA roles
- All components use --bl-* design token CSS variables
- 12 total components in @bytelyst/ui
This commit is contained in:
saravanakumardb1 2026-03-28 00:33:38 -07:00
parent 6b6f147de7
commit 5da71f3735
8 changed files with 306 additions and 1 deletions

View File

@ -9,7 +9,13 @@
"./modal": "./src/components/Modal.tsx",
"./confirm-dialog": "./src/components/ConfirmDialog.tsx",
"./badge": "./src/components/Badge.tsx",
"./empty-state": "./src/components/EmptyState.tsx"
"./empty-state": "./src/components/EmptyState.tsx",
"./input": "./src/components/Input.tsx",
"./textarea": "./src/components/Textarea.tsx",
"./card": "./src/components/Card.tsx",
"./label": "./src/components/Label.tsx",
"./select": "./src/components/Select.tsx",
"./separator": "./src/components/Separator.tsx"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",

View File

@ -0,0 +1,64 @@
import * as React from 'react';
import { clsx } from 'clsx';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg';
hover?: boolean;
}
const paddings: Record<string, string> = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export function Card({ padding = 'md', hover, className, children, ...props }: CardProps) {
return (
<div
className={clsx(
'rounded-xl border',
'bg-[var(--bl-surface-card,#1a1a2e)] border-[var(--bl-border,#2a2a4a)]',
hover && 'transition-colors hover:border-[var(--bl-accent,#5A8CFF)]/40',
paddings[padding],
className
)}
{...props}
>
{children}
</div>
);
}
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
export function CardHeader({ className, children, ...props }: CardHeaderProps) {
return (
<div className={clsx('mb-3', className)} {...props}>
{children}
</div>
);
}
export type CardTitleProps = React.ComponentPropsWithoutRef<'h3'>;
export function CardTitle({ className, children, ...props }: CardTitleProps) {
return (
<h3
className={clsx('text-lg font-semibold text-[var(--bl-text-primary,#fff)]', className)}
{...props}
>
{children}
</h3>
);
}
export type CardDescriptionProps = React.ComponentPropsWithoutRef<'p'>;
export function CardDescription({ className, children, ...props }: CardDescriptionProps) {
return (
<p className={clsx('text-sm text-[var(--bl-text-secondary,#a0a0b0)]', className)} {...props}>
{children}
</p>
);
}

View File

@ -0,0 +1,54 @@
import * as React from 'react';
import { clsx } from 'clsx';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
}
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);
return (
<div className="space-y-1">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-[var(--bl-text-secondary,#a0a0b0)]"
>
{label}
</label>
)}
<input
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)]',
'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)]',
className
)}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{hint && !error && (
<p id={`${inputId}-hint`} className="text-xs text-[var(--bl-text-tertiary,#555)]">
{hint}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import { clsx } from 'clsx';
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
required?: boolean;
}
export function Label({ required, className, children, ...props }: LabelProps) {
return (
<label
className={clsx(
'block text-sm font-medium text-[var(--bl-text-secondary,#a0a0b0)]',
className
)}
{...props}
>
{children}
{required && (
<span className="ml-0.5 text-red-400" aria-hidden="true">
*
</span>
)}
</label>
);
}

View File

@ -0,0 +1,67 @@
import * as React from 'react';
import { clsx } from 'clsx';
import { ChevronDown } from 'lucide-react';
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: Array<{ value: string; label: string; disabled?: boolean }>;
placeholder?: string;
}
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);
return (
<div className="space-y-1">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-[var(--bl-text-secondary,#a0a0b0)]"
>
{label}
</label>
)}
<div className="relative">
<select
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)]',
'focus:ring-2 focus:ring-[var(--bl-accent,#5A8CFF)] focus:ring-offset-0',
error ? 'border-red-500' : 'border-[var(--bl-border,#2a2a4a)]',
className
)}
aria-invalid={error ? 'true' : undefined}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map(opt => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--bl-text-tertiary,#555)]"
aria-hidden="true"
/>
</div>
{error && (
<p className="text-xs text-red-400" role="alert">
{error}
</p>
)}
</div>
);
}
);
Select.displayName = 'Select';

View File

@ -0,0 +1,28 @@
import * as React from 'react';
import { clsx } from 'clsx';
export interface SeparatorProps extends React.ComponentPropsWithoutRef<'hr'> {
orientation?: 'horizontal' | 'vertical';
}
export function Separator({ orientation = 'horizontal', className, ...props }: SeparatorProps) {
if (orientation === 'vertical') {
return (
<div
role="separator"
aria-orientation="vertical"
className={clsx('h-full w-px bg-[var(--bl-border,#2a2a4a)]', className)}
{...(props as React.HTMLAttributes<HTMLDivElement>)}
/>
);
}
return (
<hr
role="separator"
aria-orientation="horizontal"
className={clsx('border-t border-[var(--bl-border,#2a2a4a)]', className)}
{...props}
/>
);
}

View File

@ -0,0 +1,55 @@
import * as React from 'react';
import { clsx } from 'clsx';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
}
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);
return (
<div className="space-y-1">
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-[var(--bl-text-secondary,#a0a0b0)]"
>
{label}
</label>
)}
<textarea
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)]',
'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)]',
className
)}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${textareaId}-error` : hint ? `${textareaId}-hint` : undefined}
{...props}
/>
{error && (
<p id={`${textareaId}-error`} className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{hint && !error && (
<p id={`${textareaId}-hint`} className="text-xs text-[var(--bl-text-tertiary,#555)]">
{hint}
</p>
)}
</div>
);
}
);
Textarea.displayName = 'Textarea';

View File

@ -11,3 +11,9 @@ export { Modal, type ModalProps } from './components/Modal.js';
export { ConfirmDialog, type ConfirmDialogProps } from './components/ConfirmDialog.js';
export { Badge, type BadgeProps } from './components/Badge.js';
export { EmptyState, type EmptyStateProps } from './components/EmptyState.js';
export { Input, type InputProps } from './components/Input.js';
export { Textarea, type TextareaProps } from './components/Textarea.js';
export { Card, CardHeader, CardTitle, CardDescription, type CardProps } from './components/Card.js';
export { Label, type LabelProps } from './components/Label.js';
export { Select, type SelectProps } from './components/Select.js';
export { Separator, type SeparatorProps } from './components/Separator.js';