@eifi1/ui-kit 0.15.0 → 0.15.2

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/dist/index.d.ts CHANGED
@@ -70,7 +70,7 @@ export { Autocomplete, AutocompleteProps } from './components/autocomplete.js';
70
70
  export { DEFAULT_MEASURED_GRID_LABELS, MeasuredGrid, MeasuredGridColumn, MeasuredGridLabels, MeasuredGridProps, MeasuredGridView, MeasuredRows, UseMeasuredRowsOptions, useMeasuredRows } from './components/measured-grid.js';
71
71
  export { WindowedRows, useWindowedRows } from './hooks/use-windowed-rows.js';
72
72
  export { Treemap, TreemapCell, TreemapCellProps, TreemapNode, TreemapProps, fitLabel } from './components/treemap.js';
73
- export { AXIS_TICK_WIDTH, AXIS_TITLE_STRIP, SeriesChart, SeriesChartAxis, SeriesChartHit, SeriesChartMarker, SeriesChartPoint, SeriesChartProps, SeriesChartReference, SeriesChartRow, SeriesChartSeries, SeriesChartSpan, SeriesChartTickValues, SeriesChartTone, SeriesChartTooltip, SeriesChartType, SeriesChartX, SeriesChartXTick, SeriesChartXValue, SeriesSource, StaticSeriesChart, anchoredBand, axisBandWidth, mergeSeries, oneAxis, padBand, paddedDomain, seriesKey, seriesLegendEntries, soleSeriesColor, visibleSeries } from './components/series-chart.js';
73
+ export { AXIS_TICK_WIDTH, AXIS_TITLE_STRIP, SeriesChart, SeriesChartAxis, SeriesChartHit, SeriesChartMarker, SeriesChartPoint, SeriesChartProps, SeriesChartReference, SeriesChartRow, SeriesChartSeries, SeriesChartSpan, SeriesChartTickValues, SeriesChartTone, SeriesChartTooltip, SeriesChartType, SeriesChartX, SeriesChartXTick, SeriesChartXValue, SeriesLegendAxes, SeriesSource, StaticSeriesChart, anchoredBand, axisBandWidth, mergeSeries, oneAxis, padBand, paddedDomain, seriesKey, seriesLegendEntries, soleSeriesColor, visibleSeries } from './components/series-chart.js';
74
74
  export { DEFAULT_Y_AXIS, NO_ZOOM, SharedXZoom, ZOOM_MIN_DRAG, ZOOM_SQUARE_ENOUGH, ZoomAxes, ZoomAxesSetting, ZoomBinding, ZoomDrag, ZoomFitSeries, ZoomFitSource, ZoomRow, ZoomSelection, ZoomState, ZoomTarget, axisExtent, defaultZoomAxes, fitXToY, fitYToX, selectionFromDrag, withChartZoom, zoomAfter, zoomAxesFor, zoomDomains } from './components/chart-zoom.js';
75
75
  export { LegendColumn, LegendEntry, LegendGroup, STEP_DASH, STROKE_PATTERNS, StaticLegend, StaticLegendProps, ToggleLegend, ToggleLegendProps, strokeDash, toggleHidden } from './components/toggle-legend.js';
76
76
  export { FACING_SIDES, FacingSide, facingAxes, facingBand, facingHeadingPad } from './components/facing-pair.js';
@@ -151,6 +151,7 @@ export { DEFAULT_UI_KIT_LABELS } from './i18n/defaults.js';
151
151
  export { AccountSettingsLabels, DEFAULT_ACCOUNT_SETTINGS_LABELS, PasskeysSettingLabels, PasswordSettingLabels, ProfileSettingLabels, TwoFactorSettingLabels } from './components/account-settings-labels.js';
152
152
  export { CLIPS_ATTRIBUTE } from './lib/clipping.js';
153
153
  export { ComboClearValue, ComboOption } from './components/combobox-core.js';
154
+ export { SeriesChartAxisBudget } from './components/series-chart-budget.js';
154
155
  export { TimeTickUnit } from './components/series-chart-ticks.js';
155
156
  import 'clsx';
156
157
  import 'zustand';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -267,8 +267,17 @@ export function AlertBanner({
267
267
  // phone a long message plus an action would otherwise squeeze the text into a narrow
268
268
  // column, so the action drops under it instead. A box may wrap its text, so it
269
269
  // top-aligns and nudges the glyph onto the first line.
270
+ // A BOX with an action wraps too, below sm only (kastlan 53): the verify-email box
271
+ // squeezed "Please confirm your email address…" into a ~100px column beside a
272
+ // "Resend verification email" button. There the action takes a line of its own under
273
+ // the message, and the × stays at the top end; from sm up the box is one row as before.
274
+ const boxWraps = !inline && !strip && action !== undefined && action !== null;
270
275
  const row = cn(
271
- inlineRow ? "inline-flex items-center" : strip ? "flex flex-wrap items-center gap-y-1" : "flex items-start",
276
+ inlineRow
277
+ ? "inline-flex items-center"
278
+ : strip
279
+ ? "flex flex-wrap items-center gap-y-1"
280
+ : cn("flex items-start", boxWraps && "max-sm:flex-wrap max-sm:gap-y-2"),
272
281
  sm ? "gap-1.5 text-xs" : inline ? "gap-1.5 text-sm" : "gap-2 text-sm",
273
282
  );
274
283
  // The strip's message claims 12rem before anything shrinks. `flex-1` is a 0 basis,
@@ -324,7 +333,15 @@ export function AlertBanner({
324
333
  // `relative z-10` so it stays clickable over a whole-row banner's stretched target.
325
334
  const trailing =
326
335
  action !== undefined && action !== null ? (
327
- <div className={cn("relative z-10 flex shrink-0 items-center gap-2", !strip && !inlineRow && "self-center", strip && "ms-auto")}>
336
+ <div
337
+ className={cn(
338
+ "relative z-10 flex shrink-0 items-center gap-2",
339
+ !strip && !inlineRow && "self-center",
340
+ strip && "ms-auto",
341
+ // Last, on a line of its own, lined up with the message (past the glyph).
342
+ boxWraps && cn("max-sm:order-last max-sm:basis-full", glyph && (sm ? "max-sm:ps-5" : "max-sm:ps-6")),
343
+ )}
344
+ >
328
345
  {action}
329
346
  </div>
330
347
  ) : null;
@@ -297,7 +297,11 @@ export function ChartTooltipContent({
297
297
  <div
298
298
  ref={tipRef}
299
299
  style={boundaryRef ? { transform: flip ? "translateX(calc(-100% - 12px))" : "translateX(12px)" } : undefined}
300
- className="min-w-[9rem] rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 py-2 text-xs shadow-xl"
300
+ // Capped at the viewport: six long series names (lenkbank's German motion
301
+ // labels) made the box wider than a 390px chart, and recharts, which keeps a box
302
+ // inside the plot only when it fits, let it run off the edge with the values cut.
303
+ // Names wrap inside the cap; the values never do.
304
+ className="min-w-[9rem] max-w-[min(20rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 py-2 text-xs shadow-xl"
301
305
  >
302
306
  {!hideLabel && label != null && (
303
307
  <div className="mb-1.5 font-medium text-[var(--text-primary)]">
@@ -333,8 +337,11 @@ export function ChartTooltipContent({
333
337
  )}
334
338
  style={{ backgroundColor: color }}
335
339
  />
336
- <span className="text-[var(--text-muted)]">{name}</span>
337
- <span data-private className="ms-auto font-mono font-medium tabular-nums text-[var(--text-primary)]">
340
+ <span className="min-w-0 break-words text-[var(--text-muted)]">{name}</span>
341
+ <span
342
+ data-private
343
+ className="ms-auto shrink-0 whitespace-nowrap font-mono font-medium tabular-nums text-[var(--text-primary)]"
344
+ >
338
345
  {shown}
339
346
  </span>
340
347
  </div>
@@ -0,0 +1,121 @@
1
+ // The axis budget of a series chart: which of its y axes are drawn, when there are more
2
+ // than the width can carry. Pure, and apart from the chart, so the rule is tested on
3
+ // numbers rather than on a jsdom that has no layout to measure.
4
+ //
5
+ // lenkbank's curve plot declares four visible axes — velocity, position and acceleration
6
+ // on the left, loads on the right — and every one reserves its band of ticks and title.
7
+ // At 1280 px that is a quarter of the card; at 390 px it is all of it, and the plot the
8
+ // axes are for is ten pixels wide. An axis over budget is drawn the way a `hide: true`
9
+ // one always was: it still scales its lines (and the zoom still refits it), but it draws
10
+ // no ticks, no title, and reserves no width.
11
+
12
+ /**
13
+ * How many y axes a chart draws. A number caps the TOTAL; `{ left, right }` caps each
14
+ * side (a side left out is not capped). Axes over the cap are hidden in declaration
15
+ * order — the first of each side is the last to go, see {@link budgetedAxes}.
16
+ */
17
+ export type SeriesChartAxisBudget = number | { left?: number; right?: number };
18
+
19
+ /** The part of an axis the budget reads. */
20
+ interface BudgetAxis {
21
+ id: string;
22
+ orientation?: "left" | "right";
23
+ hide?: boolean;
24
+ title?: string;
25
+ width?: number;
26
+ }
27
+
28
+ /**
29
+ * The narrowest the plot may get before the automatic budget steps in, in px.
30
+ *
31
+ * About a phone's plot with one axis a side (390 px viewport, card padding, two bands
32
+ * of 48 + 16), where a curve's shape still reads. It is the point below which the chart
33
+ * is already broken today — a plot the width of a thumb between four columns of numbers
34
+ * — so the rule changes nothing that was working.
35
+ */
36
+ export const MIN_PLOT_WIDTH = 160;
37
+
38
+ /** The budget the automatic rule applies: one axis a side. */
39
+ export const NARROW_AXIS_BUDGET: SeriesChartAxisBudget = { left: 1, right: 1 };
40
+
41
+ /** The plot's margin on a side no axis band covers — `SeriesPlot`'s own. */
42
+ const BARE_SIDE_MARGIN = 10;
43
+
44
+ const sideOf = (axis: BudgetAxis) => axis.orientation ?? "left";
45
+
46
+ /**
47
+ * The ids of the VISIBLE axes a budget hides — axes already `hide: true` are neither
48
+ * counted nor returned. Empty for no budget.
49
+ *
50
+ * Kept, in order of priority: the first visible axis of each side (in declaration order,
51
+ * so the axis a caller lists first — the one the grid hangs its rules off — always
52
+ * stays), then the rest in declaration order, until the cap. Per side, that is simply the
53
+ * first `left` left-hand axes and the first `right` right-hand ones.
54
+ */
55
+ export function budgetedAxes(
56
+ axes: readonly BudgetAxis[],
57
+ budget: SeriesChartAxisBudget | undefined,
58
+ ): string[] {
59
+ if (budget === undefined) return [];
60
+ const visible = axes.filter((axis) => !axis.hide);
61
+ if (typeof budget === "number") {
62
+ const cap = Math.max(0, Math.floor(budget));
63
+ const firsts = new Set<string>();
64
+ const seen = new Set<string>();
65
+ for (const axis of visible) {
66
+ if (!seen.has(sideOf(axis))) firsts.add(axis.id);
67
+ seen.add(sideOf(axis));
68
+ }
69
+ const priority = [
70
+ ...visible.filter((axis) => firsts.has(axis.id)),
71
+ ...visible.filter((axis) => !firsts.has(axis.id)),
72
+ ];
73
+ const kept = new Set(priority.slice(0, cap).map((axis) => axis.id));
74
+ return visible.filter((axis) => !kept.has(axis.id)).map((axis) => axis.id);
75
+ }
76
+ const used = { left: 0, right: 0 };
77
+ const out: string[] = [];
78
+ for (const axis of visible) {
79
+ const side = sideOf(axis);
80
+ const cap = budget[side];
81
+ if (cap !== undefined && used[side] >= Math.max(0, Math.floor(cap))) out.push(axis.id);
82
+ else used[side] += 1;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * The automatic budget for a chart `width` px wide: {@link NARROW_AXIS_BUDGET} when the
89
+ * visible axes' bands would leave the plot under {@link MIN_PLOT_WIDTH} AND some side
90
+ * draws more than one axis — the only case one-a-side gives anything back. `undefined`
91
+ * otherwise, and for a width that is not known (no layout, no `ResizeObserver`).
92
+ *
93
+ * Decided on the CHART's width and the axes as declared, never on the plot the budget
94
+ * produced, so hiding an axis cannot widen the plot back over the line and bring it
95
+ * back: the rule has no feedback to oscillate on.
96
+ */
97
+ export function autoAxisBudget(
98
+ axes: readonly BudgetAxis[],
99
+ width: number | undefined,
100
+ bandWidth: (axis: BudgetAxis) => number,
101
+ ): SeriesChartAxisBudget | undefined {
102
+ if (width === undefined || !(width > 0)) return undefined;
103
+ const visible = axes.filter((axis) => !axis.hide);
104
+ const left = visible.filter((axis) => sideOf(axis) === "left");
105
+ const right = visible.filter((axis) => sideOf(axis) === "right");
106
+ if (left.length <= 1 && right.length <= 1) return undefined;
107
+ const bands = visible.reduce((sum, axis) => sum + bandWidth(axis), 0);
108
+ const margins = (left.length ? 0 : BARE_SIDE_MARGIN) + (right.length ? 0 : BARE_SIDE_MARGIN);
109
+ return width - bands - margins < MIN_PLOT_WIDTH ? NARROW_AXIS_BUDGET : undefined;
110
+ }
111
+
112
+ /**
113
+ * The unit an axis' series say when the axis is not drawn: its `unit`, or else the
114
+ * parenthesised tail of its title — "Load (N)" says "N", which is how the apps write
115
+ * every axis title. `undefined` when neither says one.
116
+ */
117
+ export function axisUnit(axis: { unit?: string; title?: string }): string | undefined {
118
+ if (axis.unit !== undefined) return axis.unit || undefined;
119
+ const tail = /\(([^()]+)\)\s*$/.exec(axis.title ?? "");
120
+ return tail?.[1].trim() || undefined;
121
+ }
@@ -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 { useLayoutEffect, 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,49 @@ 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)" — unless the name already
748
+ * ends with it. lenkbank's motion series carry "(mm/s)" in their label for the desktop
749
+ * tooltip, where every axis is drawn; the budget then read "… (mm/s) (mm/s)" (0.15.1).
750
+ * A label that says its unit keeps saying it once, whichever of the two an app chose. */
751
+ function withUnit(label: ReactNode, unit: string | undefined): ReactNode {
752
+ if (!unit) return label;
753
+ if (typeof label === "string" || typeof label === "number") {
754
+ const text = String(label);
755
+ return text.trimEnd().endsWith(`(${unit})`) ? label : `${text} (${unit})`;
756
+ }
757
+ return (
758
+ <>
759
+ {label} ({unit})
760
+ </>
761
+ );
762
+ }
763
+
764
+ /** Where a legend learns which axes are not drawn — see {@link seriesLegendEntries}. */
765
+ export interface SeriesLegendAxes {
766
+ /** The chart's axes. A series on one that is `hide`, or `budgeted`, says its unit. */
767
+ axes?: readonly SeriesChartAxis[];
768
+ /** What the chart's `onAxisBudget` last said. */
769
+ budgeted?: readonly string[];
770
+ }
771
+
698
772
  /**
699
773
  * The `ToggleLegend` entries for a chart's FULL series list — colours resolved the way
700
774
  * the chart resolves them, and a stroke mark for a line drawn in a pattern (dashed,
701
775
  * step) so the key promises the stroke the plot draws. A bar or an area is a swatch.
702
776
  * Pair with {@link visibleSeries} for the chart itself.
777
+ *
778
+ * Handed the chart's `axes` (and the `budgeted` ids its `onAxisBudget` reports), a
779
+ * series whose axis is not drawn says the unit that axis would have: "Velocity (mm/s)"
780
+ * — see {@link SeriesChartAxis.unit}. Without them, the labels are as given.
703
781
  */
704
- export function seriesLegendEntries(series: readonly SeriesChartSeries[]): LegendEntry[] {
782
+ export function seriesLegendEntries(
783
+ series: readonly SeriesChartSeries[],
784
+ { axes, budgeted }: SeriesLegendAxes = {},
785
+ ): LegendEntry[] {
786
+ const undrawn = new Map<string, string | undefined>();
787
+ for (const axis of axes ?? []) {
788
+ if (axis.hide || budgeted?.includes(axis.id)) undrawn.set(axis.id, axisUnit(axis));
789
+ }
705
790
  return series.map((entry, index) => {
706
791
  // A custom dash array goes to the legend as it is, so the swatch draws what the plot does.
707
792
  const own = entry.dash ?? (entry.dashed ? 1 : 0);
@@ -709,7 +794,7 @@ export function seriesLegendEntries(series: readonly SeriesChartSeries[]): Legen
709
794
  const line = (entry.type ?? "line") === "line";
710
795
  return {
711
796
  key: entry.key,
712
- label: entry.label,
797
+ label: withUnit(entry.label, undrawn.get(entry.axis ?? DEFAULT_Y_AXIS)),
713
798
  color: entry.color ?? paletteFor(index),
714
799
  ...(line && dash !== 0 ? { marker: "stroke" as const, dash } : {}),
715
800
  };
@@ -1077,6 +1162,9 @@ function SeriesPlot({
1077
1162
  locale: localeProp,
1078
1163
  className,
1079
1164
  minBarLength,
1165
+ maxVisibleAxes,
1166
+ axisBudget = "auto",
1167
+ onAxisBudget,
1080
1168
  }: PlotProps) {
1081
1169
  const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
1082
1170
  const locale = useKitLocale(localeProp);
@@ -1086,6 +1174,44 @@ function SeriesPlot({
1086
1174
  const [keyStop, setKeyStop] = useState(0);
1087
1175
  const [keyFocus, setKeyFocus] = useState<number | undefined>(undefined);
1088
1176
 
1177
+ // The chart's own width, for the automatic axis budget. Its box, not the plot's: the
1178
+ // box does not change when an axis is hidden, so the rule cannot oscillate. Nothing
1179
+ // measured (jsdom, the first frame) is no automatic budget.
1180
+ const [box, setBox] = useState<HTMLDivElement | null>(null);
1181
+ const [width, setWidth] = useState<number | undefined>(undefined);
1182
+ useLayoutEffect(() => {
1183
+ if (!box || axisBudget === "off" || typeof ResizeObserver === "undefined") return;
1184
+ const measure = () => setWidth(box.getBoundingClientRect().width || undefined);
1185
+ measure();
1186
+ const observer = new ResizeObserver(measure);
1187
+ observer.observe(box);
1188
+ return () => observer.disconnect();
1189
+ }, [box, axisBudget]);
1190
+ // The caller's cap first, then — on what is left — the automatic one.
1191
+ const capped = budgetedAxes(axes, maxVisibleAxes);
1192
+ const afterCap = capped.length
1193
+ ? axes.map((axis) => (capped.includes(axis.id) ? { ...axis, hide: true as const } : axis))
1194
+ : axes;
1195
+ const auto =
1196
+ axisBudget === "off"
1197
+ ? []
1198
+ : budgetedAxes(
1199
+ afterCap,
1200
+ autoAxisBudget(afterCap, width, (axis) =>
1201
+ axisBandWidth(axis.width, Boolean(axis.title)),
1202
+ ),
1203
+ );
1204
+ const budgeted = [...capped, ...auto];
1205
+ const budgetKey = budgeted.join("\n");
1206
+ // Reported when the SET changes, not on every render — an inline callback is a new
1207
+ // function each time, and the guard keeps it from being called again for nothing.
1208
+ const reported = useRef("");
1209
+ useEffect(() => {
1210
+ if (budgetKey === reported.current) return;
1211
+ reported.current = budgetKey;
1212
+ onAxisBudget?.(budgetKey ? budgetKey.split("\n") : []);
1213
+ }, [budgetKey, onAxisBudget]);
1214
+
1089
1215
  // A number is pixels, set inline; a string is a class. Either way the one value
1090
1216
  // sizes both the chart and its empty state.
1091
1217
  const heightClass = typeof height === "string" ? height : undefined;
@@ -1113,18 +1239,30 @@ function SeriesPlot({
1113
1239
  const xKey = model.plotKey;
1114
1240
  const plotted = model.rows;
1115
1241
  const rows = model.source;
1242
+ // What the budget took off the chart: an axis drawn as `hide` — and its unit, which
1243
+ // the tooltip names now that no title says it. Axes the caller hid keep their series'
1244
+ // labels as given, as they always did.
1245
+ const drawnAxes = budgeted.length
1246
+ ? axes.map((axis) => (budgeted.includes(axis.id) ? { ...axis, hide: true as const } : axis))
1247
+ : axes;
1248
+ const lostUnit = new Map(
1249
+ axes.filter((axis) => budgeted.includes(axis.id)).map((axis) => [axis.id, axisUnit(axis)]),
1250
+ );
1116
1251
  const config: ChartConfig = Object.fromEntries(
1117
1252
  series.map((entry, index) => [
1118
1253
  entry.key,
1119
- { label: entry.label, color: entry.color ?? paletteFor(index) },
1254
+ {
1255
+ label: withUnit(entry.label, lostUnit.get(entry.axis ?? DEFAULT_Y_AXIS)),
1256
+ color: entry.color ?? paletteFor(index),
1257
+ },
1120
1258
  ]),
1121
1259
  );
1122
- const visible = axes.filter((axis) => !axis.hide);
1260
+ const visible = drawnAxes.filter((axis) => !axis.hide);
1123
1261
  // The grid hangs its horizontal rules off ONE y axis, and recharts looks for the one
1124
1262
  // whose id is its own default (`0`). Every axis here is named, so without saying
1125
1263
  // which, it finds none and draws no horizontal rules at all. The first visible axis,
1126
1264
  // because those are the ticks a reader puts a ruler on.
1127
- const gridAxis = (visible[0] ?? axes[0])?.id ?? DEFAULT_Y_AXIS;
1265
+ const gridAxis = (visible[0] ?? drawnAxes[0])?.id ?? DEFAULT_Y_AXIS;
1128
1266
  const onLeft = visible.some((axis) => (axis.orientation ?? "left") === "left");
1129
1267
  const onRight = visible.some((axis) => axis.orientation === "right");
1130
1268
  // The margins are what is left once the axes have their bands: an axis band IS the
@@ -1359,6 +1497,8 @@ function SeriesPlot({
1359
1497
 
1360
1498
  return (
1361
1499
  <ChartContainer
1500
+ ref={setBox}
1501
+ data-axis-budget={budgeted.length ? budgetKey.replace(/\n/g, " ") : undefined}
1362
1502
  config={config}
1363
1503
  className={cn("w-full", heightClass, className, onPointClick && "cursor-pointer")}
1364
1504
  style={heightStyle}
@@ -1422,7 +1562,7 @@ function SeriesPlot({
1422
1562
  />
1423
1563
  )}
1424
1564
  </XAxis>
1425
- {axes.map((axis) => {
1565
+ {drawnAxes.map((axis) => {
1426
1566
  const domain = fittedY(axis);
1427
1567
  const zoomed = anchored(axis)
1428
1568
  ? zoom?.xDomain !== undefined
@@ -1439,7 +1579,11 @@ function SeriesPlot({
1439
1579
  }
1440
1580
  allowDataOverflow={zoomed}
1441
1581
  orientation={axis.orientation ?? "left"}
1442
- width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
1582
+ // Nothing for a hidden axis. An axis that turns hidden on a MOUNTED chart —
1583
+ // what the budget does on every resize across its threshold — kept its old
1584
+ // width in recharts' stacking of that side, and pushed the axes still drawn
1585
+ // off the chart's edge. A zero width leaves nothing stale to count.
1586
+ width={axis.hide ? 0 : axisBandWidth(axis.width, Boolean(axis.title))}
1443
1587
  tickLine={false}
1444
1588
  axisLine={false}
1445
1589
  tickFormatter={axis.format ?? number}
@@ -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
  >
@@ -161,8 +161,33 @@ export interface TableProps extends ComponentPropsWithoutRef<"table"> {
161
161
  * row can still say otherwise with {@link TableRowProps.bordered}.
162
162
  */
163
163
  rowDividers?: boolean;
164
+ /**
165
+ * `"phone"`: below `sm` each body row becomes a block — its first cell the row's
166
+ * title, every further cell under its column's header as a small label — and the
167
+ * head row is visually hidden (still read by screen readers). For tables of PROSE,
168
+ * not figures (lenkbank L5: a PID-terms table of three sentence columns wrapped into
169
+ * two-word slivers at 390px and ran its third column off the screen). A table of
170
+ * numbers should keep scrolling sideways, which is why this is opt-in. The labels
171
+ * are the head row's cell texts, read from the DOM; a body cell spanning several
172
+ * columns gets none.
173
+ */
174
+ stack?: "phone";
164
175
  }
165
176
 
177
+ /**
178
+ * Below `sm` on a `stack="phone"` table: rows as blocks, the head visually hidden,
179
+ * the first cell as a title, the others labelled by `data-label` (set by the table).
180
+ */
181
+ const STACK_PHONE_CLASSES = cn(
182
+ "max-sm:block max-sm:[&_tbody]:block max-sm:[&_tfoot]:block",
183
+ "max-sm:[&_thead]:sr-only",
184
+ "max-sm:[&_tbody_tr]:block max-sm:[&_tbody_tr]:py-2 max-sm:[&_tfoot_tr]:block max-sm:[&_tfoot_tr]:py-2",
185
+ "max-sm:[&_tbody_td]:block max-sm:[&_tbody_td]:px-0 max-sm:[&_tbody_td]:py-0.5 max-sm:[&_tbody_td]:text-start",
186
+ "max-sm:[&_tfoot_td]:block max-sm:[&_tfoot_td]:px-0 max-sm:[&_tfoot_td]:py-0.5",
187
+ "max-sm:[&_tbody_td:first-child]:font-medium",
188
+ "max-sm:[&_td[data-label]]:before:block max-sm:[&_td[data-label]]:before:text-[11px] max-sm:[&_td[data-label]]:before:font-medium max-sm:[&_td[data-label]]:before:text-[var(--text-muted)] max-sm:[&_td[data-label]]:before:content-[attr(data-label)]",
189
+ );
190
+
166
191
  /**
167
192
  * A plain, static HTML table in the kit's tokens — for the detail views that need
168
193
  * rows and columns and none of {@link DataTable}'s sorting, filtering or paging.
@@ -188,6 +213,7 @@ export function Table({
188
213
  layout,
189
214
  framed = false,
190
215
  rowDividers = true,
216
+ stack,
191
217
  className,
192
218
  "aria-label": ariaLabel,
193
219
  ...rest
@@ -196,6 +222,37 @@ export function Table({
196
222
  const [captions, setCaptions] = useState(0);
197
223
  const wrapper = useRef<HTMLDivElement | null>(null);
198
224
  const overflowing = useScrollOverflow(wrapper);
225
+ const tableRef = useRef<HTMLTableElement | null>(null);
226
+
227
+ // `stack="phone"`: label every body cell with its column's header text, and keep the
228
+ // table's roles explicit — `display: block` on table parts drops their table
229
+ // semantics in some engines. Re-run on any change to the rows, which the caller
230
+ // renders and this component never sees.
231
+ useLayoutEffect(() => {
232
+ const table = tableRef.current;
233
+ if (!stack || !table) return;
234
+ const label = () => {
235
+ const heads = Array.from(table.tHead?.rows[0]?.cells ?? []).map((cell) => cell.textContent?.trim() ?? "");
236
+ table.setAttribute("role", "table");
237
+ for (const row of Array.from(table.rows)) {
238
+ row.setAttribute("role", "row");
239
+ let column = 0;
240
+ for (const cell of Array.from(row.cells)) {
241
+ const span = cell.colSpan || 1;
242
+ if (cell.tagName === "TH") cell.setAttribute("role", cell.closest("thead") ? "columnheader" : "rowheader");
243
+ else cell.setAttribute("role", "cell");
244
+ const text = span === 1 && column > 0 ? heads[column] : undefined;
245
+ if (cell.tagName === "TD" && text) cell.setAttribute("data-label", text);
246
+ else cell.removeAttribute("data-label");
247
+ column += span;
248
+ }
249
+ }
250
+ };
251
+ label();
252
+ const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver(label);
253
+ observer?.observe(table, { childList: true, subtree: true, characterData: true });
254
+ return () => observer?.disconnect();
255
+ }, [stack]);
199
256
 
200
257
  const ctx = useMemo<TableContextValue>(
201
258
  () => ({
@@ -239,12 +296,14 @@ export function Table({
239
296
  >
240
297
  <table
241
298
  {...rest}
299
+ ref={tableRef}
242
300
  aria-label={ariaLabel}
243
301
  className={cn(
244
302
  "w-full caption-bottom border-collapse text-sm text-[var(--text-primary)]",
245
303
  density === "compact" && "text-xs",
246
304
  layout === "fixed" && "table-fixed",
247
305
  layout === "auto" && "table-auto",
306
+ stack === "phone" && STACK_PHONE_CLASSES,
248
307
  className,
249
308
  )}
250
309
  />
@@ -26,6 +26,15 @@ export interface ToggleGroupBaseProps<T extends string>
26
26
  /** Applied to every option button (e.g. to tune height/rounding to match
27
27
  * adjacent fields). Per-option `className` still wins over this. */
28
28
  optionClassName?: string;
29
+ /**
30
+ * What happens when the options do not fit the group's width. `"truncate"`
31
+ * (default): one row, and the labels that do not fit end in an ellipsis — the height
32
+ * never changes. `"wrap"`: the segments flow onto a second row and every label stays
33
+ * whole ("Bewegung", not "Beweg…"), at the price of a taller group on a narrow
34
+ * screen (lenkbank L4: five signal types on a 390px control page). From the width
35
+ * where they fit, both look the same.
36
+ */
37
+ overflow?: "truncate" | "wrap";
29
38
  /**
30
39
  * @deprecated Pass `aria-label` instead — the DOM spelling, which every other
31
40
  * control in this kit now answers to. Kept working because three applications ship
@@ -174,6 +183,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
174
183
  options,
175
184
  className,
176
185
  optionClassName,
186
+ overflow = "truncate",
177
187
  ariaLabel,
178
188
  disabled = false,
179
189
  size = "md",
@@ -270,6 +280,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
270
280
  // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a
271
281
  // phone does see is the focus ring — see the segment's own note below.)
272
282
  "inline-flex w-full gap-0.5 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] p-0.5 shadow-sm",
283
+ overflow === "wrap" && "flex-wrap",
273
284
  // The whole group fades, the way every other disabled control in this
274
285
  // package does; `cursor-not-allowed` is on the buttons, which is what a
275
286
  // pointer is actually over.
@@ -318,7 +329,8 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
318
329
  // overlaying the selected segment's boundary. Inset keeps the ring
319
330
  // inside the segment it belongs to; focus-visible keeps it for the
320
331
  // keyboard, which is the only input that needs it.
321
- "min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]",
332
+ overflow === "wrap" ? "whitespace-nowrap" : "min-w-0 truncate",
333
+ "flex-1 basis-auto rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]",
322
334
  size === "sm" && "px-2 py-1 text-xs",
323
335
  // In a field: no vertical padding and a 20px line — a `text-sm` line, the
324
336
  // same line a labelled Select holds under its label strip — so the field's