feat(ui): add state components and apply design system compliance

Add new reusable components:
- EmptyState: for empty list/search states with icon, title, description
- ErrorState: for error displays with optional retry button
- LoadingState: for loading states with Spinner and message
- PageHeader: for consistent page headers with title, description, actions

Apply components across pages:
- agents, collections, tool-search pages use new state components
- stats page uses LoadingState and ErrorState
- faq page uses Icon for chevrons
- health page uses Table components
- dashboard agent forms use Input, Select, Textarea, Label
- Replace custom div spinners with Spinner component
This commit is contained in:
Ajax Davis 2026-01-16 08:16:05 +10:00
parent 499a38ce1b
commit f0d271720c
15 changed files with 607 additions and 328 deletions

View file

@ -218,6 +218,22 @@
"./ToolCard/ToolCard": {
"types": "./dist/ToolCard/ToolCard.d.ts",
"default": "./dist/ToolCard/ToolCard.js"
},
"./EmptyState/EmptyState": {
"types": "./dist/EmptyState/EmptyState.d.ts",
"default": "./dist/EmptyState/EmptyState.js"
},
"./ErrorState/ErrorState": {
"types": "./dist/ErrorState/ErrorState.d.ts",
"default": "./dist/ErrorState/ErrorState.js"
},
"./LoadingState/LoadingState": {
"types": "./dist/LoadingState/LoadingState.d.ts",
"default": "./dist/LoadingState/LoadingState.js"
},
"./PageHeader/PageHeader": {
"types": "./dist/PageHeader/PageHeader.d.ts",
"default": "./dist/PageHeader/PageHeader.js"
}
},
"files": [

View file

@ -0,0 +1,110 @@
import { cn } from '@tpmjs/utils/cn';
import { forwardRef } from 'react';
import { Icon, type IconName } from '../Icon/Icon';
export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
/** Icon to display */
icon: IconName;
/** Title text */
title: string;
/** Optional description text */
description?: string;
/** Optional action element (button, link, etc.) */
action?: React.ReactNode;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
}
const sizeConfig = {
sm: {
container: 'py-8',
iconContainer: 'w-12 h-12',
iconSize: 'md' as const,
title: 'text-base',
description: 'text-sm',
},
md: {
container: 'py-12',
iconContainer: 'w-16 h-16',
iconSize: 'lg' as const,
title: 'text-lg',
description: 'text-sm',
},
lg: {
container: 'py-16',
iconContainer: 'w-20 h-20',
iconSize: 'lg' as const,
title: 'text-xl',
description: 'text-base',
},
};
/**
* EmptyState component
*
* A consistent empty state pattern for displaying when no data is available.
* Use this for empty lists, search results with no matches, or first-time states.
*
* @example
* ```tsx
* import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
* import { Button } from '@tpmjs/ui/Button/Button';
*
* function MyComponent() {
* return (
* <EmptyState
* icon="folder"
* title="No collections found"
* description="Create your first collection to get started."
* action={<Button>Create Collection</Button>}
* />
* );
* }
* ```
*/
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
(
{ className, icon, title, description, action, size = 'md', ...props },
ref
) => {
const config = sizeConfig[size];
return (
<div
ref={ref}
className={cn('text-center', config.container, className)}
{...props}
>
<div
className={cn(
'rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4',
config.iconContainer
)}
>
<Icon icon={icon} size={config.iconSize} className="text-primary" />
</div>
<h3
className={cn(
'font-medium text-foreground mb-2',
config.title
)}
>
{title}
</h3>
{description && (
<p
className={cn(
'text-foreground-secondary mb-4 max-w-md mx-auto',
config.description
)}
>
{description}
</p>
)}
{action && <div className="flex justify-center">{action}</div>}
</div>
);
}
);
EmptyState.displayName = 'EmptyState';

View file

