@seekalfred/primitives 0.7.1 → 0.7.3

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.
Files changed (101) hide show
  1. package/README.md +195 -4
  2. package/dist/alert/alert.d.ts +49 -4
  3. package/dist/alert/alert.js +66 -38
  4. package/dist/alert/alert.js.map +1 -1
  5. package/dist/alert/index.d.ts +2 -2
  6. package/dist/alert/index.js +2 -2
  7. package/dist/alert-dialog/alert-dialog.d.ts +17 -4
  8. package/dist/alert-dialog/alert-dialog.js +26 -2
  9. package/dist/alert-dialog/alert-dialog.js.map +1 -1
  10. package/dist/badge/badge.d.ts +1 -1
  11. package/dist/button/button.d.ts +2 -2
  12. package/dist/calendar/calendar.d.ts +1 -1
  13. package/dist/card/card.d.ts +22 -1
  14. package/dist/card/card.js +60 -7
  15. package/dist/card/card.js.map +1 -1
  16. package/dist/carousel/carousel.d.ts +21 -0
  17. package/dist/carousel/carousel.js +34 -8
  18. package/dist/carousel/carousel.js.map +1 -1
  19. package/dist/copy-button/copy-button.d.ts +18 -2
  20. package/dist/copy-button/copy-button.js +29 -8
  21. package/dist/copy-button/copy-button.js.map +1 -1
  22. package/dist/donut-chart/donut-chart.d.ts +22 -0
  23. package/dist/donut-chart/donut-chart.js +35 -18
  24. package/dist/donut-chart/donut-chart.js.map +1 -1
  25. package/dist/donut-chart/index.d.ts +2 -2
  26. package/dist/empty-state/empty-state.d.ts +21 -0
  27. package/dist/empty-state/empty-state.js +16 -4
  28. package/dist/empty-state/empty-state.js.map +1 -1
  29. package/dist/empty-state/index.d.ts +2 -2
  30. package/dist/empty-state/index.js +2 -2
  31. package/dist/filter-bar/filter-bar.d.ts +4 -1
  32. package/dist/filter-bar/filter-bar.js +53 -9
  33. package/dist/filter-bar/filter-bar.js.map +1 -1
  34. package/dist/index.d.ts +10 -10
  35. package/dist/index.js +7 -7
  36. package/dist/input/input.js +4 -4
  37. package/dist/input/input.js.map +1 -1
  38. package/dist/lib/field-shell.js +10 -3
  39. package/dist/lib/field-shell.js.map +1 -1
  40. package/dist/lib/field-surface.js +22 -1
  41. package/dist/lib/field-surface.js.map +1 -1
  42. package/dist/lib/use-copy-feedback.js +17 -3
  43. package/dist/lib/use-copy-feedback.js.map +1 -1
  44. package/dist/line-chart/index.d.ts +2 -2
  45. package/dist/line-chart/line-chart.d.ts +42 -2
  46. package/dist/line-chart/line-chart.js +143 -14
  47. package/dist/line-chart/line-chart.js.map +1 -1
  48. package/dist/money-input/money-input.d.ts +22 -1
  49. package/dist/money-input/money-input.js +13 -7
  50. package/dist/money-input/money-input.js.map +1 -1
  51. package/dist/morph-select/morph-select.d.ts +19 -1
  52. package/dist/morph-select/morph-select.js +21 -2
  53. package/dist/morph-select/morph-select.js.map +1 -1
  54. package/dist/multi-select/index.d.ts +2 -2
  55. package/dist/multi-select/index.js +2 -2
  56. package/dist/multi-select/multi-select.d.ts +43 -2
  57. package/dist/multi-select/multi-select.js +93 -36
  58. package/dist/multi-select/multi-select.js.map +1 -1
  59. package/dist/password-field/password-field.d.ts +16 -0
  60. package/dist/password-field/password-field.js +37 -8
  61. package/dist/password-field/password-field.js.map +1 -1
  62. package/dist/popover/index.d.ts +2 -2
  63. package/dist/popover/index.js +2 -2
  64. package/dist/popover/popover.d.ts +39 -2
  65. package/dist/popover/popover.js +29 -2
  66. package/dist/popover/popover.js.map +1 -1
  67. package/dist/progress/progress.d.ts +16 -2
  68. package/dist/progress/progress.js +15 -8
  69. package/dist/progress/progress.js.map +1 -1
  70. package/dist/radio-group/index.d.ts +2 -2
  71. package/dist/radio-group/index.js +2 -2
  72. package/dist/radio-group/radio-group.d.ts +33 -3
  73. package/dist/radio-group/radio-group.js +109 -9
  74. package/dist/radio-group/radio-group.js.map +1 -1
  75. package/dist/scroll-area/scroll-area.js +4 -3
  76. package/dist/scroll-area/scroll-area.js.map +1 -1
  77. package/dist/search-field/search-field.d.ts +22 -1
  78. package/dist/search-field/search-field.js +22 -7
  79. package/dist/search-field/search-field.js.map +1 -1
  80. package/dist/select/select.d.ts +1 -1
  81. package/dist/select/select.js +8 -6
  82. package/dist/select/select.js.map +1 -1
  83. package/dist/separator/separator.d.ts +21 -9
  84. package/dist/separator/separator.js +33 -11
  85. package/dist/separator/separator.js.map +1 -1
  86. package/dist/stat-card/index.d.ts +2 -2
  87. package/dist/stat-card/index.js +2 -2
  88. package/dist/stat-card/stat-card.d.ts +34 -1
  89. package/dist/stat-card/stat-card.js +51 -21
  90. package/dist/stat-card/stat-card.js.map +1 -1
  91. package/dist/styles.css +1 -1
  92. package/dist/table/table.d.ts +1 -1
  93. package/dist/time-picker/time-picker.d.ts +36 -1
  94. package/dist/time-picker/time-picker.js +33 -5
  95. package/dist/time-picker/time-picker.js.map +1 -1
  96. package/dist/toast/toast.d.ts +2 -2
  97. package/dist/usage-meter/index.d.ts +2 -2
  98. package/dist/usage-meter/usage-meter.d.ts +30 -0
  99. package/dist/usage-meter/usage-meter.js +56 -38
  100. package/dist/usage-meter/usage-meter.js.map +1 -1
  101. package/package.json +3 -3
