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 { IconButton, type IconButtonProps } from './components/IconButton';
|
||||
export {
|
||||
Toast,
|
||||
ToastProvider,
|
||||
@ -10,10 +11,31 @@ export {
|
||||
export { Modal, type ModalProps } from './components/Modal';
|
||||
export { ConfirmDialog, type ConfirmDialogProps } from './components/ConfirmDialog';
|
||||
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 { Input, type InputProps } from './components/Input';
|
||||
export { Textarea, type TextareaProps } from './components/Textarea';
|
||||
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 { Select, type SelectProps } from './components/Select';
|
||||
export { Separator, type SeparatorProps } from './components/Separator';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user