@eifi1/ui-kit 0.15.3 → 0.15.4

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.
Files changed (69) hide show
  1. package/README.md +8 -7
  2. package/dist/chart.d.ts +51 -1
  3. package/dist/components/amount-input.d.ts +1 -1
  4. package/dist/components/amount-input.js +5 -10
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/button-group.d.ts +1 -1
  7. package/dist/components/calculator.d.ts +1 -1
  8. package/dist/components/chart.d.ts +16 -1
  9. package/dist/components/chart.js +65 -37
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.d.ts +8 -2
  12. package/dist/components/chip.js +12 -3
  13. package/dist/components/chip.js.map +1 -1
  14. package/dist/components/copy-button.d.ts +1 -1
  15. package/dist/components/facing-pair.d.ts +51 -0
  16. package/dist/components/file-button.d.ts +1 -1
  17. package/dist/components/file-dropzone.d.ts +1 -1
  18. package/dist/components/form-actions.d.ts +1 -1
  19. package/dist/components/form-actions.js +9 -1
  20. package/dist/components/form-actions.js.map +1 -1
  21. package/dist/components/money-field.d.ts +1 -1
  22. package/dist/components/number-field.d.ts +1 -1
  23. package/dist/components/number-input.d.ts +1 -1
  24. package/dist/components/number-input.js +17 -3
  25. package/dist/components/number-input.js.map +1 -1
  26. package/dist/components/numpad-sheet.d.ts +1 -1
  27. package/dist/components/series-chart.d.ts +79 -1
  28. package/dist/components/series-chart.js +74 -13
  29. package/dist/components/series-chart.js.map +1 -1
  30. package/dist/components/settings-fields.d.ts +1 -1
  31. package/dist/components/text-link.d.ts +1 -1
  32. package/dist/components/time-input.d.ts +1 -1
  33. package/dist/components/ui.d.ts +1 -1
  34. package/dist/hooks/use-file-drop.d.ts +1 -1
  35. package/dist/i18n/defaults.d.ts +1 -1
  36. package/dist/i18n/kit-labels.d.ts +1 -1
  37. package/dist/i18n/kit-labels.js +8 -2
  38. package/dist/i18n/kit-labels.js.map +1 -1
  39. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  40. package/dist/i18n/locales/de-CH.d.ts +1 -1
  41. package/dist/i18n/locales/de-informal.d.ts +1 -1
  42. package/dist/i18n/locales/de.d.ts +1 -1
  43. package/dist/i18n/locales/es.d.ts +1 -1
  44. package/dist/i18n/locales/fr.d.ts +1 -1
  45. package/dist/i18n/locales/hu.d.ts +1 -1
  46. package/dist/i18n/locales/it.d.ts +1 -1
  47. package/dist/i18n/locales/zh.d.ts +1 -1
  48. package/dist/index.d.ts +3 -3
  49. package/dist/{kit-labels-BHfRBbHs.d.ts → kit-labels-CnnBd-2N.d.ts} +13 -2
  50. package/dist/lib/format.d.ts +15 -1
  51. package/dist/lib/format.js +16 -0
  52. package/dist/lib/format.js.map +1 -1
  53. package/dist/rhf/fields.d.ts +1 -1
  54. package/dist/rhf/form.d.ts +1 -1
  55. package/dist/rhf.d.ts +1 -1
  56. package/dist/shell/app-shell.d.ts +1 -1
  57. package/dist/shell/top-bar-brand.d.ts +1 -1
  58. package/dist/shell.d.ts +1 -1
  59. package/dist/wizard/stepper-nav.d.ts +1 -1
  60. package/dist/wizard.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/src/components/amount-input.tsx +8 -13
  63. package/src/components/chart.tsx +102 -41
  64. package/src/components/chip.tsx +29 -4
  65. package/src/components/form-actions.tsx +9 -1
  66. package/src/components/number-input.tsx +22 -2
  67. package/src/components/series-chart.tsx +122 -20
  68. package/src/i18n/kit-labels.tsx +19 -1
  69. package/src/lib/format.ts +30 -0
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { aA as KitLinkProps, az as KitLinkComponent } from '../kit-labels-BHfRBbHs.js';
3
+ import { aB as KitLinkProps, aA as KitLinkComponent } from '../kit-labels-CnnBd-2N.js';
4
4
  import '../components/data-table-labels.js';
5
5
  import '../components/mini-calendar.js';
6
6
  import '../components/calendar-heatmap.js';
package/dist/shell.d.ts CHANGED
@@ -13,7 +13,7 @@ import './theme/theme-store.js';
13
13
  import 'zustand/middleware';
14
14
  import 'zustand';
15
15
  import 'lucide-react';
