@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.
- package/README.md +8 -7
- package/dist/chart.d.ts +51 -1
- package/dist/components/amount-input.d.ts +1 -1
- package/dist/components/amount-input.js +5 -10
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/chart.d.ts +16 -1
- package/dist/components/chart.js +65 -37
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +8 -2
- package/dist/components/chip.js +12 -3
- package/dist/components/chip.js.map +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/facing-pair.d.ts +51 -0
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/form-actions.js +9 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/money-field.d.ts +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/number-input.js +17 -3
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/series-chart.d.ts +79 -1
- package/dist/components/series-chart.js +74 -13
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/{kit-labels-BHfRBbHs.d.ts → kit-labels-CnnBd-2N.d.ts} +13 -2
- package/dist/lib/format.d.ts +15 -1
- package/dist/lib/format.js +16 -0
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/amount-input.tsx +8 -13
- package/src/components/chart.tsx +102 -41
- package/src/components/chip.tsx +29 -4
- package/src/components/form-actions.tsx +9 -1
- package/src/components/number-input.tsx +22 -2
- package/src/components/series-chart.tsx +122 -20
- package/src/i18n/kit-labels.tsx +19 -1
- 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 {
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
+
"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
|
-
|
|
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 ??
|
|
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);
|
package/src/components/chart.tsx
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/components/chip.tsx
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
504
|
-
|
|
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
|
-
? {
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
1504
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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);
|
package/src/i18n/kit-labels.tsx
CHANGED
|
@@ -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();
|