@eifi1/ui-kit 0.14.2 → 0.15.1

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 (118) hide show
  1. package/dist/chart.d.ts +2 -1
  2. package/dist/components/alert-banner.js +5 -2
  3. package/dist/components/alert-banner.js.map +1 -1
  4. package/dist/components/amount-input.d.ts +1 -1
  5. package/dist/components/autocomplete.js +9 -2
  6. package/dist/components/autocomplete.js.map +1 -1
  7. package/dist/components/bulk-action-bar.js +6 -3
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +1 -1
  10. package/dist/components/calculator.d.ts +1 -1
  11. package/dist/components/calendar-heatmap.js +5 -5
  12. package/dist/components/calendar-heatmap.js.map +1 -1
  13. package/dist/components/copy-button.d.ts +1 -1
  14. package/dist/components/data-table-cells.d.ts +1 -1
  15. package/dist/components/data-table-filter-popover.d.ts +1 -1
  16. package/dist/components/data-table-filter-popover.js +3 -3
  17. package/dist/components/data-table-filter-popover.js.map +1 -1
  18. package/dist/components/data-table-filters.d.ts +1 -1
  19. package/dist/components/data-table.d.ts +1 -1
  20. package/dist/components/data-table.js +12 -4
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/facing-pair.d.ts +1 -0
  23. package/dist/components/field.js +4 -1
  24. package/dist/components/field.js.map +1 -1
  25. package/dist/components/file-button.d.ts +1 -1
  26. package/dist/components/file-dropzone.d.ts +1 -1
  27. package/dist/components/form-actions.d.ts +1 -1
  28. package/dist/components/image-grid.d.ts +8 -1
  29. package/dist/components/image-grid.js +16 -3
  30. package/dist/components/image-grid.js.map +1 -1
  31. package/dist/components/measured-grid.js +2 -2
  32. package/dist/components/measured-grid.js.map +1 -1
  33. package/dist/components/money-field.d.ts +1 -1
  34. package/dist/components/multi-select.js +3 -0
  35. package/dist/components/multi-select.js.map +1 -1
  36. package/dist/components/number-field.d.ts +1 -1
  37. package/dist/components/number-input.d.ts +5 -2
  38. package/dist/components/number-input.js +11 -3
  39. package/dist/components/number-input.js.map +1 -1
  40. package/dist/components/numpad-sheet.d.ts +1 -1
  41. package/dist/components/series-chart-budget.d.ts +60 -0
  42. package/dist/components/series-chart-budget.js +56 -0
  43. package/dist/components/series-chart-budget.js.map +1 -0
  44. package/dist/components/series-chart.d.ts +54 -2
  45. package/dist/components/series-chart.js +104 -20
  46. package/dist/components/series-chart.js.map +1 -1
  47. package/dist/components/settings-fields.d.ts +1 -1
  48. package/dist/components/slider.js +6 -2
  49. package/dist/components/slider.js.map +1 -1
  50. package/dist/components/stat-tile.js +3 -3
  51. package/dist/components/stat-tile.js.map +1 -1
  52. package/dist/components/text-link.d.ts +1 -1
  53. package/dist/components/time-input.d.ts +1 -1
  54. package/dist/components/treemap.d.ts +3 -2
  55. package/dist/components/treemap.js +14 -3
  56. package/dist/components/treemap.js.map +1 -1
  57. package/dist/components/ui.d.ts +1 -1
  58. package/dist/components/ui.js +44 -10
  59. package/dist/components/ui.js.map +1 -1
  60. package/dist/components/use-table-state.d.ts +1 -1
  61. package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
  62. package/dist/data-table.d.ts +1 -1
  63. package/dist/feedback/feedback-thread.js +31 -12
  64. package/dist/feedback/feedback-thread.js.map +1 -1
  65. package/dist/hooks/use-file-drop.d.ts +1 -1
  66. package/dist/i18n/defaults.d.ts +1 -1
  67. package/dist/i18n/kit-labels.d.ts +1 -1
  68. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  69. package/dist/i18n/locales/de-CH.d.ts +1 -1
  70. package/dist/i18n/locales/de-informal.d.ts +1 -1
  71. package/dist/i18n/locales/de.d.ts +1 -1
  72. package/dist/i18n/locales/es.d.ts +1 -1
  73. package/dist/i18n/locales/fr.d.ts +1 -1
  74. package/dist/i18n/locales/hu.d.ts +1 -1
  75. package/dist/i18n/locales/it.d.ts +1 -1
  76. package/dist/i18n/locales/zh.d.ts +1 -1
  77. package/dist/index.d.ts +5 -4
  78. package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
  79. package/dist/lib/strip-fade.d.ts +31 -0
  80. package/dist/lib/strip-fade.js +54 -0
  81. package/dist/lib/strip-fade.js.map +1 -0
  82. package/dist/rhf/fields.d.ts +1 -1
  83. package/dist/rhf/form.d.ts +1 -1
  84. package/dist/rhf/form.js +1 -1
  85. package/dist/rhf/form.js.map +1 -1
  86. package/dist/rhf.d.ts +1 -1
  87. package/dist/shell/app-shell.d.ts +11 -2
  88. package/dist/shell/app-shell.js +20 -4
  89. package/dist/shell/app-shell.js.map +1 -1
  90. package/dist/shell/top-bar-brand.d.ts +1 -1
  91. package/dist/shell.d.ts +1 -1
  92. package/dist/wizard/stepper-nav.d.ts +1 -1
  93. package/dist/wizard/stepper-nav.js +14 -2
  94. package/dist/wizard/stepper-nav.js.map +1 -1
  95. package/dist/wizard.d.ts +1 -1
  96. package/package.json +1 -1
  97. package/src/components/alert-banner.tsx +10 -2
  98. package/src/components/autocomplete.tsx +10 -1
  99. package/src/components/bulk-action-bar.tsx +12 -3
  100. package/src/components/calendar-heatmap.tsx +10 -5
  101. package/src/components/data-table-filter-popover.tsx +6 -3
  102. package/src/components/data-table.tsx +21 -7
  103. package/src/components/field.tsx +4 -1
  104. package/src/components/image-grid.tsx +30 -2
  105. package/src/components/measured-grid.tsx +3 -2
  106. package/src/components/multi-select.tsx +3 -0
  107. package/src/components/number-input.tsx +23 -4
  108. package/src/components/series-chart-budget.ts +121 -0
  109. package/src/components/series-chart.tsx +201 -15
  110. package/src/components/slider.tsx +6 -2
  111. package/src/components/stat-tile.tsx +8 -3
  112. package/src/components/treemap.tsx +30 -5
  113. package/src/components/ui.tsx +91 -11
  114. package/src/feedback/feedback-thread.tsx +36 -13
  115. package/src/lib/strip-fade.ts +82 -0
  116. package/src/rhf/form.tsx +3 -1
  117. package/src/shell/app-shell.tsx +34 -7
  118. package/src/wizard/stepper-nav.tsx +23 -2