@@ -1,5 +1,18 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  //#region src/lib/use-copy-feedback.ts
3
+ /**
4
+ * Runs a consumer's callback so that a throw inside it cannot reject the
5
+ * promise our press handler fires and forgets (an unhandled rejection). The
6
+ * error is reported the way an uncaught one would be, so it still shows up.
7
+ */
8
+ function callSafely(callback) {
9
+ try {
10
+ callback();
11
+ } catch (error) {
12
+ if (typeof globalThis.reportError === "function") globalThis.reportError(error);
13
+ else console.error(error);
14
+ }
15
+ }
3
16
  /** Clipboard state for actions that render their own button: copy, show copied or failed for a while, then go back to idle. */
4
17
  function useCopyFeedback(duration = 1900) {
5
18
  const [state, setState] = useState("idle");
@@ -14,16 +27,17 @@ function useCopyFeedback(duration = 1900) {
14
27
  }, []);
15
28
  return {
16
29
  state,
17
- copy: useCallback(async (value) => {
30
+ copy: useCallback(async (value, onError) => {
18
31
  if (timeout.current) clearTimeout(timeout.current);
19
32
  try {
20
33
  await navigator.clipboard.writeText(value);
21
34
  setState("copied");
22
35
  timeout.current = setTimeout(reset, duration);
23
36
  return true;
24
- } catch {
37
+ } catch (error) {
25
38
  setState("error");
26
39
  timeout.current = setTimeout(reset, duration);
40
+ callSafely(() => onError?.(error));
27
41
  return false;
28
42
  }
29
43
  }, [duration, reset]),
@@ -31,6 +45,6 @@ function useCopyFeedback(duration = 1900) {
31
45
  };
32
46
  }
33
47
  //#endregion
34
- export { useCopyFeedback };
48
+ export { callSafely, useCopyFeedback };
35
49
 
36
50
  //# sourceMappingURL=use-copy-feedback.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-copy-feedback.js","names":[],"sources":["../../src/lib/use-copy-feedback.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport type CopyFeedbackState = \"idle\" | \"copied\" | \"error\";\n\n/** Clipboard state for actions that render their own button: copy, show copied or failed for a while, then go back to idle. */\nexport function useCopyFeedback(duration = 1900) {\n const [state, setState] = useState<CopyFeedbackState>(\"idle\");\n const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const reset = useCallback(() => {\n if (timeout.current) clearTimeout(timeout.current);\n timeout.current = null;\n setState(\"idle\");\n }, []);\n\n useEffect(\n () => () => {\n if (timeout.current) clearTimeout(timeout.current);\n },\n [],\n );\n\n /** Resolves true if the text reached the clipboard. */\n const copy = useCallback(\n async (value: string) => {\n if (timeout.current) clearTimeout(timeout.current);\n try {\n await navigator.clipboard.writeText(value);\n setState(\"copied\");\n timeout.current = setTimeout(reset, duration);\n return true;\n } catch {\n setState(\"error\");\n timeout.current = setTimeout(reset, duration);\n return false;\n }\n },\n [duration, reset],\n );\n\n return { state, copy, reset };\n}\n"],"mappings":";;;AAKA,SAAgB,gBAAgB,WAAW,MAAM;CAC/C,MAAM,CAAC,OAAO,YAAY,SAA4B,MAAM;CAC5D,MAAM,UAAU,OAA6C,IAAI;CAEjE,MAAM,QAAQ,kBAAkB;EAC9B,IAAI,QAAQ,SAAS,aAAa,QAAQ,OAAO;EACjD,QAAQ,UAAU;EAClB,SAAS,MAAM;CACjB,GAAG,CAAC,CAAC;CAEL,sBACc;EACV,IAAI,QAAQ,SAAS,aAAa,QAAQ,OAAO;CACnD,GACA,CAAC,CACH;CAoBA,OAAO;EAAE;EAAO,MAjBH,YACX,OAAO,UAAkB;GACvB,IAAI,QAAQ,SAAS,aAAa,QAAQ,OAAO;GACjD,IAAI;IACF,MAAM,UAAU,UAAU,UAAU,KAAK;IACzC,SAAS,QAAQ;IACjB,QAAQ,UAAU,WAAW,OAAO,QAAQ;IAC5C,OAAO;GACT,QAAQ;IACN,SAAS,OAAO;IAChB,QAAQ,UAAU,WAAW,OAAO,QAAQ;IAC5C,OAAO;GACT;EACF,GACA,CAAC,UAAU,KAAK,CAGC;EAAG;CAAM;AAC9B"}
1
+ {"version":3,"file":"use-copy-feedback.js","names":[],"sources":["../../src/lib/use-copy-feedback.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport type CopyFeedbackState = \"idle\" | \"copied\" | \"error\";\n\n/**\n * Runs a consumer's callback so that a throw inside it cannot reject the\n * promise our press handler fires and forgets (an unhandled rejection). The\n * error is reported the way an uncaught one would be, so it still shows up.\n */\nexport function callSafely(callback: () => void) {\n try {\n callback();\n } catch (error) {\n if (typeof globalThis.reportError === \"function\") globalThis.reportError(error);\n else console.error(error);\n }\n}\n\n/** Clipboard state for actions that render their own button: copy, show copied or failed for a while, then go back to idle. */\nexport function useCopyFeedback(duration = 1900) {\n const [state, setState] = useState<CopyFeedbackState>(\"idle\");\n const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const reset = useCallback(() => {\n if (timeout.current) clearTimeout(timeout.current);\n timeout.current = null;\n setState(\"idle\");\n }, []);\n\n useEffect(\n () => () => {\n if (timeout.current) clearTimeout(timeout.current);\n },\n [],\n );\n\n /** Resolves true if the text reached the clipboard. `onError` gets whatever the clipboard rejected with. */\n const copy = useCallback(\n async (value: string, onError?: (error: unknown) => void) => {\n if (timeout.current) clearTimeout(timeout.current);\n try {\n await navigator.clipboard.writeText(value);\n setState(\"copied\");\n timeout.current = setTimeout(reset, duration);\n return true;\n } catch (error) {\n setState(\"error\");\n timeout.current = setTimeout(reset, duration);\n callSafely(() => onError?.(error));\n return false;\n }\n },\n [duration, reset],\n );\n\n return { state, copy, reset };\n}\n"],"mappings":";;;;;;;AASA,SAAgB,WAAW,UAAsB;CAC/C,IAAI;EACF,SAAS;CACX,SAAS,OAAO;EACd,IAAI,OAAO,WAAW,gBAAgB,YAAY,WAAW,YAAY,KAAK;OACzE,QAAQ,MAAM,KAAK;CAC1B;AACF;;AAGA,SAAgB,gBAAgB,WAAW,MAAM;CAC/C,MAAM,CAAC,OAAO,YAAY,SAA4B,MAAM;CAC5D,MAAM,UAAU,OAA6C,IAAI;CAEjE,MAAM,QAAQ,kBAAkB;EAC9B,IAAI,QAAQ,SAAS,aAAa,QAAQ,OAAO;EACjD,QAAQ,UAAU;EAClB,SAAS,MAAM;CACjB,GAAG,CAAC,CAAC;CAEL,sBACc;EACV,IAAI,QAAQ,SAAS,aAAa,QAAQ,OAAO;CACnD,GACA,CAAC,CACH;CAqBA,OAAO;EAAE;EAAO,MAlBH,YACX,OAAO,OAAe,YAAuC;GAC3D,IAAI,QAAQ,SAAS,aAAa,QAAQ,OAAO;GACjD,IAAI;IACF,MAAM,UAAU,UAAU,UAAU,KAAK;IACzC,SAAS,QAAQ;IACjB,QAAQ,UAAU,WAAW,OAAO,QAAQ;IAC5C,OAAO;GACT,SAAS,OAAO;IACd,SAAS,OAAO;IAChB,QAAQ,UAAU,WAAW,OAAO,QAAQ;IAC5C,iBAAiB,UAAU,KAAK,CAAC;IACjC,OAAO;GACT;EACF,GACA,CAAC,UAAU,KAAK,CAGC;EAAG;CAAM;AAC9B"}
@@ -1,2 +1,2 @@
1
- import { LINE_CHART_DRAW_MS, LineChart, LineChartDatum, LineChartProps, LineChartSeries } from "./line-chart.js";
2
- export { LINE_CHART_DRAW_MS, LineChart, type LineChartDatum, type LineChartProps, type LineChartSeries };
1
+ import { LINE_CHART_DRAW_MS, LineChart, LineChartDatum, LineChartProps, LineChartReference, LineChartSeries } from "./line-chart.js";
2
+ export { LINE_CHART_DRAW_MS, LineChart, type LineChartDatum, type LineChartProps, type LineChartReference, type LineChartSeries };
@@ -34,9 +34,36 @@ export interface LineChartDatum {
34
34
  label: string;
35
35
  /** Short label on the x axis. Leave it out to keep the axis quiet at that point. */
36
36
  axisLabel?: string;
37
- /** One value per series key. A missing value counts as zero. */
38
- values: Record<string, number | undefined>;
37
+ /**
38
+ * One value per series key. A missing value (`undefined` or `null`) counts as
39
+ * zero, unless the chart's `missing` is `"gap"`.
40
+ */
41
+ values: Record<string, number | null | undefined>;
39
42
  }
43
+ /**
44
+ * A reference line: a horizontal line at a value (a target, a threshold), or a
45
+ * vertical line at one datum (today, a launch). Drawn dashed and quiet, behind
46
+ * the series, with its label at the line's end.
47
+ */
48
+ export type LineChartReference = {
49
+ /** Stable identity. */
50
+ key: string;
51
+ /** Short text beside the line, such as "Target" or "Today". Also read to screen readers. */
52
+ label: string;
53
+ } & ({
54
+ /**
55
+ * A value on the value axis: a horizontal line. The scale grows to keep it
56
+ * in view, even when every value is missing. A value that is not finite
57
+ * draws nothing. Read with `formatValue` (given the chart's first series)
58
+ * and `unit`, like the series.
59
+ */
60
+ value: number;
61
+ at?: never;
62
+ } | {
63
+ /** The `key` of a datum: a vertical line at that point. A key not in `data` draws nothing. */
64
+ at: string;
65
+ value?: never;
66
+ });
40
67
  export interface LineChartProps {
41
68
  data: LineChartDatum[];
42
69
  series: LineChartSeries[];
@@ -66,6 +93,19 @@ export interface LineChartProps {
66
93
  categoryLabel?: string;
67
94
  /** Smooth monotone curves that never swing past the data, or straight segments. @default "smooth" */
68
95
  curve?: "smooth" | "linear";
96
+ /**
97
+ * How a missing value (`undefined`, `null` or not a finite number) is drawn.
98
+ * `"zero"` counts it as zero, as before. `"gap"` breaks the line (and its
99
+ * area) there, leaves it out of the scale, hides the crosshair dot on it and
100
+ * reads it as `missingLabel` in the tooltip and the data table. A point with
101
+ * missing neighbours on both sides is drawn as a dot.
102
+ * @default "zero"
103
+ */
104
+ missing?: "zero" | "gap";
105
+ /** Read in place of a missing value when `missing` is `"gap"`. @default "No data" */
106
+ missingLabel?: string;
107
+ /** Reference lines: targets at a value, or markers such as "today" at a datum. */
108
+ references?: LineChartReference[];
69
109
  /** Extra classes for the root. Merged last. */
70
110
  className?: string;
71
111
  }
@@ -88,6 +88,61 @@ function curveFor(values, smooth) {
88
88
  points.push([1, values[last]]);
89
89
  return points;
90
90
  }
91
+ /** Fills each missing value straight between its present neighbours (or with the nearest one at the ends), so a gapped line still has a shape to morph. */
92
+ function fillGaps(values) {
93
+ const present = values.flatMap((value, index) => value === null ? [] : [index]);
94
+ if (!present.length) return values.map(() => 0);
95
+ return values.map((value, index) => {
96
+ if (value !== null) return value;
97
+ const after = present.find((at) => at > index);
98
+ const before = [...present].reverse().find((at) => at < index);
99
+ if (before === void 0) return values[after];
100
+ if (after === void 0) return values[before];
101
+ return values[before] + (values[after] - values[before]) * (index - before) / (after - before);
102
+ });
103
+ }
104
+ /** Where a line with missing values is not drawn: before its first value, after its last, and between two values with missing ones between them. */
105
+ function breaksFor(values) {
106
+ const present = values.flatMap((value, index) => value === null ? [] : [index]);
107
+ if (!present.length) return values.length ? [[-Infinity, Infinity]] : [];
108
+ const last = values.length - 1;
109
+ if (last === 0) return [];
110
+ const breaks = [];
111
+ if (present[0] > 0) breaks.push([-Infinity, present[0] / last]);
112
+ for (let at = 1; at < present.length; at++) if (present[at] - present[at - 1] > 1) breaks.push([present[at - 1] / last, present[at] / last]);
113
+ if (present[present.length - 1] < last) breaks.push([present[present.length - 1] / last, Infinity]);
114
+ return breaks;
115
+ }
116
+ const EDGE = 1e-9;
117
+ /** Radius of a lone point's dot, inside its 2px stroke. */
118
+ const DOT = 1.5;
119
+ const inBreak = (breaks, x) => breaks.some(([from, to]) => x > from + EDGE && x < to - EDGE);
120
+ /** The drawn runs of a line, split at its gaps. A run of one point is a dot: a small closed circle the stroke fills out to about 5px. */
121
+ function pathsFor(shape, breaks, toX, toY, bottom) {
122
+ const runs = [];
123
+ let run = null;
124
+ for (const [x, value] of shape) {
125
+ if (breaks.length && inBreak(breaks, x)) {
126
+ run = null;
127
+ continue;
128
+ }
129
+ if (!run) runs.push(run = []);
130
+ run.push(`${toX(x).toFixed(1)},${toY(value).toFixed(1)}`);
131
+ }
132
+ return {
133
+ line: runs.map((points) => {
134
+ if (points.length > 1) return `M${points.join("L")}`;
135
+ const [x, y] = points[0].split(",").map(Number);
136
+ return `M${(x - DOT).toFixed(1)},${y.toFixed(1)}a${DOT},${DOT} 0 1,0 ${DOT * 2},0a${DOT},${DOT} 0 1,0 -3,0`;
137
+ }).join(""),
138
+ area: runs.map((points) => {
139
+ const first = points[0].split(",")[0];
140
+ const lastX = points[points.length - 1].split(",")[0];
141
+ return `M${points.join("L")}L${lastX},${bottom}L${first},${bottom}Z`;
142
+ }).join("")
143
+ };
144
+ }
145
+ const isFiniteValue = (reference) => typeof reference.value === "number" && Number.isFinite(reference.value);
91
146
  /** Value of a sampled line at x, straight between samples. */
92
147
  function valueAt(shape, x) {
93
148
  if (!shape.length) return 0;
@@ -112,7 +167,7 @@ function axisPicks(data, width) {
112
167
  const stride = Math.ceil(labeled.length / room);
113
168
  return labeled.reverse().filter((_, rank) => rank % stride === 0).reverse();
114
169
  }
115
- const LineChart = forwardRef(function LineChart({ data, series, label, unit = "", height = 220, formatValue, formatTick = (value) => COMPACT.format(value), hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange, onActiveChange, loading = false, emptyLabel = "No data for this range", legend, categoryLabel = "Date", curve = "smooth", className }, ref) {
170
+ const LineChart = forwardRef(function LineChart({ data, series, label, unit = "", height = 220, formatValue, formatTick = (value) => COMPACT.format(value), hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange, onActiveChange, loading = false, emptyLabel = "No data for this range", legend, categoryLabel = "Date", curve = "smooth", missing = "zero", missingLabel = "No data", references, className }, ref) {
116
171
  const figure = useRef(null);
117
172
  const refs = useMemo(() => mergeRefs(ref, figure), [ref]);
118
173
  const plot = useRef(null);
@@ -131,15 +186,33 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
131
186
  };
132
187
  const visible = series.filter((line) => !hidden.includes(line.key));
133
188
  const showLegend = legend ?? series.length > 1;
134
- const signature = `${curve}|${series.map((line) => line.key).join(",")}|${data.map((item) => `${item.key}:${series.map((line) => item.values[line.key] ?? 0).join(",")}`).join(";")}`;
135
- const targets = useMemo(() => new Map(series.map((line) => [line.key, curveFor(data.map((item) => item.values[line.key] ?? 0), curve === "smooth")])), [signature]);
189
+ const gaps = missing === "gap";
190
+ const read = (item, key) => {
191
+ const value = item.values[key];
192
+ if (!gaps) return value ?? 0;
193
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
194
+ };
195
+ const signature = `${curve}|${missing}|${series.map((line) => line.key).join(",")}|${data.map((item) => `${item.key}:${series.map((line) => read(item, line.key)).join(",")}`).join(";")}`;
196
+ const targets = useMemo(() => new Map(series.map((line) => {
197
+ const values = data.map((item) => read(item, line.key));
198
+ return [line.key, {
199
+ shape: curveFor(fillGaps(values), curve === "smooth"),
200
+ breaks: breaksFor(values)
201
+ }];
202
+ })), [signature]);
136
203
  let low = Infinity;
137
204
  let high = -Infinity;
138
205
  for (const line of visible) for (const item of data) {
139
- const value = item.values[line.key] ?? 0;
206
+ const value = read(item, line.key);
207
+ if (value === null) continue;
140
208
  low = Math.min(low, value);
141
209
  high = Math.max(high, value);
142
210
  }
211
+ for (const reference of references ?? []) {
212
+ if (!isFiniteValue(reference)) continue;
213
+ low = Math.min(low, reference.value);
214
+ high = Math.max(high, reference.value);
215
+ }
143
216
  const range = Number.isFinite(low) ? niceScale(low, high) : null;
144
217
  const [steady, setSteady] = useState(() => range ?? niceScale(0, 100));
145
218
  if (range && (range.min !== steady.min || range.max !== steady.max)) setSteady(range);
@@ -160,6 +233,12 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
160
233
  max: steady.max
161
234
  });
162
235
  const cursor = useRef(1);
236
+ const referenceAt = (reference) => {
237
+ if (typeof reference.value === "number") return isFiniteValue(reference) ? null : void 0;
238
+ const at = data.findIndex((item) => item.key === reference.at);
239
+ return at < 0 ? void 0 : last > 0 ? at / last : .5;
240
+ };
241
+ const drawnReferences = (references ?? []).filter((reference) => referenceAt(reference) !== void 0);
163
242
  const live = useRef({
164
243
  series,
165
244
  hidden
@@ -185,17 +264,26 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
185
264
  if (!track) continue;
186
265
  const p = track.presence;
187
266
  const y = (value) => toY(lo + (value - lo) * p);
188
- const path = track.shape.length ? `M${track.shape.map(([x, value]) => `${(x * w).toFixed(1)},${y(value).toFixed(1)}`).join("L")}` : "";
189
267
  const group = root.querySelector(`[data-series="${CSS.escape(line.key)}"]`);
190
- group?.querySelector("[data-part=line]")?.setAttribute("d", path);
191
- group?.querySelector("[data-part=area]")?.setAttribute("d", path ? `${path}L${w},${height}L0,${height}Z` : "");
268
+ if (track.breaks.length) {
269
+ const paths = pathsFor(track.shape, track.breaks, (x) => x * w, y, height);
270
+ group?.querySelector("[data-part=line]")?.setAttribute("d", paths.line);
271
+ group?.querySelector("[data-part=area]")?.setAttribute("d", paths.area);
272
+ } else {
273
+ const path = track.shape.length ? `M${track.shape.map(([x, value]) => `${(x * w).toFixed(1)},${y(value).toFixed(1)}`).join("L")}` : "";
274
+ group?.querySelector("[data-part=line]")?.setAttribute("d", path);
275
+ group?.querySelector("[data-part=area]")?.setAttribute("d", path ? `${path}L${w},${height}L0,${height}Z` : "");
276
+ }
192
277
  if (group) group.style.opacity = String(p);
193
278
  const dot = root.querySelector(`[data-dot="${CSS.escape(line.key)}"]`);
194
279
  if (dot && track.shape.length) {
280
+ const gap = inBreak(track.breaks, at);
195
281
  const cy = y(valueAt(track.shape, at));
196
282
  dot.setAttribute("cx", (at * w).toFixed(1));
197
283
  dot.setAttribute("cy", cy.toFixed(1));
198
- if (p > .5) dotYs.push(cy);
284
+ if (gap) dot.setAttribute("data-gap", "");
285
+ else dot.removeAttribute("data-gap");
286
+ if (p > .5 && !gap) dotYs.push(cy);
199
287
  }
200
288
  }
201
289
  for (const node of root.querySelectorAll("[data-grid]")) {
@@ -204,6 +292,20 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
204
292
  node.setAttribute("y2", yy);
205
293
  }
206
294
  for (const node of gutter.current?.querySelectorAll("[data-tick]") ?? []) node.style.translate = `0 ${Math.round(toY(Number(node.dataset.tick)))}px`;
295
+ for (const node of root.querySelectorAll("[data-reference-value]")) {
296
+ const yy = String(Math.round(toY(Number(node.dataset.referenceValue))) + .5);
297
+ node.setAttribute("y1", yy);
298
+ node.setAttribute("y2", yy);
299
+ }
300
+ for (const node of root.querySelectorAll("[data-reference-at]")) {
301
+ const xx = String(Math.round(Number(node.dataset.referenceAt) * w) + .5);
302
+ node.setAttribute("x1", xx);
303
+ node.setAttribute("x2", xx);
304
+ }
305
+ for (const node of plot.current?.querySelectorAll("[data-reference-label]") ?? []) {
306
+ const { referenceValue, referenceAt } = node.dataset;
307
+ node.style.translate = referenceValue !== void 0 ? `0 ${Math.round(toY(Number(referenceValue)))}px` : `${Math.round(Number(referenceAt) * w)}px 0`;
308
+ }
207
309
  const cx = Math.round(at * w) + .5;
208
310
  const cross = root.querySelector("[data-part=crosshair]");
209
311
  cross?.setAttribute("x1", String(cx));
@@ -235,16 +337,18 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
235
337
  useIsomorphicLayoutEffect(paint);
236
338
  useEffect(() => {
237
339
  const moves = [];
238
- for (const [key, target] of targets) {
340
+ for (const [key, { shape: target, breaks }] of targets) {
239
341
  const track = tracks.current.get(key);
240
342
  if (!track) {
241
343
  tracks.current.set(key, {
242
344
  shape: target,
345
+ breaks,
243
346
  presence: live.current.hidden.includes(key) ? 0 : 1,
244
347
  stop: null
245
348
  });
246
349
  continue;
247
350
  }
351
+ track.breaks = breaks;
248
352
  if (!track.shape.length || !target.length) {
249
353
  track.shape = target;
250
354
  continue;
@@ -375,11 +479,13 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
375
479
  const onPointerMove = (event) => {
376
480
  if (!empty && (event.pointerType === "mouse" || event.buttons)) setActive(pointAt(event.clientX));
377
481
  };
378
- const format = (value, line) => `${formatValue ? formatValue(value, line) : GROUPED.format(value)}${unit ? ` ${unit}` : ""}`;
482
+ const formatNumber = (value, line) => `${formatValue ? formatValue(value, line) : GROUPED.format(value)}${unit ? ` ${unit}` : ""}`;
483
+ const format = (value, line) => value === null ? missingLabel : formatNumber(value, line);
484
+ const formatReference = (value) => series[0] ? formatNumber(value, series[0]) : `${GROUPED.format(value)}${unit ? ` ${unit}` : ""}`;
379
485
  const reading = index === null ? null : data[index];
380
486
  const shown = reading ?? data[last];
381
- const valueText = reading ? `${reading.label}: ${visible.map((line) => `${line.label} ${format(reading.values[line.key] ?? 0, line)}`).join(", ")}` : empty ? emptyLabel : `${visible.length} of ${series.length} series shown`;
382
- const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${data[0].label} to ${data[last].label}. ${visible.map((line) => `${line.label}, latest ${format(data[last].values[line.key] ?? 0, line)}`).join(". ")}.`;
487
+ const valueText = reading ? `${reading.label}: ${visible.map((line) => `${line.label} ${format(read(reading, line.key), line)}`).join(", ")}` : empty ? emptyLabel : `${visible.length} of ${series.length} series shown`;
488
+ const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${data[0].label} to ${data[last].label}. ${visible.map((line) => `${line.label}, latest ${format(read(data[last], line.key), line)}`).join(". ")}.${drawnReferences.map((reference) => typeof reference.value === "number" ? ` ${reference.label}: ${formatReference(reference.value)}.` : ` ${reference.label}: ${data.find((item) => item.key === reference.at).label}.`).join("")}`;
383
489
  const picks = axisPicks(data, plotWidth);
384
490
  const allHidden = !empty && visible.length === 0;
385
491
  return /* @__PURE__ */ jsxs("figure", {
@@ -477,6 +583,19 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
477
583
  y1: height - .5,
478
584
  y2: height - .5
479
585
  }),
586
+ drawnReferences.map((reference) => typeof reference.value === "number" ? /* @__PURE__ */ jsx("line", {
587
+ "data-reference": reference.key,
588
+ "data-reference-value": reference.value,
589
+ className: "line-chart-reference",
590
+ x1: 0,
591
+ x2: "100%"
592
+ }, reference.key) : /* @__PURE__ */ jsx("line", {
593
+ "data-reference": reference.key,
594
+ "data-reference-at": referenceAt(reference),
595
+ className: "line-chart-reference",
596
+ y1: 8,
597
+ y2: height
598
+ }, reference.key)),
480
599
  /* @__PURE__ */ jsx("line", {
481
600
  "data-part": "crosshair",
482
601
  className: "line-chart-crosshair",
@@ -508,6 +627,16 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
508
627
  }, line.key))
509
628
  ]
510
629
  }),
630
+ drawnReferences.map((reference) => /* @__PURE__ */ jsx("span", {
631
+ "aria-hidden": "true",
632
+ "data-slot": "line-chart-reference-label",
633
+ "data-reference-label": reference.key,
634
+ "data-reference-value": typeof reference.value === "number" ? reference.value : void 0,
635
+ "data-reference-at": typeof reference.value === "number" ? void 0 : referenceAt(reference),
636
+ "data-axis": typeof reference.value === "number" ? "value" : "category",
637
+ className: "line-chart-reference-label",
638
+ children: reference.label
639
+ }, reference.key)),
511
640
  /* @__PURE__ */ jsxs("div", {
512
641
  ref: tip,
513
642
  "aria-hidden": "true",
@@ -530,7 +659,7 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
530
659
  }),
531
660
  /* @__PURE__ */ jsx("span", {
532
661
  className: "ms-2 flex-none whitespace-nowrap font-medium tabular-nums text-foreground",
533
- children: format(shown?.values[line.key] ?? 0, line)
662
+ children: shown ? format(read(shown, line.key), line) : format(0, line)
534
663
  })
535
664
  ]
536
665
  }, line.key))]
@@ -593,7 +722,7 @@ const LineChart = forwardRef(function LineChart({ data, series, label, unit = ""
593
722
  /* @__PURE__ */ jsx("tbody", { children: data.map((item) => /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("th", {
594
723
  scope: "row",
595
724
  children: item.label
596
- }), series.map((line) => /* @__PURE__ */ jsx("td", { children: format(item.values[line.key] ?? 0, line) }, line.key))] }, item.key)) })
725
+ }), series.map((line) => /* @__PURE__ */ jsx("td", { children: format(read(item, line.key), line) }, line.key))] }, item.key)) })
597
726
  ] })
598
727
  }),
599
728
  /* @__PURE__ */ jsx("p", {