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:
parent
6b6f147de7
commit
5da71f3735
@ -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",
|
||||
|
||||
64
packages/ui/src/components/Card.tsx
Normal file
64
packages/ui/src/components/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
packages/ui/src/components/Input.tsx
Normal file
54
packages/ui/src/components/Input.tsx
Normal 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';
|
||||
25
packages/ui/src/components/Label.tsx
Normal file
25
packages/ui/src/components/Label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
packages/ui/src/components/Select.tsx
Normal file
67
packages/ui/src/components/Select.tsx
Normal 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';
|
||||
28
packages/ui/src/components/Separator.tsx
Normal file
28
packages/ui/src/components/Separator.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
55
packages/ui/src/components/Textarea.tsx
Normal file
55
packages/ui/src/components/Textarea.tsx
Normal 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';
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user