feat(ui): add operational core primitives
This commit is contained in:
parent
e619fa8eb5
commit
23e140009f
31
packages/ui/src/components/DiffCard.tsx
Normal file
31
packages/ui/src/components/DiffCard.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
export interface DiffCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
before: React.ReactNode;
|
||||||
|
after: React.ReactNode;
|
||||||
|
beforeLabel?: string;
|
||||||
|
afterLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffCard({
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
beforeLabel = 'Before',
|
||||||
|
afterLabel = 'After',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DiffCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('grid gap-3 md:grid-cols-2', className)} {...props}>
|
||||||
|
<div className="grid gap-2 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3">
|
||||||
|
<strong className="text-sm text-[var(--bl-text-primary)]">{beforeLabel}</strong>
|
||||||
|
<div className="whitespace-pre-wrap text-sm text-[var(--bl-text-secondary)]">{before}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3">
|
||||||
|
<strong className="text-sm text-[var(--bl-text-primary)]">{afterLabel}</strong>
|
||||||
|
<div className="whitespace-pre-wrap text-sm text-[var(--bl-text-secondary)]">{after}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
packages/ui/src/components/IconButton.tsx
Normal file
26
packages/ui/src/components/IconButton.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { Button, type ButtonProps } from './Button.js';
|
||||||
|
|
||||||
|
export interface IconButtonProps extends Omit<ButtonProps, 'children'> {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||||
|
({ icon, label, className, size = 'sm', variant = 'ghost', ...props }, ref) => (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={clsx('aspect-square px-0', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
IconButton.displayName = 'IconButton';
|
||||||
28
packages/ui/src/components/ListItemButton.tsx
Normal file
28
packages/ui/src/components/ListItemButton.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
export interface ListItemButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItemButton = React.forwardRef<HTMLButtonElement, ListItemButtonProps>(
|
||||||
|
({ selected, className, children, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={clsx(
|
||||||
|
'w-full rounded-md border p-3 text-left transition-colors',
|
||||||
|
'border-[var(--bl-border)] bg-[var(--bl-surface-muted)] text-[var(--bl-text-primary)]',
|
||||||
|
'hover:border-[var(--bl-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--bl-accent)]',
|
||||||
|
selected && 'border-[var(--bl-accent)] bg-[var(--bl-accent-muted,var(--bl-surface-card))]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ListItemButton.displayName = 'ListItemButton';
|
||||||
77
packages/ui/src/components/Panel.tsx
Normal file
77
packages/ui/src/components/Panel.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
export interface PanelProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
as?: 'section' | 'aside' | 'article' | 'div';
|
||||||
|
density?: 'compact' | 'normal' | 'spacious';
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelPadding: Record<NonNullable<PanelProps['density']>, string> = {
|
||||||
|
compact: 'p-3',
|
||||||
|
normal: 'p-5',
|
||||||
|
spacious: 'p-6',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Panel({
|
||||||
|
as: Comp = 'section',
|
||||||
|
density = 'normal',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PanelProps) {
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={clsx(
|
||||||
|
'rounded-lg border bg-[var(--bl-surface-card)] border-[var(--bl-border)] shadow-sm',
|
||||||
|
panelPadding[density],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PanelHeaderProps = React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export function PanelHeader({ className, children, ...props }: PanelHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex items-center justify-between gap-3', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PanelBodyProps = React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export function PanelBody({ className, children, ...props }: PanelBodyProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('grid gap-3', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PanelTitleProps = React.ComponentPropsWithoutRef<'h2'>;
|
||||||
|
|
||||||
|
export function PanelTitle({ className, children, ...props }: PanelTitleProps) {
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
className={clsx('m-0 text-base font-semibold text-[var(--bl-text-primary)]', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PanelDescriptionProps = React.ComponentPropsWithoutRef<'p'>;
|
||||||
|
|
||||||
|
export function PanelDescription({ className, children, ...props }: PanelDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p className={clsx('m-0 text-sm text-[var(--bl-text-secondary)]', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
packages/ui/src/components/StatusBadge.tsx
Normal file
59
packages/ui/src/components/StatusBadge.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
export type StatusTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'accent';
|
||||||
|
|
||||||
|
export interface StatusBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
|
tone?: StatusTone;
|
||||||
|
dot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toneClasses: Record<StatusTone, string> = {
|
||||||
|
success:
|
||||||
|
'bg-[var(--bl-success-muted,var(--bl-surface-muted))] text-[var(--bl-success)] border-[var(--bl-success)]',
|
||||||
|
warning:
|
||||||
|
'bg-[var(--bl-warning-muted,var(--bl-surface-muted))] text-[var(--bl-warning)] border-[var(--bl-warning)]',
|
||||||
|
danger:
|
||||||
|
'bg-[var(--bl-danger-muted,var(--bl-surface-muted))] text-[var(--bl-danger)] border-[var(--bl-danger)]',
|
||||||
|
info: 'bg-[var(--bl-info-muted,var(--bl-surface-muted))] text-[var(--bl-info,var(--bl-accent))] border-[var(--bl-info,var(--bl-accent))]',
|
||||||
|
neutral: 'bg-[var(--bl-surface-muted)] text-[var(--bl-text-secondary)] border-[var(--bl-border)]',
|
||||||
|
accent:
|
||||||
|
'bg-[var(--bl-accent-muted,var(--bl-surface-muted))] text-[var(--bl-text-primary)] border-[var(--bl-accent)]',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusDot({
|
||||||
|
tone = 'neutral',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tone?: StatusTone;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx('h-1.5 w-1.5 rounded-full bg-current', toneClasses[tone], className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
tone = 'neutral',
|
||||||
|
dot,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||||
|
toneClasses[tone],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{dot && <StatusDot tone={tone} />}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
packages/ui/src/components/Timeline.tsx
Normal file
61
packages/ui/src/components/Timeline.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { StatusBadge, type StatusTone } from './StatusBadge.js';
|
||||||
|
|
||||||
|
export interface TimelineItem {
|
||||||
|
id: string;
|
||||||
|
title: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
meta?: React.ReactNode;
|
||||||
|
status?: React.ReactNode;
|
||||||
|
tone?: StatusTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
items: TimelineItem[];
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Timeline({
|
||||||
|
items,
|
||||||
|
emptyLabel = 'No activity yet.',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: TimelineProps) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className={clsx('text-sm text-[var(--bl-text-secondary)]', className)} {...props}>
|
||||||
|
{emptyLabel}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={clsx('grid gap-3', className)} {...props}>
|
||||||
|
{items.map(item => (
|
||||||
|
<article key={item.id} className="grid grid-cols-[auto_1fr] gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-1 h-2.5 w-2.5 rounded-full bg-[var(--bl-accent)]"
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<strong className="text-sm text-[var(--bl-text-primary)]">{item.title}</strong>
|
||||||
|
{item.meta && (
|
||||||
|
<span className="text-xs text-[var(--bl-text-secondary)]">{item.meta}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<div className="text-sm text-[var(--bl-text-secondary)]">{item.description}</div>
|
||||||
|
)}
|
||||||
|
{item.status && (
|
||||||
|
<StatusBadge tone={item.tone} className="w-fit">
|
||||||
|
{item.status}
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export { Button, type ButtonProps } from './components/Button';
|
export { Button, type ButtonProps } from './components/Button';
|
||||||
|
export { IconButton, type IconButtonProps } from './components/IconButton';
|
||||||
export {
|
export {
|
||||||
Toast,
|
Toast,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
@ -10,10 +11,31 @@ export {
|
|||||||
export { Modal, type ModalProps } from './components/Modal';
|
export { Modal, type ModalProps } from './components/Modal';
|
||||||
export { ConfirmDialog, type ConfirmDialogProps } from './components/ConfirmDialog';
|
export { ConfirmDialog, type ConfirmDialogProps } from './components/ConfirmDialog';
|
||||||
export { Badge, type BadgeProps } from './components/Badge';
|
export { Badge, type BadgeProps } from './components/Badge';
|
||||||
|
export {
|
||||||
|
StatusBadge,
|
||||||
|
StatusDot,
|
||||||
|
type StatusBadgeProps,
|
||||||
|
type StatusTone,
|
||||||
|
} from './components/StatusBadge';
|
||||||
export { EmptyState, type EmptyStateProps } from './components/EmptyState';
|
export { EmptyState, type EmptyStateProps } from './components/EmptyState';
|
||||||
export { Input, type InputProps } from './components/Input';
|
export { Input, type InputProps } from './components/Input';
|
||||||
export { Textarea, type TextareaProps } from './components/Textarea';
|
export { Textarea, type TextareaProps } from './components/Textarea';
|
||||||
export { Card, CardHeader, CardTitle, CardDescription, type CardProps } from './components/Card';
|
export { Card, CardHeader, CardTitle, CardDescription, type CardProps } from './components/Card';
|
||||||
|
export {
|
||||||
|
Panel,
|
||||||
|
PanelBody,
|
||||||
|
PanelDescription,
|
||||||
|
PanelHeader,
|
||||||
|
PanelTitle,
|
||||||
|
type PanelBodyProps,
|
||||||
|
type PanelDescriptionProps,
|
||||||
|
type PanelHeaderProps,
|
||||||
|
type PanelProps,
|
||||||
|
type PanelTitleProps,
|
||||||
|
} from './components/Panel';
|
||||||
|
export { ListItemButton, type ListItemButtonProps } from './components/ListItemButton';
|
||||||
|
export { Timeline, type TimelineItem, type TimelineProps } from './components/Timeline';
|
||||||
|
export { DiffCard, type DiffCardProps } from './components/DiffCard';
|
||||||
export { Label, type LabelProps } from './components/Label';
|
export { Label, type LabelProps } from './components/Label';
|
||||||
export { Select, type SelectProps } from './components/Select';
|
export { Select, type SelectProps } from './components/Select';
|
||||||
export { Separator, type SeparatorProps } from './components/Separator';
|
export { Separator, type SeparatorProps } from './components/Separator';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user