feat(ui,web): add shared components and terminal panel (MS15-UI-003, UI-004, UI-005)
UI-003: MetricsStrip (6-cell KPI grid), ProgressBar (4 variants with ARIA), FilterTabs (segmented control). UI-004: SectionHeader (title/subtitle/actions), DataTable (generic typed table with hover), LogLine (colored log entry grid). UI-005: TerminalPanel bottom-drawer component with tabs, line coloring, blinking cursor, slide animation. All components use CSS custom properties for theming. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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";
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,3 +64,27 @@ export type {
|
|||||||
// Dot
|
// Dot
|
||||||
export { Dot } from "./components/Dot.js";
|
export { Dot } from "./components/Dot.js";
|
||||||
export type { DotProps, DotVariant } 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