@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.
- package/dist/chart.d.ts +2 -1
- package/dist/components/alert-banner.js +5 -2
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +1 -1
- package/dist/components/autocomplete.js +9 -2
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/bulk-action-bar.js +6 -3
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/calendar-heatmap.js +5 -5
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +3 -3
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +12 -4
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +1 -0
- package/dist/components/field.js +4 -1
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/image-grid.d.ts +8 -1
- package/dist/components/image-grid.js +16 -3
- package/dist/components/image-grid.js.map +1 -1
- package/dist/components/measured-grid.js +2 -2
- package/dist/components/measured-grid.js.map +1 -1
- package/dist/components/money-field.d.ts +1 -1
- package/dist/components/multi-select.js +3 -0
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +5 -2
- package/dist/components/number-input.js +11 -3
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/series-chart-budget.d.ts +60 -0
- package/dist/components/series-chart-budget.js +56 -0
- package/dist/components/series-chart-budget.js.map +1 -0
- package/dist/components/series-chart.d.ts +54 -2
- package/dist/components/series-chart.js +104 -20
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/slider.js +6 -2
- package/dist/components/slider.js.map +1 -1
- package/dist/components/stat-tile.js +3 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/treemap.d.ts +3 -2
- package/dist/components/treemap.js +14 -3
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +44 -10
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-thread.js +31 -12
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
- package/dist/lib/strip-fade.d.ts +31 -0
- package/dist/lib/strip-fade.js +54 -0
- package/dist/lib/strip-fade.js.map +1 -0
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf/form.js +1 -1
- package/dist/rhf/form.js.map +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +11 -2
- package/dist/shell/app-shell.js +20 -4
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/stepper-nav.js +14 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +10 -2
- package/src/components/autocomplete.tsx +10 -1
- package/src/components/bulk-action-bar.tsx +12 -3
- package/src/components/calendar-heatmap.tsx +10 -5
- package/src/components/data-table-filter-popover.tsx +6 -3
- package/src/components/data-table.tsx +21 -7
- package/src/components/field.tsx +4 -1
- package/src/components/image-grid.tsx +30 -2
- package/src/components/measured-grid.tsx +3 -2
- package/src/components/multi-select.tsx +3 -0
- package/src/components/number-input.tsx +23 -4
- package/src/components/series-chart-budget.ts +121 -0
- package/src/components/series-chart.tsx +201 -15
- package/src/components/slider.tsx +6 -2
- package/src/components/stat-tile.tsx +8 -3
- package/src/components/treemap.tsx +30 -5
- package/src/components/ui.tsx +91 -11
- package/src/feedback/feedback-thread.tsx +36 -13
- package/src/lib/strip-fade.ts +82 -0
- package/src/rhf/form.tsx +3 -1
- package/src/shell/app-shell.tsx +34 -7
- 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
|
-
|
|
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(
|
|
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
|
-
{
|
|
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 =
|
|
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] ??
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
|
40
|
-
*
|
|
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
|
|
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={
|
|
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={
|
|
330
|
+
stroke={halo}
|
|
306
331
|
strokeWidth={2.5}
|
|
307
332
|
strokeLinejoin="round"
|
|
308
333
|
paintOrder="stroke"
|
package/src/components/ui.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
|
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
|
|
3092
|
+
className={cn(TAB_ADD_CLASSES, wrap ? TAB_ADD_WRAP_CLASSES : TAB_ADD_PHONE_CLASSES)}
|
|
3017
3093
|
>
|
|
3018
3094
|
<Plus aria-hidden />
|
|
3019
|
-
{
|
|
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>
|