feat(ui): add Sidebar, StatCard, LoadingSpinner components + README docs — Level 3 component coverage
This commit is contained in:
parent
928002ec0b
commit
a8d37bd103
239
packages/ui/README.md
Normal file
239
packages/ui/README.md
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
# @bytelyst/ui
|
||||||
|
|
||||||
|
Shared component library for the ByteLyst ecosystem. Built with Radix UI primitives, Lucide icons, and CSS custom properties from `@bytelyst/design-tokens`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @bytelyst/ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Peer dependencies: `react`, `react-dom`.
|
||||||
|
|
||||||
|
## Components (15)
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
5 variants (`primary`, `secondary`, `ghost`, `destructive`, `outline`), 3 sizes, loading state with spinner.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Button variant="primary" size="md" loading={saving}>Save</Button>
|
||||||
|
<Button variant="destructive" onClick={onDelete}>Delete</Button>
|
||||||
|
<Button variant="ghost" size="sm">Cancel</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
Text input with error/success states. Supports `aria-invalid` automatically when `error` is truthy.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Input placeholder="Email" type="email" />
|
||||||
|
<Input error="Required field" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Textarea
|
||||||
|
|
||||||
|
Auto-resize textarea with error states.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Textarea } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Textarea placeholder="Description" rows={4} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Select
|
||||||
|
|
||||||
|
Styled `<select>` with keyboard navigation.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Select } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'a', label: 'Option A' },
|
||||||
|
{ value: 'b', label: 'Option B' },
|
||||||
|
]}
|
||||||
|
value={selected}
|
||||||
|
onChange={setSelected}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal / Dialog
|
||||||
|
|
||||||
|
Radix `@radix-ui/react-dialog` with focus trap, Esc close, and `aria-modal`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Modal } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Modal open={isOpen} onOpenChange={setOpen} title="Edit item">
|
||||||
|
<p>Modal content here</p>
|
||||||
|
</Modal>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ConfirmDialog
|
||||||
|
|
||||||
|
Radix AlertDialog for destructive/warning confirmations. Supports async `onConfirm`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ConfirmDialog } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showConfirm}
|
||||||
|
onOpenChange={setShowConfirm}
|
||||||
|
title="Delete item?"
|
||||||
|
description="This action cannot be undone."
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
variant="destructive"
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast
|
||||||
|
|
||||||
|
4 types (`success`, `error`, `warning`, `info`), auto-dismiss, `role="alert"`. Use `ToastProvider` in your root layout and `toast()` anywhere.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ToastProvider, toast } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
// In providers.tsx
|
||||||
|
<ToastProvider>{children}</ToastProvider>;
|
||||||
|
|
||||||
|
// Anywhere in app
|
||||||
|
toast('Saved successfully', 'success');
|
||||||
|
toast('Something went wrong', 'error');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card
|
||||||
|
|
||||||
|
Container with `default`, `elevated`, and `interactive` variants. Includes `CardHeader`, `CardTitle`, `CardDescription` sub-components.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dashboard</CardTitle>
|
||||||
|
<CardDescription>Overview of your data</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{children}
|
||||||
|
</Card>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
|
||||||
|
Status/count/risk-level badges, color-coded by semantic tokens.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Badge } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Badge variant="success">Active</Badge>
|
||||||
|
<Badge variant="danger">Critical</Badge>
|
||||||
|
<Badge variant="warning" count={5} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### EmptyState
|
||||||
|
|
||||||
|
Placeholder for empty lists with icon, description, and optional CTA.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { EmptyState } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
icon={<FileText size={48} />}
|
||||||
|
title="No items yet"
|
||||||
|
description="Create your first item to get started."
|
||||||
|
action={<Button onClick={onCreate}>Create</Button>}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Label
|
||||||
|
|
||||||
|
Styled form label.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Label } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Label htmlFor="email">Email address</Label>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Separator
|
||||||
|
|
||||||
|
Horizontal/vertical divider.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Separator } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
|
||||||
|
Responsive collapsible sidebar with mobile toggle, overlay, and navigation items.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Sidebar, SidebarItem } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggle={() => setCollapsed(!collapsed)}
|
||||||
|
header={<span>MyApp</span>}
|
||||||
|
footer="v1.0.0"
|
||||||
|
>
|
||||||
|
<SidebarItem href="/dashboard" label="Dashboard" icon={<Home size={18} />} active />
|
||||||
|
<SidebarItem href="/settings" label="Settings" icon={<Settings size={18} />} />
|
||||||
|
</Sidebar>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### StatCard
|
||||||
|
|
||||||
|
Dashboard metric card with trend indicator.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { StatCard } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<StatCard label="Total Users" value="1,234" trend="up" trendValue="+12%" />
|
||||||
|
<StatCard label="Errors" value={42} trend="down" trendValue="-8%" icon={<AlertCircle />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### LoadingSpinner
|
||||||
|
|
||||||
|
Accessible loading indicator with `aria-busy` and `prefers-reduced-motion` support.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { LoadingSpinner } from '@bytelyst/ui';
|
||||||
|
|
||||||
|
<LoadingSpinner size="md" label="Loading data…" />
|
||||||
|
<LoadingSpinner size="sm" label="" /> {/* spinner only, no text */}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Token Integration
|
||||||
|
|
||||||
|
All components use CSS custom properties with `--bl-` prefix and sensible fallbacks. Override them per-product:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bl-accent: var(--jj-accent); /* JarvisJr */
|
||||||
|
--bl-surface-card: var(--fm-surface); /* FlowMonk */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
Every component includes:
|
||||||
|
|
||||||
|
- `focus-visible` ring styles
|
||||||
|
- ARIA attributes (`aria-label`, `aria-modal`, `aria-busy`, `aria-invalid`, `aria-current`)
|
||||||
|
- Keyboard navigation (Esc to close, Tab focus trap in modals)
|
||||||
|
- `prefers-reduced-motion` respected via Tailwind's `animate-spin`
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @bytelyst/ui storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
Storybook is configured with `@storybook/addon-a11y` for automated accessibility checks.
|
||||||
@ -19,7 +19,10 @@
|
|||||||
"./card": "./src/components/Card.tsx",
|
"./card": "./src/components/Card.tsx",
|
||||||
"./label": "./src/components/Label.tsx",
|
"./label": "./src/components/Label.tsx",
|
||||||
"./select": "./src/components/Select.tsx",
|
"./select": "./src/components/Select.tsx",
|
||||||
"./separator": "./src/components/Separator.tsx"
|
"./separator": "./src/components/Separator.tsx",
|
||||||
|
"./sidebar": "./src/components/Sidebar.tsx",
|
||||||
|
"./stat-card": "./src/components/StatCard.tsx",
|
||||||
|
"./loading-spinner": "./src/components/LoadingSpinner.tsx"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
|||||||
35
packages/ui/src/components/LoadingSpinner.tsx
Normal file
35
packages/ui/src/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({
|
||||||
|
size = 'md',
|
||||||
|
label = 'Loading…',
|
||||||
|
className,
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
const sizes: Record<string, string> = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-6 w-6',
|
||||||
|
lg: 'h-8 w-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label={label}
|
||||||
|
className={clsx('flex items-center justify-center gap-2', className)}
|
||||||
|
>
|
||||||
|
<Loader2 className={clsx(sizes[size], 'animate-spin text-[var(--bl-accent,#5A8CFF)]')} />
|
||||||
|
{label && <span className="text-sm text-[var(--bl-text-secondary,#a0a0b0)]">{label}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingSpinner.displayName = 'LoadingSpinner';
|
||||||
110
packages/ui/src/components/Sidebar.tsx
Normal file
110
packages/ui/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { Menu, X } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
className?: string;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
children,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
collapsed = false,
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
width = 240,
|
||||||
|
}: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile toggle */}
|
||||||
|
{onToggle && (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'fixed top-3 left-3 z-[38] flex items-center justify-center w-10 h-10 rounded-lg',
|
||||||
|
'border border-[var(--bl-border,#2a2a4a)] bg-[var(--bl-bg-elevated,#12151c)]',
|
||||||
|
'text-[var(--bl-text-primary,#fff)] cursor-pointer',
|
||||||
|
'md:hidden'
|
||||||
|
)}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={collapsed ? 'Open menu' : 'Close menu'}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{collapsed ? <Menu size={20} /> : <X size={20} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
{onToggle && !collapsed && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[39] bg-black/50 md:hidden"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
aria-label="Primary"
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-col h-full border-r shrink-0 transition-transform duration-200',
|
||||||
|
'border-[var(--bl-border,#2a2a4a)] bg-[var(--bl-bg-elevated,#12151c)]',
|
||||||
|
'fixed md:static z-40',
|
||||||
|
collapsed ? '-translate-x-full md:translate-x-0' : 'translate-x-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ width, minWidth: width }}
|
||||||
|
>
|
||||||
|
{header && <div className="border-b border-[var(--bl-border,#2a2a4a)] p-4">{header}</div>}
|
||||||
|
|
||||||
|
<nav aria-label="Main navigation" className="flex-1 overflow-y-auto p-2">
|
||||||
|
{children}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div className="border-t border-[var(--bl-border,#2a2a4a)] p-4 text-xs text-[var(--bl-text-secondary,#a0a0b0)]">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarItemProps {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
active?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarItem({ href, label, icon, active, className }: SidebarItemProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--bl-accent,#5A8CFF)]',
|
||||||
|
active
|
||||||
|
? 'bg-[var(--bl-surface-card,#1a1a2e)] text-[var(--bl-text-primary,#fff)] font-semibold'
|
||||||
|
: 'text-[var(--bl-text-secondary,#a0a0b0)] hover:bg-[var(--bl-surface-muted,#252540)] hover:text-[var(--bl-text-primary,#fff)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && <span className="shrink-0">{icon}</span>}
|
||||||
|
<span>{label}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Sidebar.displayName = 'Sidebar';
|
||||||
|
SidebarItem.displayName = 'SidebarItem';
|
||||||
65
packages/ui/src/components/StatCard.tsx
Normal file
65
packages/ui/src/components/StatCard.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
trend?: 'up' | 'down' | 'flat';
|
||||||
|
trendValue?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ label, value, trend, trendValue, icon, className }: StatCardProps) {
|
||||||
|
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-xl border p-5',
|
||||||
|
'bg-[var(--bl-surface-card,#1a1a2e)] border-[var(--bl-border,#2a2a4a)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-[var(--bl-text-secondary,#a0a0b0)] mb-1">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-[var(--bl-text-primary,#fff)]">{value}</p>
|
||||||
|
</div>
|
||||||
|
{icon && (
|
||||||
|
<div className="rounded-lg p-2 bg-[var(--bl-surface-muted,#252540)] text-[var(--bl-text-secondary,#a0a0b0)]">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trend && trendValue && (
|
||||||
|
<div className="flex items-center gap-1 mt-3 text-xs">
|
||||||
|
<TrendIcon
|
||||||
|
size={14}
|
||||||
|
className={clsx(
|
||||||
|
trend === 'up' && 'text-[var(--bl-success,#34D399)]',
|
||||||
|
trend === 'down' && 'text-[var(--bl-danger,#FF6E6E)]',
|
||||||
|
trend === 'flat' && 'text-[var(--bl-text-secondary,#a0a0b0)]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'font-medium',
|
||||||
|
trend === 'up' && 'text-[var(--bl-success,#34D399)]',
|
||||||
|
trend === 'down' && 'text-[var(--bl-danger,#FF6E6E)]',
|
||||||
|
trend === 'flat' && 'text-[var(--bl-text-secondary,#a0a0b0)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trendValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StatCard.displayName = 'StatCard';
|
||||||
@ -17,3 +17,11 @@ export { Card, CardHeader, CardTitle, CardDescription, type CardProps } from './
|
|||||||
export { Label, type LabelProps } from './components/Label.js';
|
export { Label, type LabelProps } from './components/Label.js';
|
||||||
export { Select, type SelectProps } from './components/Select.js';
|
export { Select, type SelectProps } from './components/Select.js';
|
||||||
export { Separator, type SeparatorProps } from './components/Separator.js';
|
export { Separator, type SeparatorProps } from './components/Separator.js';
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarItem,
|
||||||
|
type SidebarProps,
|
||||||
|
type SidebarItemProps,
|
||||||
|
} from './components/Sidebar.js';
|
||||||
|
export { StatCard, type StatCardProps } from './components/StatCard.js';
|
||||||
|
export { LoadingSpinner, type LoadingSpinnerProps } from './components/LoadingSpinner.js';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user