16
- import './kit-labels-BHfRBbHs.js';
16
+ import './kit-labels-CnnBd-2N.js';
17
17
  import './components/data-table-labels.js';
18
18
  import './components/mini-calendar.js';
19
19
  import './components/calendar-heatmap.js';
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, ReactElement } from 'react';
3
- import { f as ButtonVariant } from '../kit-labels-BHfRBbHs.js';
3
+ import { f as ButtonVariant } from '../kit-labels-CnnBd-2N.js';
4
4
  import { UseWizardReturn, WizardLabels } from './types.js';
5
5
  import '../components/data-table-labels.js';
6
6
  import '../components/mini-calendar.js';
package/dist/wizard.d.ts CHANGED
@@ -6,7 +6,7 @@ export { WizardStep, WizardStepProps } from './wizard/wizard-step.js';
6
6
  export { WizardSummary } from './wizard/wizard-summary.js';
7
7
  export { StepperNav } from './wizard/stepper-nav.js';
8
8
  import 'react';
9
- import './kit-labels-BHfRBbHs.js';
9
+ import './kit-labels-CnnBd-2N.js';
10
10
  import './components/data-table-labels.js';
11
11
  import './components/mini-calendar.js';
12
12
  import './components/calendar-heatmap.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
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.",
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
4
4
  import { CURRENCIES, CurrencyFlag, currencyName, getCurrency } from "./currency-select";
5
5
  import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FLOATING_LABEL_CLASS, PHONE_QUERY } from "./ui";
6
6
  import { cn } from "../lib/cn";
7
+ import { currencyMinorDigits } from "../lib/format";
7
8
  import { useMediaQuery } from "../hooks/use-media-query";
8
9
  import { CalculatorButton, type CalculatorButtonLabels } from "./calculator";
9
10
  import { NumberPadSheet, type NumberPadSheetLabels } from "./numpad-sheet";
@@ -182,17 +183,6 @@ interface AmountInputProps {
182
183
  onCommit?: (value: string) => void;
183
184
  }
184
185
 
185
- /** The currency's minor unit (CHF 2, JPY 0), or `undefined` for no currency or a code
186
- * `Intl` does not accept. */
187
- function currencyDigits(code: string | undefined): number | undefined {
188
- if (!code) return undefined;
189
- try {
190
- return new Intl.NumberFormat("en", { style: "currency", currency: code }).resolvedOptions()
191
- .maximumFractionDigits;
192
- } catch {
193
- return undefined;
194
- }
195
- }
196
186
 
197
187
  /**
198
188
  * A settled figure, rounded to `digits` (half away from zero) and clamped to
@@ -360,7 +350,12 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
360
350
  // draft is shown only while it still spells what the field holds; a value set from
361
351
  // outside, a calculator result or a commit falls back to the derived spelling.
362
352
  const [draft, setDraft] = useState<string | null>(null);
363
- const derived = mark === "," ? shown.replace(/\./g, ",") : shown;
353
+ // A value the HOST set (not one typed here) is shown at the currency's minor unit
354
+ // while the field is not being edited: an FX estimate of 93.4213 showed four
355
+ // decimals (keksdose live #356). Display only — `value` is the host's, untouched.
356
+ const displayDigits = digitsProp ?? currencyMinorDigits(currency);
357
+ const resting = focused ? shown : settleAmount(shown, displayDigits);
358
+ const derived = mark === "," ? resting.replace(/\./g, ",") : resting;
364
359
  const draftAs = draft === null ? "" : sanitizeLive(normalizeTypedMarks(draft, mark));
365
360
  const display =
366
361
  draft === null
@@ -380,7 +375,7 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
380
375
  // edit the field's own text that is simply what the field was showing; the
381
376
  // desktop calculator keeps its expression in a popover the field never sees,
382
377
  // so it passes it in (see `CalculatorButton`'s `onChange`).
383
- const digits = digitsProp ?? currencyDigits(currency);
378
+ const digits = digitsProp ?? currencyMinorDigits(currency);
384
379
  const settle = (text: string) => settleAmount(text, digits, min, max);
385
380
  const handleText = (raw: string, previous: string = shown) => {
386
381
  const text = sanitizeLive(raw);
@@ -243,6 +243,21 @@ interface ChartTooltipContentProps {
243
243
  boundaryRef?: RefObject<HTMLElement | null>;
244
244
  /** Injected by recharts: keep series drawn with `hide` in the tooltip. */
245
245
  includeHidden?: boolean;