@@ -26,7 +26,13 @@
26
26
  // "category"`) or real dates (`x.type: "time"`) instead of a number the app had to
27
27
  // invent. What each of those does to the zoom is decided in one place,
28
28
  // `defaultZoomAxes` in `chart-zoom.tsx`.
29
- import { useMemo, useRef, useState } from "react";
29
+ //
30
+ // **An axis budget (0.15.1).** Every visible axis reserves its band, and four of them
31
+ // leave a phone nothing to plot. A chart draws at most `maxVisibleAxes`, and — on its
32
+ // own, when it is too narrow for the ones it has — one a side; the rest go hidden,
33
+ // still scaling their lines, and their series say the unit the title no longer does.
34
+ // The rule is `series-chart-budget.ts`.
35
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
30
36
  import type { KeyboardEvent, ReactNode, RefObject } from "react";
31
37
  import {
32
38
  Area,
@@ -58,6 +64,9 @@ import { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from "./series-ch
58
64
  import { categoryTicks, integerTicks, niceTicks, timeTicksWithUnit, type TimeTickUnit } from "./series-chart-ticks";
59
65
  // The tick module stays internal; the one type of it a public prop names is re-exported.
60
66
  export type { TimeTickUnit } from "./series-chart-ticks";
67
+ import { autoAxisBudget, axisUnit, budgetedAxes, type SeriesChartAxisBudget } from "./series-chart-budget";
68
+ // The rule stays internal, like the ticks; the one type a public prop names is re-exported.
69
+ export type { SeriesChartAxisBudget } from "./series-chart-budget";
61
70
  import { paletteFor } from "../theme/chart-palette";
62
71
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
63
72
  import { cn } from "../lib/cn";
@@ -193,6 +202,15 @@ interface SeriesChartAxisShape {
193
202
  color?: string;
194
203
  /** What the TICKS need, in px. The title's strip is added on top. Default 48. */
195
204
  width?: number;
205
+ /**
206
+ * The unit, bare — `"mm/s"`. The title says it while the axis is drawn; when it is not
207
+ * (`hide`, or over the chart's axis budget — see {@link SeriesChartProps.maxVisibleAxes})
208
+ * the series on it say it instead, as "Velocity (mm/s)": in the tooltip of a chart
209
+ * whose budget hid the axis, and in the legend {@link seriesLegendEntries} builds when
210
+ * handed the axes. Default: the parenthesised tail of `title` ("Load (N)" → "N").
211
+ * `""` for a quantity with no unit whose title merely ends in brackets.
212
+ */
213
+ unit?: string;
196
214
  /**
197
215
  * Pin the scale instead of fitting it to this chart's data. For a ROW of charts
198
216
  * read against each other: auto-fitted, a 200 N loop and a 2000 N one draw the
@@ -511,6 +529,37 @@ export interface SeriesChartProps {
511
529
  /** Where the tooltip may go: for a chart inside a scroll wrapper. See
512
530
  * {@link SeriesChartTooltip}. */
513
531
  tooltip?: SeriesChartTooltip;
532
+ /**
533
+ * The most y axes the chart DRAWS: a total, or `{ left, right }` per side. Axes over it
534
+ * are drawn as `hide: true` ones are — they still scale their lines and the zoom still
535
+ * refits them, but they draw no ticks or title and reserve no width — in declaration
536
+ * order, so the first axis of each side is the last to go (`series-chart-budget.ts`).
537
+ * The series on a hidden axis say its unit instead (see {@link SeriesChartAxis.unit}).
538
+ * Default: no cap — only the automatic one of `axisBudget`.
539
+ */
540
+ maxVisibleAxes?: SeriesChartAxisBudget;
541
+ /**
542
+ * `"auto"` (the default): a chart so narrow that its axes' bands would leave the plot
543
+ * under 160 px (`MIN_PLOT_WIDTH`) draws ONE axis a side — lenkbank's four-axis curve
544
+ * plot on a phone, whose plot was 10 px wide. Measured on the chart's own box
545
+ * (`ResizeObserver`), and applied on top of `maxVisibleAxes`.
546
+ *
547
+ * On by default because it cannot touch a layout that works: it needs a side with two
548
+ * or more axes AND a plot that would otherwise be under 160 px — a four-axis chart
549
+ * below about 440 px, a two-left-axis one below about 300. A chart with at most one
550
+ * axis a side (every facing pair, every single-axis chart) is never budgeted, at any
551
+ * width. `"off"` keeps every declared axis whatever the width: for a stack of
552
+ * multi-axis charts that must keep identical bands, or a caller budgeting itself.
553
+ * Without layout (jsdom, SSR) nothing is measured and nothing is hidden.
554
+ */
555
+ axisBudget?: "auto" | "off";
556
+ /**
557
+ * The ids of the axes the budget hid (not those declared `hide`), every time that set
558
+ * changes — for a legend that says their units: pass it to {@link seriesLegendEntries}
559
+ * as `budgeted`. Called after the render that hid them; empty once the chart is wide
560
+ * again.
561
+ */
562
+ onAxisBudget?: (hidden: readonly string[]) => void;
514
563
  /** Supplied by `withChartZoom` and by nothing else. */
515
564
  zoom?: ZoomBinding;
516
565
  /** Per-chart strings over `<UiKitProvider labels={{ seriesChart }}>`. */
@@ -695,13 +744,43 @@ export function visibleSeries(
695
744
  .filter((entry) => !hidden.has(entry.key));
696
745
  }
697
746
 
747
+ /** A series' name with its unit after it: "Velocity (mm/s)". */
748
+ function withUnit(label: ReactNode, unit: string | undefined): ReactNode {
749
+ if (!unit) return label;
750
+ if (typeof label === "string" || typeof label === "number") return `${label} (${unit})`;
751
+ return (
752
+ <>
753
+ {label} ({unit})
754
+ </>
755
+ );
756
+ }
757
+
758
+ /** Where a legend learns which axes are not drawn — see {@link seriesLegendEntries}. */
759
+ export interface SeriesLegendAxes {
760
+ /** The chart's axes. A series on one that is `hide`, or `budgeted`, says its unit. */
761
+ axes?: readonly SeriesChartAxis[];
762
+ /** What the chart's `onAxisBudget` last said. */
763
+ budgeted?: readonly string[];
764
+ }
765
+
698
766
  /**
699
767
  * The `ToggleLegend` entries for a chart's FULL series list — colours resolved the way
700
768
  * the chart resolves them, and a stroke mark for a line drawn in a pattern (dashed,
701
769
  * step) so the key promises the stroke the plot draws. A bar or an area is a swatch.
702
770
  * Pair with {@link visibleSeries} for the chart itself.
771
+ *
772
+ * Handed the chart's `axes` (and the `budgeted` ids its `onAxisBudget` reports), a
773
+ * series whose axis is not drawn says the unit that axis would have: "Velocity (mm/s)"
774
+ * — see {@link SeriesChartAxis.unit}. Without them, the labels are as given.
703
775
  */
704
- export function seriesLegendEntries(series: readonly SeriesChartSeries[]): LegendEntry[] {
776
+ export function seriesLegendEntries(
777
+ series: readonly SeriesChartSeries[],
778
+ { axes, budgeted }: SeriesLegendAxes = {},
779
+ ): LegendEntry[] {
780
+ const undrawn = new Map<string, string | undefined>();
781
+ for (const axis of axes ?? []) {
782
+ if (axis.hide || budgeted?.includes(axis.id)) undrawn.set(axis.id, axisUnit(axis));
783
+ }
705
784
  return series.map((entry, index) => {
706
785
  // A custom dash array goes to the legend as it is, so the swatch draws what the plot does.
707
786
  const own = entry.dash ?? (entry.dashed ? 1 : 0);
@@ -709,7 +788,7 @@ export function seriesLegendEntries(series: readonly SeriesChartSeries[]): Legen
709
788
  const line = (entry.type ?? "line") === "line";
710
789
  return {
711
790
  key: entry.key,
712
- label: entry.label,
791
+ label: withUnit(entry.label, undrawn.get(entry.axis ?? DEFAULT_Y_AXIS)),
713
792
  color: entry.color ?? paletteFor(index),
714
793
  ...(line && dash !== 0 ? { marker: "stroke" as const, dash } : {}),
715
794
  };
@@ -884,6 +963,55 @@ function focusedBarShape(index: number) {
884
963
  };
885
964
  }
886
965
 
966
+ /** A marker label's size and weight — the one place both the text and its width
967
+ * estimate read them from. */
968
+ const MARKER_LABEL_SIZE = 11;
969
+
970
+ /**
971
+ * A marker's label, centred over its point but clamped inside the plot. recharts'
972
+ * `position: "top"` centres on the point no matter where it is, so a marker near the
973
+ * plot's end (the last sample, a projection's end point) had half its label cut off by
974
+ * the card — "over the c" on a phone. The width is measured once drawn; the first
975
+ * frame (and a DOM without layout) uses a 0.6em-per-character estimate, which errs wide.
976
+ */
977
+ function MarkerLabel({
978
+ viewBox,
979
+ value,
980
+ }: {
981
+ viewBox?: { x?: number; y?: number; width?: number };
982
+ value?: ReactNode;
983
+ }) {
984
+ const plot = usePlotArea();
985
+ const ref = useRef<SVGTextElement>(null);
986
+ const [measured, setMeasured] = useState<number>();
987
+ const text = value == null ? "" : String(value);
988
+ useLayoutEffect(() => {
989
+ const el = ref.current;
990
+ if (!el || typeof el.getComputedTextLength !== "function") return;
991
+ const w = el.getComputedTextLength();
992
+ if (w > 0 && w !== measured) setMeasured(w);
993
+ }, [text, measured]);
994
+ if (!viewBox || viewBox.x == null || viewBox.y == null || text === "") return null;
995
+ const cx = viewBox.x + (viewBox.width ?? 0) / 2;
996
+ const half = (measured ?? text.length * MARKER_LABEL_SIZE * 0.6) / 2;
997
+ // The start clamp applied last: a label wider than the plot keeps its start on screen.
998
+ const x = plot ? Math.max(plot.x + half, Math.min(cx, plot.x + plot.width - half)) : cx;
999
+ return (
1000
+ <text
1001
+ ref={ref}
1002
+ x={x}
1003
+ y={viewBox.y - 5}
1004
+ textAnchor="middle"
1005
+ fontSize={MARKER_LABEL_SIZE}
1006
+ fontWeight={600}
1007
+ fill="var(--text-primary)"
1008
+ className="recharts-label"
1009
+ >
1010
+ {text}
1011
+ </text>
1012
+ );
1013
+ }
1014
+
887
1015
  /**
888
1016
  * The keyboard's way onto a clickable chart (`onPointClick`): one focusable `<rect>` per
889
1017
  * bar, or per slot when there are no bars, laid over the plot INSIDE the recharts chart,
@@ -1028,6 +1156,9 @@ function SeriesPlot({
1028
1156
  locale: localeProp,
1029
1157
  className,
1030
1158
  minBarLength,
1159
+ maxVisibleAxes,
1160
+ axisBudget = "auto",
1161
+ onAxisBudget,
1031
1162
  }: PlotProps) {
1032
1163
  const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
1033
1164
  const locale = useKitLocale(localeProp);
@@ -1037,6 +1168,44 @@ function SeriesPlot({
1037
1168
  const [keyStop, setKeyStop] = useState(0);
1038
1169
  const [keyFocus, setKeyFocus] = useState<number | undefined>(undefined);
1039
1170
 
1171
+ // The chart's own width, for the automatic axis budget. Its box, not the plot's: the
1172
+ // box does not change when an axis is hidden, so the rule cannot oscillate. Nothing
1173
+ // measured (jsdom, the first frame) is no automatic budget.
1174
+ const [box, setBox] = useState<HTMLDivElement | null>(null);
1175
+ const [width, setWidth] = useState<number | undefined>(undefined);
1176
+ useLayoutEffect(() => {
1177
+ if (!box || axisBudget === "off" || typeof ResizeObserver === "undefined") return;
1178
+ const measure = () => setWidth(box.getBoundingClientRect().width || undefined);
1179
+ measure();
1180
+ const observer = new ResizeObserver(measure);
1181
+ observer.observe(box);
1182
+ return () => observer.disconnect();
1183
+ }, [box, axisBudget]);
1184
+ // The caller's cap first, then — on what is left — the automatic one.
1185
+ const capped = budgetedAxes(axes, maxVisibleAxes);
1186
+ const afterCap = capped.length
1187
+ ? axes.map((axis) => (capped.includes(axis.id) ? { ...axis, hide: true as const } : axis))
1188
+ : axes;
1189
+ const auto =
1190
+ axisBudget === "off"
1191
+ ? []
1192
+ : budgetedAxes(
1193
+ afterCap,
1194
+ autoAxisBudget(afterCap, width, (axis) =>
1195
+ axisBandWidth(axis.width, Boolean(axis.title)),
1196
+ ),
1197
+ );
1198
+ const budgeted = [...capped, ...auto];
1199
+ const budgetKey = budgeted.join("\n");
1200
+ // Reported when the SET changes, not on every render — an inline callback is a new
1201
+ // function each time, and the guard keeps it from being called again for nothing.
1202
+ const reported = useRef("");
1203
+ useEffect(() => {
1204
+ if (budgetKey === reported.current) return;
1205
+ reported.current = budgetKey;
1206
+ onAxisBudget?.(budgetKey ? budgetKey.split("\n") : []);
1207
+ }, [budgetKey, onAxisBudget]);
1208
+
1040
1209
  // A number is pixels, set inline; a string is a class. Either way the one value
1041
1210
  // sizes both the chart and its empty state.
1042
1211
  const heightClass = typeof height === "string" ? height : undefined;
@@ -1064,18 +1233,30 @@ function SeriesPlot({
1064
1233
  const xKey = model.plotKey;
1065
1234
  const plotted = model.rows;
1066
1235
  const rows = model.source;
1236
+ // What the budget took off the chart: an axis drawn as `hide` — and its unit, which
1237
+ // the tooltip names now that no title says it. Axes the caller hid keep their series'
1238
+ // labels as given, as they always did.
1239
+ const drawnAxes = budgeted.length
1240
+ ? axes.map((axis) => (budgeted.includes(axis.id) ? { ...axis, hide: true as const } : axis))
1241
+ : axes;
1242
+ const lostUnit = new Map(
1243
+ axes.filter((axis) => budgeted.includes(axis.id)).map((axis) => [axis.id, axisUnit(axis)]),
1244
+ );
1067
1245
  const config: ChartConfig = Object.fromEntries(
1068
1246
  series.map((entry, index) => [
1069
1247
  entry.key,
1070
- { label: entry.label, color: entry.color ?? paletteFor(index) },
1248
+ {
1249
+ label: withUnit(entry.label, lostUnit.get(entry.axis ?? DEFAULT_Y_AXIS)),
1250
+ color: entry.color ?? paletteFor(index),
1251
+ },
1071
1252
  ]),
1072
1253
  );
1073
- const visible = axes.filter((axis) => !axis.hide);
1254
+ const visible = drawnAxes.filter((axis) => !axis.hide);
1074
1255
  // The grid hangs its horizontal rules off ONE y axis, and recharts looks for the one
1075
1256
  // whose id is its own default (`0`). Every axis here is named, so without saying
1076
1257
  // which, it finds none and draws no horizontal rules at all. The first visible axis,
1077
1258
  // because those are the ticks a reader puts a ruler on.
1078
- const gridAxis = (visible[0] ?? axes[0])?.id ?? DEFAULT_Y_AXIS;
1259
+ const gridAxis = (visible[0] ?? drawnAxes[0])?.id ?? DEFAULT_Y_AXIS;
1079
1260
  const onLeft = visible.some((axis) => (axis.orientation ?? "left") === "left");
1080
1261
  const onRight = visible.some((axis) => axis.orientation === "right");
1081
1262
  // The margins are what is left once the axes have their bands: an axis band IS the
@@ -1310,6 +1491,8 @@ function SeriesPlot({
1310
1491
 
1311
1492
  return (
1312
1493
  <ChartContainer
1494
+ ref={setBox}
1495
+ data-axis-budget={budgeted.length ? budgetKey.replace(/\n/g, " ") : undefined}
1313
1496
  config={config}
1314
1497
  className={cn("w-full", heightClass, className, onPointClick && "cursor-pointer")}
1315
1498
  style={heightStyle}
@@ -1350,6 +1533,11 @@ function SeriesPlot({
1350
1533
  tickLine={false}
1351
1534
  axisLine={false}
1352
1535
  minTickGap={32}
1536
+ // Where the ticks crowd, drop every Nth rather than recharts' default
1537
+ // `preserveEnd`, which drops whichever single tick collides: a phone's whole-week
1538
+ // axis came out 2 4 6 8 _ 12, a doubled gap that reads as a missing week. Every
1539
+ // other tick, keeping the last, gives 4 8 12 — still an even ruler.
1540
+ interval="equidistantPreserveEnd"
1353
1541
  tick={x.ticks === false ? false : undefined}
1354
1542
  {...(tickAngle
1355
1543
  ? { angle: tickAngle, textAnchor: tickAngle < 0 ? "end" : "start" }
@@ -1368,7 +1556,7 @@ function SeriesPlot({
1368
1556
  />
1369
1557
  )}
1370
1558
  </XAxis>
1371
- {axes.map((axis) => {
1559
+ {drawnAxes.map((axis) => {
1372
1560
  const domain = fittedY(axis);
1373
1561
  const zoomed = anchored(axis)
1374
1562
  ? zoom?.xDomain !== undefined
@@ -1385,7 +1573,11 @@ function SeriesPlot({
1385
1573
  }
1386
1574
  allowDataOverflow={zoomed}
1387
1575
  orientation={axis.orientation ?? "left"}
1388
- width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
1576
+ // Nothing for a hidden axis. An axis that turns hidden on a MOUNTED chart —
1577
+ // what the budget does on every resize across its threshold — kept its old
1578
+ // width in recharts' stacking of that side, and pushed the axes still drawn
1579
+ // off the chart's edge. A zero width leaves nothing stale to count.
1580
+ width={axis.hide ? 0 : axisBandWidth(axis.width, Boolean(axis.title))}
1389
1581
  tickLine={false}
1390
1582
  axisLine={false}
1391
1583
  tickFormatter={axis.format ?? number}
@@ -1563,13 +1755,7 @@ function SeriesPlot({
1563
1755
  ifOverflow="discard"
1564
1756
  label={
1565
1757
  marker.label
1566
- ? {
1567
- value: marker.label,
1568
- position: "top",
1569
- fontSize: 11,
1570
- fontWeight: 600,
1571
- fill: "var(--text-primary)",
1572
- }
1758
+ ? { value: marker.label, position: "top", content: <MarkerLabel /> }
1573
1759
  : undefined
1574
1760
  }
1575
1761
  />
@@ -243,7 +243,11 @@ export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
243
243
  // cursor turns to no-drop and the thumb stops following the pointer for the
244
244
  // rest of the gesture. The readout is only made unselectable when it is plain
245
245
  // text — a NumberInput in that slot has to stay selectable to be editable.
246
- <div className="flex items-center gap-2">
246
+ // `flex-wrap`: a readout wider than the room beside the label (a value plus a
247
+ // link button, "0 / 60 / 120 km/h · Alle Geschwindigkeiten") used to print on
248
+ // top of it at 390px (lenkbank L1). Each keeps its own width, so the readout
249
+ // drops to a line of its own, still at the end, only when the two don't fit.
250
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
247
251
  {label !== undefined && (
248
252
  <label
249
253
  htmlFor={inputId}
@@ -256,7 +260,7 @@ export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
256
260
  {readout !== undefined && (
257
261
  <span
258
262
  className={cn(
259
- "ms-auto shrink-0 text-xs tabular-nums text-[var(--text-primary)]",
263
+ "ms-auto max-w-full text-xs tabular-nums text-[var(--text-primary)]",
260
264
  plainReadout && "select-none",
261
265
  )}
262
266
  >
@@ -516,12 +516,17 @@ function FigureRow({
516
516
  }) {
517
517
  const own = figure.tone ?? (tileTone === "neutral" ? undefined : tileTone);
518
518
  const svTone = own === "none" ? undefined : own;
519
+ // The LABEL gives way, never the figure. This row used to wrap, so on a narrow tile a
520
+ // long label ("AUG (SIGNED)") dropped its amount onto a line of its own while the
521
+ // next row stayed on one — two layouts in one list, and a figure no longer beside
522
+ // the period it belongs to. Now the label breaks within itself and the amount stays
523
+ // on the label's first line, end-aligned like every other row.
519
524
  return (
520
- <div className="flex flex-wrap items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]">
521
- <dt className="uppercase tracking-wide">{figure.label}</dt>
525
+ <div className="flex items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]">
526
+ <dt className="min-w-0 uppercase tracking-wide [overflow-wrap:anywhere]">{figure.label}</dt>
522
527
  <dd
523
528
  data-private={priv}
524
- className={cn("ms-auto whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"])}
529
+ className={cn("ms-auto shrink-0 whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"])}
525
530
  >
526
531
  {show(figure.value)}
527
532
  </dd>
@@ -36,8 +36,9 @@ export interface TreemapNode {
36
36
  value: number;
37
37
  /** Overrides the by-index palette colour for this tile — e.g. to paint every child
38
38
  * in its parent group's colour, so the groups stay legible in one picture. A
39
- * concrete `#rrggbb` keeps the label ink measured; anything else (a CSS var) gets
40
- * `currentColor` ink, because there is nothing to measure — set `labelColor` then. */
39
+ * concrete `#rrggbb` or a bare `var(--chart-N)` keeps the label ink measured;
40
+ * anything else (another var, a `color-mix()`) gets `currentColor` ink, because
41
+ * there is nothing to measure — set `labelColor` then. */
41
42
  fill?: string;
42
43
  /** The label's ink, for a `fill` the kit cannot measure (a `var()` or `color-mix()`):
43
44
  * keksdose's recency plot fills with `color-mix(in srgb, var(--chart-1) N%,
@@ -146,6 +147,22 @@ function useChartTokenHex(): string[] {
146
147
  return joined.split(",");
147
148
  }
148
149
 
150
+ const CHART_TOKEN_REF = /^var\(\s*--chart-(\d+)\s*\)$/;
151
+
152
+ /**
153
+ * The hex a tile fill measures as: the fill itself when it is hex, the live token when
154
+ * it is a bare `var(--chart-N)` (what `paletteFor` returns), otherwise null. Without
155
+ * the token case a hand-built recharts map coloured with the kit's own `paletteFor`
156
+ * got `currentColor` ink — dark page text on dark indigo in the light theme.
157
+ */
158
+ function measurableFill(fill: string, tokens: readonly string[]): string | null {
159
+ if (parseHex(fill)) return fill;
160
+ const m = CHART_TOKEN_REF.exec(fill.trim());
161
+ if (!m) return null;
162
+ const hex = tokens[Number(m[1]) - 1];
163
+ return hex && parseHex(hex) ? hex : null;
164
+ }
165
+
149
166
  // ── Tile ──────────────────────────────────────────────────────────────────
150
167
 
151
168
  const LABEL_FONT_SIZE = 12;
@@ -214,6 +231,8 @@ export function TreemapCell({
214
231
  nodeNote,
215
232
  dir = "ltr",
216
233
  }: TreemapCellProps) {
234
+ // Before the early return: a hook has to run on every render of the cell.
235
+ const tokenHex = useChartTokenHex();
217
236
  // Depth 0 is recharts' synthetic root, which spans the whole chart.
218
237
  if (!depth) return null;
219
238
  // Whole pixels: a rect on a half pixel renders its edge across two half-lit
@@ -223,7 +242,13 @@ export function TreemapCell({
223
242
  const pw = Math.round(width);
224
243
  const ph = Math.round(height);
225
244
  const tileFill = fill ?? "var(--chart-1)";
226
- const ink = labelColor ?? textOn(tileFill);
245
+ const measured = measurableFill(tileFill, tokenHex);
246
+ const ink = labelColor ?? (measured ? textOn(measured) : "currentColor");
247
+ // The halo only on an OPAQUE fill we could measure. A translucent fill (a
248
+ // `color-mix(… transparent)`) composited twice — once as the tile, once as the halo
249
+ // over it — draws a darker ring round every glyph, which read as doubled text on the
250
+ // faded tiles. `none` still overrides the inherited chart `stroke`.
251
+ const halo = measured ? tileFill : "none";
227
252
  // Below ~44x20 there is no room for a readable word, so no label at all rather than
228
253
  // a one-letter stub bleeding over the tile edge.
229
254
  const label = pw >= 44 && ph >= 20 ? fitLabel(name ?? "", pw, LABEL_FONT_SIZE) : null;
@@ -284,7 +309,7 @@ export function TreemapCell({
284
309
  y={py + 15}
285
310
  direction={dir}
286
311
  fill={ink}
287
- stroke={tileFill}
312
+ stroke={halo}
288
313
  strokeWidth={2.5}
289
314
  strokeLinejoin="round"
290
315
  paintOrder="stroke"
@@ -302,7 +327,7 @@ export function TreemapCell({
302
327
  direction={dir}
303
328
  fill={ink}
304
329
  fillOpacity={0.85}
305
- stroke={tileFill}
330
+ stroke={halo}
306
331
  strokeWidth={2.5}
307
332
  strokeLinejoin="round"
308
333
  paintOrder="stroke"
@@ -2,6 +2,7 @@ import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
3
  import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
5
+ import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
5
6
  import { horizontalStep } from "../lib/direction";
6
7
  import { useMediaQuery } from "../hooks/use-media-query";
7
8
  import { Tooltip, type TooltipSide } from "./tooltip";
@@ -1290,9 +1291,20 @@ export const FIELD_DISPLAY =
1290
1291
  // adjacent controls). Pair the labelled case with a static FieldLabel.
1291
1292
  // `relative` so the trigger can host an absolutely-centred FieldChevron / clear
1292
1293
  // button the way the native Select does.
1294
+ //
1295
+ // `[&>:first-child]:min-h-[1lh]` holds one line box open whatever the value is. A
1296
+ // trigger with nothing chosen and no placeholder renders an EMPTY value span, an
1297
+ // empty span has no line box, and the trigger collapsed to its padding — 18px
1298
+ // tall in a stack of 38px fields (an RhfCombobox without a placeholder; DatePicker
1299
+ // and MonthPicker had met the same thing as Keksdose live #294 and patched their
1300
+ // own span with a U+00A0). Here, on the shared class, it covers every trigger
1301
+ // built on it, including a `renderTrigger` one. `1lh` rather than a fixed height
1302
+ // so it tracks whatever type the trigger is set in; the first child is the value
1303
+ // in every trigger here (the chevron and the clear button are absolute).
1293
1304
  export const FIELD_TRIGGER = cn(
1294
1305
  FIELD_BASE,
1295
1306
  "relative flex items-center justify-between gap-2 text-start hover:bg-[var(--bg-hover)]",
1307
+ "[&>:first-child]:min-h-[1lh]",
1296
1308
  );
1297
1309
 
1298
1310
  /** An alias rather than an interface: the chevron adds nothing of its own to an
@@ -1871,7 +1883,11 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
1871
1883
  invalid?: boolean;
1872
1884
  /** See {@link Input}'s `error`. */
1873
1885
  error?: ReactNode;
1874
- /** A {@link FieldHint} for the label line — see {@link FloatingField}. */
1886
+ /** A {@link FieldHint} for the label line — see {@link FloatingField}. Plain
1887
+ * TEXT (a string or a number) is a caption instead, and goes UNDER the field
1888
+ * like every other field's hint, attached through `aria-describedby`: the label
1889
+ * line is 11px of strip shared with the label, and a sentence placed there was
1890
+ * set in the value's type on top of both the label and the value. */
1875
1891
  hint?: ReactNode;
1876
1892
  /**
1877
1893
  * `"sm"`: a 28px, 12px-type select for a toolbar or a table header, where the
@@ -1911,12 +1927,33 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1911
1927
  const listBox = (nativeSize !== undefined && nativeSize > 1) || Boolean(rest.multiple);
1912
1928
  const small = size === "sm" && label === undefined;
1913
1929
  const fieldId = id ?? generated;
1930
+ const hintId = useId();
1931
+ // Text is a caption under the field; anything else (a FieldHint) rides the label
1932
+ // line. See `hint` above. An empty string is no caption at all.
1933
+ const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
1914
1934
  const { isInvalid, describedBy, errorEl } = useFieldError(
1915
1935
  error,
1916
1936
  invalid,
1917
- rest["aria-describedby"],
1937
+ // The caption goes in BEFORE the error — the standing advice first, the news
1938
+ // second, the order useFieldError keeps for a caller's own description.
1939
+ textHint
1940
+ ? rest["aria-describedby"]
1941
+ ? `${rest["aria-describedby"]} ${hintId}`
1942
+ : hintId
1943
+ : rest["aria-describedby"],
1918
1944
  rest["aria-invalid"],
1919
1945
  );
1946
+ const below =
1947
+ textHint || errorEl !== null ? (
1948
+ <>
1949
+ {textHint && (
1950
+ <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
1951
+ {hint}
1952
+ </p>
1953
+ )}
1954
+ {errorEl}
1955
+ </>
1956
+ ) : null;
1920
1957
  // Custom chevron (native arrow hidden via appearance-none) so it sits a touch
1921
1958
  // in from the end border and matches both themes — feedback #223. A DISABLED
1922
1959
  // select has no menu to drop, so it drops the chevron too: the arrow is the one
@@ -1927,10 +1964,17 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1927
1964
  <FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
1928
1965
  );
1929
1966
  // `pe-9` is the room the chevron takes; a list box has none to make room for.
1930
- const dress = listBox ? "overflow-y-auto" : "appearance-none pe-9";
1967
+ //
1968
+ // A list box's height is `size` rows plus its padding, but its rows are clipped at
1969
+ // the PADDING edge, not the content edge — so FIELD_BASE's `pb-2` showed the next
1970
+ // row through the bottom padding: `size={4}` drew four and a half. The vertical room
1971
+ // moves onto the rows instead (option padding counts toward the row height the
1972
+ // browser sizes the box by), and the box keeps no bottom padding: exactly `size`
1973
+ // whole rows, at any width.
1974
+ const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
1931
1975
  if (label === undefined) {
1932
1976
  return (
1933
- <FieldGroup errorEl={errorEl}>
1977
+ <FieldGroup errorEl={below}>
1934
1978
  <div className={cn("relative", className)}>
1935
1979
  <select
1936
1980
  ref={ref}
@@ -1953,6 +1997,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1953
1997
  className={cn(
1954
1998
  FIELD_BASE,
1955
1999
  dress,
2000
+ listBox && "py-0",
1956
2001
  small && SELECT_SM,
1957
2002
  selectClassName,
1958
2003
  isInvalid && FIELD_INVALID,
@@ -1966,8 +2011,8 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1966
2011
  );
1967
2012
  }
1968
2013
  return (
1969
- <FieldGroup errorEl={errorEl}>
1970
- <FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={hint}>
2014
+ <FieldGroup errorEl={below}>
2015
+ <FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={textHint ? undefined : hint}>
1971
2016
  <select
1972
2017
  ref={ref}
1973
2018
  id={fieldId}
@@ -1978,8 +2023,9 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1978
2023
  className={cn(
1979
2024
  FIELD_BASE,
1980
2025
  // The floated label takes the top strip of a list box too, so its first
1981
- // row starts under the label rather than behind it.
1982
- listBox ? "pt-5 pb-1" : FIELD_FLOATING_PAD,
2026
+ // row starts under the label rather than behind it. No bottom padding — see
2027
+ // `dress`.
2028
+ listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
1983
2029
  "peer",
1984
2030
  dress,
1985
2031
  selectClassName,
@@ -2695,6 +2741,10 @@ const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-[var(--bo
2695
2741
  const TAB_ADD_CLASSES =
2696
2742
  "inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4";
2697
2743
  const TAB_ADD_WRAP_CLASSES = "px-2.5 text-xs md:px-3 md:text-sm";
2744
+ // `relative`: the phone's visually hidden label needs a positioned ancestor (see the
2745
+ // sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone "+".
2746
+ const TAB_ADD_PHONE_CLASSES = "relative max-md:px-2";
2747
+
2698
2748
 
2699
2749
  export function Tabs<T extends string>({
2700
2750
  tabs,
@@ -2719,6 +2769,18 @@ export function Tabs<T extends string>({
2719
2769
  const phone = useMediaQuery(PHONE_QUERY, false);
2720
2770
  const vertical = orientation === "vertical" && !phone;
2721
2771
  const stripRef = useRef<HTMLDivElement>(null);
2772
+ const fade = useStripFade(stripRef);
2773
+ // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
2774
+ // deep link to the seventh report. A selection behind the cut edge was the worst
2775
+ // case of the overflow above: the one tab the panel below belongs to, sliced to
2776
+ // "80 km," under the add button. Only the strip scrolls (`scrollBy` on it, not
2777
+ // `scrollIntoView`, which would scroll the page as well), and only as far as needed
2778
+ // to clear the fade.
2779
+ useLayoutEffect(() => {
2780
+ const strip = stripRef.current;
2781
+ const tab = strip?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
2782
+ if (strip && tab) scrollIntoStrip(strip, tab);
2783
+ }, [active]);
2722
2784
  const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
2723
2785
  // Whether Delete reaches this tab: every removable tab, or only the open one.
2724
2786
  const deletesOnKey = (tab: TabItem<T>) =>
@@ -2821,6 +2883,11 @@ export function Tabs<T extends string>({
2821
2883
  // Only when vertical: `horizontal` is the tablist's implicit value, and the
2822
2884
  // attribute on every existing strip would be noise in every snapshot of them.
2823
2885
  aria-orientation={vertical ? "vertical" : undefined}
2886
+ // Merged over a caller's own style rather than replacing it; see useStripFade.
2887
+ // `data-overflow` names the cut ends in reading-direction terms, for a caller's
2888
+ // own affordance (a scroll button) and for tests, which cannot read the mask.
2889
+ style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}
2890
+ data-overflow={fade.overflow}
2824
2891
  // With an add button the strip gains an outer box, and `className` goes there
2825
2892
  // — it is the box a caller's margin or width is meant for.
2826
2893
  className={cn(
@@ -2892,7 +2959,16 @@ export function Tabs<T extends string>({
2892
2959
  {/* A separator for the NAME only — whitespace between flex items is
2893
2960
  not drawn, and without it the two lines ran together into one
2894
2961
  word for a screen reader ("80 km/hloop 2"). */}{" "}
2895
- <span className="text-[11px] font-normal leading-tight text-[var(--text-muted)]">
2962
+ <span
2963
+ className={cn(
2964
+ "text-[11px] font-normal leading-tight text-[var(--text-muted)]",
2965
+ // On a wrapped strip's filled chip (below `md`) the muted grey
2966
+ // was dark text on the brand fill — "loop 2" all but vanished
2967
+ // under the one label that most needed reading. It takes the
2968
+ // chip's own ink instead, as the label and the × already do.
2969
+ wrap && isActive && !vertical && "text-[var(--brand-contrast)] md:text-[var(--text-muted)]",
2970
+ )}
2971
+ >
2896
2972
  {tab.detail}
2897
2973
  </span>
2898
2974
  </span>
@@ -3013,10 +3089,14 @@ export function Tabs<T extends string>({
3013
3089
  type="button"
3014
3090
  onClick={onAdd}
3015
3091
  disabled={busy}
3016
- className={cn(TAB_ADD_CLASSES, wrap && TAB_ADD_WRAP_CLASSES)}
3092
+ className={cn(TAB_ADD_CLASSES, wrap ? TAB_ADD_WRAP_CLASSES : TAB_ADD_PHONE_CLASSES)}
3017
3093
  >
3018
3094
  <Plus aria-hidden />
3019
- {addLabel ?? text.add}
3095
+ {/* Below md a scrolling strip keeps the room for tabs: the label is visually
3096
+ hidden and the "+" carries it as its name. Beside "+ Add loop" a 390px strip
3097
+ showed the open tab and a sliver of the next (showcase audit). A wrapping
3098
+ strip has its own row there, so it keeps the label. */}
3099
+ <span className={wrap ? undefined : "max-md:sr-only"}>{addLabel ?? text.add}</span>
3020
3100
  </button>
3021
3101
  </div>
3022
3102
  </div>