feat(ui): create @bytelyst/ui with Button, Toast, Modal, ConfirmDialog, Badge, EmptyState

This commit is contained in:
saravanakumardb1 2026-03-27 16:29:37 -07:00
parent b3a1ee5db9
commit 252403f74e
11 changed files with 486 additions and 0 deletions

View File

@ -79,6 +79,31 @@ export default [
ReactNode: 'readonly',
React: 'readonly',
JSX: 'readonly',
HTMLElement: 'readonly',
HTMLButtonElement: 'readonly',
HTMLSpanElement: 'readonly',
HTMLDivElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLFormElement: 'readonly',
HTMLAnchorElement: 'readonly',
HTMLImageElement: 'readonly',
HTMLTextAreaElement: 'readonly',
HTMLSelectElement: 'readonly',
HTMLLabelElement: 'readonly',
Element: 'readonly',
Event: 'readonly',
KeyboardEvent: 'readonly',
MouseEvent: 'readonly',
FocusEvent: 'readonly',
CustomEvent: 'readonly',
MediaQueryList: 'readonly',
MutationObserver: 'readonly',
ResizeObserver: 'readonly',
IntersectionObserver: 'readonly',
EventTarget: 'readonly',
NodeList: 'readonly',
DOMRect: 'readonly',
SVGElement: 'readonly',
},
},
plugins: {

View File

@ -0,0 +1,18 @@
export default [
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: {
HTMLButtonElement: 'readonly',
HTMLSpanElement: 'readonly',
HTMLDivElement: 'readonly',
HTMLInputElement: 'readonly',
React: 'readonly',
setTimeout: 'readonly',
Date: 'readonly',
Math: 'readonly',
console: 'readonly',
},
},
},
];

30
packages/ui/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "@bytelyst/ui",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./button": "./src/components/Button.tsx",
"./toast": "./src/components/Toast.tsx",
"./modal": "./src/components/Modal.tsx",
"./confirm-dialog": "./src/components/ConfirmDialog.tsx",
"./badge": "./src/components/Badge.tsx",
"./empty-state": "./src/components/EmptyState.tsx"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-alert-dialog": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"lucide-react": "^0.460.0",
"clsx": "^2.1.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}

View File

@ -0,0 +1,49 @@
import * as React from 'react';
import { clsx } from 'clsx';
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
size?: 'sm' | 'md';
dot?: boolean;
}
const variantStyles: Record<string, string> = {
success: 'bg-green-500/10 text-green-400 border-green-500/20',
warning: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
error: 'bg-red-500/10 text-red-400 border-red-500/20',
info: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
neutral:
'bg-[var(--bl-surface-muted,#252540)] text-[var(--bl-text-secondary,#a0a0b0)] border-[var(--bl-border,#2a2a4a)]',
};
const dotColors: Record<string, string> = {
success: 'bg-green-400',
error: 'bg-red-400',
warning: 'bg-yellow-400',
info: 'bg-blue-400',
neutral: 'bg-gray-400',
};
export function Badge({
variant = 'neutral',
size = 'sm',
dot,
className,
children,
...props
}: BadgeProps) {
return (
<span
className={clsx(
'inline-flex items-center gap-1.5 rounded-full border font-medium',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm',
variantStyles[variant],
className
)}
{...props}
>
{dot && <span className={clsx('h-1.5 w-1.5 rounded-full', dotColors[variant])} />}
{children}
</span>
);
}

View File

@ -0,0 +1,54 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { clsx } from 'clsx';
import { Loader2 } from 'lucide-react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = 'primary', size = 'md', loading, asChild, className, children, disabled, ...props },
ref
) => {
const Comp = asChild ? Slot : 'button';
const baseStyles =
'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',
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',
outline:
'border border-[var(--bl-border,#2a2a4a)] text-[var(--bl-text-primary,#fff)] hover:bg-[var(--bl-surface-muted,#252540)]',
};
const sizes: Record<string, string> = {
sm: 'h-8 px-3 text-xs gap-1.5',
md: 'h-10 px-4 text-sm gap-2',
lg: 'h-12 px-6 text-base gap-2.5',
};
return (
<Comp
ref={ref}
className={clsx(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</Comp>
);
}
);
Button.displayName = 'Button';

View File

