feat(ui): create @bytelyst/ui with Button, Toast, Modal, ConfirmDialog, Badge, EmptyState
This commit is contained in:
parent
b3a1ee5db9
commit
252403f74e
@ -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: {
|
||||
|
||||
18
packages/ui/eslint.config.js
Normal file
18
packages/ui/eslint.config.js
Normal 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
30
packages/ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
packages/ui/src/components/Badge.tsx
Normal file
49
packages/ui/src/components/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
packages/ui/src/components/Button.tsx
Normal file
54
packages/ui/src/components/Button.tsx
Normal 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';
|
||||
80
packages/ui/src/components/ConfirmDialog.tsx
Normal file
80
packages/ui/src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
packages/ui/src/components/EmptyState.tsx
Normal file
46
packages/ui/src/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
packages/ui/src/components/Modal.tsx
Normal file
64
packages/ui/src/components/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
packages/ui/src/components/Toast.tsx
Normal file
97
packages/ui/src/components/Toast.tsx
Normal 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
13
packages/ui/src/index.ts
Normal 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
10
packages/ui/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user