@momoi-labs/kiso-react 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -193,3 +193,49 @@ isolated consumer, including TypeScript, CSS bundling, and rendered semantics.
193
193
 
194
194
  See [Publishing Kiso](../../docs/publishing.md) for releases. Adapted shadcn/ui
195
195
  code retains its upstream MIT notice in `SHADCN-LICENSE`.
196
+
197
+ ## Metrics dashboards
198
+
199
+ Chart supports `line` and `stacked-area`, one to five named series, null gaps,
200
+ UTC timestamps, synchronized inspection through `syncId`, a summary legend,
201
+ and a disclosure containing every exact sample. Pass the same timestamp grid
202
+ to synchronized panels. Put the common unit in `label`.
203
+
204
+ ```tsx
205
+ <Chart
206
+ label="CPU (%)"
207
+ series={[{ key: "user", label: "User", slot: 1 }]}
208
+ data={[
209
+ { timestamp: 1789203600000, values: { user: 24 } },
210
+ { timestamp: 1789203610000, values: { user: null } },
211
+ { timestamp: 1789203620000, values: { user: 31 } },
212
+ ]}
213
+ />
214
+ ```
215
+
216
+ ChartLegend is also exported for standalone summaries. Meter, Progress,
217
+ BarGauge, Disclosure, TimeRangeControl, DashboardGrid, and DashboardPanel
218
+ compose the surrounding dashboard. See their contracts in `@momoi-labs/kiso`
219
+ and the gallery previews for complete examples. Sparkline now accepts null
220
+ gaps without shifting later sample positions.
221
+
222
+ Chart presentation options compose independently:
223
+
224
+ ```tsx
225
+ <Chart
226
+ label="CPU (%)"
227
+ data={samples}
228
+ series={series}
229
+ variant="stacked-area"
230
+ layout="compact"
231
+ legend="inline"
232
+ highlightSeries={highlight}
233
+ onHighlightSeriesChange={setHighlight}
234
+ />
235
+ ```
236
+
237
+ `layout` accepts `standard`, `compact`, or `split`; `legend` accepts `table`,
238
+ `inline`, or `sidebar`. Split requires `variant="line"` and keeps a shared
239
+ scale across lanes. The sidebar follows the inspected timestamp. Omit
240
+ `highlightSeries` for local legend selection, or pass a key/null with the
241
+ callback for controlled selection. All combinations retain exact values.
@@ -0,0 +1,12 @@
1
+ import * as React from "react";
2
+ export type BarGaugeProps = Omit<React.ComponentProps<"div">, "children"> & {
3
+ label: string;
4
+ rows: readonly {
5
+ key: string;
6
+ label: string;
7
+ value: number | null;
8
+ }[];
9
+ max: number;
10
+ formatValue?: (value: number) => string;
11
+ };
12
+ export declare function BarGauge({ label, rows, max, formatValue, className, ...props }: BarGaugeProps): React.JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { clsx as cn } from "clsx";
3
+ import { Meter } from "./meter.js";
4
+ export function BarGauge({ label, rows, max, formatValue = String, className, ...props }) {
5
+ if (!Number.isFinite(max) || max <= 0)
6
+ throw new RangeError("Bar gauge max must be positive.");
7
+ return _jsx("div", { ...props, role: "group", "aria-label": label, "data-slot": "bar-gauge", className: cn("bar-gauge", className), children: rows.length ? rows.map(row => _jsx(Meter, { label: row.label, value: row.value, max: max, valueText: row.value !== null && Number.isFinite(row.value) ? formatValue(row.value) : "Not collected" }, row.key))
8
+ : _jsx("p", { children: "No measurements." }) });
9
+ }
@@ -0,0 +1,24 @@
1
+ export type ChartSample = {
2
+ /** Epoch milliseconds. Include every expected timestamp, including gaps. */
3
+ timestamp: number;
4
+ values: Readonly<Record<string, number | null>>;
5
+ };
6
+ export type ChartSeries = {
7
+ key: string;
8
+ label: string;
9
+ /** Stable identity across panels. Defaults to its position, starting at 1. */
10
+ slot?: 1 | 2 | 3 | 4 | 5;
11
+ };
12
+ export declare const chartStrokes: readonly [undefined, "8 4", "2 3", "8 3 2 3", "12 3 2 3 2 3"];
13
+ export declare const chartSlot: (series: ChartSeries, index: number) => number;
14
+ export declare const chartColor: (slot: number) => string;
15
+ export declare const measured: (value: number | null | undefined) => value is number;
16
+ export declare function validateChart(data: readonly ChartSample[], series: readonly ChartSeries[]): void;
17
+ export declare function seriesSummary(data: readonly ChartSample[], key: string): {
18
+ min: number | null;
19
+ max: number | null;
20
+ avg: number | null;
21
+ current: number | null;
22
+ };
23
+ export declare const formatChartTime: (timestamp: number) => string;
24
+ export declare const formatChartValue: (value: number) => string;
@@ -0,0 +1,34 @@
1
+ export const chartStrokes = [undefined, "8 4", "2 3", "8 3 2 3", "12 3 2 3 2 3"];
2
+ export const chartSlot = (series, index) => series.slot ?? index + 1;
3
+ export const chartColor = (slot) => `var(--color-chart-${slot})`;
4
+ export const measured = (value) => typeof value === "number" && Number.isFinite(value);
5
+ export function validateChart(data, series) {
6
+ if (series.length < 1 || series.length > 5)
7
+ throw new RangeError("Charts require one to five series.");
8
+ const keys = new Set();
9
+ const slots = new Set();
10
+ series.forEach((item, index) => {
11
+ const slot = chartSlot(item, index);
12
+ if (!item.key || !item.label || keys.has(item.key) || slots.has(slot) || slot < 1 || slot > 5 || !Number.isInteger(slot)) {
13
+ throw new RangeError("Chart series require unique keys, slots from 1 to 5, and labels.");
14
+ }
15
+ keys.add(item.key);
16
+ slots.add(slot);
17
+ });
18
+ data.forEach((sample, index) => {
19
+ if (!Number.isFinite(sample.timestamp) || Math.abs(sample.timestamp) > 8.64e15 || (index > 0 && sample.timestamp <= data[index - 1].timestamp)) {
20
+ throw new RangeError("Chart timestamps must be finite and strictly increasing.");
21
+ }
22
+ });
23
+ }
24
+ export function seriesSummary(data, key) {
25
+ const values = data.map(sample => sample.values[key]).filter(measured);
26
+ return {
27
+ min: values.length ? Math.min(...values) : null,
28
+ max: values.length ? Math.max(...values) : null,
29
+ avg: values.length ? values.reduce((sum, value) => sum + value / values.length, 0) : null,
30
+ current: measured(data.at(-1)?.values[key]) ? data.at(-1).values[key] : null,
31
+ };
32
+ }
33
+ export const formatChartTime = (timestamp) => new Date(timestamp).toISOString();
34
+ export const formatChartValue = (value) => String(value);
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ import { type ChartSample, type ChartSeries } from "./chart-data.js";
3
+ export type ChartLegendProps = React.ComponentProps<"div"> & {
4
+ data: readonly ChartSample[];
5
+ series: readonly ChartSeries[];
6
+ label: string;
7
+ formatValue?: (value: number) => string;
8
+ variant?: "table" | "inline" | "sidebar";
9
+ activeTimestamp?: number | null;
10
+ formatTime?: (timestamp: number) => string;
11
+ highlightSeries?: string | null;
12
+ onHighlightSeriesChange?: (key: string | null) => void;
13
+ };
14
+ export declare function ChartSeriesLabel({ series, index }: {
15
+ series: ChartSeries;
16
+ index: number;
17
+ }): React.JSX.Element;
18
+ export declare function ChartLegend({ data, series, label, formatValue, variant, activeTimestamp, formatTime, highlightSeries, onHighlightSeriesChange, className, ...props }: ChartLegendProps): React.JSX.Element;
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { clsx as cn } from "clsx";
4
+ import { chartColor, chartSlot, formatChartTime, formatChartValue, measured, seriesSummary, validateChart } from "./chart-data.js";
5
+ export function ChartSeriesLabel({ series, index }) {
6
+ const slot = chartSlot(series, index);
7
+ return _jsxs("span", { className: "chart-series-label", children: [_jsx("svg", { "aria-hidden": "true", width: "28", height: "12", viewBox: "0 0 28 12", children: _jsx("path", { d: "M0 6H28", stroke: chartColor(slot), strokeWidth: "2" }) }), _jsxs("span", { children: [slot, ". ", series.label] })] });
8
+ }
9
+ export function ChartLegend({ data, series, label, formatValue = formatChartValue, variant = "table", activeTimestamp, formatTime = formatChartTime, highlightSeries, onHighlightSeriesChange, className, ...props }) {
10
+ validateChart(data, series);
11
+ const seriesLabel = (item, index) => onHighlightSeriesChange
12
+ ? _jsx("button", { type: "button", className: "chart-series-button", "aria-pressed": highlightSeries === item.key, onClick: () => onHighlightSeriesChange(highlightSeries === item.key ? null : item.key), children: _jsx(ChartSeriesLabel, { series: item, index: index }) })
13
+ : _jsx(ChartSeriesLabel, { series: item, index: index });
14
+ if (variant !== "table") {
15
+ const sample = activeTimestamp == null ? data.at(-1) : data.find(item => item.timestamp === activeTimestamp);
16
+ return _jsxs("div", { ...props, "data-slot": "chart-legend", "data-variant": variant, className: cn("chart-legend", `chart-legend-${variant}`, className), "aria-label": label, "aria-live": variant === "sidebar" ? "polite" : undefined, children: [_jsx("span", { className: "chart-legend-caption", children: variant === "sidebar" && activeTimestamp != null
17
+ ? formatTime(activeTimestamp) : "Current" }), _jsx("dl", { children: series.map((item, index) => _jsxs("div", { children: [_jsx("dt", { children: seriesLabel(item, index) }), _jsx("dd", { children: measured(sample?.values[item.key]) ? formatValue(sample.values[item.key]) : "Not collected" })] }, item.key)) })] });
18
+ }
19
+ return _jsx("div", { ...props, "data-slot": "chart-legend", "data-variant": "table", className: cn("chart-table-scroll", className), children: _jsxs("table", { className: "chart-table", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Series" }), ["Min", "Max", "Avg", "Current"].map(name => _jsx("th", { scope: "col", children: name }, name))] }) }), _jsx("tbody", { children: series.map((item, index) => {
20
+ const summary = seriesSummary(data, item.key);
21
+ return _jsxs("tr", { children: [_jsx("th", { scope: "row", children: seriesLabel(item, index) }), ["min", "max", "avg", "current"].map(stat => _jsx("td", { children: summary[stat] === null ? "Not collected" : formatValue(summary[stat]) }, stat))] }, item.key);
22
+ }) })] }) });
23
+ }
@@ -0,0 +1,22 @@
1
+ import * as React from "react";
2
+ import { type ChartSample, type ChartSeries } from "./chart-data.js";
3
+ export type { ChartSample, ChartSeries } from "./chart-data.js";
4
+ export type ChartProps = Omit<React.ComponentProps<"figure">, "children"> & {
5
+ data: readonly ChartSample[];
6
+ series: readonly ChartSeries[];
7
+ label: string;
8
+ variant?: "line" | "stacked-area";
9
+ layout?: "standard" | "compact" | "split";
10
+ legend?: "table" | "inline" | "sidebar";
11
+ /** A controlled highlight. Pass null to clear it, or omit for local selection. */
12
+ highlightSeries?: string | null;
13
+ onHighlightSeriesChange?: (key: string | null) => void;
14
+ height?: number;
15
+ /** Panels in a group inspect the same timestamp, not the same array index. */
16
+ syncId?: string;
17
+ formatTime?: (timestamp: number) => string;
18
+ formatValue?: (value: number) => string;
19
+ min?: number;
20
+ max?: number;
21
+ };
22
+ export declare function Chart({ data, series, label, variant, layout, legend, highlightSeries, onHighlightSeriesChange, height, syncId, formatTime, formatValue, min, max, className, ...props }: ChartProps): React.JSX.Element;
package/dist/chart.js ADDED
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { clsx as cn } from "clsx";
5
+ import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
6
+ import { ChartLegend, ChartSeriesLabel } from "./chart-legend.js";
7
+ import { chartColor, chartSlot, formatChartTime, formatChartValue, measured, validateChart } from "./chart-data.js";
8
+ export function Chart({ data, series, label, variant = "line", layout = "standard", legend = "table", highlightSeries, onHighlightSeriesChange, height = layout === "compact" ? 165 : layout === "split" ? 90 : 200, syncId, formatTime = formatChartTime, formatValue = formatChartValue, min, max, className, ...props }) {
9
+ const id = React.useId();
10
+ const [localHighlight, setLocalHighlight] = React.useState(null);
11
+ const [activeTimestamp, setActiveTimestamp] = React.useState(null);
12
+ const requestedHighlight = highlightSeries === undefined ? localHighlight : highlightSeries;
13
+ const highlighted = series.some(item => item.key === requestedHighlight) ? requestedHighlight : null;
14
+ const highlight = (key) => {
15
+ if (highlightSeries === undefined)
16
+ setLocalHighlight(key);
17
+ onHighlightSeriesChange?.(key);
18
+ };
19
+ validateChart(data, series);
20
+ if (layout === "split" && variant === "stacked-area") {
21
+ throw new RangeError("Split layout requires line charts. Stacked areas share one plot.");
22
+ }
23
+ if (variant === "stacked-area" && data.some(sample => series.some(item => measured(sample.values[item.key]) && sample.values[item.key] < 0))) {
24
+ throw new RangeError("Stacked areas require nonnegative values. Use lines for signed data.");
25
+ }
26
+ if (!Number.isFinite(height) || height <= 0 || (min !== undefined && !Number.isFinite(min))
27
+ || (max !== undefined && !Number.isFinite(max)) || (min !== undefined && max !== undefined && min >= max)) {
28
+ throw new RangeError("Chart height must be positive and numeric bounds must be finite and ordered.");
29
+ }
30
+ // A partial stack has an unknown total. Omit the entire column rather than
31
+ // drawing the missing contribution as zero. Tables retain the known values.
32
+ const plot = data.map(sample => {
33
+ const gap = variant === "stacked-area" && series.some(item => !measured(sample.values[item.key]));
34
+ return { timestamp: sample.timestamp, values: series.map(item => !gap && measured(sample.values[item.key]) ? sample.values[item.key] : null) };
35
+ });
36
+ const hasData = plot.some(sample => sample.values.some(measured));
37
+ const displayValue = (value) => measured(value) ? formatValue(value) : "Not collected";
38
+ const known = plot.flatMap(sample => sample.values).filter(measured);
39
+ let splitMin = min ?? Math.min(0, ...known);
40
+ let splitMax = max ?? Math.max(0, ...known);
41
+ if (splitMin === splitMax) {
42
+ if (max === undefined)
43
+ splitMax += Math.max(1, Math.abs(splitMax) * 0.1);
44
+ else
45
+ splitMin -= Math.max(1, Math.abs(splitMin) * 0.1);
46
+ }
47
+ const renderPlot = (indices, lane) => _jsx("div", { className: "chart-plot", style: { height }, children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(ComposedChart, { data: plot, syncId: syncId ?? (layout === "split" ? id : undefined), syncMethod: "value", accessibilityLayer: true, "aria-label": lane ? `${label}: ${lane.label}` : label, "aria-describedby": `${id}-help`, margin: { top: 8, right: 12, bottom: 0, left: 0 }, children: [_jsx(CartesianGrid, { vertical: false, stroke: "var(--color-border)", strokeWidth: 0.5 }), _jsx(XAxis, { dataKey: "timestamp", type: "number", domain: ["dataMin", "dataMax"], scale: "time", axisLine: false, tickLine: false, tickFormatter: formatTime, minTickGap: 48, tick: { fill: "var(--color-muted-foreground)" } }), _jsx(YAxis, { domain: layout === "split" ? [splitMin, splitMax]
48
+ : [min ?? (variant === "stacked-area" ? 0 : "auto"), max ?? "auto"], tickCount: 3, axisLine: false, tickLine: false, tickFormatter: formatValue, tick: { fill: "var(--color-muted-foreground)" }, width: 56 }), _jsx(Tooltip, { filterNull: false, isAnimationActive: false, cursor: { stroke: "var(--color-border-strong)", strokeDasharray: "3 3" }, content: ({ active, label: timestamp }) => _jsx(ChartReadout, { active: active, timestamp: Number(timestamp), data: data, series: series, formatTime: formatTime, displayValue: displayValue, sidebar: legend === "sidebar", onInspect: legend === "sidebar" && (!lane || indices[0] === 0) ? setActiveTimestamp : undefined }) }), indices.map(index => {
49
+ const item = series[index];
50
+ const slot = chartSlot(item, index);
51
+ const dimmed = highlighted !== null && item.key !== highlighted;
52
+ const common = { dataKey: `values.${index}`, name: item.label, stroke: chartColor(slot),
53
+ strokeWidth: item.key === highlighted ? 2.2 : 1.5, strokeOpacity: dimmed ? 0.3 : 1,
54
+ connectNulls: false, isAnimationActive: false,
55
+ dot: (dotProps) => {
56
+ const at = dotProps.index ?? -1;
57
+ const isolated = measured(plot[at]?.values[index])
58
+ && !measured(plot[at - 1]?.values[index]) && !measured(plot[at + 1]?.values[index]);
59
+ return _jsx("circle", { cx: dotProps.cx, cy: dotProps.cy, r: isolated ? 3 : 0, fill: chartColor(slot), opacity: dimmed ? 0.3 : 1 }, at);
60
+ }, activeDot: { r: 4 } };
61
+ return variant === "stacked-area"
62
+ ? _jsx(Area, { ...common, type: "linear", stackId: "total", fill: chartColor(slot), fillOpacity: dimmed ? 0.06 : 0.2 }, item.key)
63
+ : _jsx(Line, { ...common, type: "linear" }, item.key);
64
+ })] }) }) });
65
+ const legendContent = _jsx(ChartLegend, { data: data, series: series, label: `${label}: summary`, variant: legend, formatValue: formatValue, formatTime: formatTime, activeTimestamp: legend === "sidebar" && hasData ? activeTimestamp : null, highlightSeries: highlighted, onHighlightSeriesChange: highlight });
66
+ return _jsxs("figure", { ...props, "data-slot": "chart", "data-layout": layout, "data-legend": legend, className: cn("framed-chart", className), "aria-labelledby": `${id}-title`, children: [_jsx("figcaption", { id: `${id}-title`, className: "t-h3", children: label }), _jsx("p", { id: `${id}-help`, className: "chart-help", children: "Focus the chart and use Left and Right to inspect samples. Select a series in the legend to highlight it. Exact values are available below." }), _jsxs("div", { className: "chart-body", children: [_jsx("div", { className: "chart-plots", children: hasData ? layout === "split" ? series.map((item, index) => _jsxs("div", { className: "chart-lane", children: [_jsxs("div", { className: "chart-lane-heading", children: [_jsx(ChartSeriesLabel, { series: item, index: index }), _jsx("span", { children: displayValue(data.at(-1)?.values[item.key]) })] }), renderPlot([index], item)] }, item.key)) : renderPlot(series.map((_, index) => index))
67
+ : _jsx("div", { className: "chart-empty hatch", style: { minHeight: height }, children: "No collected samples in this window." }) }), legend === "sidebar" && legendContent] }), legend !== "sidebar" && legendContent, _jsxs("details", { className: "disclosure chart-values", children: [_jsx("summary", { children: "View exact values" }), _jsx("div", { className: "chart-table-scroll", children: _jsxs("table", { className: "chart-table", children: [_jsxs("caption", { children: [label, ": collected samples, timestamps in UTC"] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Timestamp" }), series.map(item => _jsx("th", { scope: "col", children: item.label }, item.key))] }) }), _jsx("tbody", { children: data.map(sample => _jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatChartTime(sample.timestamp) }), series.map(item => _jsx("td", { children: measured(sample.values[item.key]) ? String(sample.values[item.key]) : "Not collected" }, item.key))] }, sample.timestamp)) })] }) })] })] });
68
+ }
69
+ // Observe Recharts' synchronized tooltip state after render. This keeps the
70
+ // sidebar on the same timestamp for mouse, keyboard, touch, and sibling panels.
71
+ function ChartReadout({ active, timestamp, data, series, formatTime, displayValue, sidebar, onInspect }) {
72
+ const sample = active ? data.find(item => item.timestamp === timestamp) : undefined;
73
+ const inspectedTimestamp = sample?.timestamp ?? null;
74
+ React.useEffect(() => { onInspect?.(inspectedTimestamp); }, [onInspect, inspectedTimestamp]);
75
+ if (!sample || sidebar)
76
+ return null;
77
+ return _jsxs("div", { className: "chart-inspection", role: "status", children: [_jsx("strong", { children: formatTime(sample.timestamp) }), _jsx("dl", { children: series.map((item, index) => _jsxs(React.Fragment, { children: [_jsx("dt", { children: _jsx(ChartSeriesLabel, { series: item, index: index }) }), _jsx("dd", { children: displayValue(sample.values[item.key]) })] }, item.key)) })] });
78
+ }
@@ -0,0 +1,6 @@
1
+ import * as React from "react";
2
+ export declare function DashboardGrid({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
3
+ export type DashboardPanelProps = React.ComponentProps<"div"> & {
4
+ span?: 3 | 4 | 6 | 8 | 12;
5
+ };
6
+ export declare function DashboardPanel({ span, className, style, ...props }: DashboardPanelProps): React.JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { clsx as cn } from "clsx";
3
+ export function DashboardGrid({ className, ...props }) {
4
+ return _jsx("div", { ...props, "data-slot": "dashboard-grid", className: cn("dashboard-grid", className) });
5
+ }
6
+ export function DashboardPanel({ span = 6, className, style, ...props }) {
7
+ return _jsx("div", { ...props, "data-slot": "dashboard-panel", "data-span": span, className: cn("dashboard-panel", className), style: { "--dashboard-span": span, ...style } });
8
+ }
@@ -0,0 +1,5 @@
1
+ import * as React from "react";
2
+ export type DisclosureProps = Omit<React.ComponentProps<"details">, "title"> & {
3
+ summary: React.ReactNode;
4
+ };
5
+ export declare function Disclosure({ summary, className, children, ...props }: DisclosureProps): React.JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { clsx as cn } from "clsx";
3
+ export function Disclosure({ summary, className, children, ...props }) {
4
+ return _jsxs("details", { ...props, "data-slot": "disclosure", className: cn("disclosure", className), children: [_jsx("summary", { children: summary }), _jsx("div", { className: "disclosure-content", children: children })] });
5
+ }
package/dist/index.d.ts CHANGED
@@ -43,3 +43,10 @@ export * from "./theme-selector.js";
43
43
  export * from "./toast.js";
44
44
  export * from "./tooltip.js";
45
45
  export * from "./validation-message.js";
46
+ export * from "./chart.js";
47
+ export * from "./chart-legend.js";
48
+ export * from "./meter.js";
49
+ export * from "./bar-gauge.js";
50
+ export * from "./disclosure.js";
51
+ export * from "./time-range-control.js";
52
+ export * from "./dashboard-grid.js";
package/dist/index.js CHANGED
@@ -44,3 +44,10 @@ export * from "./theme-selector.js";
44
44
  export * from "./toast.js";
45
45
  export * from "./tooltip.js";
46
46
  export * from "./validation-message.js";
47
+ export * from "./chart.js";
48
+ export * from "./chart-legend.js";
49
+ export * from "./meter.js";
50
+ export * from "./bar-gauge.js";
51
+ export * from "./disclosure.js";
52
+ export * from "./time-range-control.js";
53
+ export * from "./dashboard-grid.js";
@@ -0,0 +1,13 @@
1
+ import * as React from "react";
2
+ export type MeterProps = Omit<React.ComponentProps<"div">, "children"> & {
3
+ label: string;
4
+ value: number | null;
5
+ min?: number;
6
+ max?: number;
7
+ valueText?: string;
8
+ };
9
+ export declare function Meter({ label, value, min, max, valueText, className, ...props }: MeterProps): React.JSX.Element;
10
+ export type ProgressProps = Omit<MeterProps, "min" | "value"> & {
11
+ value?: number | null;
12
+ };
13
+ export declare function Progress({ label, value, max, valueText, className, ...props }: ProgressProps): React.JSX.Element;
package/dist/meter.js ADDED
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { clsx as cn } from "clsx";
3
+ export function Meter({ label, value, min = 0, max = 100, valueText, className, ...props }) {
4
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min)
5
+ throw new RangeError("Meter max must exceed min.");
6
+ const known = value !== null && Number.isFinite(value);
7
+ const bounded = known ? Math.min(max, Math.max(min, value)) : undefined;
8
+ const text = valueText ?? (known ? `${value} / ${max}` : "Not collected");
9
+ return _jsxs("div", { ...props, "data-slot": "meter", className: cn("meter", className), children: [_jsxs("div", { className: "meter-label", children: [_jsx("span", { children: label }), _jsx("span", { children: text })] }), _jsx("div", { role: known ? "meter" : undefined, "aria-label": label, "aria-valuemin": known ? min : undefined, "aria-valuemax": known ? max : undefined, "aria-valuenow": bounded, "aria-valuetext": known ? text : undefined, className: cn("meter-track", !known && "hatch"), children: bounded !== undefined && _jsx("span", { style: { width: `${(bounded - min) / (max - min) * 100}%` } }) })] });
10
+ }
11
+ export function Progress({ label, value, max = 100, valueText, className, ...props }) {
12
+ if (!Number.isFinite(max) || max <= 0)
13
+ throw new RangeError("Progress max must be positive.");
14
+ const known = value != null && Number.isFinite(value);
15
+ const text = valueText ?? (known ? `${value} / ${max}` : "In progress");
16
+ return _jsxs("div", { ...props, "data-slot": "progress", className: cn("meter", className), children: [_jsxs("div", { className: "meter-label", children: [_jsx("span", { children: label }), _jsx("span", { children: text })] }), _jsx("div", { role: "progressbar", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": max, "aria-valuenow": known ? Math.min(max, Math.max(0, value)) : undefined, "aria-valuetext": text, className: cn("meter-track", !known && "hatch"), children: known && _jsx("span", { style: { width: `${Math.min(1, Math.max(0, value / max)) * 100}%` } }) })] });
17
+ }
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  export type SparklineProps = Omit<React.ComponentProps<"div">, "ref"> & {
3
- values: number[];
3
+ values: readonly (number | null)[];
4
4
  height?: number;
5
5
  tone?: "neutral" | "primary";
6
6
  fill?: boolean;
package/dist/sparkline.js CHANGED
@@ -5,15 +5,16 @@ import { clsx as cn } from "clsx";
5
5
  function Sparkline({ values, height = 24, tone = "neutral", fill = false, min, max, label, className, style, ref, ...props }) {
6
6
  // Below two samples there is no shape to show, and a flat rule across a
7
7
  // cell reads as a border rather than as a measurement.
8
- if (values.length < 2)
8
+ const known = values.filter((value) => value !== null && Number.isFinite(value));
9
+ if (known.length < 2)
9
10
  return null;
10
11
  // Default domain is the data's own extent; pass min and max to make
11
12
  // sibling sparklines share one scale, so rows compare honestly.
12
- let lo = min ?? Math.min(...values);
13
- let hi = max ?? Math.max(...values);
13
+ let lo = min ?? Math.min(...known);
14
+ let hi = max ?? Math.max(...known);
14
15
  const pad = lo === hi ? Math.max(1, Math.abs(lo) * 0.1) : 0;
15
16
  lo -= pad;
16
17
  hi += pad;
17
- return (_jsx("div", { ref: ref, "data-slot": "sparkline", "data-tone": tone, role: label ? "img" : undefined, "aria-label": label, "aria-hidden": label ? undefined : true, className: cn("sparkline", tone === "primary" && "primary", className), style: { height, ...style }, ...props, children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(AreaChart, { data: values.map((value) => ({ value })), margin: { top: 2, right: 0, bottom: 2, left: 0 }, children: [_jsx(YAxis, { hide: true, domain: [lo, hi] }), _jsx(Area, { type: "monotone", dataKey: "value", stroke: "currentColor", strokeWidth: 1.5, fill: fill ? "currentColor" : "none", fillOpacity: fill ? 0.12 : 0, isAnimationActive: false, dot: false, activeDot: false })] }) }) }));
18
+ return (_jsx("div", { ref: ref, "data-slot": "sparkline", "data-tone": tone, role: label ? "img" : undefined, "aria-label": label, "aria-hidden": label ? undefined : true, className: cn("sparkline", tone === "primary" && "primary", className), style: { height, ...style }, ...props, children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(AreaChart, { data: values.map((value) => ({ value: value !== null && Number.isFinite(value) ? value : null })), margin: { top: 2, right: 0, bottom: 2, left: 0 }, children: [_jsx(YAxis, { hide: true, domain: [lo, hi] }), _jsx(Area, { type: "monotone", connectNulls: false, dataKey: "value", stroke: "currentColor", strokeWidth: 1.5, fill: fill ? "currentColor" : "none", fillOpacity: fill ? 0.12 : 0, isAnimationActive: false, dot: false, activeDot: false })] }) }) }));
18
19
  }
19
20
  export { Sparkline };
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ export type TimeRange = {
3
+ from: number;
4
+ to: number;
5
+ };
6
+ export type TimeRangeControlProps = {
7
+ value: TimeRange;
8
+ bounds: TimeRange;
9
+ onValueChange: (range: TimeRange) => void;
10
+ label?: string;
11
+ disabled?: boolean;
12
+ className?: string;
13
+ presets?: readonly {
14
+ label: string;
15
+ duration: number;
16
+ }[];
17
+ };
18
+ export declare function TimeRangeControl({ value, bounds, onValueChange, label, disabled, className, presets }: TimeRangeControlProps): React.JSX.Element;
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { clsx as cn } from "clsx";
5
+ import { Button } from "./button.js";
6
+ import { Input } from "./input.js";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
8
+ const defaultPresets = [
9
+ { label: "Last 5 minutes", duration: 300_000 },
10
+ { label: "Last 15 minutes", duration: 900_000 },
11
+ { label: "Last 30 minutes", duration: 1_800_000 },
12
+ ];
13
+ const dateInput = (value) => new Date(value).toISOString().slice(0, 23);
14
+ const shortRange = (range) => {
15
+ const from = dateInput(range.from);
16
+ const to = dateInput(range.to);
17
+ const sameDay = from.slice(0, 10) === to.slice(0, 10);
18
+ const compact = (date) => (sameDay ? date.slice(11) : date.replace("T", " ")).replace(/:00\.000$/, "").replace(/\.000$/, "");
19
+ return `${compact(from)} to ${compact(to)} UTC`;
20
+ };
21
+ const validRange = (range) => Number.isFinite(range.from) && Number.isFinite(range.to)
22
+ && Math.abs(range.from) <= 8.64e15 && Math.abs(range.to) <= 8.64e15 && range.from < range.to;
23
+ export function TimeRangeControl({ value, bounds, onValueChange, label = "Time range", disabled, className, presets = defaultPresets }) {
24
+ const [open, setOpen] = React.useState(false);
25
+ const [custom, setCustom] = React.useState(false);
26
+ if (!validRange(bounds) || !validRange(value) || value.from < bounds.from || value.to > bounds.to) {
27
+ throw new RangeError("Time range must be ordered and contained within the collection bounds.");
28
+ }
29
+ if (presets.some(preset => !Number.isFinite(preset.duration) || preset.duration <= 0)) {
30
+ throw new RangeError("Time range preset durations must be positive milliseconds.");
31
+ }
32
+ const apply = (range) => { onValueChange(range); setOpen(false); };
33
+ return _jsxs(Popover, { open: open, onOpenChange: next => { setOpen(next); setCustom(false); }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { disabled: disabled, className: cn("time-range-trigger", className), "aria-label": `${label}: ${dateInput(value.from).replace("T", " ")} to ${dateInput(value.to).replace("T", " ")} UTC`, children: [shortRange(value), " ", _jsx("span", { "aria-hidden": "true", children: "\u2304" })] }) }), _jsx(PopoverContent, { className: "time-range-content", "aria-label": label, children: custom ? _jsxs("div", { className: "stack-sm", children: [_jsx(Button, { variant: "ghost", onClick: () => setCustom(false), children: "Back to presets" }), _jsx(TimeRangeForm, { value: value, bounds: bounds, onApply: apply }, `${value.from}-${value.to}-${open}`)] }) : _jsxs("div", { className: "time-range-presets", children: [_jsxs(Button, { variant: "ghost", onClick: () => apply(bounds), children: [_jsx("span", { children: "Entire run" }), _jsx("small", { children: shortRange(bounds) })] }), presets.map(preset => {
34
+ const range = { from: Math.max(bounds.from, bounds.to - preset.duration), to: bounds.to };
35
+ return _jsxs(Button, { variant: "ghost", onClick: () => apply(range), children: [_jsx("span", { children: preset.label }), _jsx("small", { children: shortRange(range) })] }, preset.label);
36
+ }), _jsx(Button, { variant: "ghost", onClick: () => setCustom(true), children: "Custom range" })] }) })] });
37
+ }
38
+ function TimeRangeForm({ value, bounds, onApply }) {
39
+ const id = React.useId();
40
+ const [from, setFrom] = React.useState(dateInput(value.from));
41
+ const [to, setTo] = React.useState(dateInput(value.to));
42
+ const [error, setError] = React.useState("");
43
+ return _jsxs("form", { className: "stack-sm", onSubmit: event => {
44
+ event.preventDefault();
45
+ const range = { from: Date.parse(`${from}Z`), to: Date.parse(`${to}Z`) };
46
+ if (!validRange(range) || range.from < bounds.from || range.to > bounds.to) {
47
+ setError("Choose a start before the end, within the collection window.");
48
+ return;
49
+ }
50
+ onApply(range);
51
+ }, children: [_jsx("label", { htmlFor: `${id}-from`, children: "From (UTC)" }), _jsx(Input, { id: `${id}-from`, type: "datetime-local", step: "0.001", required: true, value: from, min: dateInput(bounds.from), max: dateInput(bounds.to), onChange: event => { setFrom(event.target.value); setError(""); }, "aria-invalid": !!error, "aria-describedby": error ? `${id}-error` : undefined }), _jsx("label", { htmlFor: `${id}-to`, children: "To (UTC)" }), _jsx(Input, { id: `${id}-to`, type: "datetime-local", step: "0.001", required: true, value: to, min: dateInput(bounds.from), max: dateInput(bounds.to), onChange: event => { setTo(event.target.value); setError(""); }, "aria-invalid": !!error, "aria-describedby": error ? `${id}-error` : undefined }), error && _jsx("p", { id: `${id}-error`, role: "alert", className: "field-error", children: error }), _jsx(Button, { type: "submit", variant: "primary", children: "Apply range" })] });
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momoi-labs/kiso-react",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Kiso React components built with shadcn/ui and Radix",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "prepack": "npm run build"
33
33
  },
34
34
  "dependencies": {
35
- "@momoi-labs/kiso": "^0.9.0",
35
+ "@momoi-labs/kiso": "^0.10.0",
36
36
  "class-variance-authority": "0.7.1",
37
37
  "clsx": "2.1.1",
38
38
  "radix-ui": "1.6.7",