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",
|
"./modal": "./src/components/Modal.tsx",
|
||||||
"./confirm-dialog": "./src/components/ConfirmDialog.tsx",
|
"./confirm-dialog": "./src/components/ConfirmDialog.tsx",
|
||||||
"./badge": "./src/components/Badge.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": {
|
"peerDependencies": {
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
"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 { ConfirmDialog, type ConfirmDialogProps } from './components/ConfirmDialog.js';
|
||||||
export { Badge, type BadgeProps } from './components/Badge.js';
|
export { Badge, type BadgeProps } from './components/Badge.js';
|
||||||
export { EmptyState, type EmptyStateProps } from './components/EmptyState.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