@eifi1/ui-kit 0.13.0 → 0.13.1
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/amount-input.d.ts +6 -0
- package/dist/components/amount-input.js +35 -5
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/error-boundary.js +14 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +7 -3
- package/dist/components/numpad-sheet.js +4 -3
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/text.js.map +1 -1
- package/dist/lib/format.js +1 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +3 -1
- package/dist/rhf/fields.js.map +1 -1
- package/package.json +1 -1
- package/src/components/amount-input.tsx +73 -7
- package/src/components/error-boundary.tsx +17 -1
- package/src/components/numpad-sheet.tsx +13 -4
- package/src/components/text.tsx +2 -1
- package/src/lib/format.ts +3 -1
- package/src/rhf/fields.tsx +3 -1
|
@@ -158,6 +158,12 @@ interface AmountInputProps {
|
|
|
158
158
|
* zero (12.345 → 12.35, -12.345 → -12.35) and happens when the figure settles: on
|
|
159
159
|
* blur, on Enter, and on every result the calculator writes back. Keystrokes are left
|
|
160
160
|
* alone, so "12.3" can still become "12.34".
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ **A unit price or a rate needs `digits`.** The default is right for an amount of
|
|
163
|
+
* money and silently wrong for a price per unit: a fund's NAV of 123.4567 or fuel at
|
|
164
|
+
* 1.789 €/l is stored as 123.46 / 1.79, and no test fails (keksdose G2, a
|
|
165
|
+
* `Numeric(18,6)` holding price and a `Numeric(18,4)` unit price). Pass the column's
|
|
166
|
+
* scale: `digits={4}`, `digits={6}`.
|
|
161
167
|
*/
|
|
162
168
|
digits?: number;
|
|
163
169
|
/** The smallest amount the field settles on. A lower figure is raised to it when it
|
|
@@ -218,6 +224,36 @@ function decimalMark(locale: string | undefined): "," | "." {
|
|
|
218
224
|
}
|
|
219
225
|
}
|
|
220
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Which typed mark is the decimal one (keksdose G1). {@link sanitizeLive} folds every
|
|
229
|
+
* "," into ".", so on its own it read the German "1.234,56" as 1.23456 — and the
|
|
230
|
+
* settle then made that 1.23, final. This decides per operand, from the typist's own
|
|
231
|
+
* text, before that fold:
|
|
232
|
+
*
|
|
233
|
+
* - both marks present: the one that is not the locale's is grouping, and goes;
|
|
234
|
+
* - in a "," locale, only "." present: grouping when it cannot be a decimal as a
|
|
235
|
+
* German reader writes it — twice ("1.234.567"), or in thousands shape ("1.234",
|
|
236
|
+
* "12.500") — otherwise a decimal ("1.5", typed by a dot-decimal habit);
|
|
237
|
+
* - in a "." locale, only "," present: grouping when it appears twice ("1,234,567");
|
|
238
|
+
* once it stays a decimal, because Swiss and German typists write "1,5" in a
|
|
239
|
+
* de-CH form (kastlan 40) and a unit price "1,789" is a decimal.
|
|
240
|
+
*
|
|
241
|
+
* Only a keystroke's text comes through here. A calculator result is dot-decimal
|
|
242
|
+
* already, and "1.234" from `100/81.03…` is never a thousand.
|
|
243
|
+
*/
|
|
244
|
+
function normalizeTypedMarks(raw: string, mark: "," | "."): string {
|
|
245
|
+
const other = mark === "," ? "." : ",";
|
|
246
|
+
return raw.replace(/[0-9.,]+/g, (operand) => {
|
|
247
|
+
const hasMark = operand.includes(mark);
|
|
248
|
+
const count = operand.split(other).length - 1;
|
|
249
|
+
if (count === 0) return operand;
|
|
250
|
+
const grouping = hasMark
|
|
251
|
+
? true
|
|
252
|
+
: count > 1 || (mark === "," && /^\d{1,3}(\.\d{3})+$/.test(operand));
|
|
253
|
+
return grouping ? operand.split(other).join("") : operand;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
221
257
|
// The consuming app's ONE money palette (`--money-expense` / `--money-income`),
|
|
222
258
|
// not a bespoke rose/emerald pairing: a figure being typed has to wear the same
|
|
223
259
|
// colour the same figure will wear once it is a row in the table behind the form
|
|
@@ -310,7 +346,23 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
|
310
346
|
// follows `Intl` rather than a guess about a country.
|
|
311
347
|
const locale = useKitLocale();
|
|
312
348
|
const mark = decimalMark(locale);
|
|
313
|
-
|
|
349
|
+
// While the typist is typing, the field shows THEIR text ("1.234,5" stays exactly
|
|
350
|
+
// that), not the value re-spelt: re-spelling turned a grouping "." into a "," under
|
|
351
|
+
// their fingers, after which no rule could tell the two apart (keksdose G1). The
|
|
352
|
+
// draft is shown only while it still spells what the field holds; a value set from
|
|
353
|
+
// outside, a calculator result or a commit falls back to the derived spelling.
|
|
354
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
355
|
+
const derived = mark === "," ? shown.replace(/\./g, ",") : shown;
|
|
356
|
+
const draftAs = draft === null ? "" : sanitizeLive(normalizeTypedMarks(draft, mark));
|
|
357
|
+
const display =
|
|
358
|
+
draft === null
|
|
359
|
+
? derived
|
|
360
|
+
: draftAs === shown
|
|
361
|
+
? draft
|
|
362
|
+
: // The caller owns the sign: the typed "12" is shown as the "-12" it is.
|
|
363
|
+
signOwned && negative && `-${draftAs}` === shown
|
|
364
|
+
? `-${draft}`
|
|
365
|
+
: derived;
|
|
314
366
|
// Every route into the field — typing, the numpad sheet, the desktop
|
|
315
367
|
// calculator, the blur/Enter commit — funnels through here, so the split is
|
|
316
368
|
// written once and the four entry paths cannot drift.
|
|
@@ -345,7 +397,14 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
|
345
397
|
}
|
|
346
398
|
onChange(sign ? rest : text);
|
|
347
399
|
};
|
|
348
|
-
const commit = () =>
|
|
400
|
+
const commit = () => {
|
|
401
|
+
setDraft(null);
|
|
402
|
+
handleText(settle(commitExpression(shown)));
|
|
403
|
+
};
|
|
404
|
+
const typed = (raw: string) => {
|
|
405
|
+
setDraft(raw);
|
|
406
|
+
handleText(normalizeTypedMarks(raw, mark));
|
|
407
|
+
};
|
|
349
408
|
const { open, setOpen, wrapperRef, panelRef, query, setQuery, inputRef } = useDropdownSearch();
|
|
350
409
|
// The chip the currency list hangs off (Keksdose dev#548). It is portalled now, so
|
|
351
410
|
// the panel needs a real trigger rect rather than a relative parent — see
|
|
@@ -404,7 +463,7 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
|
404
463
|
// only the app knows the locale's decimal separator.
|
|
405
464
|
placeholder={asDisplay ? (placeholder ?? "0") : label !== undefined ? " " : placeholder}
|
|
406
465
|
value={display}
|
|
407
|
-
onChange={(e) =>
|
|
466
|
+
onChange={(e) => typed(e.target.value)}
|
|
408
467
|
onFocus={() => setFocused(true)}
|
|
409
468
|
onBlur={() => {
|
|
410
469
|
commit();
|
|
@@ -472,7 +531,10 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
|
472
531
|
// The mark the field shows, as NumberInput hands its calculator; the
|
|
473
532
|
// evaluator reads either, and its result comes back dot-decimal.
|
|
474
533
|
value={display}
|
|
475
|
-
onChange={(result, expression) =>
|
|
534
|
+
onChange={(result, expression) => {
|
|
535
|
+
setDraft(null);
|
|
536
|
+
handleText(settle(result), expression);
|
|
537
|
+
}}
|
|
476
538
|
className="px-1.5"
|
|
477
539
|
ariaLabel={labels?.calculatorTrigger}
|
|
478
540
|
labels={labels?.calculator}
|
|
@@ -554,10 +616,14 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
|
554
616
|
</div>
|
|
555
617
|
{showNumpad && (
|
|
556
618
|
<NumberPadSheet
|
|
557
|
-
// Localised like the field it mirrors
|
|
558
|
-
//
|
|
619
|
+
// Localised like the field it mirrors. Keys are typing, read by the
|
|
620
|
+
// locale's marks like the keyboard's; "=" is a result, dot-decimal.
|
|
559
621
|
value={display}
|
|
560
|
-
onChange={
|
|
622
|
+
onChange={typed}
|
|
623
|
+
onResult={(result) => {
|
|
624
|
+
setDraft(null);
|
|
625
|
+
handleText(result);
|
|
626
|
+
}}
|
|
561
627
|
onDone={() => innerRef.current?.blur()}
|
|
562
628
|
label={label}
|
|
563
629
|
labels={labels?.pad}
|
|
@@ -73,13 +73,29 @@ function read(get: () => unknown): string | undefined {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** `JSON.stringify` that never throws (a cycle, a BigInt, a hostile getter) and never
|
|
77
|
+
* returns more than 500 characters. "" when there is nothing to say. */
|
|
78
|
+
function safeJson(value: unknown): string {
|
|
79
|
+
try {
|
|
80
|
+
const json = JSON.stringify(value);
|
|
81
|
+
if (!json || json === "{}") return "";
|
|
82
|
+
return json.length > 500 ? `${json.slice(0, 499)}…` : json;
|
|
83
|
+
} catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
76
88
|
/** {@link ErrorBoundaryDetails} of any thrown value, without ever throwing. */
|
|
77
89
|
export function describeThrown(error: unknown): ErrorBoundaryDetails {
|
|
78
90
|
if (typeof error === "object" && error !== null) {
|
|
79
91
|
const e = error as { name?: unknown; message?: unknown; stack?: unknown };
|
|
92
|
+
const message = read(() => e.message) ?? "";
|
|
80
93
|
return {
|
|
81
94
|
name: read(() => e.name) ?? "Error",
|
|
82
|
-
|
|
95
|
+
// `throw { code: 500 }` has no message, and an empty line leaves crash triage with
|
|
96
|
+
// nothing (keksdose G3a; its old reporter filed `{"code":500}`). Such a value is
|
|
97
|
+
// quoted as JSON instead, capped, and never at the cost of a throw.
|
|
98
|
+
message: message || (error instanceof Error ? "" : safeJson(error)),
|
|
83
99
|
stack: read(() => e.stack),
|
|
84
100
|
};
|
|
85
101
|
}
|
|
@@ -100,15 +100,20 @@ export function NumberPadSheet({
|
|
|
100
100
|
label,
|
|
101
101
|
labels,
|
|
102
102
|
decimalMark = ".",
|
|
103
|
+
onResult,
|
|
103
104
|
}: {
|
|
104
105
|
value: string;
|
|
105
106
|
/** Fired with the raw (sanitised) field text on every key — same contract as
|
|
106
107
|
* the host field's own `onChange`. */
|
|
107
108
|
onChange: (value: string) => void;
|
|
108
109
|
/** The glyph on the decimal key — "," where the host shows a comma (AmountInput
|
|
109
|
-
* in fr-CH, kastlan 40).
|
|
110
|
-
*
|
|
110
|
+
* in fr-CH, kastlan 40). With "," the key inserts a comma and the text is handed
|
|
111
|
+
* back unsanitized, for the host to read by its locale (keksdose G1). */
|
|
111
112
|
decimalMark?: "." | ",";
|
|
113
|
+
/** "=": the evaluated result, dot-decimal. Default `onChange`. A host that reads
|
|
114
|
+
* typed text by the locale's marks takes it apart from keystrokes, since a result
|
|
115
|
+
* like "1.234" is a decimal and never a grouped thousand. */
|
|
116
|
+
onResult?: (value: string) => void;
|
|
112
117
|
/** Fired on "Done": the host blurs the input, which commits (evaluates) and
|
|
113
118
|
* unmounts the sheet via its existing blur handler. */
|
|
114
119
|
onDone: () => void;
|
|
@@ -185,11 +190,15 @@ export function NumberPadSheet({
|
|
|
185
190
|
const result = evaluateExpression(value);
|
|
186
191
|
const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : "";
|
|
187
192
|
|
|
188
|
-
|
|
193
|
+
// With a "," mark the host keeps the typist's own text (AmountInput, keksdose G1):
|
|
194
|
+
// the key inserts the comma it shows, and the host, not this pad, decides which mark
|
|
195
|
+
// is the decimal one — sanitizing here would fold a grouping "." into a decimal.
|
|
196
|
+
const insert = (ch: string) =>
|
|
197
|
+
onChange(decimalMark === "," ? value + (ch === "." ? "," : ch) : sanitizeLive(value + ch));
|
|
189
198
|
const backspace = () => onChange(value.slice(0, -1));
|
|
190
199
|
const clearAll = () => onChange("");
|
|
191
200
|
const equals = () => {
|
|
192
|
-
if (result !== null) onChange(formatResult(result));
|
|
201
|
+
if (result !== null) (onResult ?? onChange)(formatResult(result));
|
|
193
202
|
};
|
|
194
203
|
|
|
195
204
|
const sheet = (
|
package/src/components/text.tsx
CHANGED
|
@@ -58,7 +58,8 @@ export type SectionLabelVariant = "plain" | "band";
|
|
|
58
58
|
|
|
59
59
|
/** The band's own type and box, per size — spelled out rather than merged over
|
|
60
60
|
* {@link SECTION_LABEL_CLASS}, so the result does not hang on tailwind-merge telling
|
|
61
|
-
* a font-size `text
|
|
61
|
+
* a font-size arbitrary `text-` value from a colour one. (Not spelt out as class
|
|
62
|
+
* names here: Tailwind scans comments, and a literal one broke the showcase CSS.) */
|
|
62
63
|
const SECTION_LABEL_BAND_CLASS: Record<SectionLabelSize, string> = {
|
|
63
64
|
xs: "text-[10px]",
|
|
64
65
|
md: "text-[11px]",
|
package/src/lib/format.ts
CHANGED
|
@@ -115,7 +115,9 @@ function numberOptions(o: FormatNumberBaseOptions): Intl.NumberFormatOptions {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
function isMissing(value: number | null | undefined): value is null | undefined {
|
|
118
|
-
|
|
118
|
+
// ±Infinity too (lenkbank, adopting 0.13): a division by zero upstream is a missing
|
|
119
|
+
// figure, and "∞" printed in a money or measurement column reads like data.
|
|
120
|
+
return value === null || value === undefined || !Number.isFinite(value);
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
/** A number in `locale`: "1,234.5", "1.234,5", "1’234.5" — with `unit`, "3.400 N". */
|
package/src/rhf/fields.tsx
CHANGED
|
@@ -545,7 +545,9 @@ export type RhfMoneyFieldProps<
|
|
|
545
545
|
/** What an emptied field stores when `valueAs` is `"number"`. Default `null`. */
|
|
546
546
|
emptyValue?: null | "";
|
|
547
547
|
/** Decimals the amount settles to on blur, Enter or a calculator result. Default:
|
|
548
|
-
* the currency's minor unit (CHF 2, JPY 0).
|
|
548
|
+
* the currency's minor unit (CHF 2, JPY 0). ⚠️ A unit price or a rate needs its
|
|
549
|
+
* column's scale here (`digits={4}` for 1.789 €/l), or it is stored rounded to
|
|
550
|
+
* cents without a word (keksdose G2). See {@link AmountInput}'s `digits`. */
|
|
549
551
|
digits?: number;
|
|
550
552
|
/** Clamp the settled amount (a release capped at the deposit's balance). Validate
|
|
551
553
|
* with `rules` as well if a clamp needs explaining. */
|