@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/chart.d.ts +2 -1
- package/dist/components/alert-banner.js +15 -2
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/chart.js +10 -3
- package/dist/components/chart.js.map +1 -1
- package/dist/components/facing-pair.d.ts +1 -0
- 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 +71 -13
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/slider.js +6 -2
- package/dist/components/slider.js.map +1 -1
- package/dist/components/table.d.ts +12 -1
- package/dist/components/table.js +38 -0
- package/dist/components/table.js.map +1 -1
- package/dist/components/toggle-group.d.ts +9 -0
- package/dist/components/toggle-group.js +4 -1
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.js +26 -22
- package/dist/components/ui.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +19 -2
- package/src/components/chart.tsx +10 -3
- package/src/components/series-chart-budget.ts +121 -0
- package/src/components/series-chart.tsx +152 -8
- package/src/components/slider.tsx +6 -2
- package/src/components/table.tsx +59 -0
- package/src/components/toggle-group.tsx +13 -1
- package/src/components/ui.tsx +13 -7
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.
|
|
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
|
|
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
|
|
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;
|
package/src/components/chart.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
{
|
|
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 =
|
|
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] ??
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
>
|
package/src/components/table.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|