feat(ui,web): Phase 2 — Shared Components & Terminal Panel (#449) #452
257
apps/web/src/components/terminal/TerminalPanel.tsx
Normal file
257
apps/web/src/components/terminal/TerminalPanel.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import type { ReactElement, CSSProperties } from "react";
|
||||||
|
|
||||||
|
export interface TerminalLine {
|
||||||
|
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tabs?: TerminalTab[];
|
||||||
|
activeTab?: string;
|
||||||
|
onTabChange?: (id: string) => void;
|
||||||
|
lines?: TerminalLine[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTabs: TerminalTab[] = [
|
||||||
|
{ id: "main", label: "main" },
|
||||||
|
{ id: "build", label: "build" },
|
||||||
|
{ id: "logs", label: "logs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const blinkKeyframes = `
|
||||||
|
@keyframes ms-terminal-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let blinkStyleInjected = false;
|
||||||
|
|
||||||
|
function ensureBlinkStyle(): void {
|
||||||
|
if (blinkStyleInjected || typeof document === "undefined") return;
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.textContent = blinkKeyframes;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
blinkStyleInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineColor(type: TerminalLine["type"]): string {
|
||||||
|
switch (type) {
|
||||||
|
case "prompt":
|
||||||
|
return "var(--success)";
|
||||||
|
case "command":
|
||||||
|
return "var(--text-2)";
|
||||||
|
case "output":
|
||||||
|
return "var(--muted)";
|
||||||
|
case "error":
|
||||||
|
return "var(--danger)";
|
||||||
|
case "warning":
|
||||||
|
return "var(--warn)";
|
||||||
|
case "success":
|
||||||
|
return "var(--success)";
|
||||||
|
default:
|
||||||
|
return "var(--muted)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
lines = [],
|
||||||
|
className = "",
|
||||||
|
}: TerminalPanelProps): ReactElement {
|
||||||
|
ensureBlinkStyle();
|
||||||
|
|
||||||
|
const resolvedTabs = tabs ?? defaultTabs;
|
||||||
|
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
||||||
|
|
||||||
|
const panelStyle: CSSProperties = {
|
||||||
|
background: "var(--bg-deep)",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: open ? 280 : 0,
|
||||||
|
transition: "height 0.3s ease",
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "6px 16px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabBarStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionsStyle: CSSProperties = {
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyStyle: CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "10px 16px",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cursorStyle: CSSProperties = {
|
||||||
|
display: "inline-block",
|
||||||
|
width: 7,
|
||||||
|
height: 14,
|
||||||
|
background: "var(--success)",
|
||||||
|
marginLeft: 2,
|
||||||
|
animation: "ms-terminal-blink 1s step-end infinite",
|
||||||
|
verticalAlign: "text-bottom",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={panelStyle}
|
||||||
|
role="region"
|
||||||
|
aria-label="Terminal panel"
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={headerStyle}>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||||
|
{resolvedTabs.map((tab) => {
|
||||||
|
const isActive = tab.id === resolvedActiveTab;
|
||||||
|
const tabStyle: CSSProperties = {
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: isActive ? "var(--success)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: isActive ? "var(--surface)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
style={tabStyle}
|
||||||
|
onClick={(): void => {
|
||||||
|
onTabChange?.(tab.id);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div style={actionsStyle}>
|
||||||
|
<button
|
||||||
|
aria-label="Close terminal"
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close icon — simple X using SVG */}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M1 1L11 11M11 1L1 11"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const isLast = index === lines.length - 1;
|
||||||
|
const lineStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
const contentStyle: CSSProperties = {
|
||||||
|
color: getLineColor(line.type),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} style={lineStyle}>
|
||||||
|
<span style={contentStyle}>
|
||||||
|
{line.content}
|
||||||
|
{isLast && <span aria-hidden="true" style={cursorStyle} />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Show cursor even when no lines */}
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<span style={{ color: "var(--success)" }}>
|
||||||
|
<span aria-hidden="true" style={cursorStyle} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
apps/web/src/components/terminal/index.ts
Normal file
2
apps/web/src/components/terminal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
|
||||||
|
export { TerminalPanel } from "./TerminalPanel";
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
| MS15-FE-004 | done | Topbar/Header component (logo, search, status, notifications, theme toggle, avatar dropdown) | #448 | web | feat/ms15-design-system | MS15-FE-002 | MS15-FE-005 | w-4 | 2026-02-22T15:30Z | 2026-02-22T16:00Z | 25K | 44K | Search, status, notifications, avatar dropdown. Commit 04f9918. |
|
| MS15-FE-004 | done | Topbar/Header component (logo, search, status, notifications, theme toggle, avatar dropdown) | #448 | web | feat/ms15-design-system | MS15-FE-002 | MS15-FE-005 | w-4 | 2026-02-22T15:30Z | 2026-02-22T16:00Z | 25K | 44K | Search, status, notifications, avatar dropdown. Commit 04f9918. |
|
||||||
| MS15-FE-005 | done | Responsive layout (breakpoints, hamburger, sidebar auto-hide at mobile) | #448 | web | feat/ms15-design-system | MS15-FE-003,MS15-FE-004 | MS15-QA-001 | w-5 | 2026-02-22T16:00Z | 2026-02-22T16:30Z | 20K | 57K | Mobile overlay, hamburger, matchMedia. Commit 28620b2. |
|
| MS15-FE-005 | done | Responsive layout (breakpoints, hamburger, sidebar auto-hide at mobile) | #448 | web | feat/ms15-design-system | MS15-FE-003,MS15-FE-004 | MS15-QA-001 | w-5 | 2026-02-22T16:00Z | 2026-02-22T16:30Z | 20K | 57K | Mobile overlay, hamburger, matchMedia. Commit 28620b2. |
|
||||||
| MS15-FE-006 | done | Loading spinner (Mosaic logo icon with rotation animation, site-wide) | #448 | web | feat/ms15-design-system | MS15-FE-001 | | w-2 | 2026-02-22T14:30Z | 2026-02-22T15:00Z | 10K | 8K | MosaicLogo + MosaicSpinner components. Same commit e615fa8. |
|
| MS15-FE-006 | done | Loading spinner (Mosaic logo icon with rotation animation, site-wide) | #448 | web | feat/ms15-design-system | MS15-FE-001 | | w-2 | 2026-02-22T14:30Z | 2026-02-22T15:00Z | 10K | 8K | MosaicLogo + MosaicSpinner components. Same commit e615fa8. |
|
||||||
| MS15-UI-001 | not-started | Align packages/ui tokens with new CSS variable design system | #449 | ui | feat/ms15-shared-components | MS15-FE-001 | MS15-UI-002,MS15-UI-003,MS15-UI-004 | | | | 20K | | |
|
| MS15-UI-001 | done | Align packages/ui tokens with new CSS variable design system | #449 | ui | feat/ms15-shared-components | MS15-FE-001 | MS15-UI-002,MS15-UI-003,MS15-UI-004 | w-6 | 2026-02-22T17:00Z | 2026-02-22T17:30Z | 20K | 35K | Combined with UI-002. Commit 44011f4. Build passes. |
|
||||||
| MS15-UI-002 | not-started | Update Card, Badge, Button, Dot component variants to match reference | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | | | | 25K | | |
|
| MS15-UI-002 | done | Update Card, Badge, Button, Dot component variants to match reference | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | w-6 | 2026-02-22T17:00Z | 2026-02-22T17:30Z | 25K | 0K | Combined with UI-001 (same commit 44011f4). |
|
||||||
| MS15-UI-003 | not-started | Create MetricsStrip, ProgressBar, FilterTabs shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | | | | 20K | | |
|
| MS15-UI-003 | done | Create MetricsStrip, ProgressBar, FilterTabs shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | w-7 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 20K | 30K | Commit 9b0445c. Build passes. |
|
||||||
| MS15-UI-004 | not-started | Create SectionHeader, Table, LogLine shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-002 | | | | 15K | | |
|
| MS15-UI-004 | done | Create SectionHeader, Table, LogLine shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-002 | w-8 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 15K | 25K | Commit 9b0445c. Build passes. |
|
||||||
| MS15-UI-005 | not-started | Create Terminal panel component (bottom drawer, tabs, output) | #449 | web | feat/ms15-shared-components | MS15-UI-001 | | | | | 20K | | |
|
| MS15-UI-005 | done | Create Terminal panel component (bottom drawer, tabs, output) | #449 | web | feat/ms15-shared-components | MS15-UI-001 | | w-9 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 20K | 30K | Commit 9b0445c. Build passes. |
|
||||||
| MS15-DASH-001 | not-started | Dashboard metrics strip (6 cells, colored borders, values, trends) | #450 | web | feat/ms15-dashboard-page | MS15-UI-002,MS15-UI-003 | | | | | 15K | | |
|
| MS15-DASH-001 | not-started | Dashboard metrics strip (6 cells, colored borders, values, trends) | #450 | web | feat/ms15-dashboard-page | MS15-UI-002,MS15-UI-003 | | | | | 15K | | |
|
||||||
| MS15-DASH-002 | not-started | Active Orchestrator Sessions card with agent nodes | #450 | web | feat/ms15-dashboard-page | MS15-UI-004 | | | | | 20K | | |
|
| MS15-DASH-002 | not-started | Active Orchestrator Sessions card with agent nodes | #450 | web | feat/ms15-dashboard-page | MS15-UI-004 | | | | | 20K | | |
|
||||||
| MS15-DASH-003 | not-started | Quick Actions 2x2 grid | #450 | web | feat/ms15-dashboard-page | MS15-UI-002 | | | | | 10K | | |
|
| MS15-DASH-003 | not-started | Quick Actions 2x2 grid | #450 | web | feat/ms15-dashboard-page | MS15-UI-002 | | | | | 10K | | |
|
||||||
|
|||||||
@@ -15,41 +15,51 @@ export function Avatar({
|
|||||||
fallback,
|
fallback,
|
||||||
initials,
|
initials,
|
||||||
className = "",
|
className = "",
|
||||||
|
style,
|
||||||
...props
|
...props
|
||||||
}: AvatarProps): ReactElement {
|
}: AvatarProps): ReactElement {
|
||||||
const sizeStyles = {
|
type AvatarSize = "sm" | "md" | "lg" | "xl";
|
||||||
|
const sizeStyles: Record<AvatarSize, string> = {
|
||||||
sm: "w-6 h-6 text-xs",
|
sm: "w-6 h-6 text-xs",
|
||||||
md: "w-8 h-8 text-sm",
|
md: "w-8 h-8 text-sm",
|
||||||
lg: "w-12 h-12 text-base",
|
lg: "w-12 h-12 text-base",
|
||||||
xl: "w-16 h-16 text-xl",
|
xl: "w-16 h-16 text-xl",
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseStyles =
|
const baseClass = `rounded-full overflow-hidden flex items-center justify-center font-medium ${sizeStyles[size]} ${className}`;
|
||||||
"rounded-full overflow-hidden flex items-center justify-center bg-gray-200 font-medium text-gray-600";
|
|
||||||
|
|
||||||
const combinedClassName = [baseStyles, sizeStyles[size], className].filter(Boolean).join(" ");
|
const gradientStyle: React.CSSProperties = {
|
||||||
|
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||||
|
color: "#fff",
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
if (src) {
|
if (src) {
|
||||||
return <img src={src} alt={alt} className={`${combinedClassName} object-cover`} {...props} />;
|
return (
|
||||||
|
<img src={src} alt={alt} className={`${baseClass} object-cover`} style={style} {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
return <div className={combinedClassName}>{fallback}</div>;
|
return (
|
||||||
|
<div className={baseClass} style={gradientStyle}>
|
||||||
|
{fallback}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initials) {
|
if (initials) {
|
||||||
return <div className={combinedClassName}>{initials}</div>;
|
return (
|
||||||
|
<div className={baseClass} style={gradientStyle}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback with user icon
|
// Default fallback with user icon
|
||||||
return (
|
return (
|
||||||
<div className={combinedClassName}>
|
<div className={baseClass} style={gradientStyle}>
|
||||||
<svg
|
<svg className="w-1/2 h-1/2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
className="w-1/2 h-1/2 text-gray-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||||
|
|||||||
@@ -8,38 +8,199 @@ export type BadgeVariant =
|
|||||||
| "status-warning"
|
| "status-warning"
|
||||||
| "status-error"
|
| "status-error"
|
||||||
| "status-info"
|
| "status-info"
|
||||||
| "status-neutral";
|
| "status-neutral"
|
||||||
|
| "badge-teal"
|
||||||
|
| "badge-amber"
|
||||||
|
| "badge-red"
|
||||||
|
| "badge-blue"
|
||||||
|
| "badge-muted"
|
||||||
|
| "badge-purple"
|
||||||
|
| "badge-pulse";
|
||||||
|
|
||||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<BadgeVariant, string> = {
|
interface BadgeStyleDef {
|
||||||
"priority-high": "bg-red-100 text-red-800 border-red-200",
|
style: React.CSSProperties;
|
||||||
"priority-medium": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
pulse?: boolean;
|
||||||
"priority-low": "bg-green-100 text-green-800 border-green-200",
|
}
|
||||||
"status-success": "bg-green-100 text-green-800 border-green-200",
|
|
||||||
"status-warning": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
const variantDefs: Record<BadgeVariant, BadgeStyleDef> = {
|
||||||
"status-error": "bg-red-100 text-red-800 border-red-200",
|
"priority-high": {
|
||||||
"status-info": "bg-blue-100 text-blue-800 border-blue-200",
|
style: {
|
||||||
"status-neutral": "bg-gray-100 text-gray-800 border-gray-200",
|
background: "rgba(229,72,77,0.12)",
|
||||||
|
color: "var(--ms-red-400)",
|
||||||
|
border: "1px solid rgba(229,72,77,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"priority-medium": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(245,158,11,0.12)",
|
||||||
|
color: "var(--ms-amber-400)",
|
||||||
|
border: "1px solid rgba(245,158,11,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"priority-low": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(20,184,166,0.12)",
|
||||||
|
color: "var(--ms-teal-400)",
|
||||||
|
border: "1px solid rgba(20,184,166,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status-success": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(20,184,166,0.12)",
|
||||||
|
color: "var(--ms-teal-400)",
|
||||||
|
border: "1px solid rgba(20,184,166,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status-warning": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(245,158,11,0.12)",
|
||||||
|
color: "var(--ms-amber-400)",
|
||||||
|
border: "1px solid rgba(245,158,11,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status-error": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(229,72,77,0.12)",
|
||||||
|
color: "var(--ms-red-400)",
|
||||||
|
border: "1px solid rgba(229,72,77,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status-info": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(47,128,255,0.12)",
|
||||||
|
color: "var(--ms-blue-400)",
|
||||||
|
border: "1px solid rgba(47,128,255,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status-neutral": {
|
||||||
|
style: {
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"badge-teal": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(20,184,166,0.12)",
|
||||||
|
color: "var(--ms-teal-400)",
|
||||||
|
border: "1px solid rgba(20,184,166,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"badge-amber": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(245,158,11,0.12)",
|
||||||
|
color: "var(--ms-amber-400)",
|
||||||
|
border: "1px solid rgba(245,158,11,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"badge-red": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(229,72,77,0.12)",
|
||||||
|
color: "var(--ms-red-400)",
|
||||||
|
border: "1px solid rgba(229,72,77,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"badge-blue": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(47,128,255,0.12)",
|
||||||
|
color: "var(--ms-blue-400)",
|
||||||
|
border: "1px solid rgba(47,128,255,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"badge-muted": {
|
||||||
|
style: {
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"badge-purple": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(139,92,246,0.12)",
|
||||||
|
color: "var(--ms-purple-400)",
|
||||||
|
border: "1px solid rgba(139,92,246,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"badge-pulse": {
|
||||||
|
style: {
|
||||||
|
background: "rgba(47,128,255,0.12)",
|
||||||
|
color: "var(--ms-blue-400)",
|
||||||
|
border: "1px solid rgba(47,128,255,0.2)",
|
||||||
|
},
|
||||||
|
pulse: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pulseKeyframes = `
|
||||||
|
@keyframes ms-badge-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let pulseStyleInjected = false;
|
||||||
|
|
||||||
|
function ensurePulseStyle(): void {
|
||||||
|
if (pulseStyleInjected || typeof document === "undefined") return;
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.textContent = pulseKeyframes;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
pulseStyleInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
export function Badge({
|
export function Badge({
|
||||||
variant = "status-neutral",
|
variant = "status-neutral",
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
|
style,
|
||||||
...props
|
...props
|
||||||
}: BadgeProps): ReactElement {
|
}: BadgeProps): ReactElement {
|
||||||
const baseStyles =
|
const def = variantDefs[variant];
|
||||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border";
|
|
||||||
const combinedClassName = [baseStyles, variantStyles[variant], className]
|
if (def.pulse) {
|
||||||
.filter(Boolean)
|
ensurePulseStyle();
|
||||||
.join(" ");
|
}
|
||||||
|
|
||||||
|
const baseStyle: React.CSSProperties = {
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "20px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "5px",
|
||||||
|
...def.style,
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={combinedClassName} role="status" aria-label={children as string} {...props}>
|
<span
|
||||||
|
className={className}
|
||||||
|
style={baseStyle}
|
||||||
|
role="status"
|
||||||
|
aria-label={children as string}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{def.pulse && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--ms-blue-400)",
|
||||||
|
flexShrink: 0,
|
||||||
|
animation: "ms-badge-pulse 1.4s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +1,120 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode, ReactElement } from "react";
|
import { useState, type ButtonHTMLAttributes, type ReactNode, type ReactElement } from "react";
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VariantStyle {
|
||||||
|
base: React.CSSProperties;
|
||||||
|
hover: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||||
|
|
||||||
|
const variantStyles: Record<ButtonVariant, VariantStyle> = {
|
||||||
|
primary: {
|
||||||
|
base: {
|
||||||
|
background: "var(--ms-blue-500)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
background: "var(--ms-blue-400)",
|
||||||
|
boxShadow: "0 4px 16px rgba(47,128,255,0.3)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
base: {
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
base: {
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
base: {
|
||||||
|
background: "rgba(229,72,77,0.12)",
|
||||||
|
border: "1px solid rgba(229,72,77,0.3)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
background: "rgba(229,72,77,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
base: {
|
||||||
|
background: "rgba(20,184,166,0.12)",
|
||||||
|
border: "1px solid rgba(20,184,166,0.3)",
|
||||||
|
color: "var(--success)",
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
background: "rgba(20,184,166,0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
const sizeStyles: Record<ButtonSize, string> = {
|
||||||
|
sm: "px-3 py-1.5 text-sm",
|
||||||
|
md: "px-4 py-2 text-base",
|
||||||
|
lg: "px-6 py-3 text-lg",
|
||||||
|
};
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
variant = "primary",
|
variant = "primary",
|
||||||
size = "md",
|
size = "md",
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
|
style,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps): ReactElement {
|
}: ButtonProps): ReactElement {
|
||||||
const baseStyles = "inline-flex items-center justify-center font-medium rounded-md";
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
const variantStyles = {
|
const vStyles = variantStyles[variant];
|
||||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
const baseClass = `inline-flex items-center justify-center font-medium rounded-md transition-colors ${sizeStyles[size]} ${className}`;
|
||||||
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
|
||||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
const computedStyle: React.CSSProperties = {
|
||||||
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border border-gray-300",
|
...vStyles.base,
|
||||||
|
...(isHovered && !disabled ? vStyles.hover : {}),
|
||||||
|
...(disabled ? { opacity: 0.5, cursor: "not-allowed" } : { cursor: "pointer" }),
|
||||||
|
...style,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
|
||||||
sm: "px-3 py-1.5 text-sm",
|
|
||||||
md: "px-4 py-2 text-base",
|
|
||||||
lg: "px-6 py-3 text-lg",
|
|
||||||
};
|
|
||||||
|
|
||||||
const combinedClassName = [baseStyles, variantStyles[variant], sizeStyles[size], className]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={combinedClassName} {...props}>
|
<button
|
||||||
|
className={baseClass}
|
||||||
|
style={computedStyle}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
setIsHovered(true);
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
setIsHovered(false);
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ReactNode, ReactElement } from "react";
|
|||||||
export interface CardProps {
|
export interface CardProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
id?: string;
|
id?: string;
|
||||||
onMouseEnter?: () => void;
|
onMouseEnter?: () => void;
|
||||||
onMouseLeave?: () => void;
|
onMouseLeave?: () => void;
|
||||||
@@ -11,21 +12,25 @@ export interface CardProps {
|
|||||||
export interface CardHeaderProps {
|
export interface CardHeaderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardContentProps {
|
export interface CardContentProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardFooterProps {
|
export interface CardFooterProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({
|
export function Card({
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
|
style,
|
||||||
id,
|
id,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
@@ -35,24 +40,52 @@ export function Card({
|
|||||||
id={id}
|
id={id}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
className={`bg-white rounded-lg shadow-md border border-gray-200 ${className}`}
|
className={className}
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: "16px",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeader({ children, className = "" }: CardHeaderProps): ReactElement {
|
export function CardHeader({ children, className = "", style }: CardHeaderProps): ReactElement {
|
||||||
return <div className={`px-6 py-4 border-b border-gray-200 ${className}`}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardContent({ children, className = "" }: CardContentProps): ReactElement {
|
|
||||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardFooter({ children, className = "" }: CardFooterProps): ReactElement {
|
|
||||||
return (
|
return (
|
||||||
<div className={`px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg ${className}`}>
|
<div
|
||||||
|
className={`px-6 py-4 ${className}`}
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ children, className = "", style }: CardContentProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 ${className}`} style={style}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ children, className = "", style }: CardFooterProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-6 py-4 rounded-b-lg ${className}`}
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
102
packages/ui/src/components/DataTable.tsx
Normal file
102
packages/ui/src/components/DataTable.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState, type ReactElement, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface DataTableColumn<T> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
render?: (row: T) => ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<T extends Record<string, unknown>> {
|
||||||
|
columns: DataTableColumn<T>[];
|
||||||
|
data: T[];
|
||||||
|
className?: string;
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<T extends Record<string, unknown>>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
className = "",
|
||||||
|
onRowClick,
|
||||||
|
}: DataTableProps<T>): ReactElement {
|
||||||
|
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={col.className}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.07em",
|
||||||
|
color: "var(--muted)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row, rowIndex) => {
|
||||||
|
const isLast = rowIndex === data.length - 1;
|
||||||
|
const isHovered = hoveredRow === rowIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={rowIndex}
|
||||||
|
style={{
|
||||||
|
background: isHovered ? "var(--surface)" : undefined,
|
||||||
|
cursor: onRowClick !== undefined ? "pointer" : undefined,
|
||||||
|
}}
|
||||||
|
onClick={
|
||||||
|
onRowClick !== undefined
|
||||||
|
? (): void => {
|
||||||
|
onRowClick(row);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseEnter={(): void => {
|
||||||
|
setHoveredRow(rowIndex);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(): void => {
|
||||||
|
setHoveredRow(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className={col.className}
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderBottom: isLast ? undefined : "1px solid var(--border)",
|
||||||
|
color: isHovered ? "var(--text)" : "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{col.render !== undefined ? col.render(row) : (row[col.key] as ReactNode)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
packages/ui/src/components/Dot.tsx
Normal file
39
packages/ui/src/components/Dot.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
export type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
|
||||||
|
|
||||||
|
export interface DotProps {
|
||||||
|
variant?: DotVariant;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DotColorDef {
|
||||||
|
bg: string;
|
||||||
|
shadow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dot({ variant = "muted", className = "" }: DotProps): ReactElement {
|
||||||
|
const colors: Record<DotVariant, DotColorDef> = {
|
||||||
|
teal: { bg: "var(--success)", shadow: "0 0 5px var(--success)" },
|
||||||
|
blue: { bg: "var(--primary)", shadow: "0 0 5px var(--primary)" },
|
||||||
|
amber: { bg: "var(--warn)", shadow: "0 0 5px var(--warn)" },
|
||||||
|
red: { bg: "var(--danger)", shadow: "0 0 5px var(--danger)" },
|
||||||
|
muted: { bg: "var(--muted)", shadow: "none" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { bg, shadow } = colors[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block ${className}`}
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: bg,
|
||||||
|
boxShadow: shadow,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
packages/ui/src/components/FilterTabs.tsx
Normal file
85
packages/ui/src/components/FilterTabs.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
export interface FilterTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterTabsProps {
|
||||||
|
tabs: FilterTab[];
|
||||||
|
activeTab: string;
|
||||||
|
onTabChange: (id: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterTabItem({
|
||||||
|
tab,
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
tab: FilterTab;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={(): void => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(): void => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "5px 14px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isActive ? "var(--text)" : hovered ? "var(--text-2)" : "var(--muted)",
|
||||||
|
background: isActive ? "var(--surface-2)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "color 0.15s ease, background 0.15s ease",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterTabs({
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
className = "",
|
||||||
|
}: FilterTabsProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
gap: 2,
|
||||||
|
padding: 3,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<FilterTabItem
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
isActive={tab.id === activeTab}
|
||||||
|
onClick={(): void => {
|
||||||
|
onTabChange(tab.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef } from "react";
|
import { useState, forwardRef } from "react";
|
||||||
import type { InputHTMLAttributes, ReactElement } from "react";
|
import type { InputHTMLAttributes, ReactElement } from "react";
|
||||||
|
|
||||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||||
@@ -9,44 +9,75 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||||
{ label, error, helperText, fullWidth = false, className = "", id, ...props },
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
fullWidth = false,
|
||||||
|
className = "",
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
...props
|
||||||
|
},
|
||||||
ref
|
ref
|
||||||
): ReactElement {
|
): ReactElement {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const inputId = id ?? `input-${Math.random().toString(36).substring(2, 11)}`;
|
const inputId = id ?? `input-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
const errorId = error ? `${inputId}-error` : undefined;
|
const errorId = error ? `${inputId}-error` : undefined;
|
||||||
const helperId = helperText ? `${inputId}-helper` : undefined;
|
const helperId = helperText ? `${inputId}-helper` : undefined;
|
||||||
|
|
||||||
const baseStyles =
|
const inputStyle: React.CSSProperties = {
|
||||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
background: "var(--bg-mid)",
|
||||||
const widthStyles = fullWidth ? "w-full" : "";
|
border: error
|
||||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
? `1px solid var(--danger)`
|
||||||
|
: isFocused
|
||||||
|
? `1px solid var(--primary)`
|
||||||
|
: `1px solid var(--border)`,
|
||||||
|
color: "var(--text)",
|
||||||
|
outline: "none",
|
||||||
|
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
const combinedClassName = [baseStyles, widthStyles, errorStyles, className]
|
const widthClass = fullWidth ? "w-full" : "";
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={fullWidth ? "w-full" : ""}>
|
<div className={fullWidth ? "w-full" : ""}>
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className={combinedClassName}
|
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${className}`}
|
||||||
|
style={inputStyle}
|
||||||
aria-invalid={error ? "true" : "false"}
|
aria-invalid={error ? "true" : "false"}
|
||||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setIsFocused(true);
|
||||||
|
onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setIsFocused(false);
|
||||||
|
onBlur?.(e);
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{helperText && !error && (
|
{helperText && !error && (
|
||||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||||
{helperText}
|
{helperText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
40
packages/ui/src/components/LogLine.tsx
Normal file
40
packages/ui/src/components/LogLine.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
export type LogLevel = "info" | "warn" | "error" | "debug" | "success";
|
||||||
|
|
||||||
|
export interface LogLineProps {
|
||||||
|
timestamp: string;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelColors: Record<LogLevel, string> = {
|
||||||
|
info: "var(--primary-l)",
|
||||||
|
warn: "var(--warn)",
|
||||||
|
error: "var(--danger)",
|
||||||
|
debug: "var(--muted)",
|
||||||
|
success: "var(--success)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogLine({ timestamp, level, message, className = "" }: LogLineProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "100px 50px 1fr",
|
||||||
|
gap: 10,
|
||||||
|
padding: "4px 0",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.76rem",
|
||||||
|
borderBottom: "1px solid rgba(47,59,82,0.3)",
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--muted)" }}>{timestamp}</span>
|
||||||
|
<span style={{ fontWeight: 600, color: levelColors[level] }}>{level}</span>
|
||||||
|
<span style={{ color: "var(--text-2)", wordBreak: "break-all" }}>{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
packages/ui/src/components/MetricsStrip.tsx
Normal file
99
packages/ui/src/components/MetricsStrip.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
export interface MetricCell {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color: string; // CSS color, e.g., "var(--ms-blue-400)"
|
||||||
|
trend?: {
|
||||||
|
direction: "up" | "down" | "neutral";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricsStripProps {
|
||||||
|
cells: MetricCell[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean }): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const trendColor =
|
||||||
|
cell.trend?.direction === "up"
|
||||||
|
? "var(--success)"
|
||||||
|
: cell.trend?.direction === "down"
|
||||||
|
? "var(--danger)"
|
||||||
|
: "var(--muted)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={(): void => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(): void => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
||||||
|
borderLeft: isFirst ? "none" : "1px solid var(--border)",
|
||||||
|
borderTop: `2px solid ${cell.color}`,
|
||||||
|
transition: "background 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "1.4rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
color: cell.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell.value}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginTop: 3,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</div>
|
||||||
|
{cell.trend && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
marginTop: 4,
|
||||||
|
color: trendColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell.trend.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${String(cells.length)}, 1fr)`,
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells.map((cell, index) => (
|
||||||
|
<MetricCellItem key={cell.label} cell={cell} isFirst={index === 0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ export function Modal({
|
|||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
const modalId = useRef(`modal-${Math.random().toString(36).substring(2, 11)}`);
|
const modalId = useRef(`modal-${Math.random().toString(36).substring(2, 11)}`);
|
||||||
|
|
||||||
const sizeStyles = {
|
type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
||||||
|
const sizeStyles: Record<ModalSize, string> = {
|
||||||
sm: "max-w-md",
|
sm: "max-w-md",
|
||||||
md: "max-w-lg",
|
md: "max-w-lg",
|
||||||
lg: "max-w-2xl",
|
lg: "max-w-2xl",
|
||||||
@@ -65,7 +66,8 @@ export function Modal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style={{ background: "rgba(0,0,0,0.5)" }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -75,18 +77,30 @@ export function Modal({
|
|||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={`bg-white rounded-lg shadow-xl w-full ${sizeStyles[size]} ${className}`}
|
className={`rounded-lg w-full ${sizeStyles[size]} ${className}`}
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
role="document"
|
role="document"
|
||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
<div
|
||||||
<h2 id={`${modalId.current}-title`} className="text-lg font-semibold text-gray-900">
|
className="px-6 py-4 flex items-center justify-between"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id={`${modalId.current}-title`}
|
||||||
|
className="text-lg font-semibold"
|
||||||
|
style={{ color: "var(--text)" }}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
|
className="transition-colors p-1 rounded"
|
||||||
|
style={{ color: "var(--muted)" }}
|
||||||
aria-label="Close modal"
|
aria-label="Close modal"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -108,7 +122,13 @@ export function Modal({
|
|||||||
)}
|
)}
|
||||||
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg flex justify-end gap-2">
|
<div
|
||||||
|
className="px-6 py-4 rounded-b-lg flex justify-end gap-2"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
53
packages/ui/src/components/ProgressBar.tsx
Normal file
53
packages/ui/src/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
export type ProgressBarVariant = "blue" | "teal" | "purple" | "amber";
|
||||||
|
|
||||||
|
export interface ProgressBarProps {
|
||||||
|
value: number; // 0-100
|
||||||
|
variant?: ProgressBarVariant;
|
||||||
|
className?: string;
|
||||||
|
label?: string; // screen reader label
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantColors: Record<ProgressBarVariant, string> = {
|
||||||
|
blue: "var(--primary)",
|
||||||
|
teal: "var(--success)",
|
||||||
|
purple: "var(--purple)",
|
||||||
|
amber: "var(--warn)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProgressBar({
|
||||||
|
value,
|
||||||
|
variant = "blue",
|
||||||
|
className = "",
|
||||||
|
label,
|
||||||
|
}: ProgressBarProps): ReactElement {
|
||||||
|
const clampedValue = Math.min(100, Math.max(0, value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={clampedValue}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={label}
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 4,
|
||||||
|
width: `${String(clampedValue)}%`,
|
||||||
|
background: variantColors[variant],
|
||||||
|
transition: "width 0.4s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
packages/ui/src/components/SectionHeader.tsx
Normal file
62
packages/ui/src/components/SectionHeader.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface SectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
className = "",
|
||||||
|
}: SectionHeaderProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle !== undefined && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions !== undefined && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { SelectHTMLAttributes, ReactElement } from "react";
|
import type { SelectHTMLAttributes, ReactElement } from "react";
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
@@ -24,33 +25,56 @@ export function Select({
|
|||||||
placeholder = "Select an option...",
|
placeholder = "Select an option...",
|
||||||
className = "",
|
className = "",
|
||||||
id,
|
id,
|
||||||
|
style,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
...props
|
...props
|
||||||
}: SelectProps): ReactElement {
|
}: SelectProps): ReactElement {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const selectId = id ?? `select-${Math.random().toString(36).substring(2, 11)}`;
|
const selectId = id ?? `select-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
const errorId = error ? `${selectId}-error` : undefined;
|
const errorId = error ? `${selectId}-error` : undefined;
|
||||||
const helperId = helperText ? `${selectId}-helper` : undefined;
|
const helperId = helperText ? `${selectId}-helper` : undefined;
|
||||||
|
|
||||||
const baseStyles =
|
const selectStyle: React.CSSProperties = {
|
||||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white";
|
background: "var(--bg-mid)",
|
||||||
const widthStyles = fullWidth ? "w-full" : "";
|
border: error
|
||||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
? `1px solid var(--danger)`
|
||||||
|
: isFocused
|
||||||
|
? `1px solid var(--primary)`
|
||||||
|
: `1px solid var(--border)`,
|
||||||
|
color: "var(--text)",
|
||||||
|
outline: "none",
|
||||||
|
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
const combinedClassName = [baseStyles, widthStyles, errorStyles, className]
|
const widthClass = fullWidth ? "w-full" : "";
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={fullWidth ? "w-full" : ""}>
|
<div className={fullWidth ? "w-full" : ""}>
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700 mb-1">
|
<label
|
||||||
|
htmlFor={selectId}
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<select
|
<select
|
||||||
id={selectId}
|
id={selectId}
|
||||||
className={combinedClassName}
|
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${className}`}
|
||||||
|
style={selectStyle}
|
||||||
aria-invalid={error ? "true" : "false"}
|
aria-invalid={error ? "true" : "false"}
|
||||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setIsFocused(true);
|
||||||
|
onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setIsFocused(false);
|
||||||
|
onBlur?.(e);
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
@@ -63,12 +87,12 @@ export function Select({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{error && (
|
{error && (
|
||||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{helperText && !error && (
|
{helperText && !error && (
|
||||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||||
{helperText}
|
{helperText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { TextareaHTMLAttributes, ReactElement } from "react";
|
import type { TextareaHTMLAttributes, ReactElement } from "react";
|
||||||
|
|
||||||
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
|
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
|
||||||
@@ -16,48 +17,72 @@ export function Textarea({
|
|||||||
resize = "vertical",
|
resize = "vertical",
|
||||||
className = "",
|
className = "",
|
||||||
id,
|
id,
|
||||||
|
style,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
...props
|
...props
|
||||||
}: TextareaProps): ReactElement {
|
}: TextareaProps): ReactElement {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const textareaId = id ?? `textarea-${Math.random().toString(36).substring(2, 11)}`;
|
const textareaId = id ?? `textarea-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
const errorId = error ? `${textareaId}-error` : undefined;
|
const errorId = error ? `${textareaId}-error` : undefined;
|
||||||
const helperId = helperText ? `${textareaId}-helper` : undefined;
|
const helperId = helperText ? `${textareaId}-helper` : undefined;
|
||||||
|
|
||||||
const baseStyles =
|
const resizeStyles: Record<string, string> = {
|
||||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
|
||||||
const widthStyles = fullWidth ? "w-full" : "";
|
|
||||||
const resizeStyles = {
|
|
||||||
none: "resize-none",
|
none: "resize-none",
|
||||||
both: "resize",
|
both: "resize",
|
||||||
horizontal: "resize-x",
|
horizontal: "resize-x",
|
||||||
vertical: "resize-y",
|
vertical: "resize-y",
|
||||||
};
|
};
|
||||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
|
||||||
|
|
||||||
const combinedClassName = [baseStyles, widthStyles, resizeStyles[resize], errorStyles, className]
|
const textareaStyle: React.CSSProperties = {
|
||||||
.filter(Boolean)
|
background: "var(--bg-mid)",
|
||||||
.join(" ");
|
border: error
|
||||||
|
? `1px solid var(--danger)`
|
||||||
|
: isFocused
|
||||||
|
? `1px solid var(--primary)`
|
||||||
|
: `1px solid var(--border)`,
|
||||||
|
color: "var(--text)",
|
||||||
|
outline: "none",
|
||||||
|
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
|
const widthClass = fullWidth ? "w-full" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={fullWidth ? "w-full" : ""}>
|
<div className={fullWidth ? "w-full" : ""}>
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={textareaId} className="block text-sm font-medium text-gray-700 mb-1">
|
<label
|
||||||
|
htmlFor={textareaId}
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
id={textareaId}
|
id={textareaId}
|
||||||
className={combinedClassName}
|
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${resizeStyles[resize] ?? "resize-y"} ${className}`}
|
||||||
|
style={textareaStyle}
|
||||||
aria-invalid={error ? "true" : "false"}
|
aria-invalid={error ? "true" : "false"}
|
||||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setIsFocused(true);
|
||||||
|
onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setIsFocused(false);
|
||||||
|
onBlur?.(e);
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{helperText && !error && (
|
{helperText && !error && (
|
||||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||||
{helperText}
|
{helperText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -97,13 +97,37 @@ interface ToastItemProps {
|
|||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToastVariantStyle {
|
||||||
|
background: string;
|
||||||
|
border: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<ToastVariant, ToastVariantStyle> = {
|
||||||
|
success: {
|
||||||
|
background: "rgba(20,184,166,0.15)",
|
||||||
|
border: "1px solid rgba(20,184,166,0.35)",
|
||||||
|
color: "var(--success)",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
background: "rgba(229,72,77,0.15)",
|
||||||
|
border: "1px solid rgba(229,72,77,0.35)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
background: "rgba(245,158,11,0.15)",
|
||||||
|
border: "1px solid rgba(245,158,11,0.35)",
|
||||||
|
color: "var(--warn)",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
background: "rgba(47,128,255,0.15)",
|
||||||
|
border: "1px solid rgba(47,128,255,0.35)",
|
||||||
|
color: "var(--info)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
||||||
const variantStyles: Record<ToastVariant, string> = {
|
const vStyle = variantStyles[toast.variant ?? "info"];
|
||||||
success: "bg-green-500 text-white border-green-600",
|
|
||||||
error: "bg-red-500 text-white border-red-600",
|
|
||||||
warning: "bg-yellow-500 text-white border-yellow-600",
|
|
||||||
info: "bg-blue-500 text-white border-blue-600",
|
|
||||||
};
|
|
||||||
|
|
||||||
const icon: Record<ToastVariant, ReactNode> = {
|
const icon: Record<ToastVariant, ReactNode> = {
|
||||||
success: (
|
success: (
|
||||||
@@ -146,7 +170,12 @@ function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${variantStyles[toast.variant ?? "info"]} border rounded-md shadow-lg px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md`}
|
className="rounded-md px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md"
|
||||||
|
style={{
|
||||||
|
background: vStyle.background,
|
||||||
|
border: vStyle.border,
|
||||||
|
color: vStyle.color,
|
||||||
|
}}
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<span className="flex-shrink-0">{icon[toast.variant ?? "info"]}</span>
|
<span className="flex-shrink-0">{icon[toast.variant ?? "info"]}</span>
|
||||||
@@ -155,7 +184,7 @@ function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRemove(toast.id);
|
onRemove(toast.id);
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-white/20"
|
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded"
|
||||||
aria-label="Close notification"
|
aria-label="Close notification"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
|||||||
@@ -60,3 +60,31 @@ export type {
|
|||||||
AuthStatusPillProps,
|
AuthStatusPillProps,
|
||||||
AuthDividerProps,
|
AuthDividerProps,
|
||||||
} from "./components/AuthSurface.js";
|
} from "./components/AuthSurface.js";
|
||||||
|
|
||||||
|
// Dot
|
||||||
|
export { Dot } from "./components/Dot.js";
|
||||||
|
export type { DotProps, DotVariant } from "./components/Dot.js";
|
||||||
|
|
||||||
|
// MetricsStrip
|
||||||
|
export { MetricsStrip } from "./components/MetricsStrip.js";
|
||||||
|
export type { MetricsStripProps, MetricCell } from "./components/MetricsStrip.js";
|
||||||
|
|
||||||
|
// ProgressBar
|
||||||
|
export { ProgressBar } from "./components/ProgressBar.js";
|
||||||
|
export type { ProgressBarProps, ProgressBarVariant } from "./components/ProgressBar.js";
|
||||||
|
|
||||||
|
// FilterTabs
|
||||||
|
export { FilterTabs } from "./components/FilterTabs.js";
|
||||||
|
export type { FilterTabsProps, FilterTab } from "./components/FilterTabs.js";
|
||||||
|
|
||||||
|
// SectionHeader
|
||||||
|
export { SectionHeader } from "./components/SectionHeader.js";
|
||||||
|
export type { SectionHeaderProps } from "./components/SectionHeader.js";
|
||||||
|
|
||||||
|
// DataTable
|
||||||
|
export { DataTable } from "./components/DataTable.js";
|
||||||
|
export type { DataTableColumn, DataTableProps } from "./components/DataTable.js";
|
||||||
|
|
||||||
|
// LogLine
|
||||||
|
export { LogLine } from "./components/LogLine.js";
|
||||||
|
export type { LogLineProps, LogLevel } from "./components/LogLine.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user