246
+ /**
247
+ * `"floating"` (the default): the box recharts moves with the cursor. `"readout"`: the
248
+ * same heading and values as a flat row for a FIXED place outside the plot — a
249
+ * `SeriesChart` whose `tooltip.placement` is `"above"`/`"below"` portals it there, so
250
+ * a finger on a phone no longer sits on the box it is trying to read (keksdose #359).
251
+ * No border, shadow, width cap or viewport nudge; the values flow in a grid whose
252
+ * column count depends on the row's width alone, so the row is the same height
253
+ * whatever is being pointed at — and, idle, it shows {@link idleKeys} with "—".
254
+ */
255
+ variant?: "floating" | "readout";
256
+ /** Readout only: the series to list while nothing is active, so the row keeps the
257
+ * height it will have once something is. */
258
+ idleKeys?: readonly string[];
259
+ /** Readout only: the heading while nothing is active. Default "—". */
260
+ idleLabel?: ReactNode;
246
261
  }
247
262
 
248
263
  export function ChartTooltipContent({
@@ -257,13 +272,19 @@ export function ChartTooltipContent({
257
272
  coordinate,
258
273
  boundaryRef,
259
274
  includeHidden = false,
275
+ variant = "floating",
276
+ idleKeys,
277
+ idleLabel,
260
278
  }: ChartTooltipContentProps) {
261
279
  const config = useChart();
262
280
  const tipRef = useRef<HTMLDivElement>(null);
281
+ const readout = variant === "readout";
263
282
  // `ChartTooltip` keeps null values (so they can print "—"), which also keeps the
264
283
  // series a legend toggle switched off with `hide`; those go here instead.
265
284
  const items = includeHidden ? payload : payload?.filter((item) => item.hide !== true);
266
- const visible = Boolean(active && items?.length);
285
+ const shown = Boolean(active && items?.length);
286
+ // Everything below that measures and moves the box is about a FLOATING one.
287
+ const visible = shown && !readout;
267
288
  // Recharts anchors the tooltip at coordinate.x inside the full (scrolled) chart
268
289
  // width; subtract the container's scrollLeft to get its on-screen x, then flip
269
290
  // left if the tooltip's own width would run past the visible right edge.
@@ -332,6 +353,85 @@ export function ChartTooltipContent({
332
353
  setNudge(next);
333
354
  }
334
355
  }, [visible, x, payload, label, flip, settled]);
356
+ // One series' line — the floating box stacks them; the readout lays them in a grid,
357
+ // where a long name is cut rather than wrapped so every cell stays one line high.
358
+ const tooltipRow = (item: TooltipPayloadItem, i: number, inRow: boolean) => {
359
+ // Bars/lines/areas identify a series by dataKey; pies/treemaps key off
360
+ // `name` (the nameKey value). Prefer whichever the config knows.
361
+ const dk = item.dataKey != null ? String(item.dataKey) : undefined;
362
+ const nm = item.name != null ? String(item.name) : undefined;
363
+ const cfgKey = dk && config[dk] ? dk : nm && config[nm] ? nm : (dk ?? nm ?? String(i));
364
+ const series = config[cfgKey];
365
+ const fill = typeof item.payload?.fill === "string" ? item.payload.fill : undefined;
366
+ // The data point's own `fill` before recharts' `item.color`: a treemap tile,
367
+ // or a bar with a per-cell colour, IS the colour the reader is pointing at,
368
+ // while `item.color` is the series-wide one — every tile's swatch came out
369
+ // `--chart-1` whatever colour the tile was.
370
+ const color = paintedColor(cfgKey, series) ?? fill ?? item.color ?? "#64748b";
371
+ const name = series?.label ?? nm ?? cfgKey;
372
+ const raw = tooltipValue(item.value);
373
+ let text: ReactNode;
374
+ // An idle readout cell has asked nothing yet, so there is no absence for
375
+ // `formatValue` to word — it is the bare mark.
376
+ if (inRow && !shown) text = NO_VALUE;
377
+ else if (formatValue) text = formatValue(raw);
378
+ else if (raw == null) text = NO_VALUE;
379
+ else text = valueFormatter ? valueFormatter(raw) : raw;
380
+ return (
381
+ <div
382
+ key={`${cfgKey}-${i}`}
383
+ // A readout cell is capped so a wide row does not push its value a column away
384
+ // from its name.
385
+ className={cn("flex items-center", inRow ? "min-w-0 max-w-[12rem] gap-1.5" : "gap-2")}
386
+ >
387
+ <span
388
+ className={cn(
389
+ "shrink-0 rounded-[2px]",
390
+ indicator === "dot" ? "h-2.5 w-2.5 rounded-full" : "h-2.5 w-1",
391
+ )}
392
+ style={{ backgroundColor: color }}
393
+ />
394
+ <span
395
+ className={cn("min-w-0 text-[var(--text-muted)]", inRow ? "truncate" : "break-words")}
396
+ >
397
+ {name}
398
+ </span>
399
+ <span
400
+ data-private
401
+ // Isolated, so an RTL page does not turn "-26.6" into "26.6-": a figure has no
402
+ // strong direction of its own, and one formatted with an RTL mark keeps it.
403
+ dir="auto"
404
+ className="ms-auto shrink-0 whitespace-nowrap font-mono font-medium tabular-nums text-[var(--text-primary)]"
405
+ >
406
+ {text}
407
+ </span>
408
+ </div>
409
+ );
410
+ };
411
+
412
+ if (readout) {
413
+ // Idle, the row lists every series with no value — the same cells it will have, so
414
+ // nothing under it moves on the first tap.
415
+ const rows: TooltipPayloadItem[] = shown
416
+ ? items!
417
+ : (idleKeys ?? Object.keys(config)).map((key) => ({ dataKey: key }));
418
+ return (
419
+ <div className="text-xs" data-chart-readout={shown ? "active" : "idle"}>
420
+ {/* A full 24 px line: the zoom's reset button sits at the top corner of a chart
421
+ whose row is above it, and has to fit beside the heading, not over a value. */}
422
+ <div data-readout-heading className="truncate font-medium leading-6 text-[var(--text-primary)]">
423
+ {shown && !hideLabel && label != null
424
+ ? labelFormatter
425
+ ? labelFormatter(label)
426
+ : label
427
+ : (idleLabel ?? NO_VALUE)}
428
+ </div>
429
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-x-4 gap-y-1">
430
+ {rows.map((item, i) => tooltipRow(item, i, true))}
431
+ </div>
432
+ </div>
433
+ );
434
+ }
335
435
  if (!visible || !items) return null;
336
436
  return (
337
437
  <div
@@ -359,46 +459,7 @@ export function ChartTooltipContent({
359
459
  {labelFormatter ? labelFormatter(label) : label}
360
460
  </div>
361
461
  )}
362
- <div className="grid gap-1.5">
363
- {items.map((item, i) => {
364
- // Bars/lines/areas identify a series by dataKey; pies/treemaps key off
365
- // `name` (the nameKey value). Prefer whichever the config knows.
366
- const dk = item.dataKey != null ? String(item.dataKey) : undefined;
367
- const nm = item.name != null ? String(item.name) : undefined;
368
- const cfgKey = dk && config[dk] ? dk : nm && config[nm] ? nm : (dk ?? nm ?? String(i));
369
- const series = config[cfgKey];
370
- const fill = typeof item.payload?.fill === "string" ? item.payload.fill : undefined;
371
- // The data point's own `fill` before recharts' `item.color`: a treemap tile,
372
- // or a bar with a per-cell colour, IS the colour the reader is pointing at,
373
- // while `item.color` is the series-wide one — every tile's swatch came out
374
- // `--chart-1` whatever colour the tile was.
375
- const color = paintedColor(cfgKey, series) ?? fill ?? item.color ?? "#64748b";
376
- const name = series?.label ?? nm ?? cfgKey;
377
- const raw = tooltipValue(item.value);
378
- let shown: ReactNode;
379
- if (formatValue) shown = formatValue(raw);
380
- else if (raw == null) shown = NO_VALUE;
381
- else shown = valueFormatter ? valueFormatter(raw) : raw;
382
- return (
383
- <div key={`${cfgKey}-${i}`} className="flex items-center gap-2">
384
- <span
385
- className={cn(
386
- "shrink-0 rounded-[2px]",
387
- indicator === "dot" ? "h-2.5 w-2.5 rounded-full" : "h-2.5 w-1",
388
- )}
389
- style={{ backgroundColor: color }}
390
- />
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
- >
396
- {shown}
397
- </span>
398
- </div>
399
- );
400
- })}
401
- </div>
462
+ <div className="grid gap-1.5">{items.map((item, i) => tooltipRow(item, i, false))}</div>
402
463
  </div>
403
464
  );
404
465
  }
@@ -1,4 +1,4 @@
1
- import { forwardRef, useId, useRef, useState } from "react";
1
+ import { forwardRef, isValidElement, useId, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
3
  import { Check, X } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
@@ -307,7 +307,13 @@ interface ChipBaseProps {
307
307
  * drawn, so write it in normal case.
308
308
  */
309
309
  caps?: boolean;
310
- icon?: LucideIcon;
310
+ /**
311
+ * The leading glyph: a lucide component (`icon={Check}`, drawn at the chip's size), or
312
+ * — since 0.15.4 — a ready element (`icon={<Check className="text-[var(--success)]" />}`)
313
+ * for a glyph whose colour or stroke carries a state; it is sized to the same box
314
+ * (keksdose live #358 built that slot by hand inside `children`).
315
+ */
316
+ icon?: LucideIcon | ReactElement;
311
317
  /**
312
318
  * Marks the chip as the current one — `aria-current` on a link, `aria-pressed` on a
313
319
  * toggle.
@@ -500,8 +506,27 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
500
506
  </span>
501
507
  )}
502
508
  {dot && <span aria-hidden data-chip-dot="" className={cn("shrink-0 rounded-full", DOT_SIZE[size], DOT[tone])} />}
503
- {Icon && <Icon className={cn(s.icon, "shrink-0")} aria-hidden />}
504
- <span className="min-w-0 truncate">{children}</span>
509
+ {Icon &&
510
+ (isValidElement(Icon) ? (
511
+ <span aria-hidden className={cn(s.icon, "inline-flex shrink-0 [&>svg]:size-full")}>
512
+ {Icon}
513
+ </span>
514
+ ) : (
515
+ <Icon className={cn(s.icon, "shrink-0")} aria-hidden />
516
+ ))}
517
+ {/* Text truncates. Mixed children (an icon and a word) sit in a row instead: as a
518
+ plain span, the svg — a block under Tailwind's preflight — stacked above the
519
+ text (keksdose live #358). */}
520
+ <span
521
+ className={cn(
522
+ "min-w-0",
523
+ typeof children === "string" || typeof children === "number"
524
+ ? "truncate"
525
+ : "inline-flex items-center gap-1 [&>svg]:size-[1em] [&>svg]:shrink-0",
526
+ )}
527
+ >
528
+ {children}
529
+ </span>
505
530
  </>
506
531
  );
507
532
 
@@ -160,7 +160,15 @@ export function FormActions({
160
160
  {...rest}
161
161
  style={
162
162
  placement === "sticky"
163
- ? { paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))", ...style }
163
+ ? {
164
+ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))",
165
+ // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`
166
+ // pinned the row under AppShell's nav, which covers the page's last ~56px.
167
+ // `--app-nav-h` is the nav's measured height (0px from md up, and outside an
168
+ // AppShell); the 1px overlaps the two top borders into one line.
169
+ bottom: "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
170
+ ...style,
171
+ }
164
172
  : style
165
173
  }