@ -0,0 +1,80 @@
'use client';
import * as React from 'react';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { clsx } from 'clsx';
import { AlertTriangle } from 'lucide-react';
import { Button } from './Button.js';
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'destructive' | 'warning';
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'destructive',
loading,
onConfirm,
}: ConfirmDialogProps) {
return (
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-[9998] bg-black/60 backdrop-blur-sm" />
<AlertDialog.Content
className={clsx(
'fixed left-1/2 top-1/2 z-[9999] -translate-x-1/2 -translate-y-1/2',
'w-full max-w-md rounded-xl border p-6 shadow-xl',
'bg-[var(--bl-bg-elevated,#12151c)] border-[var(--bl-border,#2a2a4a)] text-[var(--bl-text-primary,#fff)]',
'focus:outline-none'
)}
>
<div className="flex items-start gap-3">
<div
className={clsx(
'mt-0.5 rounded-full p-2',
variant === 'destructive'
? 'bg-red-500/10 text-red-400'
: 'bg-yellow-500/10 text-yellow-400'
)}
>
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<AlertDialog.Title className="text-lg font-semibold">{title}</AlertDialog.Title>
<AlertDialog.Description className="mt-2 text-sm text-[var(--bl-text-secondary,#a0a0b0)]">
{description}
</AlertDialog.Description>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<AlertDialog.Cancel asChild>
<Button variant="ghost">{cancelLabel}</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button
variant={variant === 'destructive' ? 'destructive' : 'primary'}
loading={loading}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}

View File

@ -0,0 +1,46 @@
import * as React from 'react';
import { clsx } from 'clsx';
import { Inbox } from 'lucide-react';
import { Button } from './Button.js';
export interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
export function EmptyState({
icon,
title,
description,
actionLabel,
onAction,
className,
}: EmptyStateProps) {
return (
<div
className={clsx(
'flex flex-col items-center justify-center py-16 px-4 text-center',
className
)}
>
<div className="mb-4 text-[var(--bl-text-tertiary,#555)]">
{icon ?? <Inbox className="h-12 w-12" />}
</div>
<h3 className="text-lg font-medium text-[var(--bl-text-primary,#fff)]">{title}</h3>
{description && (
<p className="mt-2 max-w-sm text-sm text-[var(--bl-text-secondary,#a0a0b0)]">
{description}
</p>
)}
{actionLabel && onAction && (
<Button variant="primary" size="sm" className="mt-4" onClick={onAction}>
{actionLabel}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,64 @@
'use client';
import * as React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { clsx } from 'clsx';
import { X } from 'lucide-react';
export interface ModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
size?: 'sm' | 'md' | 'lg' | 'full';
children: React.ReactNode;
}
const sizes: Record<string, string> = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
full: 'max-w-[90vw]',
};
export function Modal({
open,
onOpenChange,
title,
description,
size = 'md',
children,
}: ModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-[9998] bg-black/60 backdrop-blur-sm" />
<Dialog.Content
className={clsx(
'fixed left-1/2 top-1/2 z-[9999] -translate-x-1/2 -translate-y-1/2',
'w-full rounded-xl border p-6 shadow-xl',
'bg-[var(--bl-bg-elevated,#12151c)] border-[var(--bl-border,#2a2a4a)] text-[var(--bl-text-primary,#fff)]',
'focus:outline-none',
sizes[size]
)}
>
<Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
{description && (
<Dialog.Description className="mt-1 text-sm text-[var(--bl-text-secondary,#a0a0b0)]">
{description}
</Dialog.Description>
)}
<div className="mt-4">{children}</div>
<Dialog.Close asChild>
<button
className="absolute right-4 top-4 text-[var(--bl-text-tertiary,#666)] hover:text-[var(--bl-text-primary,#fff)]"
aria-label="Close dialog"
>
<X className="h-4 w-4" />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -0,0 +1,97 @@
'use client';
import * as React from 'react';
import { clsx } from 'clsx';
import { X, CheckCircle, AlertTriangle, Info, AlertCircle } from 'lucide-react';
export interface ToastMessage {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
description?: string;
duration?: number;
}
type ToastListener = (toasts: ToastMessage[]) => void;
let globalToasts: ToastMessage[] = [];
const listeners = new Set<ToastListener>();
function notifyListeners() {
listeners.forEach(l => l([...globalToasts]));
}
export function toast(msg: Omit<ToastMessage, 'id'>) {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
globalToasts = [...globalToasts, { ...msg, id }];
notifyListeners();
const duration = msg.duration ?? 5000;
if (duration > 0) setTimeout(() => dismissToast(id), duration);
}
export function dismissToast(id: string) {
globalToasts = globalToasts.filter(t => t.id !== id);
notifyListeners();
}
export function useToast() {
return { toast, dismiss: dismissToast };
}
const icons: Record<string, React.ReactNode> = {
success: <CheckCircle className="h-5 w-5 text-green-400" />,
error: <AlertCircle className="h-5 w-5 text-red-400" />,
warning: <AlertTriangle className="h-5 w-5 text-yellow-400" />,
info: <Info className="h-5 w-5 text-blue-400" />,
};
export function Toast({ message, onDismiss }: { message: ToastMessage; onDismiss: () => void }) {
return (
<div
role="alert"
className={clsx(
'flex items-start gap-3 rounded-lg border p-4 shadow-lg backdrop-blur-sm',
'bg-[var(--bl-surface-card,#1a1a2e)] border-[var(--bl-border,#2a2a4a)] text-[var(--bl-text-primary,#fff)]'
)}
>
{icons[message.type]}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{message.title}</p>
{message.description && (
<p className="mt-1 text-xs text-[var(--bl-text-secondary,#a0a0b0)]">
{message.description}
</p>
)}
</div>
<button
onClick={onDismiss}
className="text-[var(--bl-text-tertiary,#666)] hover:text-[var(--bl-text-primary,#fff)]"
aria-label="Dismiss notification"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
React.useEffect(() => {
listeners.add(setToasts);
return () => {
listeners.delete(setToasts);
};
}, []);
return (
<>
{children}
<div
className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm"
aria-live="polite"
>
{toasts.map(t => (
<Toast key={t.id} message={t} onDismiss={() => dismissToast(t.id)} />
))}
</div>
</>
);
}

13
packages/ui/src/index.ts Normal file
View File

@ -0,0 +1,13 @@
export { Button, type ButtonProps } from './components/Button.js';
export {
Toast,
ToastProvider,
useToast,
toast,
dismissToast,
type ToastMessage,
} from './components/Toast.js';
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';

10
packages/ui/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}