@tristan2828/ui-foundation 4.63.3 → 4.64.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.
@@ -18,13 +18,13 @@
18
18
  - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 516–630
19
19
  - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 632–701
20
20
  - [Notices](#notices) · lines 703–731
21
- - [A value over time](#a-value-over-time) · lines 733–811
22
- - [Several values over time](#several-values-over-time) · lines 774–811
23
- - [A list as a board](#a-list-as-a-board) · lines 813–864
24
- - [A list as cards](#a-list-as-cards) · lines 866–912
25
- - [Key numbers on a record](#key-numbers-on-a-record) · lines 914–936
26
- - [Picking at random](#picking-at-random) · lines 938–969
27
- - [Filling in what's missing](#filling-in-whats-missing) · lines 971–1038
21
+ - [A value over time](#a-value-over-time) · lines 733–819
22
+ - [Several values over time](#several-values-over-time) · lines 782–819
23
+ - [A list as a board](#a-list-as-a-board) · lines 821–872
24
+ - [A list as cards](#a-list-as-cards) · lines 874–920
25
+ - [Key numbers on a record](#key-numbers-on-a-record) · lines 922–944
26
+ - [Picking at random](#picking-at-random) · lines 946–977
27
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 979–1046
28
28
 
29
29
  <!-- /index -->
30
30
 
@@ -768,6 +768,14 @@ or `'last'`) with its value.
768
768
  measured. Its points come after the last of `points`; the mark stays on
769
769
  `points`, and a screen reader gets the projection's end in the summary
770
770
  and its rows in the table, each named by `label`.
771
+ - **Levels** (4.64): for a value that is one of a few named steps and
772
+ holds until it changes (a confidence, a severity, a stage), pass
773
+ `levels`, lowest first, and give each point its step's index as y:
774
+ `levels={['Very low', 'Low', 'Moderate', 'High']}`. The steps are named
775
+ down the plot's left, the range is all of them (so one step looks like
776
+ one step), and the line steps instead of sloping. Values read by name
777
+ in the mark, the summary and the table; don't pass `formatY` unless the
778
+ names need more.
771
779
  - One series and a few dozen points. Zoom or tooltips are a request for
772
780
  the foundation, not a hand-drawn chart in the app.
773
781
 
@@ -21,6 +21,12 @@ export type LineChartProps = {
21
21
  };
22
22
  /** Which point to mark with a dot and its value: the lowest, the highest or the latest. */
23
23
  mark?: LineChartMark;
24
+ /**
25
+ * Named steps, lowest first: `['Very low', 'Low', 'Moderate', 'High']`.
26
+ * Each point's y is its step's index. The steps are named on the plot's
27
+ * left, the range is all of them, and the line steps (4.64).
28
+ */
29
+ levels?: readonly string[];
24
30
  /** A value as shown: `usd`. Defaults to the number as the browser writes it. */
25
31
  formatY?: (y: number) => string;
26
32
  /** A point's x as shown: `formatPlainDate`. Defaults to x as given. */
@@ -32,4 +38,4 @@ export type LineChartProps = {
32
38
  emptyMessage?: string;
33
39
  className?: string;
34
40
  };
35
- export declare function LineChart({ label, points, target, projection, mark, formatY, formatX, height, isLoading, emptyMessage, className, }: LineChartProps): import("react").JSX.Element;
41
+ export declare function LineChart({ label, points, target, projection, mark, levels, formatY: formatYGiven, formatX, height, isLoading, emptyMessage, className, }: LineChartProps): import("react").JSX.Element;
@@ -18,12 +18,19 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
18
18
  // A `projection` (4.51) carries the line on past its last point, dashed in
19
19
  // the same colour: where a balance may go, not where it went. It shares
20
20
  // the plot's x and y ranges, so the target and the projection meet where
21
- // they would. The four states: a skeleton at
21
+ // they would.
22
+ //
23
+ // `levels` (4.64) is for a value that is one of a few named steps (a
24
+ // confidence, Very low to High): each y is its step's index. The steps
25
+ // are named down the plot's left with a faint rule at each, the range is
26
+ // every step, and the line holds each value until the next point, then
27
+ // steps. A value reads by its name, in the summary and the table too,
28
+ // unless `formatY` says otherwise. The four states: a skeleton at
22
29
  // `height` while loading, one quiet line with no points, a dot (no line)
23
30
  // for one point, and the chart.
24
31
  import { cn } from 'cn';
25
32
  import { Skeleton } from '../ui/skeleton.js';
26
- import { chartLayout } from '../../lib/line-chart-layout.js';
33
+ import { chartLayout, steppedPlaces } from '../../lib/line-chart-layout.js';
27
34
  const defaultY = (y) => y.toLocaleString();
28
35
  const defaultX = (x) => String(x);
29
36
  const percent = (fraction) => `${fraction * 100}%`;
@@ -34,7 +41,8 @@ const PLATE = 'rounded-sm bg-card px-1';
34
41
  function besides(x) {
35
42
  return x > 0.75 ? '-translate-x-full -ml-1.5' : 'ml-1.5';
36
43
  }
37
- export function LineChart({ label, points, target, projection, mark, formatY = defaultY, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
44
+ export function LineChart({ label, points, target, projection, mark, levels, formatY: formatYGiven, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
45
+ const formatY = formatYGiven ?? (levels ? (y) => levels[Math.round(y)] ?? String(y) : defaultY);
38
46
  if (isLoading) {
39
47
  return (_jsxs("div", { "data-slot": "line-chart", "data-state": "loading", "aria-busy": "true", className: cn('min-w-0', className), children: [_jsxs("span", { className: "sr-only", children: ["Loading ", label] }), _jsx(Skeleton, { style: { height }, className: "w-full" })] }));
40
48
  }
@@ -42,13 +50,18 @@ export function LineChart({ label, points, target, projection, mark, formatY = d
42
50
  return (_jsx("figure", { "data-slot": "line-chart", "data-state": "empty", "aria-label": label, className: cn('min-w-0', className), children: _jsx("p", { style: { height }, className: "flex items-center justify-center rounded-md border border-dashed border-border type-body text-muted-foreground", children: emptyMessage }) }));
43
51
  }
44
52
  const ahead = projection?.points ?? [];
45
- const layout = { ...chartLayout([...points, ...ahead], target?.y), markIndex: chartLayout(points, undefined, mark).markIndex };
53
+ const steps = levels && levels.length > 0 ? levels.length : undefined;
54
+ const layout = {
55
+ ...chartLayout([...points, ...ahead], target?.y, undefined, steps),
56
+ markIndex: chartLayout(points, undefined, mark).markIndex,
57
+ };
58
+ const toLine = (places) => (steps ? steppedPlaces(places) : places).map((place) => `${place.x * 100},${place.y * 100}`).join(' ');
46
59
  const projected = ahead.length > 0 ? layout.places.slice(points.length - 1) : [];
47
60
  const lastX = ahead.length > 0 ? ahead[ahead.length - 1].x : points[points.length - 1].x;
48
61
  const values = points.map((point) => point.y);
49
62
  const lowest = values.indexOf(Math.min(...values));
50
63
  const highest = values.indexOf(Math.max(...values));
51
- const line = layout.places.slice(0, points.length).map((place) => `${place.x * 100},${place.y * 100}`).join(' ');
64
+ const line = toLine(layout.places.slice(0, points.length));
52
65
  const marked = layout.markIndex === undefined ? undefined : layout.places[layout.markIndex];
53
66
  const single = points.length === 1 ? layout.places[0] : undefined;
54
67
  const dot = marked ?? single;
@@ -62,7 +75,8 @@ export function LineChart({ label, points, target, projection, mark, formatY = d
62
75
  ]
63
76
  .filter(Boolean)
64
77
  .join('; ');
65
- return (_jsxs("figure", { "data-slot": "line-chart", "data-state": "success", "aria-label": label, className: cn('flex min-w-0 flex-col gap-1.5', className), children: [_jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: "6 4", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: cn('absolute right-0 -translate-y-full type-caption text-success-text tabular-nums', PLATE), style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), points.length > 1 && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: line, fill: "none", strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), projected.length > 1 && (_jsx("svg", { "data-slot": "line-chart-projection", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: projected.map((place) => `${place.x * 100},${place.y * 100}`).join(' '), fill: "none", strokeWidth: 2, strokeDasharray: "6 4", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), dot && (_jsxs(_Fragment, { children: [_jsx("span", { "data-slot": "line-chart-mark", className: "absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-info-text ring-2 ring-card", style: { left: percent(dot.x), top: percent(dot.y) } }), _jsx("span", { className: cn('absolute type-caption font-medium text-foreground tabular-nums', PLATE, besides(dot.x),
78
+ return (_jsxs("figure", { "data-slot": "line-chart", "data-state": "success", "aria-label": label, className: cn('grid min-w-0 gap-x-2 gap-y-1.5', steps ? 'grid-cols-[auto_minmax(0,1fr)]' : 'grid-cols-1', className), children: [steps && (_jsxs("div", { "aria-hidden": "true", "data-slot": "line-chart-levels", className: "relative", style: { height }, children: [levels.map((name, index) => (_jsx("span", { className: "absolute right-0 -translate-y-1/2 type-caption whitespace-nowrap text-muted-foreground", style: { top: percent(layout.levelYs[index]) }, children: name }, name))), _jsx("span", { className: "invisible flex h-0 flex-col overflow-hidden type-caption whitespace-nowrap", children: levels.map((name) => (_jsx("span", { children: name }, name))) })] })), _jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [steps &&
79
+ layout.levelYs.map((y, index) => (_jsx("span", { "data-slot": "line-chart-level-rule", className: "absolute inset-x-0 border-t border-dashed border-border", style: { top: percent(y) } }, index))), target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: "6 4", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: cn('absolute right-0 -translate-y-full type-caption text-success-text tabular-nums', PLATE), style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), points.length > 1 && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: line, fill: "none", strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), projected.length > 1 && (_jsx("svg", { "data-slot": "line-chart-projection", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: toLine(projected), fill: "none", strokeWidth: 2, strokeDasharray: "6 4", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), dot && (_jsxs(_Fragment, { children: [_jsx("span", { "data-slot": "line-chart-mark", className: "absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-info-text ring-2 ring-card", style: { left: percent(dot.x), top: percent(dot.y) } }), _jsx("span", { className: cn('absolute type-caption font-medium text-foreground tabular-nums', PLATE, besides(dot.x),
66
80
  // Under the lowest point, where the line isn't; over any other.
67
- mark === 'min' ? 'mt-1' : '-translate-y-full -mt-1'), style: { left: percent(dot.x), top: percent(dot.y) }, children: formatY(points[marked ? layout.markIndex : 0].y) })] }))] }), _jsxs("div", { "aria-hidden": "true", className: "flex justify-between gap-3 type-caption text-muted-foreground tabular-nums", children: [_jsx("span", { children: formatX(points[0].x) }), (points.length > 1 || ahead.length > 0) && _jsx("span", { children: formatX(lastX) })] }), _jsx("figcaption", { className: "sr-only", children: summary }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "When" }), _jsx("th", { scope: "col", children: "Value" })] }) }), _jsxs("tbody", { children: [points.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsx("td", { children: formatY(point.y) })] }, index))), ahead.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsxs("td", { children: [formatY(point.y), " (", projection.label, ")"] })] }, `ahead-${index}`)))] })] })] }));
81
+ mark === 'min' ? 'mt-1' : '-translate-y-full -mt-1'), style: { left: percent(dot.x), top: percent(dot.y) }, children: formatY(points[marked ? layout.markIndex : 0].y) })] }))] }), _jsxs("div", { "aria-hidden": "true", className: cn('flex justify-between gap-3 type-caption text-muted-foreground tabular-nums', steps && 'col-start-2'), children: [_jsx("span", { children: formatX(points[0].x) }), (points.length > 1 || ahead.length > 0) && _jsx("span", { children: formatX(lastX) })] }), _jsx("figcaption", { className: "sr-only", children: steps ? `${summary}; one of ${levels.join(', ')}` : summary }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "When" }), _jsx("th", { scope: "col", children: "Value" })] }) }), _jsxs("tbody", { children: [points.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsx("td", { children: formatY(point.y) })] }, index))), ahead.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsxs("td", { children: [formatY(point.y), " (", projection.label, ")"] })] }, `ahead-${index}`)))] })] })] }));
68
82
  }