166
174
  className={cn("flex flex-wrap items-center gap-2", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}
@@ -1,4 +1,4 @@
1
- import { useId, useRef, useState } from "react";
1
+ import { useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import type { KeyboardEvent, ReactNode } from "react";
3
3
  import { CalculatorButton, type CalculatorButtonLabels } from "./calculator";
4
4
  import { NumberPadSheet, type NumberPadSheetLabels } from "./numpad-sheet";
@@ -191,6 +191,21 @@ export function NumberInput({
191
191
  const asDisplay = isMobile && variant === "display";
192
192
  const showCalc = calculator && !disabled && !isMobile;
193
193
  const [focused, setFocused] = useState(false);
194
+ // The unit and the calculator's measured width, reserved as the field's end padding.
195
+ const endRef = useRef<HTMLDivElement>(null);
196
+ const [endWidth, setEndWidth] = useState<number | null>(null);
197
+ const hasEnd = showCalc || suffix !== undefined;
198
+ useLayoutEffect(() => {
199
+ const el = endRef.current;
200
+ if (!hasEnd || !el || typeof ResizeObserver === "undefined") return;
201
+ // The first callback fires on `observe`; no layout (jsdom) keeps the class fallback.
202
+ const observer = new ResizeObserver(() => {
203
+ const width = el.getBoundingClientRect().width;
204
+ setEndWidth(width > 0 ? Math.ceil(width) : null);
205
+ });
206
+ observer.observe(el);
207
+ return () => observer.disconnect();
208
+ }, [hasEnd]);
194
209
  // On mobile, focusing opens the numpad bottom sheet instead of the native
195
210
  // keyboard (#334); the ref lets its "Done" blur → commit + close.
196
211
  const showNumpad = isMobile && !disabled && focused;
@@ -272,12 +287,17 @@ export function NumberInput({
272
287
  showCalc && suffix !== undefined ? "pe-16" : showCalc ? "pe-9" : suffix !== undefined ? "pe-8" : undefined,
273
288
  invalid && FIELD_INVALID,
274
289
  )}
290
+ // The classes above are the fallback; once the end controls are measured the
291
+ // field reserves exactly their width (+4px). A fixed pe-8 for any unit left a
292
+ // "%" in a ~78px table cell 32px of padding and the digits ~32px of room
293
+ // (keksdose live #367); a long unit ("km/h") got too little.
294
+ style={hasEnd && endWidth != null ? { paddingInlineEnd: endWidth + 4 } : undefined}
275
295
  />
276
296
  {(showCalc || suffix !== undefined) && (
277
297
  // One flex track for both, so the unit and the calculator sit side by side
278
298
  // instead of stacking on the same corner — AmountInput's arrangement, which
279
299
  // has carried a chip and a calculator together since #430.
280
- <div className="absolute inset-y-0 end-0 flex items-center">
300
+ <div ref={endRef} className="absolute inset-y-0 end-0 flex items-center">
281
301
  {showCalc && (
282
302
  <CalculatorButton
283
303
  value={value}
@@ -68,7 +68,13 @@ import { autoAxisBudget, axisUnit, budgetedAxes, type SeriesChartAxisBudget } fr
68
68
  // The rule stays internal, like the ticks; the one type a public prop names is re-exported.
69
69
  export type { SeriesChartAxisBudget } from "./series-chart-budget";
70
70
  import { paletteFor } from "../theme/chart-palette";
71
- import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
71
+ import {
72
+ useKitChartTooltipPlacement,
73
+ useKitLabels,
74
+ useKitLocale,
75
+ type ChartTooltipPlacement,
76
+ } from "../i18n/kit-labels";
77
+ import { useMediaQuery } from "../hooks/use-media-query";
72
78
  import { cn } from "../lib/cn";
73
79
 
74
80
  export interface SeriesChartSeries {
@@ -356,8 +362,49 @@ export interface SeriesChartX {
356
362
  integerTicks?: boolean;
357
363
  }
358
364
 
365
+ /** Where the tooltip goes — see {@link SeriesChartTooltip.placement}. */
366
+ export type SeriesChartTooltipPlacement = ChartTooltipPlacement;
367
+
368
+ /** Below Tailwind's `sm` — where `"auto"` takes the tooltip off the plot. Narrower than
369
+ * the kit's `PHONE_QUERY` (767 px) on purpose: a tablet's plot is wide enough that the
370
+ * box beside the finger leaves the data in view; a phone's is not. */
371
+ const TOOLTIP_READOUT_QUERY = "(max-width: 639px)";
372
+
373
+ /**
374
+ * The placement a chart actually uses: its own, else the provider's, else `"cursor"`;
375
+ * `"auto"` is `"above"` on a phone (`phone`: below `sm`) and `"cursor"` from `sm` up.
376
+ */
377
+ export function resolveTooltipPlacement(
378
+ own: SeriesChartTooltipPlacement | undefined,
379
+ fromProvider: SeriesChartTooltipPlacement | undefined,
380
+ phone: boolean,
381
+ ): "cursor" | "above" | "below" {
382
+ const wanted = own ?? fromProvider ?? "cursor";
383
+ return wanted === "auto" ? (phone ? "above" : "cursor") : wanted;
384
+ }
385
+
359
386
  /** Room for the tooltip beyond the chart — see {@link SeriesChartProps.tooltip}. */
360
387
  export interface SeriesChartTooltip {
388
+ /**
389
+ * Where the values under the pointer are shown (keksdose #359: on a phone the box
390
+ * sat under the finger, on the very data it was reporting).
391
+ * - `"cursor"` (the default): the floating box that follows the pointer.
392
+ * - `"above"` / `"below"`: a READOUT row — the same heading and values, flat — in
393
+ * room reserved over or under the plot from the first render, so nothing jumps on
394
+ * the first tap. It follows hover, a tap and the keyboard's stops; the cursor line
395
+ * and the active dot stay on the plot, and no box floats. Idle, it lists the series
396
+ * with "—" under the x axis' title. The row is taken out of the chart's `height`
397
+ * (the plot gets shorter, the page around it does not move).
398
+ * - `"auto"`: `"above"` below Tailwind's `sm` (640 px), `"cursor"` from there up.
399
+ *
400
+ * Default: the provider's `chartTooltipPlacement`, else `"cursor"`.
401
+ *
402
+ * The row is deliberately NOT an `aria-live` region: scrubbing a line announces a
403
+ * value per pixel of travel. A keyboard reader already hears each stop's name
404
+ * ("period — series: value") from the stop itself, and the row stays in the
405
+ * document for a screen reader to read on its way past.
406
+ */
407
+ placement?: SeriesChartTooltipPlacement;
361
408
  /** Let the tooltip run past the chart's own box on that axis instead of recharts
362
409
  * clamping it inside. Default: `{ x: true }` when `boundary` is set, else neither. */
363
410
  allowEscapeViewBox?: { x?: boolean; y?: boolean };
@@ -1173,6 +1220,10 @@ function SeriesPlot({
1173
1220
  // Up here, above the empty state's early return, like every hook.
1174
1221
  const [keyStop, setKeyStop] = useState(0);
1175
1222
  const [keyFocus, setKeyFocus] = useState<number | undefined>(undefined);
1223
+ // Where the tooltip goes, and — off the plot — the row it is portalled into.
1224
+ const phone = useMediaQuery(TOOLTIP_READOUT_QUERY, false);
1225
+ const placement = resolveTooltipPlacement(tooltip?.placement, useKitChartTooltipPlacement(), phone);
1226
+ const [readout, setReadout] = useState<HTMLDivElement | null>(null);
1176
1227
 
1177
1228
  // The chart's own width, for the automatic axis budget. Its box, not the plot's: the
1178
1229
  // box does not change when an axis is hidden, so the rule cannot oscillate. Nothing
@@ -1495,13 +1546,21 @@ function SeriesPlot({
1495
1546
  animationEasing: "ease-out" as const,
1496
1547
  };
1497
1548
 
1498
- return (
1549
+ const inRow = placement !== "cursor";
1550
+ const chart = (
1499
1551
  <ChartContainer
1500
1552
  ref={setBox}
1501
1553
  data-axis-budget={budgeted.length ? budgetKey.replace(/\n/g, " ") : undefined}
1502
1554
  config={config}
1503
- className={cn("w-full", heightClass, className, onPointClick && "cursor-pointer")}
1504
- style={heightStyle}
1555
+ className={cn(
1556
+ "w-full",
1557
+ // With a readout the chart's height is the whole block's, and the plot takes
1558
+ // what the row leaves.
1559
+ inRow ? "min-h-0 flex-1" : heightClass,
1560
+ className,
1561
+ onPointClick && "cursor-pointer",
1562
+ )}
1563
+ style={inRow ? undefined : heightStyle}
1505
1564
  >
1506
1565
  <ComposedChart
1507
1566
  data={plotted as Record<string, unknown>[]}
@@ -1607,23 +1666,45 @@ function SeriesPlot({
1607
1666
  </YAxis>
1608
1667
  );
1609
1668
  })}
1610
- <ChartTooltip
1611
- {...(tooltip?.allowEscapeViewBox || tooltip?.boundary
1612
- ? { allowEscapeViewBox: tooltip.allowEscapeViewBox ?? { x: true } }
1613
- : {})}
1614
- // The content shifts itself 12 px off the cursor (or flips) against the
1615
- // boundary; recharts' own offset on top would double it.
1616
- {...(tooltip?.boundary ? { offset: 0 } : {})}
1617
- // The keyboard's stop shows its tooltip as the pointer's would.
1618
- defaultIndex={focusedStop?.index}
1619
- content={
1620
- <ChartTooltipContent
1621
- labelFormatter={(value) => xLabel(Number(value))}
1622
- valueFormatter={valueFormat ?? number}
1623
- boundaryRef={tooltip?.boundary}
1669
+ {inRow ? (
1670
+ // Not before the row exists: without its own portal recharts would float this
1671
+ // content over the plot for the first frame.
1672
+ readout && (
1673
+ <ChartTooltip
1674
+ portal={readout}
1675
+ // recharts hides an inactive tooltip; the row shows its idle state instead.
1676
+ wrapperStyle={{ visibility: "visible" }}
1677
+ defaultIndex={focusedStop?.index}
1678
+ content={
1679
+ <ChartTooltipContent
1680
+ variant="readout"
1681
+ idleKeys={series.map((entry) => entry.key)}
1682
+ idleLabel={x.title || undefined}
1683
+ labelFormatter={(value) => xLabel(Number(value))}
1684
+ valueFormatter={valueFormat ?? number}
1685
+ />
1686
+ }
1624
1687
  />
1625
- }
1626
- />
1688
+ )
1689
+ ) : (
1690
+ <ChartTooltip
1691
+ {...(tooltip?.allowEscapeViewBox || tooltip?.boundary
1692
+ ? { allowEscapeViewBox: tooltip.allowEscapeViewBox ?? { x: true } }
1693
+ : {})}
1694
+ // The content shifts itself 12 px off the cursor (or flips) against the
1695
+ // boundary; recharts' own offset on top would double it.
1696
+ {...(tooltip?.boundary ? { offset: 0 } : {})}
1697
+ // The keyboard's stop shows its tooltip as the pointer's would.
1698
+ defaultIndex={focusedStop?.index}
1699
+ content={
1700
+ <ChartTooltipContent
1701
+ labelFormatter={(value) => xLabel(Number(value))}
1702
+ valueFormatter={valueFormat ?? number}
1703
+ boundaryRef={tooltip?.boundary}
1704
+ />
1705
+ }
1706
+ />
1707
+ )}
1627
1708
  {series.map((entry) => {
1628
1709
  const type = entry.type ?? "line";
1629
1710
  const yAxisId = entry.axis ?? DEFAULT_Y_AXIS;
@@ -1787,6 +1868,27 @@ function SeriesPlot({
1787
1868
  </ComposedChart>
1788
1869
  </ChartContainer>
1789
1870
  );
1871
+ if (!inRow) return chart;
1872
+ // The row's own box, reserved from the first render: its idle state has the cells the
1873
+ // active one will, so the first tap fills it in rather than pushing the plot down.
1874
+ const row = (
1875
+ <div
1876
+ ref={setReadout}
1877
+ data-tooltip-readout={placement}
1878
+ className={cn(
1879
+ "shrink-0",
1880
+ // Above, the heading line shares the top corner with the zoom's reset button.
1881
+ placement === "above" ? "pb-2 [&_[data-readout-heading]]:pe-32" : "pt-2",
1882
+ )}
1883
+ />
1884
+ );
1885
+ return (
1886
+ <div className={cn("flex w-full flex-col", heightClass)} style={heightStyle}>
1887
+ {placement === "above" && row}
1888
+ {chart}
1889
+ {placement === "below" && row}
1890
+ </div>
1891
+ );
1790
1892
  }
1791
1893
 
1792
1894
  const ZoomablePlot = withChartZoom(SeriesPlot);
@@ -417,8 +417,12 @@ interface KitI18n {
417
417
  locale?: string;
418
418
  weekStartsOn?: WeekDay;
419
419
  linkComponent?: KitLinkComponent;
420
+ chartTooltipPlacement?: ChartTooltipPlacement;
420
421
  }
421
422
 
423
+ /** Where a chart shows the values under the pointer — see `SeriesChartTooltip.placement`. */
424
+ export type ChartTooltipPlacement = "cursor" | "above" | "below" | "auto";
425
+
422
426
  /**
423
427
  * What every kit link receives: the anchor attributes the kit decided (href, class,
424
428
  * aria-current, handlers, children, ref). An app's router link maps them —
@@ -475,6 +479,13 @@ export interface UiKitProviderProps {
475
479
  * hash-router `#/path` is handed to it like `/path` (see `pickLinkRenderer`).
476
480
  */
477
481
  linkComponent?: KitLinkComponent;
482
+ /**
483
+ * Where every `SeriesChart` below shows its tooltip when it does not say so itself
484
+ * (`tooltip.placement`) — set once so an app's phone charts stop putting the box under
485
+ * the reader's finger (keksdose #359). Default: `"cursor"`, the box that follows the
486
+ * pointer, as before.
487
+ */
488
+ chartTooltipPlacement?: ChartTooltipPlacement;
478
489
  children: ReactNode;
479
490
  }
480
491
 
@@ -491,6 +502,7 @@ export function UiKitProvider({
491
502
  locale,
492
503
  weekStartsOn,
493
504
  linkComponent,
505
+ chartTooltipPlacement,
494
506
  children,
495
507
  }: UiKitProviderProps) {
496
508
  const outer = useContext(KitI18nContext);
@@ -500,8 +512,9 @@ export function UiKitProvider({
500
512
  weekStartsOn: weekStartsOn ?? outer.weekStartsOn,
501
513
  labels: mergeOverrides(outer.labels, labels),
502
514
  linkComponent: linkComponent ?? outer.linkComponent,
515
+ chartTooltipPlacement: chartTooltipPlacement ?? outer.chartTooltipPlacement,
503
516
  }),
504
- [outer, labels, locale, weekStartsOn, linkComponent],
517
+ [outer, labels, locale, weekStartsOn, linkComponent, chartTooltipPlacement],
505
518
  );
506
519
  return <KitI18nContext.Provider value={value}>{children}</KitI18nContext.Provider>;
507
520
  }
@@ -589,6 +602,11 @@ export function useKitWeekStart(): WeekDay | undefined {
589
602
  return useContext(KitI18nContext).weekStartsOn;
590
603
  }
591
604
 
605
+ /** The provider's `chartTooltipPlacement`, or `undefined` — a chart's own prop wins. */
606
+ export function useKitChartTooltipPlacement(): ChartTooltipPlacement | undefined {
607
+ return useContext(KitI18nContext).chartTooltipPlacement;
608
+ }
609
+
592
610
  /** {@link DEFAULT_FILE_LABELS}, but formatting in the provider's locale. */
593
611
  export function useKitFileLabels(prop?: Partial<FileLabels>): FileLabels {
594
612
  const locale = useKitLocale();