@eifi1/ui-kit 0.15.1 → 0.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/alert-banner.js +15 -2
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/chart.js +48 -5
- package/dist/components/chart.js.map +1 -1
- package/dist/components/series-chart.js +4 -1
- package/dist/components/series-chart.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/package.json +1 -1
- package/src/components/alert-banner.tsx +19 -2
- package/src/components/chart.tsx +63 -5
- package/src/components/series-chart.tsx +8 -2
- package/src/components/table.tsx +59 -0
- package/src/components/toggle-group.tsx +13 -1
- package/src/components/ui.tsx +13 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.3",
|
|
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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The value on show is the *structure*: config-driven colors injected as CSS
|
|
9
9
|
// vars, muted axis/grid styling, and a polished tooltip/legend — the things
|
|
10
10
|
// that make shadcn charts read as "designed" rather than "default Recharts".
|
|
11
|
-
import { createContext, useContext, useId, useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
12
12
|
import type { ComponentProps, CSSProperties, ReactNode, RefObject } from "react";
|
|
13
13
|
import {
|
|
14
14
|
Legend as RechartsLegend,
|
|
@@ -292,12 +292,67 @@ export function ChartTooltipContent({
|
|
|
292
292
|
const tipWidth = tipRef.current?.offsetWidth ?? 160;
|
|
293
293
|
setFlip(visibleX + tipWidth + 12 > boundary.clientWidth);
|
|
294
294
|
}, [visible, x, boundaryRef, payload, label]);
|
|
295
|
+
|
|
296
|
+
// Whatever placed it — recharts' own rule or the boundary flip above — the box then
|
|
297
|
+
// clamps itself into the viewport, 8px off each edge. The width cap alone did not
|
|
298
|
+
// keep it on screen: on a 390px phone recharts opened a near-full-width tooltip to
|
|
299
|
+
// the RIGHT of a pointer 35% into the plot, and the values ran off the edge
|
|
300
|
+
// (lenkbank, on 0.15.2). Measured after placement, minus the shift it already has,
|
|
301
|
+
// so a re-measure never compounds; an unmeasured box (no layout) is left alone.
|
|
302
|
+
const [nudge, setNudge] = useState(0);
|
|
303
|
+
const nudgeRef = useRef(0);
|
|
304
|
+
// recharts slides its wrapper to each new position with a CSS transition, so the box
|
|
305
|
+
// measured on commit is still where it WAS; measure again once the slide ends.
|
|
306
|
+
const [settled, setSettled] = useState(0);
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const wrapper = tipRef.current?.closest<HTMLElement>(".recharts-tooltip-wrapper");
|
|
309
|
+
if (!visible || !wrapper) return;
|
|
310
|
+
const onEnd = () => setSettled((n) => n + 1);
|
|
311
|
+
wrapper.addEventListener("transitionend", onEnd);
|
|
312
|
+
return () => wrapper.removeEventListener("transitionend", onEnd);
|
|
313
|
+
}, [visible]);
|
|
314
|
+
useLayoutEffect(() => {
|
|
315
|
+
const el = tipRef.current;
|
|
316
|
+
if (!visible || !el) {
|
|
317
|
+
nudgeRef.current = 0;
|
|
318
|
+
setNudge(0);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const rect = el.getBoundingClientRect();
|
|
322
|
+
if (rect.width === 0) return;
|
|
323
|
+
const margin = 8;
|
|
324
|
+
const viewport = document.documentElement.clientWidth || window.innerWidth;
|
|
325
|
+
const left = rect.left - nudgeRef.current;
|
|
326
|
+
const right = left + rect.width;
|
|
327
|
+
let next = 0;
|
|
328
|
+
if (right > viewport - margin) next = viewport - margin - right;
|
|
329
|
+
if (left + next < margin) next = margin - left;
|
|
330
|
+
if (next !== nudgeRef.current) {
|
|
331
|
+
nudgeRef.current = next;
|
|
332
|
+
setNudge(next);
|
|
333
|
+
}
|
|
334
|
+
}, [visible, x, payload, label, flip, settled]);
|
|
295
335
|
if (!visible || !items) return null;
|
|
296
336
|
return (
|
|
297
337
|
<div
|
|
298
338
|
ref={tipRef}
|
|
299
|
-
style={
|
|
300
|
-
|
|
339
|
+
style={
|
|
340
|
+
boundaryRef || nudge
|
|
341
|
+
? {
|
|
342
|
+
transform: [
|
|
343
|
+
boundaryRef ? (flip ? "translateX(calc(-100% - 12px))" : "translateX(12px)") : "",
|
|
344
|
+
nudge ? `translateX(${nudge}px)` : "",
|
|
345
|
+
]
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.join(" "),
|
|
348
|
+
}
|
|
349
|
+
: undefined
|
|
350
|
+
}
|
|
351
|
+
// Capped at the viewport: six long series names (lenkbank's German motion
|
|
352
|
+
// labels) made the box wider than a 390px chart, and recharts, which keeps a box
|
|
353
|
+
// inside the plot only when it fits, let it run off the edge with the values cut.
|
|
354
|
+
// Names wrap inside the cap; the values never do.
|
|
355
|
+
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
356
|
>
|
|
302
357
|
{!hideLabel && label != null && (
|
|
303
358
|
<div className="mb-1.5 font-medium text-[var(--text-primary)]">
|
|
@@ -333,8 +388,11 @@ export function ChartTooltipContent({
|
|
|
333
388
|
)}
|
|
334
389
|
style={{ backgroundColor: color }}
|
|
335
390
|
/>
|
|
336
|
-
<span className="text-[var(--text-muted)]">{name}</span>
|
|
337
|
-
<span
|
|
391
|
+
<span className="min-w-0 break-words text-[var(--text-muted)]">{name}</span>
|
|
392
|
+
<span
|
|
393
|
+
data-private
|
|
394
|
+
className="ms-auto shrink-0 whitespace-nowrap font-mono font-medium tabular-nums text-[var(--text-primary)]"
|
|
395
|
+
>
|
|
338
396
|
{shown}
|
|
339
397
|
</span>
|
|
340
398
|
</div>
|
|
@@ -744,10 +744,16 @@ export function visibleSeries(
|
|
|
744
744
|
.filter((entry) => !hidden.has(entry.key));
|
|
745
745
|
}
|
|
746
746
|
|
|
747
|
-
/** A series' name with its unit after it: "Velocity (mm/s)"
|
|
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. */
|
|
748
751
|
function withUnit(label: ReactNode, unit: string | undefined): ReactNode {
|
|
749
752
|
if (!unit) return label;
|
|
750
|
-
if (typeof label === "string" || typeof label === "number")
|
|
753
|
+
if (typeof label === "string" || typeof label === "number") {
|
|
754
|
+
const text = String(label);
|
|
755
|
+
return text.trimEnd().endsWith(`(${unit})`) ? label : `${text} (${unit})`;
|
|
756
|
+
}
|
|
751
757
|
return (
|
|
752
758
|
<>
|
|
753
759
|
{label} ({unit})
|
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
|
package/src/components/ui.tsx
CHANGED
|
@@ -2712,8 +2712,12 @@ const TAB_VERTICAL_INACTIVE_CLASSES =
|
|
|
2712
2712
|
const TABLIST_WRAP_CLASSES =
|
|
2713
2713
|
"flex flex-wrap gap-1.5 md:flex-nowrap md:gap-1 md:overflow-x-auto md:overflow-y-hidden md:border-b md:border-[var(--border)]";
|
|
2714
2714
|
|
|
2715
|
+
// `max-w-full`: no tab outgrows its strip. A sheet's `detail` line ("Spurstangenkraft
|
|
2716
|
+
// links · … · 31 Punkte") made one tab 448px wide in a 390px strip, and a tab wider
|
|
2717
|
+
// than the strip cannot be scrolled into view — its end was simply off screen
|
|
2718
|
+
// (lenkbank L3). The label and detail truncate inside it instead; see `inner`.
|
|
2715
2719
|
const TAB_CLASSES =
|
|
2716
|
-
"whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)]";
|
|
2720
|
+
"max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)]";
|
|
2717
2721
|
const TAB_ACTIVE_CLASSES = "border-[var(--text-primary)] text-[var(--text-primary)]";
|
|
2718
2722
|
const TAB_INACTIVE_CLASSES =
|
|
2719
2723
|
"border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-strong)]";
|
|
@@ -2727,7 +2731,7 @@ const TAB_INACTIVE_CLASSES =
|
|
|
2727
2731
|
// all ten have to stay readable; it is the FILL, not the text weight, that says
|
|
2728
2732
|
// which one is open.
|
|
2729
2733
|
const TAB_WRAP_CLASSES =
|
|
2730
|
-
"whitespace-nowrap rounded-md px-2.5 py-2 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)] md:rounded-none md:border-b-2 md:-mb-px md:bg-transparent md:px-3 md:py-2 md:text-sm";
|
|
2734
|
+
"max-w-full whitespace-nowrap rounded-md px-2.5 py-2 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)] md:rounded-none md:border-b-2 md:-mb-px md:bg-transparent md:px-3 md:py-2 md:text-sm";
|
|
2731
2735
|
const TAB_WRAP_ACTIVE_CLASSES =
|
|
2732
2736
|
"bg-[var(--brand)] text-[var(--brand-contrast)] md:border-[var(--text-primary)] md:text-[var(--text-primary)]";
|
|
2733
2737
|
const TAB_WRAP_INACTIVE_CLASSES =
|
|
@@ -2944,7 +2948,7 @@ export function Tabs<T extends string>({
|
|
|
2944
2948
|
className={cn(
|
|
2945
2949
|
// In a side nav the row is the full width, so the label takes it and a
|
|
2946
2950
|
// badge lands at the row's end, where a column of counts lines up.
|
|
2947
|
-
vertical ? "flex min-w-0 flex-1 items-center gap-1.5" : "inline-flex items-center gap-1.5",
|
|
2951
|
+
vertical ? "flex min-w-0 flex-1 items-center gap-1.5" : "inline-flex max-w-full min-w-0 items-center gap-1.5",
|
|
2948
2952
|
tab.empty && TAB_EMPTY_CLASSES,
|
|
2949
2953
|
)}
|
|
2950
2954
|
>
|
|
@@ -2954,14 +2958,16 @@ export function Tabs<T extends string>({
|
|
|
2954
2958
|
</span>
|
|
2955
2959
|
)}
|
|
2956
2960
|
{tab.detail != null ? (
|
|
2957
|
-
|
|
2958
|
-
|
|
2961
|
+
// `min-w-0` + `truncate` on each line: capped by the tab's `max-w-full`, a
|
|
2962
|
+
// long detail ends in an ellipsis. The whole text stays the tab's name.
|
|
2963
|
+
<span className="flex min-w-0 flex-col items-start text-start">
|
|
2964
|
+
<span className="max-w-full truncate">{tab.label}</span>
|
|
2959
2965
|
{/* A separator for the NAME only — whitespace between flex items is
|
|
2960
2966
|
not drawn, and without it the two lines ran together into one
|
|
2961
2967
|
word for a screen reader ("80 km/hloop 2"). */}{" "}
|
|
2962
2968
|
<span
|
|
2963
2969
|
className={cn(
|
|
2964
|
-
"text-[11px] font-normal leading-tight text-[var(--text-muted)]",
|
|
2970
|
+
"max-w-full truncate text-[11px] font-normal leading-tight text-[var(--text-muted)]",
|
|
2965
2971
|
// On a wrapped strip's filled chip (below `md`) the muted grey
|
|
2966
2972
|
// was dark text on the brand fill — "loop 2" all but vanished
|
|
2967
2973
|
// under the one label that most needed reading. It takes the
|
|
@@ -3025,7 +3031,7 @@ export function Tabs<T extends string>({
|
|
|
3025
3031
|
// The × cannot go INSIDE the tab: a button in a button is invalid HTML, and
|
|
3026
3032
|
// a tab's children are presentational, so a reader would flatten it into the
|
|
3027
3033
|
// tab's name. It sits beside the tab, over the gutter the tab reserves.
|
|
3028
|
-
<div key={tab.id} className={cn("relative flex", !vertical && "shrink-0")}>
|
|
3034
|
+
<div key={tab.id} className={cn("relative flex max-w-full", !vertical && "shrink-0")}>
|
|
3029
3035
|
{element}
|
|
3030
3036
|
{isActive && (
|
|
3031
3037
|
<IconButton
|