@@ -19,8 +19,17 @@ export type ChartLayout = {
19
19
  targetY?: number;
20
20
  /** Which point the mark is on, if any. */
21
21
  markIndex?: number;
22
+ /** With levels: each level's height, lowest level first. */
23
+ levelYs?: number[];
22
24
  };
23
- export declare function chartLayout(points: readonly LineChartPoint[], target?: number, mark?: LineChartMark): ChartLayout;
25
+ export declare function chartLayout(points: readonly LineChartPoint[], target?: number, mark?: LineChartMark, levels?: number): ChartLayout;
26
+ export declare function steppedPlaces(places: readonly {
27
+ x: number;
28
+ y: number;
29
+ }[]): {
30
+ x: number;
31
+ y: number;
32
+ }[];
24
33
  export type SeriesLayout = {
25
34
  /** Each series' places, in the order given, each in its points' order. */
26
35
  places: {
@@ -10,11 +10,15 @@ function along(x) {
10
10
  // Room above and below the values, as a share of their range, so the line
11
11
  // never runs along the plot's edge.
12
12
  const PAD = 0.1;
13
- export function chartLayout(points, target, mark) {
13
+ // `levels` (4.64): the count of named steps a value is one of (a
14
+ // confidence, Very low to High), each value its step's index. The range is
15
+ // then every step, whatever the values, so a move from one step to the
16
+ // next looks as big as it is, not the plot's whole height.
17
+ export function chartLayout(points, target, mark, levels) {
14
18
  const values = points.map((point) => point.y);
15
19
  const all = target === undefined ? values : [...values, target];
16
- let min = Math.min(...all);
17
- let max = Math.max(...all);
20
+ let min = levels ? 0 : Math.min(...all);
21
+ let max = levels ? levels - 1 : Math.max(...all);
18
22
  // One value (or all the same): a band around it, so the line sits mid-plot.
19
23
  const spread = max - min || Math.abs(max) || 1;
20
24
  min -= spread * PAD;
@@ -40,8 +44,15 @@ export function chartLayout(points, target, mark) {
40
44
  domain: { min, max },
41
45
  targetY: target === undefined ? undefined : height(target),
42
46
  markIndex,
47
+ levelYs: levels ? Array.from({ length: levels }, (_, index) => height(index)) : undefined,
43
48
  };
44
49
  }
50
+ // A line that holds each value until the next point, then steps to it:
51
+ // for values that change on a day and stay (a confidence), not drift.
52
+ // Places in, polyline places out.
53
+ export function steppedPlaces(places) {
54
+ return places.flatMap((place, index) => (index === 0 ? [place] : [{ x: place.x, y: places[index - 1].y }, place]));
55
+ }
45
56
  // Several series on one plot (MultiLineChart): one y range over all their
46
57
  // values and the target, and one x range over all their points, so a
47
58
  // series that starts later starts further right. When an x isn't a time
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.63.3",
3
+ "version": "4.64.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {