@eifi1/ui-kit 0.15.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -267,8 +267,17 @@ export function AlertBanner({
267
267
  // phone a long message plus an action would otherwise squeeze the text into a narrow
268
268
  // column, so the action drops under it instead. A box may wrap its text, so it
269
269
  // top-aligns and nudges the glyph onto the first line.
270
+ // A BOX with an action wraps too, below sm only (kastlan 53): the verify-email box
271
+ // squeezed "Please confirm your email address…" into a ~100px column beside a
272
+ // "Resend verification email" button. There the action takes a line of its own under
273
+ // the message, and the × stays at the top end; from sm up the box is one row as before.
274
+ const boxWraps = !inline && !strip && action !== undefined && action !== null;
270
275
  const row = cn(
271
- inlineRow ? "inline-flex items-center" : strip ? "flex flex-wrap items-center gap-y-1" : "flex items-start",
276
+ inlineRow
277
+ ? "inline-flex items-center"
278
+ : strip
279
+ ? "flex flex-wrap items-center gap-y-1"
280
+ : cn("flex items-start", boxWraps && "max-sm:flex-wrap max-sm:gap-y-2"),
272
281
  sm ? "gap-1.5 text-xs" : inline ? "gap-1.5 text-sm" : "gap-2 text-sm",
273
282
  );
274
283
  // The strip's message claims 12rem before anything shrinks. `flex-1` is a 0 basis,
@@ -324,7 +333,15 @@ export function AlertBanner({
324
333
  // `relative z-10` so it stays clickable over a whole-row banner's stretched target.
325
334
  const trailing =
326
335
  action !== undefined && action !== null ? (
327
- <div className={cn("relative z-10 flex shrink-0 items-center gap-2", !strip && !inlineRow && "self-center", strip && "ms-auto")}>
336
+ <div
337
+ className={cn(
338
+ "relative z-10 flex shrink-0 items-center gap-2",
339
+ !strip && !inlineRow && "self-center",
340
+ strip && "ms-auto",
341
+ // Last, on a line of its own, lined up with the message (past the glyph).
342
+ boxWraps && cn("max-sm:order-last max-sm:basis-full", glyph && (sm ? "max-sm:ps-5" : "max-sm:ps-6")),
343
+ )}
344
+ >
328
345
  {action}
329
346
  </div>
330
347
  ) : null;
@@ -297,7 +297,11 @@ export function ChartTooltipContent({
297
297
  <div
298
298
  ref={tipRef}
299
299
  style={boundaryRef ? { transform: flip ? "translateX(calc(-100% - 12px))" : "translateX(12px)" } : undefined}
300
- className="min-w-[9rem] rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 py-2 text-xs shadow-xl"
300
+ // Capped at the viewport: six long series names (lenkbank's German motion
301
+ // labels) made the box wider than a 390px chart, and recharts, which keeps a box
302
+ // inside the plot only when it fits, let it run off the edge with the values cut.
303
+ // Names wrap inside the cap; the values never do.
304
+ className="min-w-[9rem] max-w-[min(20rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 py-2 text-xs shadow-xl"
301
305
  >
302
306
  {!hideLabel && label != null && (
303
307
  <div className="mb-1.5 font-medium text-[var(--text-primary)]">
@@ -333,8 +337,11 @@ export function ChartTooltipContent({
333
337
  )}
334
338
  style={{ backgroundColor: color }}
335
339
  />
336
- <span className="text-[var(--text-muted)]">{name}</span>
337
- <span data-private className="ms-auto font-mono font-medium tabular-nums text-[var(--text-primary)]">
340
+ <span className="min-w-0 break-words text-[var(--text-muted)]">{name}</span>
341
+ <span
342
+ data-private
343
+ className="ms-auto shrink-0 whitespace-nowrap font-mono font-medium tabular-nums text-[var(--text-primary)]"
344
+ >
338
345
  {shown}
339
346
  </span>
340
347
  </div>
@@ -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") return `${label} (${unit})`;
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})
@@ -161,8 +161,33 @@ export interface TableProps extends ComponentPropsWithoutRef<"table"> {
161
161
  * row can still say otherwise with {@link TableRowProps.bordered}.
162
162
  */
163
163
  rowDividers?: boolean;
164
+ /**
165
+ * `"phone"`: below `sm` each body row becomes a block — its first cell the row's
166
+ * title, every further cell under its column's header as a small label — and the
167
+ * head row is visually hidden (still read by screen readers). For tables of PROSE,
168
+ * not figures (lenkbank L5: a PID-terms table of three sentence columns wrapped into
169
+ * two-word slivers at 390px and ran its third column off the screen). A table of
170
+ * numbers should keep scrolling sideways, which is why this is opt-in. The labels
171
+ * are the head row's cell texts, read from the DOM; a body cell spanning several
172
+ * columns gets none.
173
+ */
174
+ stack?: "phone";
164
175
  }
165
176
 
177
+ /**
178
+ * Below `sm` on a `stack="phone"` table: rows as blocks, the head visually hidden,
179
+ * the first cell as a title, the others labelled by `data-label` (set by the table).
180
+ */
181
+ const STACK_PHONE_CLASSES = cn(
182
+ "max-sm:block max-sm:[&_tbody]:block max-sm:[&_tfoot]:block",
183
+ "max-sm:[&_thead]:sr-only",
184
+ "max-sm:[&_tbody_tr]:block max-sm:[&_tbody_tr]:py-2 max-sm:[&_tfoot_tr]:block max-sm:[&_tfoot_tr]:py-2",
185
+ "max-sm:[&_tbody_td]:block max-sm:[&_tbody_td]:px-0 max-sm:[&_tbody_td]:py-0.5 max-sm:[&_tbody_td]:text-start",
186
+ "max-sm:[&_tfoot_td]:block max-sm:[&_tfoot_td]:px-0 max-sm:[&_tfoot_td]:py-0.5",
187
+ "max-sm:[&_tbody_td:first-child]:font-medium",
188
+ "max-sm:[&_td[data-label]]:before:block max-sm:[&_td[data-label]]:before:text-[11px] max-sm:[&_td[data-label]]:before:font-medium max-sm:[&_td[data-label]]:before:text-[var(--text-muted)] max-sm:[&_td[data-label]]:before:content-[attr(data-label)]",
189
+ );
190
+
166
191
  /**
167
192
  * A plain, static HTML table in the kit's tokens — for the detail views that need
168
193
  * rows and columns and none of {@link DataTable}'s sorting, filtering or paging.
@@ -188,6 +213,7 @@ export function Table({
188
213
  layout,
189
214
  framed = false,
190
215
  rowDividers = true,
216
+ stack,
191
217
  className,
192
218
  "aria-label": ariaLabel,
193
219
  ...rest
@@ -196,6 +222,37 @@ export function Table({
196
222
  const [captions, setCaptions] = useState(0);
197
223
  const wrapper = useRef<HTMLDivElement | null>(null);
198
224
  const overflowing = useScrollOverflow(wrapper);
225
+ const tableRef = useRef<HTMLTableElement | null>(null);
226
+
227
+ // `stack="phone"`: label every body cell with its column's header text, and keep the
228
+ // table's roles explicit — `display: block` on table parts drops their table
229
+ // semantics in some engines. Re-run on any change to the rows, which the caller
230
+ // renders and this component never sees.
231
+ useLayoutEffect(() => {
232
+ const table = tableRef.current;
233
+ if (!stack || !table) return;
234
+ const label = () => {
235
+ const heads = Array.from(table.tHead?.rows[0]?.cells ?? []).map((cell) => cell.textContent?.trim() ?? "");
236
+ table.setAttribute("role", "table");
237
+ for (const row of Array.from(table.rows)) {
238
+ row.setAttribute("role", "row");
239
+ let column = 0;
240
+ for (const cell of Array.from(row.cells)) {
241
+ const span = cell.colSpan || 1;
242
+ if (cell.tagName === "TH") cell.setAttribute("role", cell.closest("thead") ? "columnheader" : "rowheader");
243
+ else cell.setAttribute("role", "cell");
244
+ const text = span === 1 && column > 0 ? heads[column] : undefined;
245
+ if (cell.tagName === "TD" && text) cell.setAttribute("data-label", text);
246
+ else cell.removeAttribute("data-label");
247
+ column += span;
248
+ }
249
+ }
250
+ };
251
+ label();
252
+ const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver(label);
253
+ observer?.observe(table, { childList: true, subtree: true, characterData: true });
254
+ return () => observer?.disconnect();
255
+ }, [stack]);
199
256
 
200
257
  const ctx = useMemo<TableContextValue>(
201
258
  () => ({
@@ -239,12 +296,14 @@ export function Table({
239
296
  >
240
297
  <table
241
298
  {...rest}
299
+ ref={tableRef}
242
300
  aria-label={ariaLabel}
243
301
  className={cn(
244
302
  "w-full caption-bottom border-collapse text-sm text-[var(--text-primary)]",
245
303
  density === "compact" && "text-xs",
246
304
  layout === "fixed" && "table-fixed",
247
305
  layout === "auto" && "table-auto",
306
+ stack === "phone" && STACK_PHONE_CLASSES,
248
307
  className,
249
308
  )}
250
309
  />
@@ -26,6 +26,15 @@ export interface ToggleGroupBaseProps<T extends string>
26
26
  /** Applied to every option button (e.g. to tune height/rounding to match
27
27
  * adjacent fields). Per-option `className` still wins over this. */
28
28
  optionClassName?: string;
29
+ /**
30
+ * What happens when the options do not fit the group's width. `"truncate"`
31
+ * (default): one row, and the labels that do not fit end in an ellipsis — the height
32
+ * never changes. `"wrap"`: the segments flow onto a second row and every label stays
33
+ * whole ("Bewegung", not "Beweg…"), at the price of a taller group on a narrow
34
+ * screen (lenkbank L4: five signal types on a 390px control page). From the width
35
+ * where they fit, both look the same.
36
+ */
37
+ overflow?: "truncate" | "wrap";
29
38
  /**
30
39
  * @deprecated Pass `aria-label` instead — the DOM spelling, which every other
31
40
  * control in this kit now answers to. Kept working because three applications ship
@@ -174,6 +183,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
174
183
  options,
175
184
  className,
176
185
  optionClassName,
186
+ overflow = "truncate",
177
187
  ariaLabel,
178
188
  disabled = false,
179
189
  size = "md",
@@ -270,6 +280,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
270
280
  // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a
271
281
  // phone does see is the focus ring — see the segment's own note below.)
272
282
  "inline-flex w-full gap-0.5 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] p-0.5 shadow-sm",
283
+ overflow === "wrap" && "flex-wrap",
273
284
  // The whole group fades, the way every other disabled control in this
274
285
  // package does; `cursor-not-allowed` is on the buttons, which is what a
275
286
  // pointer is actually over.
@@ -318,7 +329,8 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
318
329
  // overlaying the selected segment's boundary. Inset keeps the ring
319
330
  // inside the segment it belongs to; focus-visible keeps it for the
320
331
  // keyboard, which is the only input that needs it.
321
- "min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]",
332
+ overflow === "wrap" ? "whitespace-nowrap" : "min-w-0 truncate",
333
+ "flex-1 basis-auto rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]",
322
334
  size === "sm" && "px-2 py-1 text-xs",
323
335
  // In a field: no vertical padding and a 20px line — a `text-sm` line, the
324
336
  // same line a labelled Select holds under its label strip — so the field's
@@ -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
- <span className="flex flex-col items-start text-start">
2958
- <span>{tab.label}</span>
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