@eifi1/ui-kit 0.12.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.
Files changed (197) hide show
  1. package/README.md +10 -10
  2. package/dist/components/amount-input.d.ts +23 -1
  3. package/dist/components/amount-input.js +78 -13
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/bulk-action-bar.d.ts +10 -2
  6. package/dist/components/bulk-action-bar.js.map +1 -1
  7. package/dist/components/button-group.d.ts +1 -1
  8. package/dist/components/calculator.d.ts +1 -1
  9. package/dist/components/chart.js +14 -8
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.js +20 -15
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/combobox-core.d.ts +3 -1
  14. package/dist/components/combobox-core.js +16 -5
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +1 -1
  18. package/dist/components/data-table-filter-popover.js +8 -1
  19. package/dist/components/data-table-filter-popover.js.map +1 -1
  20. package/dist/components/data-table.js +28 -20
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/entity-combobox.js +22 -17
  23. package/dist/components/entity-combobox.js.map +1 -1
  24. package/dist/components/error-boundary.d.ts +202 -13
  25. package/dist/components/error-boundary.js +225 -21
  26. package/dist/components/error-boundary.js.map +1 -1
  27. package/dist/components/field-sync.js +90 -83
  28. package/dist/components/field-sync.js.map +1 -1
  29. package/dist/components/file-button.d.ts +1 -1
  30. package/dist/components/file-dropzone.d.ts +1 -1
  31. package/dist/components/form-actions.d.ts +1 -1
  32. package/dist/components/full-bleed-dialog.js +4 -0
  33. package/dist/components/full-bleed-dialog.js.map +1 -1
  34. package/dist/components/hover-menu.js +64 -56
  35. package/dist/components/hover-menu.js.map +1 -1
  36. package/dist/components/list.d.ts +14 -2
  37. package/dist/components/list.js +3 -1
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/modal.js.map +1 -1
  40. package/dist/components/multi-entity-combobox.js +23 -17
  41. package/dist/components/multi-entity-combobox.js.map +1 -1
  42. package/dist/components/number-field.d.ts +1 -1
  43. package/dist/components/number-input.d.ts +1 -1
  44. package/dist/components/number-input.js.map +1 -1
  45. package/dist/components/numpad-sheet.d.ts +10 -2
  46. package/dist/components/numpad-sheet.js +6 -4
  47. package/dist/components/numpad-sheet.js.map +1 -1
  48. package/dist/components/page-header.d.ts +35 -6
  49. package/dist/components/page-header.js +7 -1
  50. package/dist/components/page-header.js.map +1 -1
  51. package/dist/components/picker-sheet.js +3 -0
  52. package/dist/components/picker-sheet.js.map +1 -1
  53. package/dist/components/popover.js +3 -0
  54. package/dist/components/popover.js.map +1 -1
  55. package/dist/components/settings-fields.d.ts +1 -1
  56. package/dist/components/signed-amount.d.ts +2 -1
  57. package/dist/components/signed-amount.js +1 -1
  58. package/dist/components/signed-amount.js.map +1 -1
  59. package/dist/components/stat-tile.js +1 -1
  60. package/dist/components/stat-tile.js.map +1 -1
  61. package/dist/components/swipeable-row.js +4 -3
  62. package/dist/components/swipeable-row.js.map +1 -1
  63. package/dist/components/text-link.d.ts +73 -11
  64. package/dist/components/text-link.js +45 -10
  65. package/dist/components/text-link.js.map +1 -1
  66. package/dist/components/text.d.ts +26 -2
  67. package/dist/components/text.js +16 -2
  68. package/dist/components/text.js.map +1 -1
  69. package/dist/components/time-input.d.ts +1 -1
  70. package/dist/components/toggle-group.d.ts +22 -0
  71. package/dist/components/toggle-group.js +8 -8
  72. package/dist/components/toggle-group.js.map +1 -1
  73. package/dist/components/toggle-legend.d.ts +17 -0
  74. package/dist/components/toggle-legend.js +6 -2
  75. package/dist/components/toggle-legend.js.map +1 -1
  76. package/dist/components/tooltip.d.ts +51 -1
  77. package/dist/components/tooltip.js +59 -35
  78. package/dist/components/tooltip.js.map +1 -1
  79. package/dist/components/ui.d.ts +1 -1
  80. package/dist/components/ui.js +166 -28
  81. package/dist/components/ui.js.map +1 -1
  82. package/dist/feedback/feedback-attachment.js +4 -6
  83. package/dist/feedback/feedback-attachment.js.map +1 -1
  84. package/dist/feedback/feedback-dialog.js +5 -3
  85. package/dist/feedback/feedback-dialog.js.map +1 -1
  86. package/dist/feedback/feedback-inbox.js +86 -75
  87. package/dist/feedback/feedback-inbox.js.map +1 -1
  88. package/dist/feedback/feedback-thread.d.ts +43 -3
  89. package/dist/feedback/feedback-thread.js +53 -26
  90. package/dist/feedback/feedback-thread.js.map +1 -1
  91. package/dist/feedback.d.ts +1 -1
  92. package/dist/hooks/use-file-drop.d.ts +1 -1
  93. package/dist/i18n/defaults.d.ts +1 -1
  94. package/dist/i18n/kit-labels.d.ts +1 -1
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  97. package/dist/i18n/locales/de-CH.d.ts +1 -1
  98. package/dist/i18n/locales/de-informal.d.ts +1 -1
  99. package/dist/i18n/locales/de.d.ts +1 -1
  100. package/dist/i18n/locales/de.js +9 -1
  101. package/dist/i18n/locales/de.js.map +1 -1
  102. package/dist/i18n/locales/es.d.ts +1 -1
  103. package/dist/i18n/locales/es.js +9 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +1 -1
  106. package/dist/i18n/locales/fr.js +9 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +1 -1
  109. package/dist/i18n/locales/hu.js +9 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +1 -1
  112. package/dist/i18n/locales/it.js +9 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +1 -1
  115. package/dist/i18n/locales/zh.js +9 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/index.d.ts +6 -6
  118. package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
  119. package/dist/lib/format.d.ts +23 -5
  120. package/dist/lib/format.js +4 -2
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +59 -15
  123. package/dist/rhf/fields.js +41 -11
  124. package/dist/rhf/fields.js.map +1 -1
  125. package/dist/rhf/form.d.ts +1 -1
  126. package/dist/rhf.d.ts +2 -2
  127. package/dist/rhf.js.map +1 -1
  128. package/dist/search/command-palette.js +20 -14
  129. package/dist/search/command-palette.js.map +1 -1
  130. package/dist/shell/app-shell.d.ts +1 -1
  131. package/dist/shell/app-shell.js +62 -54
  132. package/dist/shell/app-shell.js.map +1 -1
  133. package/dist/shell/top-bar-brand.d.ts +1 -1
  134. package/dist/shell/topbar-action-menu.d.ts +9 -3
  135. package/dist/shell/topbar-action-menu.js +2 -1
  136. package/dist/shell/topbar-action-menu.js.map +1 -1
  137. package/dist/shell.d.ts +1 -1
  138. package/dist/tour/tour.js +10 -4
  139. package/dist/tour/tour.js.map +1 -1
  140. package/dist/wizard/stepper-nav.d.ts +1 -1
  141. package/dist/wizard/use-wizard.js +4 -2
  142. package/dist/wizard/use-wizard.js.map +1 -1
  143. package/dist/wizard/wizard-step.d.ts +11 -1
  144. package/dist/wizard/wizard-step.js +24 -6
  145. package/dist/wizard/wizard-step.js.map +1 -1
  146. package/dist/wizard.d.ts +1 -1
  147. package/package.json +6 -6
  148. package/src/components/amount-input.tsx +165 -12
  149. package/src/components/bulk-action-bar.tsx +16 -3
  150. package/src/components/chart.tsx +19 -8
  151. package/src/components/chip.tsx +6 -0
  152. package/src/components/combobox-core.tsx +28 -9
  153. package/src/components/combobox.tsx +2 -0
  154. package/src/components/data-table-filter-popover.tsx +13 -5
  155. package/src/components/data-table.tsx +6 -0
  156. package/src/components/entity-combobox.tsx +7 -4
  157. package/src/components/error-boundary.tsx +470 -30
  158. package/src/components/field-sync.tsx +48 -40
  159. package/src/components/full-bleed-dialog.tsx +4 -0
  160. package/src/components/hover-menu.tsx +26 -17
  161. package/src/components/list.tsx +17 -3
  162. package/src/components/modal.tsx +4 -0
  163. package/src/components/multi-entity-combobox.tsx +8 -4
  164. package/src/components/number-input.tsx +1 -0
  165. package/src/components/numpad-sheet.tsx +17 -3
  166. package/src/components/page-header.tsx +42 -5
  167. package/src/components/picker-sheet.tsx +3 -0
  168. package/src/components/popover.tsx +3 -0
  169. package/src/components/signed-amount.tsx +12 -3
  170. package/src/components/stat-tile.tsx +3 -1
  171. package/src/components/swipeable-row.tsx +7 -7
  172. package/src/components/text-link.tsx +126 -18
  173. package/src/components/text.tsx +47 -2
  174. package/src/components/toggle-group.tsx +35 -10
  175. package/src/components/toggle-legend.tsx +22 -1
  176. package/src/components/tooltip.tsx +79 -8
  177. package/src/components/ui.tsx +307 -39
  178. package/src/feedback/feedback-attachment.tsx +10 -7
  179. package/src/feedback/feedback-dialog.tsx +7 -4
  180. package/src/feedback/feedback-inbox.tsx +7 -0
  181. package/src/feedback/feedback-thread.tsx +109 -33
  182. package/src/i18n/kit-labels.tsx +16 -0
  183. package/src/i18n/locales/de.ts +8 -0
  184. package/src/i18n/locales/es.ts +8 -0
  185. package/src/i18n/locales/fr.ts +8 -0
  186. package/src/i18n/locales/hu.ts +8 -0
  187. package/src/i18n/locales/it.ts +8 -0
  188. package/src/i18n/locales/zh.ts +8 -0
  189. package/src/lib/format.ts +32 -7
  190. package/src/rhf/fields.tsx +99 -16
  191. package/src/rhf.ts +1 -1
  192. package/src/search/command-palette.tsx +29 -19
  193. package/src/shell/app-shell.tsx +6 -1
  194. package/src/shell/topbar-action-menu.tsx +11 -4
  195. package/src/tour/tour.tsx +16 -5
  196. package/src/wizard/use-wizard.ts +8 -4
  197. package/src/wizard/wizard-step.tsx +38 -6
@@ -1,9 +1,11 @@
1
- import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2
- import type { ReactNode } from "react";
1
+ import { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import type { ReactNode, Ref } from "react";
3
3
  import { Paperclip, Send } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
+ import { formatRelativeTime } from "../lib/format";
5
6
  import { Button, EmptyState, Spinner, Textarea } from "../components/ui";
6
7
  import { Skeleton } from "../components/skeleton";
8
+ import { Tooltip } from "../components/tooltip";
7
9
  import { useKitFileLabels, useKitLabels, useKitLocale } from "../i18n/kit-labels";
8
10
  import { FeedbackAttachmentField } from "./feedback-attachment";
9
11
  import type { FeedbackNoteAttachment } from "./feedback-inbox";
@@ -190,13 +192,19 @@ export function FeedbackThread({
190
192
  </span>
191
193
  )}
192
194
  {date && (
193
- <time
194
- dateTime={date.toISOString()}
195
- title={new Intl.DateTimeFormat(locale, { dateStyle: "full", timeStyle: "short" }).format(date)}
196
- className="tabular-nums"
197
- >
198
- {formatRelativeTime(date, reference, locale)}
199
- </time>
195
+ // The kit's Tooltip, not a native title: one tooltip look app-wide
196
+ // (keksdose F0 / dev#523).
197
+ <Tooltip label={new Intl.DateTimeFormat(locale, { dateStyle: "full", timeStyle: "short" }).format(date)}>
198
+ <time dateTime={date.toISOString()} className="tabular-nums">
199
+ {formatRelativeTime(date, {
200
+ now: reference,
201
+ locale,
202
+ // Past the kit's day steps it is a date: "2 weeks ago" makes
203
+ // the reader do arithmetic the date already did.
204
+ absoluteAfterDays: 6.5,
205
+ })}
206
+ </time>
207
+ </Tooltip>
200
208
  )}
201
209
  </header>
202
210
  {message.body && (
@@ -287,27 +295,6 @@ function toDate(value: Date | string | number): Date | null {
287
295
  return Number.isNaN(date.getTime()) ? null : date;
288
296
  }
289
297
 
290
- const MINUTE = 60_000;
291
- const HOUR = 60 * MINUTE;
292
- const DAY = 24 * HOUR;
293
-
294
- /**
295
- * "just now", "5 minutes ago", "yesterday", "3 days ago" — `Intl.RelativeTimeFormat`
296
- * in `locale`, so the grammar is the language's. Past a week it is a date: "12 days
297
- * ago" makes the reader do arithmetic that the date already did. The full date and
298
- * time are on the `<time>`'s title.
299
- */
300
- function formatRelativeTime(date: Date, now: number, locale: string | undefined): string {
301
- const diff = date.getTime() - now;
302
- const abs = Math.abs(diff);
303
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
304
- if (abs < 45_000) return rtf.format(0, "second");
305
- if (abs < 45 * MINUTE) return rtf.format(Math.round(diff / MINUTE), "minute");
306
- if (abs < 22 * HOUR) return rtf.format(Math.round(diff / HOUR), "hour");
307
- if (abs < 7 * DAY) return rtf.format(Math.round(diff / DAY), "day");
308
- return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
309
- }
310
-
311
298
  /* ── Composer ─────────────────────────────────────────────────────────────── */
312
299
 
313
300
  /** What the composer's attach control takes — {@link FeedbackNoteAttachment} with the
@@ -332,10 +319,51 @@ export interface FeedbackComposerProps {
332
319
  * reader. */
333
320
  disabledReason?: ReactNode;
334
321
  rows?: number;
322
+ /**
323
+ * The box's hint, over the `placeholder` label — keksdose F9: an admin answering a
324
+ * support ticket is asked something else ("Reply to Anna…") than a member adding to
325
+ * their own report, and one provider-wide label cannot say both.
326
+ */
327
+ placeholder?: string;
328
+ /**
329
+ * The draft, controlled — for a host that keeps it itself (saves it per ticket,
330
+ * prefills a template). Left out, the composer keeps its own. Either way a send
331
+ * that resolves clears only what was sent: controlled, that is an
332
+ * `onValueChange("")` the host receives like any keystroke, and only if the draft
333
+ * is still the one that went out.
334
+ */
335
+ value?: string;
336
+ /** Every change to the draft — typing, {@link FeedbackComposerHandle.insertText}, the
337
+ * clear after a send. Required to change a controlled `value`. */
338
+ onValueChange?: (value: string) => void;
339
+ /** {@link FeedbackComposerHandle}: put text into the draft from outside (canned-reply
340
+ * chips) or focus the box. */
341
+ ref?: Ref<FeedbackComposerHandle>;
335
342
  labels?: Partial<FeedbackComposerLabels>;
336
343
  className?: string;
337
344
  }
338
345
 
346
+ /**
347
+ * What a {@link FeedbackComposer} `ref` holds.
348
+ *
349
+ * keksdose F9: the support admin answers the same five questions all day and wants
350
+ * chips above the box ("Thanks, fixed in the next release") that drop their text in
351
+ * where the caret is. A controlled `value` alone cannot do that well — the host sees
352
+ * the string, not the caret, so it could only append — so the composer, which owns
353
+ * the textarea, does the splice.
354
+ */
355
+ export interface FeedbackComposerHandle {
356
+ /**
357
+ * Insert `text` at the caret, replacing any selection (the caret the box last had:
358
+ * a click on a chip blurs it, and the selection survives the blur), then focus the
359
+ * box with the caret after the inserted text so the reply can go on. Nothing is
360
+ * added around it — put a space or a newline in `text` if the reply needs one. A
361
+ * no-op while `disabledReason` replaces the box.
362
+ */
363
+ insertText: (text: string) => void;
364
+ focus: () => void;
365
+ }
366
+
339
367
  const noSubscribe = () => () => {};
340
368
  const isApple = () =>
341
369
  typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent);
@@ -352,13 +380,60 @@ export function FeedbackComposer({
352
380
  attachment,
353
381
  disabledReason,
354
382
  rows = 3,
383
+ placeholder,
384
+ value,
385
+ onValueChange,
386
+ ref,
355
387
  labels: labelsProp,
356
388
  className,
357
389
  }: FeedbackComposerProps) {
358
390
  const labels = useKitLabels("feedbackComposer", DEFAULT_FEEDBACK_COMPOSER_LABELS, labelsProp);
359
- const [draft, setDraft] = useState("");
391
+ const [ownDraft, setOwnDraft] = useState("");
392
+ const controlled = value !== undefined;
393
+ const draft = controlled ? value : ownDraft;
360
394
  const [file, setFile] = useState<File | null>(null);
361
395
  const root = useRef<HTMLDivElement>(null);
396
+ const box = useRef<HTMLTextAreaElement>(null);
397
+ // The draft as of the last commit. The send's `.then` and `insertText` run after
398
+ // renders their closure did not see — text typed while the send was in flight, a
399
+ // second chip clicked before the first one's render — and must not act on a stale
400
+ // string (for a controlled draft there is no updater function to lean on).
401
+ const latest = useRef(draft);
402
+ useLayoutEffect(() => {
403
+ latest.current = draft;
404
+ });
405
+ // Where the caret goes once an inserted text has rendered.
406
+ const caret = useRef<number | null>(null);
407
+ useLayoutEffect(() => {
408
+ const el = box.current;
409
+ if (caret.current === null || !el) return;
410
+ el.focus();
411
+ el.setSelectionRange(caret.current, caret.current);
412
+ caret.current = null;
413
+ });
414
+
415
+ const setDraft = (next: string) => {
416
+ latest.current = next;
417
+ if (!controlled) setOwnDraft(next);
418
+ onValueChange?.(next);
419
+ };
420
+
421
+ useImperativeHandle(ref, () => ({
422
+ insertText: (text) => {
423
+ const el = box.current;
424
+ if (!el) return;
425
+ const current = latest.current;
426
+ // A second insert before the first has rendered goes after the first, not at the
427
+ // textarea's not-yet-moved caret. Otherwise the textarea's selection, which may
428
+ // be past the end of a controlled value the host has just shortened: clamp it.
429
+ const pending = caret.current;
430
+ const start = Math.min(pending ?? el.selectionStart ?? current.length, current.length);
431
+ const end = pending ?? Math.max(start, Math.min(el.selectionEnd ?? start, current.length));
432
+ caret.current = start + text.length;
433
+ setDraft(current.slice(0, start) + text + current.slice(end));
434
+ },
435
+ focus: () => box.current?.focus(),
436
+ }));
362
437
  // The server snapshot is "Ctrl"; the client corrects it after hydration.
363
438
  const modifier = useSyncExternalStore(
364
439
  noSubscribe,
@@ -377,7 +452,7 @@ export function FeedbackComposer({
377
452
  const sentFile = file;
378
453
  Promise.resolve(onSend(draft.trim(), file))
379
454
  .then(() => {
380
- setDraft((current) => (current === sentDraft ? "" : current));
455
+ if (latest.current === sentDraft) setDraft("");
381
456
  setFile((current) => (current === sentFile ? null : current));
382
457
  })
383
458
  .catch(() => {});
@@ -391,7 +466,8 @@ export function FeedbackComposer({
391
466
  rows={rows}
392
467
  dir="auto"
393
468
  aria-label={labels.field}
394
- placeholder={labels.placeholder}
469
+ ref={box}
470
+ placeholder={placeholder ?? labels.placeholder}
395
471
  value={draft}
396
472
  onChange={(event) => setDraft(event.target.value)}
397
473
  onKeyDown={(event) => {
@@ -423,10 +423,26 @@ interface KitI18n {
423
423
  * What every kit link receives: the anchor attributes the kit decided (href, class,
424
424
  * aria-current, handlers, children, ref). An app's router link maps them —
425
425
  * `({ href, ...p }) => <Link to={href} {...p} />` — ONCE, on the provider.
426
+ *
427
+ * `replace` is the one prop that is not an anchor attribute: see there. Map it
428
+ * explicitly if your router link is not react-router's —
429
+ * `({ href, replace, ...p }) => <Link to={href} replace={replace} {...p} />` — and never
430
+ * spread it onto a DOM `<a>`, where React warns about an unknown attribute.
426
431
  */
427
432
  export type KitLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
428
433
  href: string;
429
434
  ref?: Ref<HTMLAnchorElement>;
435
+ /**
436
+ * Navigate by REPLACING the current history entry, so Back skips it — keksdose F1:
437
+ * a one-shot URL (a magic-link landing, `?checkout=done`, a wizard's "finish" hop)
438
+ * that must not come back when the user presses Back. Present ONLY when the kit link
439
+ * was given `replace` (`true`), so a provider link that spreads its props onto a
440
+ * plain `<a>` keeps working for every link that never asks for it. react-router's
441
+ * `<Link>` takes the same prop, so `({ href, ...p }) => <Link to={href} {...p} />`
442
+ * honours it with no change; any other router maps it to its own replace option.
443
+ * The kit never puts it on a DOM element itself.
444
+ */
445
+ replace?: boolean;
430
446
  };
431
447
  export type KitLinkComponent = (props: KitLinkProps) => ReactElement;
432
448
 
@@ -553,6 +553,14 @@ export function uiKitLabelsDe(numberLocale = "de-DE"): UiKitLabels {
553
553
  message: "Dieser Teil der Seite konnte nicht angezeigt werden. Bitte erneut versuchen oder die Seite neu laden.",
554
554
  retry: "Erneut versuchen",
555
555
  details: "Fehlerdetails",
556
+ reload: "Neu laden",
557
+ updateTitle: "Eine neue Version ist verfügbar",
558
+ updateMessage: "Ein Teil der App hat sich seit dem Laden dieser Seite geändert. Zum Aktualisieren die Seite neu laden.",
559
+ offlineTitle: "Keine Internetverbindung",
560
+ offlineMessage: "Diese Seite konnte ohne Verbindung nicht geladen werden. Nach dem Wiederherstellen der Verbindung die Seite neu laden.",
561
+ copyReport: "Fehlerbericht kopieren",
562
+ reported: "Der Fehler wurde automatisch gemeldet.",
563
+ reportedAs: (reference) => `Gemeldet unter ${reference}`,
556
564
  },
557
565
  authedImage: {
558
566
  loading: "Bild wird geladen…",
@@ -543,6 +543,14 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
543
543
  message: "No se pudo mostrar esta parte de la página. Inténtelo de nuevo o recargue la página.",
544
544
  retry: "Reintentar",
545
545
  details: "Detalles del error",
546
+ reload: "Recargar",
547
+ updateTitle: "Hay una nueva versión disponible",
548
+ updateMessage: "Una parte de la aplicación ha cambiado desde que se cargó esta página. Recárguela para obtener la nueva versión.",
549
+ offlineTitle: "Sin conexión",
550
+ offlineMessage: "No se pudo cargar esta página sin conexión. Vuelva a conectarse y recargue la página.",
551
+ copyReport: "Copiar informe de error",
552
+ reported: "El error se ha notificado automáticamente.",
553
+ reportedAs: (reference) => `Notificado con la referencia ${reference}`,
546
554
  },
547
555
  authedImage: {
548
556
  loading: "Cargando imagen…",
@@ -559,6 +559,14 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
559
559
  message: "Cette partie de la page n’a pas pu s’afficher. Réessayez ou rechargez la page.",
560
560
  retry: "Réessayer",
561
561
  details: "Détails de l’erreur",
562
+ reload: "Recharger",
563
+ updateTitle: "Une nouvelle version est disponible",
564
+ updateMessage: "Une partie de l’application a changé depuis le chargement de cette page. Rechargez-la pour obtenir la nouvelle version.",
565
+ offlineTitle: "Vous êtes hors ligne",
566
+ offlineMessage: "Cette page n’a pas pu être chargée sans connexion. Reconnectez-vous, puis rechargez la page.",
567
+ copyReport: "Copier le rapport d’erreur",
568
+ reported: "L’erreur a été signalée automatiquement.",
569
+ reportedAs: (reference) => `Signalée sous la référence ${reference}`,
562
570
  },
563
571
  authedImage: {
564
572
  loading: "Chargement de l’image…",
@@ -545,6 +545,14 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
545
545
  message: "Az oldal ezen része nem jeleníthető meg. Próbálja újra, vagy töltse be újra az oldalt.",
546
546
  retry: "Újrapróbálás",
547
547
  details: "Hiba részletei",
548
+ reload: "Újratöltés",
549
+ updateTitle: "Új verzió érhető el",
550
+ updateMessage: "Az alkalmazás egy része megváltozott az oldal betöltése óta. Az új verzióhoz töltse be újra az oldalt.",
551
+ offlineTitle: "Nincs internetkapcsolat",
552
+ offlineMessage: "Az oldal kapcsolat nélkül nem tölthető be. A kapcsolat helyreállása után töltse be újra az oldalt.",
553
+ copyReport: "Hibajelentés másolása",
554
+ reported: "A hibát automatikusan jelentettük.",
555
+ reportedAs: (reference) => `Bejelentés azonosítója: ${reference}`,
548
556
  },
549
557
  authedImage: {
550
558
  loading: "Kép betöltése…",
@@ -546,6 +546,14 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
546
546
  message: "Impossibile mostrare questa parte della pagina. Riprova o ricarica la pagina.",
547
547
  retry: "Riprova",
548
548
  details: "Dettagli dell’errore",
549
+ reload: "Ricarica",
550
+ updateTitle: "È disponibile una nuova versione",
551
+ updateMessage: "Una parte dell’app è cambiata da quando questa pagina è stata caricata. Ricarica per ottenere la nuova versione.",
552
+ offlineTitle: "Sei offline",
553
+ offlineMessage: "Impossibile caricare questa pagina senza connessione. Riconnettiti, poi ricarica la pagina.",
554
+ copyReport: "Copia il rapporto di errore",
555
+ reported: "L’errore è stato segnalato automaticamente.",
556
+ reportedAs: (reference) => `Segnalato con riferimento ${reference}`,
549
557
  },
550
558
  authedImage: {
551
559
  loading: "Caricamento immagine…",
@@ -533,6 +533,14 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
533
533
  message: "无法显示页面的这一部分。请重试或重新加载页面。",
534
534
  retry: "重试",
535
535
  details: "错误详情",
536
+ reload: "重新加载",
537
+ updateTitle: "有新版本可用",
538
+ updateMessage: "自此页面加载以来,应用的部分内容已更新。请重新加载以获取新版本。",
539
+ offlineTitle: "当前处于离线状态",
540
+ offlineMessage: "没有网络连接,无法加载此页面。请恢复连接后重新加载页面。",
541
+ copyReport: "复制错误报告",
542
+ reported: "此错误已自动报告。",
543
+ reportedAs: (reference) => `报告编号:${reference}`,
536
544
  },
537
545
  authedImage: {
538
546
  loading: "正在加载图片…",
package/src/lib/format.ts CHANGED
@@ -37,7 +37,8 @@ interface BaseOptions {
37
37
  empty?: string;
38
38
  }
39
39
 
40
- export interface FormatNumberOptions extends BaseOptions {
40
+ /** The options every number formatter shares (money and percent carry their own unit). */
41
+ export interface FormatNumberBaseOptions extends BaseOptions {
41
42
  digits?: FormatDigits;
42
43
  /** Compact notation: 12 400 → "12K". */
43
44
  compact?: boolean;
@@ -47,12 +48,33 @@ export interface FormatNumberOptions extends BaseOptions {
47
48
  options?: Intl.NumberFormatOptions;
48
49
  }
49
50
 
50
- export interface FormatMoneyOptions extends FormatNumberOptions {
51
+ /** The options {@link formatNumber} takes: the shared ones plus a free-text `unit`. */
52
+ export interface FormatNumberOptions extends FormatNumberBaseOptions {
53
+ /**
54
+ * A unit printed after the figure, joined by a narrow no-break space (U+202F):
55
+ * "3.400 N", "12 kg", "4,5 m²". lenkbank (P8) prints forces, masses and lengths in
56
+ * every table and summary, and each call site glued `${n} N` together with a plain
57
+ * space — which wraps the "N" onto the next line in a narrow column and reads loose
58
+ * beside the digit grouping. The narrow no-break space is the SI brochure's and the
59
+ * Swiss typesetting rule's separator between value and unit, and it never breaks.
60
+ *
61
+ * Free text, not `Intl`'s `style: "unit"`: that one only knows the ECMA-402
62
+ * sanctioned units (no "N", no "kN", no "m³/h"), so an app would still be gluing.
63
+ * A missing value prints `empty` ALONE — "— N" says there is a newton-shaped
64
+ * nothing, which is noise in a column that already heads its unit.
65
+ */
66
+ unit?: string;
67
+ }
68
+
69
+ /** Narrow no-break space (U+202F): between a figure and its {@link FormatNumberOptions.unit}. */
70
+ const UNIT_SEPARATOR = "\u202F";
71
+
72
+ export interface FormatMoneyOptions extends FormatNumberBaseOptions {
51
73
  /** `"symbol"` (default), `"narrowSymbol"`, `"code"` ("CHF 12.00") or `"name"`. */
52
74
  currencyDisplay?: Intl.NumberFormatOptions["currencyDisplay"];
53
75
  }
54
76
 
55
- export interface FormatPercentOptions extends FormatNumberOptions {
77
+ export interface FormatPercentOptions extends FormatNumberBaseOptions {
56
78
  /**
57
79
  * `true` (default): `value` is a RATIO, as `Intl` and `StatTile`'s percent delta read
58
80
  * it — `0.12` is 12 %. `false`: it is already a percentage — `12` is 12 %
@@ -83,7 +105,7 @@ function digitOptions(digits: FormatDigits | undefined): Intl.NumberFormatOption
83
105
  };
84
106
  }
85
107
 
86
- function numberOptions(o: FormatNumberOptions): Intl.NumberFormatOptions {
108
+ function numberOptions(o: FormatNumberBaseOptions): Intl.NumberFormatOptions {
87
109
  return {
88
110
  ...digitOptions(o.digits),
89
111
  ...(o.compact ? { notation: "compact" } : null),
@@ -93,13 +115,16 @@ function numberOptions(o: FormatNumberOptions): Intl.NumberFormatOptions {
93
115
  }
94
116
 
95
117
  function isMissing(value: number | null | undefined): value is null | undefined {
96
- return value === null || value === undefined || Number.isNaN(value);
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);
97
121
  }
98
122
 
99
- /** A number in `locale`: "1,234.5", "1.234,5", "1’234.5". */
123
+ /** A number in `locale`: "1,234.5", "1.234,5", "1’234.5" — with `unit`, "3.400 N". */
100
124
  export function formatNumber(value: number | null | undefined, options: FormatNumberOptions = {}): string {
101
125
  if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;
102
- return numberFormatter(options.locale, numberOptions(options)).format(value);
126
+ const figure = numberFormatter(options.locale, numberOptions(options)).format(value);
127
+ return options.unit ? `${figure}${UNIT_SEPARATOR}${options.unit}` : figure;
103
128
  }
104
129
 
105
130
  /** An amount of `currency` (ISO 4217) in `locale`: "CHF 1’234.50", "1.234,50 €". */