@ -0,0 +1,98 @@
import { cn } from '@tpmjs/utils/cn';
import { forwardRef } from 'react';
import { Button } from '../Button/Button';
import { Icon } from '../Icon/Icon';
export interface ErrorStateProps extends React.HTMLAttributes<HTMLDivElement> {
/** Optional title (defaults to "Error") */
title?: string;
/** Error message to display */
message: string;
/** Optional retry callback - shows retry button if provided */
onRetry?: () => void;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
}
const sizeConfig = {
sm: {
container: 'py-8',
iconSize: 'md' as const,
title: 'text-base',
message: 'text-sm',
},
md: {
container: 'py-12',
iconSize: 'lg' as const,
title: 'text-lg',
message: 'text-sm',
},
lg: {
container: 'py-16',
iconSize: 'lg' as const,
title: 'text-xl',
message: 'text-base',
},
};
/**
* ErrorState component
*
* A consistent error state pattern for displaying error messages with optional retry.
*
* @example
* ```tsx
* import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState';
*
* function MyComponent() {
* return (
* <ErrorState
* message="Failed to load data"
* onRetry={() => refetch()}
* />
* );
* }
* ```
*/
export const ErrorState = forwardRef<HTMLDivElement, ErrorStateProps>(
(
{
className,
title = 'Error',
message,
onRetry,
size = 'md',
...props
},
ref
) => {
const config = sizeConfig[size];
return (
<div
ref={ref}
className={cn('text-center', config.container, className)}
{...props}
>
<Icon
icon="alertCircle"
size={config.iconSize}
className="mx-auto text-error mb-4"
/>
<h2 className={cn('font-medium text-foreground mb-2', config.title)}>
{title}
</h2>
<p className={cn('text-foreground-secondary mb-4', config.message)}>
{message}
</p>
{onRetry && (
<Button onClick={onRetry} variant="default">
Try Again
</Button>
)}
</div>
);
}
);
ErrorState.displayName = 'ErrorState';

View file

@ -0,0 +1,81 @@
import { cn } from '@tpmjs/utils/cn';
import { forwardRef } from 'react';
import { Spinner } from '../Spinner/Spinner';
export interface LoadingStateProps extends React.HTMLAttributes<HTMLDivElement> {
/** Optional loading message */
message?: string;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
}
const sizeConfig = {
sm: {
container: 'py-8',
spinnerSize: 'sm' as const,
messageClass: 'text-xs',
gap: 'gap-2',
},
md: {
container: 'py-16',
spinnerSize: 'md' as const,
messageClass: 'text-sm',
gap: 'gap-3',
},
lg: {
container: 'py-24',
spinnerSize: 'lg' as const,
messageClass: 'text-sm',
gap: 'gap-4',
},
};
/**
* LoadingState component
*
* A consistent loading state pattern with spinner and optional message.
*
* @example
* ```tsx
* import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
*
* function MyComponent() {
* if (isLoading) {
* return <LoadingState message="Loading tools..." />;
* }
* return <div>Content</div>;
* }
* ```
*/
export const LoadingState = forwardRef<HTMLDivElement, LoadingStateProps>(
({ className, message, size = 'md', ...props }, ref) => {
const config = sizeConfig[size];
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center',
config.container,
config.gap,
className
)}
{...props}
>
<Spinner size={config.spinnerSize} />
{message && (
<span
className={cn(
'text-foreground-secondary font-mono tracking-wide',
config.messageClass
)}
>
{message}
</span>
)}
</div>
);
}
);
LoadingState.displayName = 'LoadingState';

View file

@ -0,0 +1,86 @@
import { cn } from '@tpmjs/utils/cn';
import { forwardRef } from 'react';
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
/** Page title */
title: string;
/** Optional description text */
description?: string;
/** Optional action elements (buttons, links, etc.) */
actions?: React.ReactNode;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
}
const sizeConfig = {
sm: {
container: 'mb-4 space-y-1',
title: 'text-xl sm:text-2xl',
description: 'text-sm',
},
md: {
container: 'mb-6 space-y-2',
title: 'text-2xl sm:text-3xl',
description: 'text-base',
},
lg: {
container: 'mb-8 space-y-4',
title: 'text-2xl sm:text-3xl md:text-4xl',
description: 'text-lg',
},
};
/**
* PageHeader component
*
* A consistent page header pattern with title, description, and optional actions.
*
* @example
* ```tsx
* import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
* import { Button } from '@tpmjs/ui/Button/Button';
*
* function MyPage() {
* return (
* <PageHeader
* title="Tool Registry"
* description="Browse and discover AI agent tools"
* actions={<Button>Add Tool</Button>}
* />
* );
* }
* ```
*/
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
(
{ className, title, description, actions, size = 'md', ...props },
ref
) => {
const config = sizeConfig[size];
return (
<div ref={ref} className={cn(config.container, className)} {...props}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<h1 className={cn('font-bold text-foreground', config.title)}>
{title}
</h1>
{description && (
<p
className={cn(
'text-foreground-secondary',
config.description
)}
>
{description}
</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
</div>
);
}
);
PageHeader.displayName = 'PageHeader';

View file

@ -83,7 +83,7 @@ const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
<tr
ref={ref}
className={cn(
'border-b border-border transition-colors',
'border-b border-border bg-white dark:bg-zinc-900 transition-colors',
interactive && 'cursor-pointer hover:bg-surface',
selected && 'bg-surface',
className