feat(client): add UI kit, data layer, app shell, and Table component
- design token system (Atlantis, Kimberly, Powder Ash palette) - Button, Badge, Input, Select, Card, SidePanel, Breadcrumbs components - generic typed Table<T> component with loading/empty states - API data layer: typed fetch client for environments, keys, sessions, scenarios - Vite dev proxy targeting server on port 13000 - App shell with SidePanel nav and four entity pages (Environments, Keys, Sessions, Scenarios) - Storybook config with dark/light theme toggle and a11y addon
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success { background: var(--color-success-bg); color: var(--color-success-fg); }
|
||||
.error { background: var(--color-error-bg); color: var(--color-error-fg); }
|
||||
.warning { background: var(--color-warning-bg); color: var(--color-warning-fg); }
|
||||
.info { background: var(--color-info-bg); color: var(--color-info-fg); }
|
||||
.neutral { background: var(--color-neutral-bg); color: var(--color-neutral-fg); }
|
||||
.running { background: var(--color-running-bg); color: var(--color-running-fg); }
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: 'UI/Badge',
|
||||
component: Badge,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['success', 'error', 'warning', 'info', 'neutral', 'running'],
|
||||
},
|
||||
dot: { control: 'boolean' },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const Success: Story = { args: { children: 'Passed', variant: 'success', dot: true } };
|
||||
export const Error: Story = { args: { children: 'Failed', variant: 'error', dot: true } };
|
||||
export const Warning: Story = { args: { children: 'Warning', variant: 'warning' } };
|
||||
export const Info: Story = { args: { children: 'Info', variant: 'info' } };
|
||||
export const Neutral: Story = { args: { children: 'Pending', variant: 'neutral' } };
|
||||
export const Running: Story = { args: { children: 'Running', variant: 'running', dot: true } };
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import styles from './Badge.module.css';
|
||||
|
||||
export type BadgeVariant = 'success' | 'error' | 'warning' | 'info' | 'neutral' | 'running';
|
||||
|
||||
export interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
dot?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Badge({ variant = 'neutral', dot = false, children }: BadgeProps) {
|
||||
return (
|
||||
<span className={[styles.badge, styles[variant]].join(' ')}>
|
||||
{dot && <span className={styles.dot} aria-hidden="true" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.nav {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.current {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-border);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Breadcrumbs } from './Breadcrumbs';
|
||||
|
||||
const meta: Meta<typeof Breadcrumbs> = {
|
||||
title: 'UI/Breadcrumbs',
|
||||
component: Breadcrumbs,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Breadcrumbs>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Scenarios', href: '#' },
|
||||
{ label: 'Login flow' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Deep: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Home', href: '#' },
|
||||
{ label: 'Scenarios', href: '#' },
|
||||
{ label: 'Login flow', href: '#' },
|
||||
{ label: 'Step 3' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleItem: Story = {
|
||||
args: {
|
||||
items: [{ label: 'Dashboard' }],
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomSeparator: Story = {
|
||||
args: {
|
||||
separator: '/',
|
||||
items: [
|
||||
{ label: 'Environments', href: '#' },
|
||||
{ label: 'Staging', href: '#' },
|
||||
{ label: 'Config' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import styles from './Breadcrumbs.module.css';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
separator?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items, separator, className }: BreadcrumbsProps) {
|
||||
const sep = separator ?? (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={[styles.nav, className].filter(Boolean).join(' ')}>
|
||||
<ol className={styles.list}>
|
||||
{items.map((item, i) => {
|
||||
const isLast = i === items.length - 1;
|
||||
return (
|
||||
<li key={i} className={styles.item}>
|
||||
{isLast ? (
|
||||
<span className={styles.current} aria-current="page">{item.label}</span>
|
||||
) : item.href ? (
|
||||
<a href={item.href} className={styles.link} onClick={item.onClick}>{item.label}</a>
|
||||
) : (
|
||||
<button type="button" className={styles.link} onClick={item.onClick}>{item.label}</button>
|
||||
)}
|
||||
{!isLast && <span className={styles.separator}>{sep}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background var(--transition),
|
||||
border-color var(--transition),
|
||||
color var(--transition),
|
||||
opacity var(--transition);
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.sm {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
height: 28px;
|
||||
}
|
||||
.md {
|
||||
font-size: var(--font-size-md);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
height: 36px;
|
||||
}
|
||||
.lg {
|
||||
font-size: var(--font-size-lg);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
}
|
||||
.primary:hover:not(:disabled) { background: var(--color-primary-hover); }
|
||||
.primary:active:not(:disabled) { background: var(--color-primary-active); }
|
||||
|
||||
.secondary {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-secondary-fg);
|
||||
border-color: var(--color-secondary-border);
|
||||
}
|
||||
.secondary:hover:not(:disabled) { background: var(--color-secondary-hover); }
|
||||
.secondary:active:not(:disabled) { background: var(--color-secondary-active); }
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ghost:hover:not(:disabled) { background: var(--color-secondary); }
|
||||
.ghost:active:not(:disabled) { background: var(--color-secondary-hover); }
|
||||
|
||||
.danger {
|
||||
background: var(--color-danger);
|
||||
color: var(--color-danger-fg);
|
||||
}
|
||||
.danger:hover:not(:disabled) { background: var(--color-danger-hover); }
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button } from './Button';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'UI/Button',
|
||||
component: Button,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger'] },
|
||||
size: { control: 'select', options: ['sm', 'md', 'lg'] },
|
||||
loading: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = { args: { children: 'Button', variant: 'primary' } };
|
||||
export const Secondary: Story = { args: { children: 'Button', variant: 'secondary' } };
|
||||
export const Ghost: Story = { args: { children: 'Button', variant: 'ghost' } };
|
||||
export const Danger: Story = { args: { children: 'Delete', variant: 'danger' } };
|
||||
export const Small: Story = { args: { children: 'Small', size: 'sm' } };
|
||||
export const Large: Story = { args: { children: 'Large', size: 'lg' } };
|
||||
export const Loading: Story = { args: { children: 'Saving…', loading: true } };
|
||||
export const Disabled: Story = { args: { children: 'Button', disabled: true } };
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={[styles.button, styles[variant], styles[size], className].filter(Boolean).join(' ')}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && <span className={styles.spinner} aria-hidden="true" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.card {
|
||||
background: var(--color-bg-subtle);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.sm { padding: var(--space-3) var(--space-4); }
|
||||
.md { padding: var(--space-4) var(--space-6); }
|
||||
.lg { padding: var(--space-6) var(--space-8); }
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Card } from './Card';
|
||||
import { Badge } from '../Badge/Badge';
|
||||
import { Button } from '../Button/Button';
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: 'UI/Card',
|
||||
component: Card,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 360 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
argTypes: {
|
||||
padding: { control: 'select', options: ['sm', 'md', 'lg'] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: 'Card content goes here.', padding: 'md' },
|
||||
};
|
||||
|
||||
export const ScenarioCard: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Login flow</span>
|
||||
<Badge variant="success" dot>Passed</Badge>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 16px', fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
Last run 2 minutes ago · 4 steps
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="sm" variant="primary">Run</Button>
|
||||
<Button size="sm" variant="ghost">Edit</Button>
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import styles from './Card.module.css';
|
||||
|
||||
export interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
padding?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function Card({ children, className, padding = 'md' }: CardProps) {
|
||||
return (
|
||||
<div className={[styles.card, styles[padding], className].filter(Boolean).join(' ')}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
const palette = [
|
||||
{ name: 'Atlantis', token: '--color-primary', hex: '#9DD341', role: 'Primary / Action' },
|
||||
{ name: 'Outer Space', token: '--color-text', hex: '#2D3339', role: 'Text / Surface' },
|
||||
{ name: 'Kimberly', token: '--color-text-muted', hex: '#6650C0', role: 'Muted text / Info' },
|
||||
{ name: 'Powder Ash', token: '--color-border', hex: '#AFBFB9', role: 'Border / Neutral' },
|
||||
];
|
||||
|
||||
const semanticColors = [
|
||||
{ name: 'Success bg', token: '--color-success-bg', hex: '#EEF8D6' },
|
||||
{ name: 'Success fg', token: '--color-success-fg', hex: '#3E6010' },
|
||||
{ name: 'Error bg', token: '--color-error-bg', hex: '#FAEAEA' },
|
||||
{ name: 'Error fg', token: '--color-error-fg', hex: '#8B2020' },
|
||||
{ name: 'Warning bg', token: '--color-warning-bg', hex: '#FEF5E0' },
|
||||
{ name: 'Warning fg', token: '--color-warning-fg', hex: '#7A4E00' },
|
||||
{ name: 'Info bg', token: '--color-info-bg', hex: '#EEEDF6' },
|
||||
{ name: 'Info fg', token: '--color-info-fg', hex: '#4A4270' },
|
||||
{ name: 'Neutral bg', token: '--color-neutral-bg', hex: '#EDF1EF' },
|
||||
{ name: 'Neutral fg', token: '--color-neutral-fg', hex: '#4D6059' },
|
||||
{ name: 'Running bg', token: '--color-running-bg', hex: '#FEF9C3' },
|
||||
{ name: 'Running fg', token: '--color-running-fg', hex: '#6B4A00' },
|
||||
];
|
||||
|
||||
const surfaceColors = [
|
||||
{ name: 'Background', token: '--color-bg', hex: '#E6EDEA' },
|
||||
{ name: 'Background subtle', token: '--color-bg-subtle', hex: '#D8E2DE' },
|
||||
{ name: 'Secondary', token: '--color-secondary', hex: '#EDF1EF' },
|
||||
{ name: 'Secondary hover', token: '--color-secondary-hover', hex: '#DFE6E3' },
|
||||
{ name: 'Danger', token: '--color-danger', hex: '#D94F4F' },
|
||||
{ name: 'Primary fg', token: '--color-primary-fg', hex: '#2D3339' },
|
||||
];
|
||||
|
||||
function Swatch({ name, token, hex, role }: { name: string; token: string; hex: string; role?: string }) {;
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
border: 'var(--border-width) solid var(--color-border)',
|
||||
minWidth: 160,
|
||||
flex: '1 1 160px',
|
||||
maxWidth: 220,
|
||||
}}>
|
||||
<div style={{
|
||||
background: `var(${token}, ${hex})`,
|
||||
height: 80,
|
||||
}} />
|
||||
<div style={{ padding: '10px 12px', background: 'var(--color-bg)' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text)', marginBottom: 2 }}>{name}</div>
|
||||
{role && <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{role}</div>}
|
||||
<div style={{ fontSize: 11, fontFamily: 'monospace', color: 'var(--color-text-muted)' }}>{hex}</div>
|
||||
<div style={{ fontSize: 11, fontFamily: 'monospace', color: 'var(--color-text-muted)' }}>{token}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 40 }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-family)', color: 'var(--color-text)', fontSize: 14, fontWeight: 600, marginBottom: 16, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{title}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorPalette() {
|
||||
return (
|
||||
<div style={{ padding: 32, fontFamily: 'var(--font-family)' }}>
|
||||
<h1 style={{ color: 'var(--color-text)', fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Color Palette</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, marginBottom: 40 }}>
|
||||
All colors are exposed as CSS custom properties on <code>:root</code> via <code>tokens.css</code>.
|
||||
</p>
|
||||
<Section title="Brand">
|
||||
{palette.map(c => <Swatch key={c.token} {...c} />)}
|
||||
</Section>
|
||||
<Section title="Semantic">
|
||||
{semanticColors.map(c => <Swatch key={c.token} {...c} />)}
|
||||
</Section>
|
||||
<Section title="Surface & Action">
|
||||
{surfaceColors.map(c => <Swatch key={c.token} {...c} />)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Design Tokens/Colors',
|
||||
parameters: { layout: 'fullscreen' },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
export const All: Story = { render: () => <ColorPalette /> };
|
||||
@@ -0,0 +1,60 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-family: var(--font-family);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.input {
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.hasError {
|
||||
border-color: var(--color-error-fg);
|
||||
}
|
||||
.hasError:focus {
|
||||
border-color: var(--color-error-fg);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Input } from './Input';
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'UI/Input',
|
||||
component: Input,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 320 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Input>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { label: 'Label', placeholder: 'Placeholder' },
|
||||
};
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
label: 'Environment name',
|
||||
placeholder: 'e.g. staging',
|
||||
hint: 'Used to identify this environment in scenarios.',
|
||||
},
|
||||
};
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'URL',
|
||||
placeholder: 'https://...',
|
||||
defaultValue: 'not a url',
|
||||
error: 'Must be a valid URL.',
|
||||
},
|
||||
};
|
||||
export const Disabled: Story = {
|
||||
args: { label: 'Label', value: 'Some value', disabled: true, readOnly: true },
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useId } from 'react';
|
||||
import styles from './Input.module.css';
|
||||
|
||||
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'id'> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, hint, error, className, ...props }: InputProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{label && (
|
||||
<label htmlFor={id} className={styles.label}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
className={[styles.input, error ? styles.hasError : '', className].filter(Boolean).join(' ')}
|
||||
aria-describedby={error ? `${id}-error` : hint ? `${id}-hint` : undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error ? (
|
||||
<span id={`${id}-error`} className={styles.error} role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : hint ? (
|
||||
<span id={`${id}-hint`} className={styles.hint}>
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-family: var(--font-family);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select {
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding-right: calc(var(--space-3) + 20px);
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.hasError {
|
||||
border-color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
.hasError:focus {
|
||||
border-color: var(--color-error-fg);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error-fg) 20%, transparent);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
pointer-events: none;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select:disabled ~ .chevron {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Select } from './Select';
|
||||
|
||||
const ENVIRONMENTS = [
|
||||
{ value: 'dev', label: 'Development' },
|
||||
{ value: 'staging', label: 'Staging' },
|
||||
{ value: 'prod', label: 'Production' },
|
||||
];
|
||||
|
||||
const SCENARIO_TYPES = [
|
||||
{ value: 'smoke', label: 'Smoke test' },
|
||||
{ value: 'regression', label: 'Regression' },
|
||||
{ value: 'e2e', label: 'End-to-end' },
|
||||
{ value: 'perf', label: 'Performance', disabled: true },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: 'UI/Select',
|
||||
component: Select,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 320 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Select>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Environment',
|
||||
options: ENVIRONMENTS,
|
||||
placeholder: 'Select an environment…',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
label: 'Environment',
|
||||
options: ENVIRONMENTS,
|
||||
defaultValue: 'staging',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
label: 'Test type',
|
||||
options: SCENARIO_TYPES,
|
||||
placeholder: 'Choose a type…',
|
||||
hint: 'Performance tests require a dedicated runner.',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Environment',
|
||||
options: ENVIRONMENTS,
|
||||
defaultValue: '',
|
||||
placeholder: 'Select an environment…',
|
||||
error: 'Please select an environment.',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Environment',
|
||||
options: ENVIRONMENTS,
|
||||
defaultValue: 'prod',
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useId } from 'react';
|
||||
import styles from './Select.module.css';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'id'> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function Select({ label, hint, error, options, placeholder, className, ...props }: SelectProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{label && (
|
||||
<label htmlFor={id} className={styles.label}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className={styles.control}>
|
||||
<select
|
||||
id={id}
|
||||
className={[styles.select, error ? styles.hasError : '', className].filter(Boolean).join(' ')}
|
||||
aria-describedby={error ? `${id}-error` : hint ? `${id}-hint` : undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className={styles.chevron} aria-hidden="true">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 4L6 8L10 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<span id={`${id}-error`} className={styles.error} role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : hint ? (
|
||||
<span id={`${id}-hint`} className={styles.hint}>
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
.panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: var(--side-panel-width, 240px);
|
||||
background: var(--color-bg-subtle);
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
font-family: var(--font-family);
|
||||
transition: width 220ms ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.panel.collapsed {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.inner {
|
||||
width: var(--side-panel-width, 240px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.collapsed .inner {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--space-4) var(--space-4) var(--space-3);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
/* ── Toggle button ───────────────────────────────────────── */
|
||||
.toggle {
|
||||
position: absolute;
|
||||
top: var(--space-5);
|
||||
right: -14px;
|
||||
z-index: 10;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-secondary-fg);
|
||||
border-color: var(--color-secondary-border);
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Chevron rotation ────────────────────────────────────── */
|
||||
.chevron {
|
||||
transition: transform 220ms ease;
|
||||
/* Points right (→) by default = collapse direction */
|
||||
}
|
||||
|
||||
.collapsed .chevron {
|
||||
transform: rotate(180deg);
|
||||
/* Points left (←) = expand direction */
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { SidePanel } from './SidePanel';
|
||||
|
||||
const NAV_ITEMS = ['Dashboard', 'Scenarios', 'Environments', 'Reports', 'Settings'];
|
||||
|
||||
function NavList() {
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item}>
|
||||
<button
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-text)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-family)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-secondary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SidePanel> = {
|
||||
title: 'UI/SidePanel',
|
||||
component: SidePanel,
|
||||
parameters: { layout: 'fullscreen' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ display: 'flex', height: 420, background: 'var(--color-bg)' }}>
|
||||
<Story />
|
||||
<div style={{ flex: 1, padding: 'var(--space-6)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||||
Main content area
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SidePanel>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
header: 'Navigation',
|
||||
children: <NavList />,
|
||||
},
|
||||
};
|
||||
|
||||
export const DefaultCollapsed: Story = {
|
||||
args: {
|
||||
header: 'Navigation',
|
||||
children: <NavList />,
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NarrowWidth: Story = {
|
||||
args: {
|
||||
header: 'Filters',
|
||||
width: 180,
|
||||
children: <NavList />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WideWidth: Story = {
|
||||
args: {
|
||||
header: 'Scenarios',
|
||||
width: 320,
|
||||
children: <NavList />,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
import styles from './SidePanel.module.css';
|
||||
|
||||
export interface SidePanelProps {
|
||||
children: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
width?: number;
|
||||
defaultCollapsed?: boolean;
|
||||
collapsed?: boolean;
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidePanel({
|
||||
children,
|
||||
header,
|
||||
width = 240,
|
||||
defaultCollapsed = false,
|
||||
collapsed: controlledCollapsed,
|
||||
onCollapsedChange,
|
||||
className,
|
||||
}: SidePanelProps) {
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
|
||||
const isControlled = controlledCollapsed !== undefined;
|
||||
const collapsed = isControlled ? controlledCollapsed : internalCollapsed;
|
||||
|
||||
function toggle() {
|
||||
const next = !collapsed;
|
||||
if (!isControlled) setInternalCollapsed(next);
|
||||
onCollapsedChange?.(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[styles.panel, collapsed ? styles.collapsed : '', className].filter(Boolean).join(' ')}
|
||||
style={{ '--side-panel-width': `${width}px` } as React.CSSProperties}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<div className={styles.inner}>
|
||||
{header && <div className={styles.header}>{header}</div>}
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
<button
|
||||
className={styles.toggle}
|
||||
onClick={toggle}
|
||||
aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
<svg
|
||||
className={styles.chevron}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 3L11 8L6 13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-subtle);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
/* Rounded top corners on first/last header cells */
|
||||
.th:first-child { border-radius: var(--radius-lg) 0 0 0; }
|
||||
.th:last-child { border-radius: 0 var(--radius-lg) 0 0; }
|
||||
|
||||
.tr {
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.tr:not(:last-child) .td {
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Table } from './Table';
|
||||
import { Badge } from '../Badge/Badge';
|
||||
|
||||
const meta: Meta<typeof Table> = {
|
||||
title: 'UI/Table',
|
||||
component: Table,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
// ── Simple string rows ────────────────────────────────────────────────────────
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
const users: User[] = [
|
||||
{ id: 1, name: 'Alice Müller', email: 'alice@example.com', status: 'active' },
|
||||
{ id: 2, name: 'Bob Nguyen', email: 'bob@example.com', status: 'inactive' },
|
||||
{ id: 3, name: 'Carol Santos', email: 'carol@example.com', status: 'active' },
|
||||
];
|
||||
|
||||
export const Default: StoryObj<typeof Table<User>> = {
|
||||
args: {
|
||||
data: users,
|
||||
rowKey: (u) => u.id,
|
||||
emptyMessage: 'No users found.',
|
||||
columns: [
|
||||
{ key: 'id', header: 'ID', render: (u) => u.id, width: 60 },
|
||||
{ key: 'name', header: 'Name', render: (u) => u.name },
|
||||
{ key: 'email', header: 'Email', render: (u) => u.email },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: 110,
|
||||
render: (u) => (
|
||||
<Badge variant={u.status === 'active' ? 'success' : 'neutral'}>
|
||||
{u.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: StoryObj<typeof Table<User>> = {
|
||||
args: {
|
||||
...Default.args,
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: StoryObj<typeof Table<User>> = {
|
||||
args: {
|
||||
...Default.args,
|
||||
data: [],
|
||||
emptyMessage: 'No users found.',
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleRow: StoryObj<typeof Table<User>> = {
|
||||
args: {
|
||||
...Default.args,
|
||||
data: [users[0]],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './Table.module.css';
|
||||
|
||||
export interface TableColumn<T> {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
render: (row: T) => ReactNode;
|
||||
width?: string | number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
data: T[];
|
||||
rowKey: (row: T) => string | number;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Table<T>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
loading = false,
|
||||
emptyMessage = 'No data.',
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
return (
|
||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={styles.th}
|
||||
style={{
|
||||
width: col.width,
|
||||
textAlign: col.align ?? 'left',
|
||||
}}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className={styles.empty}>
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className={styles.empty}>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={rowKey(row)} className={styles.tr}>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={styles.td}
|
||||
style={{ textAlign: col.align ?? 'left' }}
|
||||
>
|
||||
{col.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export { Button } from './Button/Button';
|
||||
export type { ButtonProps } from './Button/Button';
|
||||
|
||||
export { Badge } from './Badge/Badge';
|
||||
export type { BadgeProps, BadgeVariant } from './Badge/Badge';
|
||||
|
||||
export { Input } from './Input/Input';
|
||||
export type { InputProps } from './Input/Input';
|
||||
|
||||
export { Select } from './Select/Select';
|
||||
export type { SelectProps, SelectOption } from './Select/Select';
|
||||
|
||||
export { Card } from './Card/Card';
|
||||
export type { CardProps } from './Card/Card';
|
||||
|
||||
export { SidePanel } from './SidePanel/SidePanel';
|
||||
export type { SidePanelProps } from './SidePanel/SidePanel';
|
||||
|
||||
export { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
|
||||
export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs/Breadcrumbs';
|
||||
|
||||
export { Table } from './Table/Table';
|
||||
export type { TableProps, TableColumn } from './Table/Table';
|
||||
@@ -0,0 +1,134 @@
|
||||
:root {
|
||||
/* ── Palette ──────────────────────────────────────────────────────────────
|
||||
* Atlantis #9DD341 — primary / action
|
||||
* Outer Space #2D3339 — text / surfaces
|
||||
* Kimberly #6650C0 — accent / info / muted text
|
||||
* Powder Ash #AFBFB9 — borders / neutral
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Primary — Atlantis */
|
||||
--color-primary: #9DD341;
|
||||
--color-primary-hover: #8ABD2E;
|
||||
--color-primary-active: #77A320;
|
||||
--color-primary-fg: #2D3339; /* Outer Space on bright green */
|
||||
|
||||
/* Secondary — Kimberly tint */
|
||||
--color-secondary: #EDEAF8;
|
||||
--color-secondary-hover: #DDD8F2;
|
||||
--color-secondary-active: #CCC5EB;
|
||||
--color-secondary-fg: #6650C0; /* Kimberly */
|
||||
--color-secondary-border: #B8B0E0;
|
||||
|
||||
/* Danger */
|
||||
--color-danger: #D94F4F;
|
||||
--color-danger-hover: #C03A3A;
|
||||
--color-danger-fg: #ffffff;
|
||||
|
||||
/* Semantic */
|
||||
--color-success-bg: #EEF8D6; /* Atlantis tint */
|
||||
--color-success-fg: #3E6010;
|
||||
--color-error-bg: #FAEAEA;
|
||||
--color-error-fg: #8B2020;
|
||||
--color-warning-bg: #FEF5E0;
|
||||
--color-warning-fg: #7A4E00;
|
||||
--color-info-bg: #EEEDF6; /* Kimberly tint */
|
||||
--color-info-fg: #4A4270;
|
||||
--color-neutral-bg: #EDF1EF; /* Powder Ash tint */
|
||||
--color-neutral-fg: #4D6059;
|
||||
--color-running-bg: #FEF9C3;
|
||||
--color-running-fg: #6B4A00;
|
||||
|
||||
/* Text & Surface — Outer Space */
|
||||
--color-text: #2D3339;
|
||||
--color-text-muted: #6650C0; /* Kimberly */
|
||||
--color-border: #AFBFB9; /* Powder Ash */
|
||||
--color-bg: #E6EDEA; /* Powder Ash tint */
|
||||
--color-bg-subtle: #D8E2DE; /* Powder Ash tint, 1 step darker */
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Typography */
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-size-xs: 13px;
|
||||
--font-size-sm: 15px;
|
||||
--font-size-md: 17px;
|
||||
--font-size-lg: 20px;
|
||||
|
||||
/* Borders */
|
||||
--border-width: 1.5px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Transitions */
|
||||
--transition: 150ms ease;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Dark theme ─────────────────────────────────────────────────────────── */
|
||||
[data-theme="dark"] {
|
||||
/* Primary — Atlantis unchanged, pops well on dark */
|
||||
--color-primary: #9DD341;
|
||||
--color-primary-hover: #AEDE55;
|
||||
--color-primary-active: #8ABD2E;
|
||||
--color-primary-fg: #1A1F24;
|
||||
|
||||
/* Secondary — dark Kimberly tint */
|
||||
--color-secondary: #2A2548;
|
||||
--color-secondary-hover: #352F58;
|
||||
--color-secondary-active: #403868;
|
||||
--color-secondary-fg: #A9A3C9; /* lightened Kimberly */
|
||||
--color-secondary-border: #5A538A;
|
||||
|
||||
/* Danger */
|
||||
--color-danger: #E06060;
|
||||
--color-danger-hover: #CC4A4A;
|
||||
--color-danger-fg: #1A1F24;
|
||||
|
||||
/* Semantic */
|
||||
--color-success-bg: #1A3410;
|
||||
--color-success-fg: #9DD341;
|
||||
--color-error-bg: #3A1212;
|
||||
--color-error-fg: #F08888;
|
||||
--color-warning-bg: #3A2800;
|
||||
--color-warning-fg: #F0C860;
|
||||
--color-info-bg: #1E1A38;
|
||||
--color-info-fg: #B0AAD6; /* lightened Kimberly */
|
||||
--color-neutral-bg: #2D3339;
|
||||
--color-neutral-fg: #AFBFB9;
|
||||
--color-running-bg: #382E00;
|
||||
--color-running-fg: #F0D060;
|
||||
|
||||
/* Text & Surface */
|
||||
--color-text: #E2EAE6;
|
||||
--color-text-muted: #A9A3C9; /* lightened Kimberly */
|
||||
--color-border: #3D4850;
|
||||
--color-bg: #1A1F24; /* deeper than Outer Space */
|
||||
--color-bg-subtle: #2D3339; /* Outer Space */
|
||||
}
|
||||
Reference in New Issue
Block a user