@eifi1/ui-kit 0.51.6 → 0.51.7

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 (124) hide show
  1. package/README.md +5 -3
  2. package/dist/admin/admin-action-confirm.js +2 -4
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/auth/complete-name-dialog.d.ts +3 -2
  5. package/dist/auth/complete-name-dialog.js +1 -4
  6. package/dist/auth/complete-name-dialog.js.map +1 -1
  7. package/dist/billing/cancel-request.d.ts +11 -2
  8. package/dist/billing/cancel-request.js +15 -3
  9. package/dist/billing/cancel-request.js.map +1 -1
  10. package/dist/billing/cancel-subscription.d.ts +2 -2
  11. package/dist/billing/cancel-subscription.js +2 -7
  12. package/dist/billing/cancel-subscription.js.map +1 -1
  13. package/dist/billing/plan-switch.d.ts +2 -2
  14. package/dist/billing/plan-switch.js +2 -4
  15. package/dist/billing/plan-switch.js.map +1 -1
  16. package/dist/components/amount-input.d.ts +9 -4
  17. package/dist/components/amount-input.js +2 -3
  18. package/dist/components/amount-input.js.map +1 -1
  19. package/dist/components/calculator.d.ts +21 -2
  20. package/dist/components/calculator.js +53 -14
  21. package/dist/components/calculator.js.map +1 -1
  22. package/dist/components/combobox-core.d.ts +8 -5
  23. package/dist/components/combobox-core.js +3 -4
  24. package/dist/components/combobox-core.js.map +1 -1
  25. package/dist/components/combobox.js +2 -2
  26. package/dist/components/combobox.js.map +1 -1
  27. package/dist/components/data-table-mobile.d.ts +24 -0
  28. package/dist/components/data-table-mobile.js +20 -1
  29. package/dist/components/data-table-mobile.js.map +1 -1
  30. package/dist/components/data-table.d.ts +13 -5
  31. package/dist/components/data-table.js +35 -9
  32. package/dist/components/data-table.js.map +1 -1
  33. package/dist/components/dialog-frame.d.ts +2 -1
  34. package/dist/components/dialog-frame.js.map +1 -1
  35. package/dist/components/entity-combobox.js +4 -2
  36. package/dist/components/entity-combobox.js.map +1 -1
  37. package/dist/components/inline-entity-combobox.js +4 -3
  38. package/dist/components/inline-entity-combobox.js.map +1 -1
  39. package/dist/components/list.js +1 -1
  40. package/dist/components/list.js.map +1 -1
  41. package/dist/components/modal.d.ts +24 -0
  42. package/dist/components/modal.js +9 -3
  43. package/dist/components/modal.js.map +1 -1
  44. package/dist/components/money-draft.js +7 -5
  45. package/dist/components/money-draft.js.map +1 -1
  46. package/dist/components/money-field.d.ts +2 -2
  47. package/dist/components/money-field.js.map +1 -1
  48. package/dist/components/multi-entity-combobox.js +2 -1
  49. package/dist/components/multi-entity-combobox.js.map +1 -1
  50. package/dist/components/number-field.d.ts +6 -4
  51. package/dist/components/number-field.js +3 -2
  52. package/dist/components/number-field.js.map +1 -1
  53. package/dist/components/number-input.d.ts +4 -1
  54. package/dist/components/number-input.js +3 -2
  55. package/dist/components/number-input.js.map +1 -1
  56. package/dist/components/numpad-sheet.js.map +1 -1
  57. package/dist/components/reauth-dialog.d.ts +4 -5
  58. package/dist/components/reauth-dialog.js +1 -4
  59. package/dist/components/reauth-dialog.js.map +1 -1
  60. package/dist/components/series-chart-axes.d.ts +7 -0
  61. package/dist/components/series-chart-axes.js +1 -0
  62. package/dist/components/series-chart-axes.js.map +1 -1
  63. package/dist/components/tabs.js +12 -10
  64. package/dist/components/tabs.js.map +1 -1
  65. package/dist/eslint/index.js +1 -1
  66. package/dist/hooks/use-dismiss.js.map +1 -1
  67. package/dist/hooks/use-overlay-history.d.ts +7 -1
  68. package/dist/hooks/use-overlay-history.js +16 -2
  69. package/dist/hooks/use-overlay-history.js.map +1 -1
  70. package/dist/lib/calc.d.ts +7 -1
  71. package/dist/lib/calc.js +3 -1
  72. package/dist/lib/calc.js.map +1 -1
  73. package/dist/lib/clipboard-minus.d.ts +2 -2
  74. package/dist/lib/clipboard-minus.js +1 -1
  75. package/dist/lib/clipboard-minus.js.map +1 -1
  76. package/dist/lib/decimal-marks.d.ts +7 -1
  77. package/dist/lib/decimal-marks.js +5 -2
  78. package/dist/lib/decimal-marks.js.map +1 -1
  79. package/dist/lib/format.d.ts +5 -3
  80. package/dist/lib/format.js +12 -0
  81. package/dist/lib/format.js.map +1 -1
  82. package/dist/lib/text-match.d.ts +9 -0
  83. package/dist/lib/text-match.js +4 -0
  84. package/dist/lib/text-match.js.map +1 -1
  85. package/dist/tour/tour.d.ts +3 -1
  86. package/dist/tour/tour.js +9 -3
  87. package/dist/tour/tour.js.map +1 -1
  88. package/dist/tour/use-tour-url-sync.js +2 -2
  89. package/dist/tour/use-tour-url-sync.js.map +1 -1
  90. package/package.json +3 -3
  91. package/src/admin/admin-action-confirm.tsx +4 -5
  92. package/src/auth/complete-name-dialog.tsx +5 -7
  93. package/src/billing/cancel-request.ts +28 -6
  94. package/src/billing/cancel-subscription.tsx +4 -11
  95. package/src/billing/plan-switch.tsx +5 -8
  96. package/src/components/amount-input.tsx +14 -7
  97. package/src/components/calculator.tsx +98 -17
  98. package/src/components/combobox-core.tsx +10 -7
  99. package/src/components/combobox.tsx +5 -6
  100. package/src/components/data-table-mobile.tsx +49 -1
  101. package/src/components/data-table.tsx +66 -11
  102. package/src/components/dialog-frame.tsx +2 -1
  103. package/src/components/entity-combobox.tsx +4 -1
  104. package/src/components/inline-entity-combobox.tsx +5 -3
  105. package/src/components/list.tsx +4 -1
  106. package/src/components/modal.tsx +39 -3
  107. package/src/components/money-draft.ts +16 -5
  108. package/src/components/money-field.tsx +2 -2
  109. package/src/components/multi-entity-combobox.tsx +1 -0
  110. package/src/components/number-field.tsx +14 -7
  111. package/src/components/number-input.tsx +9 -3
  112. package/src/components/numpad-sheet.tsx +4 -3
  113. package/src/components/reauth-dialog.tsx +6 -10
  114. package/src/components/series-chart-axes.ts +8 -0
  115. package/src/components/tabs.tsx +33 -17
  116. package/src/hooks/use-dismiss.ts +3 -2
  117. package/src/hooks/use-overlay-history.ts +38 -2
  118. package/src/lib/calc.ts +13 -2
  119. package/src/lib/clipboard-minus.ts +14 -4
  120. package/src/lib/decimal-marks.ts +12 -3
  121. package/src/lib/format.ts +30 -3
  122. package/src/lib/text-match.ts +12 -0
  123. package/src/tour/tour.tsx +21 -4
  124. package/src/tour/use-tour-url-sync.ts +7 -3
@@ -1,4 +1,4 @@
1
- import { Fragment, isValidElement, useEffect, useId, useMemo, useRef, useState } from "react";
1
+ import { Fragment, isValidElement, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
3
3
  import { ArrowDownWideNarrow, ArrowUpNarrowWide, ChevronDown, ChevronRight, Filter } from "lucide-react";
4
4
  import { FOCUS_RING } from "./focus-ring";
@@ -776,6 +776,54 @@ export function MobileRow<T>({
776
776
  );
777
777
  }
778
778
 
779
+ // ---------- The card list's leading row (0.51.7, audit 2026-10-30 DSP-2) ----------
780
+
781
+ /**
782
+ * DataTable's `leadingRow` on a phone: the card list's first item. It was desktop-only
783
+ * and the card list dropped it without a word — kastlan's inline "Add meter" editor
784
+ * never appeared (the page's button disabled itself, its only create path gone), and
785
+ * keksdose kept an `md:hidden` copy of its hidden-accounts toggle above the table.
786
+ *
787
+ * A plain `<li>`: the list is a `<ul>`, and axe's `list` rule fails an `<li
788
+ * role="none">` in one (checked against axe 4.13), so it is one more item, as the
789
+ * section headers, the reveal sentinel and the load-more footer are. It is no card — no
790
+ * row link, swipe or long press, no toggle in selection mode, no `rowAttributes` — and
791
+ * selection, the reveal and the row count work on the rows, not on the list's items.
792
+ *
793
+ * `sticky`: pinned at the top of whatever scrolls the list (the list itself under a
794
+ * bounded `fillHeight`, else the page), opaque over the cards passing beneath it, and
795
+ * over the section headers (`z-10`) — which stand under it, at the height `onHeight`
796
+ * reports, measured and followed (an inline editor wraps at Large text). The desktop
797
+ * stacks the same way: its leading row over the group bands.
798
+ */
799
+ export function MobileLeadingRow({
800
+ sticky,
801
+ onHeight,
802
+ children,
803
+ }: {
804
+ sticky: boolean;
805
+ /** Under `sticky`: the row's height, and each change of it. */
806
+ onHeight?: (height: number) => void;
807
+ children: ReactNode;
808
+ }) {
809
+ const ref = useRef<HTMLLIElement>(null);
810
+ useLayoutEffect(() => {
811
+ const el = ref.current;
812
+ if (!sticky || !onHeight || !el) return;
813
+ const measure = () => onHeight(el.getBoundingClientRect().height);
814
+ measure();
815
+ if (typeof ResizeObserver === "undefined") return;
816
+ const observer = new ResizeObserver(measure);
817
+ observer.observe(el);
818
+ return () => observer.disconnect();
819
+ }, [sticky, onHeight]);
820
+ return (
821
+ <li ref={ref} data-leading-row={sticky ? "sticky" : ""} className={cn(sticky && "sticky top-0 z-[11] bg-surface")}>
822
+ {children}
823
+ </li>
824
+ );
825
+ }
826
+
779
827
  // ---------- Phone selection bar (0.40, keksdose live #421) ----------
780
828
 
781
829
  /**
@@ -8,6 +8,7 @@ import { useRangeSelection } from "./data-table-selection";
8
8
  import { columnLabel, logicalAlign, resolveDataTableLabels } from "./data-table-shared";
9
9
  import {
10
10
  MobileFilters,
11
+ MobileLeadingRow,
11
12
  MobileSelectBar,
12
13
  MobileSort,
13
14
  StackedFields,
@@ -67,6 +68,10 @@ export interface DataTableColumn<T> {
67
68
  key: string;
68
69
  header: ReactNode;
69
70
  cell: (row: T) => ReactNode;
71
+ /** What a client sort orders the column by. Strings collate in the table's `locale`
72
+ * (digits as numbers, case and accents left out; 0.51.7, audit 2026-10-30 DSP-3),
73
+ * numbers compare as numbers and sort before strings, `null` and `undefined` sort
74
+ * last in either direction. */
70
75
  sortBy?: (row: T) => string | number | null | undefined;
71
76
  filter?: ColumnFilter<T>;
72
77
  filterBy?: (row: T) => string;
@@ -618,8 +623,10 @@ export interface DataTableProps<T> {
618
623
  */
619
624
  desktopSwipeActions?: boolean | DataTableDesktopSwipeActions<T>;
620
625
  /**
621
- * Renders as a full-width row above the data rows in the desktop table
622
- * (mobile has no equivalent list-row slot, so it's desktop-only). For a
626
+ * Renders as a full-width row above the data rows in the desktop table, and since
627
+ * 0.51.7 as the first item of the phone's card list (audit 2026-10-30 DSP-2: the card
628
+ * list dropped it, and kastlan's inline "Add meter" editor never appeared on a phone).
629
+ * On the phone it is no card: no row link, swipe or selection toggle. For a
623
630
  * toggle that shows/hides a slice of rows, style it as an expand/collapse
624
631
  * disclosure (chevron + label) so it reads as part of the table rather
625
632
  * than a floating control above it. See feedback #10/#11 (transactions'
@@ -629,9 +636,11 @@ export interface DataTableProps<T> {
629
636
  /**
630
637
  * 0.48 (keksdose dev #603: "it should rather be sticky, so the user can show hide them
631
638
  * everywhere on click"): pin the {@link leadingRow} under the sticky header while the
632
- * rows scroll beneath both — its `top` is the header's measured height. Desktop only,
633
- * as `leadingRow` is; it sticks within the table's own scroller (a bounded body:
634
- * `fillHeight` or `maxBodyHeight`). Default: it scrolls with the rows.
639
+ * rows scroll beneath both — its `top` is the header's measured height. It sticks
640
+ * within the table's own scroller (a bounded body: `fillHeight` or `maxBodyHeight`).
641
+ * On a phone (0.51.7) it is pinned at the top of the card list's scroller (the list
642
+ * under a bounded `fillHeight`, else the page), with `mobileGroupBy`'s section headers
643
+ * under it. Default: it scrolls with the rows.
635
644
  */
636
645
  leadingRowSticky?: boolean;
637
646
  /**
@@ -953,6 +962,38 @@ export const DEFAULT_DATA_TABLE_SORT_LABELS: DataTableSortLabels = {
953
962
 
954
963
  const flipDir = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
955
964
 
965
+ /**
966
+ * The client sort's and the derived select options' string order (audit 2026-10-30
967
+ * DSP-3): the table locale's collation, digits as numbers ("Item 2" before "Item 10"),
968
+ * case and accents left out (two values that differ only by them go to the next sort
969
+ * key, then keep the order given). `<` and `Array.sort()` compared UTF-16 code units —
970
+ * every lower-case initial after every capital, Ä/Ö/Ü after Z — on the payees, names,
971
+ * insurers and titles all three apps sort. Built once per table locale, not per
972
+ * comparison: `localeCompare` with a locale sets one up on every call, n·log n times a
973
+ * sort. An unknown tag (a typo in a provider's `locale`) throws a RangeError, and a
974
+ * sort is no reason for a table to stop rendering: the runtime's own collation then,
975
+ * as CountrySelect falls back.
976
+ */
977
+ function sortCollator(locale: string | undefined): Intl.Collator {
978
+ const options: Intl.CollatorOptions = { numeric: true, sensitivity: "base" };
979
+ try {
980
+ return new Intl.Collator(locale, options);
981
+ } catch {
982
+ return new Intl.Collator(undefined, options);
983
+ }
984
+ }
985
+
986
+ /** Two present `sortBy` values, ascending. Strings collate; a number sorts before a
987
+ * string (`<` called that pair equal, so they stayed wherever they were given); any
988
+ * other pair — numbers, and a `Date` an untyped caller returns — keeps `<` / `>`. */
989
+ function compareSortValues(a: string | number, b: string | number, collator: Intl.Collator): number {
990
+ const aText = typeof a === "string";
991
+ const bText = typeof b === "string";
992
+ if (aText && bText) return collator.compare(a, b);
993
+ if (aText !== bText) return aText ? 1 : -1;
994
+ return a < b ? -1 : a > b ? 1 : 0;
995
+ }
996
+
956
997
  // `virtualize`'s constants (0.48.2): the band levels a group's `<tbody>` opens with.
957
998
  const NO_BANDS: readonly (1 | 2)[] = [];
958
999
  const BAND_1: readonly (1 | 2)[] = [1];
@@ -1052,6 +1093,7 @@ export function DataTable<T>({
1052
1093
  const storageKeyPrefix = storageKeyPrefixProp ?? providerStorageKeyPrefix ?? DEFAULT_PERSIST_PREFIX;
1053
1094
  const labels = useMemo(() => resolveDataTableLabels(labelOverrides, labelsProp), [labelOverrides, labelsProp]);
1054
1095
  const locale = useKitLocale(localeProp);
1096
+ const collator = useMemo(() => sortCollator(locale), [locale]);
1055
1097
 
1056
1098
  // ---- Row actions ---- (data-table-row-actions.tsx)
1057
1099
  // The actions column joins the caller's columns; on a phone the same actions are the
@@ -1159,13 +1201,13 @@ export function DataTable<T>({
1159
1201
  if (v && !seen.has(v)) seen.add(v);
1160
1202
  }
1161
1203
  map[col.key] = Array.from(seen)
1162
- .sort()
1204
+ .sort(collator.compare)
1163
1205
  .map((v) => ({ value: v, label: v }));
1164
1206
  }
1165
1207
  return map;
1166
1208
  // `columns` by signature — see the note above the signatures.
1167
1209
  // eslint-disable-next-line react-hooks/exhaustive-deps
1168
- }, [columnSig, selectOptionsSig, rows]);
1210
+ }, [columnSig, selectOptionsSig, rows, collator]);
1169
1211
 
1170
1212
  const filtered = useMemo(() => {
1171
1213
  if (serverRows) return rows;
@@ -1198,14 +1240,14 @@ export function DataTable<T>({
1198
1240
  if (av == null && bv == null) continue;
1199
1241
  if (av == null) return 1;
1200
1242
  if (bv == null) return -1;
1201
- if (av < bv) return dir === "asc" ? -1 : 1;
1202
- if (av > bv) return dir === "asc" ? 1 : -1;
1243
+ const order = compareSortValues(av, bv, collator);
1244
+ if (order !== 0) return dir === "asc" ? order : -order;
1203
1245
  }
1204
1246
  return 0;
1205
1247
  });
1206
1248
  return copy;
1207
1249
  // eslint-disable-next-line react-hooks/exhaustive-deps
1208
- }, [filtered, sorts, columnSig, serverRows]);
1250
+ }, [filtered, sorts, columnSig, serverRows, collator]);
1209
1251
 
1210
1252
  // `paginated={false}` is expressed as "page size = everything" rather than as a
1211
1253
  // second code path, so slicing, the page clamp and the range summary all keep
@@ -1541,6 +1583,9 @@ export function DataTable<T>({
1541
1583
  if (el.parentElement) ro.observe(el.parentElement);
1542
1584
  return () => ro.disconnect();
1543
1585
  }, [phoneFill]);
1586
+ // A sticky `leadingRow`'s height on a phone (audit 2026-10-30 DSP-2): the section
1587
+ // headers stick under it, as the desktop's group bands stick under the desktop row.
1588
+ const [phoneLeadHeight, setPhoneLeadHeight] = useState(0);
1544
1589
 
1545
1590
  const { mobileSlice, canRevealMoreMobile, loadMoreRef } = useMobileReveal({
1546
1591
  rows: sorted,
@@ -1894,10 +1939,20 @@ export function DataTable<T>({
1894
1939
  )}
1895
1940
  aria-busy={isLoading || undefined}
1896
1941
  >
1942
+ {/* The desktop's leading row, as the list's first item (0.51.7, audit
1943
+ 2026-10-30 DSP-2; MobileLeadingRow says what it is and is not). */}
1944
+ {leadingRow ? (
1945
+ <MobileLeadingRow sticky={leadingRowSticky} onHeight={setPhoneLeadHeight}>
1946
+ {leadingRow}
1947
+ </MobileLeadingRow>
1948
+ ) : null}
1897
1949
  {mobileGroups
1898
1950
  ? mobileGroups.map((g) => (
1899
1951
  <Fragment key={`hb-group:${g.key}`}>
1900
- <li className="sticky top-0 z-10 flex flex-wrap items-baseline justify-between gap-x-3 bg-surface-2 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted backdrop-blur">
1952
+ <li
1953
+ className="sticky top-0 z-10 flex flex-wrap items-baseline justify-between gap-x-3 bg-surface-2 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted backdrop-blur"
1954
+ style={leadingRow && leadingRowSticky ? { top: phoneLeadHeight } : undefined}
1955
+ >
1901
1956
  <span className="min-w-0">
1902
1957
  {mobileGroupLabel
1903
1958
  ? mobileGroupLabel(g.key)
@@ -25,7 +25,8 @@ export { DEFAULT_DIALOG_FRAME_LABELS };
25
25
  * `PickerSheet` met first. Everything else, `size`, `draggable`, `fullBleed`,
26
26
  * `onKeyDown`, a `data-tour` anchor, still reaches the `Modal` — and since 0.35 `ref`
27
27
  * (round 0.35 R4): the panel, the `role="dialog"` element the frame's header, body and
28
- * actions sit in.
28
+ * actions sit in. `busy` too (DUP-3): it holds the frame's X and the `close` that
29
+ * `actions` and `headerActions` receive, since both are the `Modal`'s own close.
29
30
  */
30
31
  export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "children" | "title"> {
31
32
  /** The heading, and therefore the dialog's accessible name (`aria-labelledby`). */
@@ -12,7 +12,7 @@ import {
12
12
  type ComboClearValue,
13
13
  type ComboOption,
14
14
  } from "./combobox-core";
15
- import { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
15
+ import { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
16
16
  import {
17
17
  assignRef,
18
18
  EndHintRow,
@@ -248,11 +248,14 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
248
248
  const chosen: V | null = value == null || value === clearValue ? null : (value as V);
249
249
  const selectedOption = chosen == null ? null : resolve(chosen);
250
250
  const q = query.trim();
251
+ // The create row's "is this name already listed" folds as the list's match does (DUP-5).
252
+ const locale = useKitLocale();
251
253
  const showCreate = offersCreate(
252
254
  onCreate,
253
255
  q,
254
256
  results.map((o) => o.label),
255
257
  createEmptyLabel,
258
+ locale,
256
259
  );
257
260
  const showClear = Boolean(clearable && chosen != null && !inert);
258
261
  /** What the closed control is showing — the second half of its accessible name. */
@@ -24,7 +24,7 @@ import {
24
24
  type ComboOption,
25
25
  } from "./combobox-core";
26
26
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
27
- import { matchesSearch, rankMatch, searchWords } from "../lib/text-match";
27
+ import { matchesSearch, rankMatch, sameName, searchWords } from "../lib/text-match";
28
28
  import {
29
29
  EndHintRow,
30
30
  FieldCaption,
@@ -353,6 +353,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
353
353
  createQuery,
354
354
  options.map((o) => o.label),
355
355
  createEmptyLabel,
356
+ locale,
356
357
  );
357
358
  const createText = createQuery ? labels.create(createQuery) : createEmptyLabel;
358
359
  // Every row the keyboard can land on: the options, then the create row — passed over
@@ -410,8 +411,9 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
410
411
  if (value != null) onChange(clearValue);
411
412
  } else {
412
413
  // A disabled option's label typed out in full is not a way round `disabled`:
413
- // it is not a hit, so the text reverts like any other non-answer.
414
- const hits = options.filter((o) => !o.disabled && o.label.toLowerCase() === q.toLowerCase());
414
+ // it is not a hit, so the text reverts like any other non-answer. A label is
415
+ // typed out in full as the list matches it, so "cafe" commits Café (DUP-5).
416
+ const hits = options.filter((o) => !o.disabled && sameName(o.label, q, locale));
415
417
  const ids = new Set(hits.map((h) => h.value));
416
418
  if (ids.size === 1 && hits[0].value !== value) onChange(hits[0].value);
417
419
  }
@@ -117,7 +117,10 @@ export function List({
117
117
  className,
118
118
  )}
119
119
  >
120
- {folds && !open ? rows.slice(0, Math.max(0, limit!)) : children}
120
+ {/* The same array folded and open: `Children.toArray` re-keys every row (`.$a`),
121
+ so the raw `children` on one side remounted the first rows at each toggle and
122
+ dropped their state — an opened row, a typed input (audit 2026-10-30 DSP-20). */}
123
+ {folds && !open ? rows.slice(0, Math.max(0, limit!)) : (rows ?? children)}
121
124
  {folds && (
122
125
  <li role="none" className={MORE_PAD[density]}>
123
126
  <Button
@@ -1,4 +1,4 @@
1
- import { createContext, useEffect, useMemo, useRef, useState } from "react";
1
+ import { createContext, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type {
4
4
  ComponentPropsWithoutRef,
@@ -230,6 +230,30 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
230
230
  * `onClose` still runs, after the param is cleared. Requires a react-router Router.
231
231
  */
232
232
  urlParam?: DialogParam;
233
+ /**
234
+ * A request the dialog started is in flight: it stays until the caller says otherwise
235
+ * (refactor audit 2026-10-30, DUP-3). A request that has left cannot be called back by
236
+ * closing the dialog, and the dialog should not pretend it can.
237
+ *
238
+ * While true the panel is `aria-busy`, and every dismissal is refused before the exit
239
+ * starts — the backdrop, Escape, Back, the frame's X and an `actions(close)` button —
240
+ * so nothing lowers and snaps back. Five dialogs used to hold Escape by hand, in an
241
+ * `onKeyDown`, and nothing else: a backdrop press or Android's Back closed four of them
242
+ * mid-request, and CancelSubscriptionConfirm, which guarded its `onClose`, lowered its
243
+ * panel and popped it back up.
244
+ *
245
+ * Escape still goes to whatever is open over the dialog first (a list, a tooltip); the
246
+ * press that reaches the dialog's own layer is taken and dropped. A refused Back puts
247
+ * the dialog's history entry back, so the Back after the request closes the dialog
248
+ * rather than leaving the page.
249
+ *
250
+ * Not held: a close the caller decides on (`open={false}`, unmounting), and — with
251
+ * `urlParam` — Back, which leaves the param's entry like any other navigation. A close
252
+ * the dialog asks for itself through `ModalCloseContext` while busy is refused too; one
253
+ * made from an effect that depends on that close (ReauthDialog's on a resolved submit)
254
+ * runs again once `busy` turns false, because the close changes with it.
255
+ */
256
+ busy?: boolean;
233
257
  /**
234
258
  * The PANEL — the `role="dialog"` element the rest of the props go on, not the
235
259
  * backdrop around it (0.35, round 0.35 R4, the follow-up to API-2; as on
@@ -295,6 +319,8 @@ function ModalPanel({
295
319
  style,
296
320
  role = "dialog",
297
321
  urlParam: _urlParam,
322
+ busy = false,
323
+ "aria-busy": ariaBusy,
298
324
  ownHistoryEntry = true,
299
325
  ref,
300
326
  ...rest
@@ -336,10 +362,17 @@ function ModalPanel({
336
362
  // dismissal below goes through `requestClose`; `onClose` still does the closing, one
337
363
  // animation later. A caller that closes the dialog ITSELF — after a save, say —
338
364
  // unmounts with no exit unless it passes `open`, which is what that prop is for.
339
- const { closing, requestClose } = useCloseTransition(() => {
365
+ const { closing, requestClose: lower } = useCloseTransition(() => {
340
366
  setDismissed(true);
341
367
  onClose();
342
368
  });
369
+ // `busy` (DUP-3) refuses HERE, before `lower` starts the exit: a refusal in the
370
+ // caller's `onClose` comes 220ms too late, after the panel has already gone down.
371
+ // A new function when `busy` flips, so a close asked for from an effect while busy
372
+ // (a resolved action whose caller's `isPending` trails its promise) runs again.
373
+ const requestClose = useCallback(() => {
374
+ if (!busy) lower();
375
+ }, [busy, lower]);
343
376
  // A panel on its way out after `open` turned false is already closed: a press on
344
377
  // its fading backdrop must not report a second close.
345
378
  const backdropClose = useBackdropClose(active ? requestClose : () => {});
@@ -347,7 +380,8 @@ function ModalPanel({
347
380
  // this the only way out of a dialog is finding its close button — and Back, the
348
381
  // gesture everyone reaches for, navigated the page underneath instead (#172).
349
382
  // Held only while open: a closed `open={false}` dialog must not own a history entry.
350
- useOverlayHistory(active && ownHistoryEntry, requestClose);
383
+ // `held`: a Back while busy is refused, and the entry it took is put back.
384
+ useOverlayHistory(active && ownHistoryEntry, requestClose, { held: busy });
351
385
 
352
386
  // Through the shared hook and not by hand: this component's own save/restore copy
353
387
  // was one half of the pair that left the page permanently unscrollable when a dialog
@@ -423,6 +457,8 @@ function ModalPanel({
423
457
  // take the dialog role away (props-passthrough pins that).
424
458
  role={role === "alertdialog" ? "alertdialog" : "dialog"}
425
459
  aria-modal="true"
460
+ // A caller's own `aria-busy` (a preview still loading) is kept beside `busy`.
461
+ aria-busy={busy ? true : ariaBusy}
426
462
  aria-labelledby={labelledBy}
427
463
  tabIndex={-1}
428
464
  onKeyDown={handleKeyDown}
@@ -1,4 +1,5 @@
1
1
  import { useState } from "react";
2
+ import { formatResult } from "../lib/calc";
2
3
 
3
4
  /**
4
5
  * The draft-and-settle loop of a money field whose store is not AmountInput's text.
@@ -49,12 +50,22 @@ export interface MoneyDraft {
49
50
  * spelling ("12.50" stays "12.50" when 12.5 comes back).
50
51
  */
51
52
  export function useMoneyDraft(value: unknown, valueAs: "number" | "string" = "number"): MoneyDraft {
52
- const external = value === null || value === undefined ? "" : String(value);
53
+ // A number that is not finite is no amount, as on NumberField. NaN never equals itself,
54
+ // and the re-read below took it for a new value on every render until React threw "Too
55
+ // many re-renders" — MoneyField and RhfMoneyField both (refactor audit 2026-10-30,
56
+ // INP-11).
57
+ const stored = typeof value === "number" && !Number.isFinite(value) ? null : value;
58
+ // `formatResult`, not `String`: `String` writes exponent form below 1e-6 and from 1e21,
59
+ // and the commit's digits-only cleanup strips the "e" — one click into the field and
60
+ // away committed a float leftover of 5.1e-15 as 5.12, 1e-7 as 17 and 1e21 as 121
61
+ // (INP-12, BUG-11's class).
62
+ const external =
63
+ stored === null || stored === undefined ? "" : typeof stored === "number" ? formatResult(stored) : String(stored);
53
64
  const [draft, setDraft] = useState(external);
54
- const [seen, setSeen] = useState<unknown>(value);
55
- if (seen !== value) {
56
- setSeen(value);
57
- const same = valueAs === "string" ? draft === external : readAmount(draft) === amountOf(value);
65
+ const [seen, setSeen] = useState<unknown>(stored);
66
+ if (!Object.is(seen, stored)) {
67
+ setSeen(stored);
68
+ const same = valueAs === "string" ? draft === external : readAmount(draft) === amountOf(stored);
58
69
  if (!same) setDraft(external);
59
70
  }
60
71
  return {
@@ -11,8 +11,8 @@ export interface MoneyFieldProps extends Omit<
11
11
  /** The amount, or `null` for an empty field. */
12
12
  value: number | null;
13
13
  /**
14
- * The settled amount — on blur, on Enter, on the numpad's "=" and on a calculator
15
- * result; never a half-typed "1200+". Rounded to the currency's minor unit (or
14
+ * The settled amount — on blur, on Enter, on the numpad's "=" and once per calculator
15
+ * session (its "=", or the pad closing on a newer result); never a half-typed "1200+". Rounded to the currency's minor unit (or
16
16
  * `digits`) and clamped to `min` / `max`, like {@link AmountInput}. `null` when the
17
17
  * field was emptied. Not fired when the settled amount equals `value`, so an
18
18
  * Enter-then-blur saves once.
@@ -222,6 +222,7 @@ export function MultiEntityCombobox<V extends string | number>(props: MultiEntit
222
222
  q,
223
223
  results.map((o) => o.label),
224
224
  createEmptyLabel,
225
+ locale,
225
226
  );
226
227
  const showClear = Boolean(clearable && value.length > 0 && !inert);
227
228
 
@@ -12,12 +12,14 @@ export interface NumberFieldProps extends Omit<
12
12
  NumberInputProps,
13
13
  "value" | "onChange" | "onCommit" | "suffix" | "invalid" | "error" | "locale"
14
14
  > {
15
- /** The committed number. `null` is "no value" — shown as an empty field. */
15
+ /** The committed number. `null` is "no value" — shown as an empty field. So is a
16
+ * number that is not finite (NaN, ±Infinity). */
16
17
  value: number | null;
17
18
  /**
18
- * The parsed number, on blur or Enter — never per keystroke — and only when it
19
- * differs from the last one committed. Already rounded to `digits` and clamped
20
- * into `[min, max]`. `null` only when `nullable` and the field was emptied.
19
+ * The parsed number, on blur or Enter and once per calculator session (see
20
+ * {@link NumberInput}'s `onCommit`) — never per keystroke — and only when it differs
21
+ * from the last one committed. Already rounded to `digits` and clamped into
22
+ * `[min, max]`. `null` only when `nullable` and the field was emptied.
21
23
  */
22
24
  onCommit: (value: number | null) => void;
23
25
  /**
@@ -160,7 +162,7 @@ function roundTo(n: number, digits: number | undefined): number {
160
162
  */
161
163
  export function NumberField(props: NumberFieldProps) {
162
164
  const {
163
- value,
165
+ value: stored,
164
166
  onCommit,
165
167
  onValueChange,
166
168
  digits,
@@ -179,6 +181,10 @@ export function NumberField(props: NumberFieldProps) {
179
181
  "aria-required": ariaRequired,
180
182
  ...rest
181
183
  } = props;
184
+ // A number that is not finite is no value. NaN in particular never equals itself, and
185
+ // the external-change check below re-read it as new on every render until React threw
186
+ // "Too many re-renders" (refactor audit 2026-10-30, INP-11).
187
+ const value = stored === null || Number.isFinite(stored) ? stored : null;
182
188
  const locale = useKitLocale(localeProp);
183
189
  const mark = decimalMark(locale);
184
190
  const localize = (text: string) => (mark === "," ? text.replace(/\./g, ",") : text);
@@ -201,8 +207,9 @@ export function NumberField(props: NumberFieldProps) {
201
207
 
202
208
  // Adjust state during render rather than in an effect: React re-renders at once
203
209
  // with the new draft, before anything is painted, so there is no frame showing
204
- // the old text.
205
- if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {
210
+ // the old text. `Object.is`, not `!==`, under which NaN is new on every render: the
211
+ // value is read above, but a NaN `digits` looped the same way.
212
+ if (!Object.is(seen.value, value) || seen.mark !== mark || !Object.is(seen.digits, digits)) {
206
213
  setSeen({ value, mark, digits });
207
214
  // Live mode echoing back what it was just told: the draft already says it, in
208
215
  // the typist's own spelling ("1," or "12+5"), and reformatting would fight them.
@@ -25,7 +25,10 @@ export interface NumberInputProps extends FieldHelpProps, DataAttributes {
25
25
  * folded to a dot, operators kept so a typed expression survives. */
26
26
  onChange: (value: string) => void;
27
27
  /** Fired on blur/Enter with the *evaluated* value — use this for save-on-blur
28
- * fields so the save always sees the resolved number, never "10+5". */
28
+ * fields so the save always sees the resolved number, never "10+5". And once per
29
+ * calculator session — its "=", or closing it on a result not committed yet — never
30
+ * per calculator keystroke (refactor audit 2026-10-30, INP-1: a result used to reach
31
+ * `onChange` only, and the host kept the number from before the calculation). */
29
32
  onCommit?: (value: string) => void;
30
33
  label?: ReactNode;
31
34
  /** Names the field when it has no visible `label` (a table cell, a phone's display
@@ -377,9 +380,11 @@ export function NumberInput(props: NumberInputProps) {
377
380
  );
378
381
  }, [suffix, label, ariaLabel, placeholder]);
379
382
 
380
- const commit = () => {
383
+ // `text`: the calculator commits its session's result (INP-1), which on its "=" can be
384
+ // newer than this render's `value` — `onChange` was handed it in the same event.
385
+ const commit = (text: string = value) => {
381
386
  setDraft(null);
382
- const next = numeric.settle(commitExpression(value));
387
+ const next = numeric.settle(commitExpression(text));
383
388
  if (next !== value) onChange(next);
384
389
  onCommit?.(next);
385
390
  };
@@ -495,6 +500,7 @@ export function NumberInput(props: NumberInputProps) {
495
500
  // grouping: the evaluator reads either mark, but "1.234,5" is two of them.
496
501
  value={derived}
497
502
  onChange={result}
503
+ onCommit={commit}
498
504
  className="self-stretch px-2.5"
499
505
  aria-label={labels?.calculatorTrigger}
500
506
  labels={labels?.calculator}
@@ -46,9 +46,10 @@ type PadKey = { kind: "ins"; label: string; ins: string; accent?: boolean; name?
46
46
  /** The {@link CalculatorLabels} key naming a non-digit key; digits are their glyph. */
47
47
  type PadKeyName = "plus" | "minus" | "times" | "divide" | "decimal";
48
48
 
49
- // Insert chars that survive `sanitizeLive` (it keeps `× ÷` and ASCII `+ - * /`
50
- // but strips the unicode minus `−`, so minus inserts an ASCII "-" while showing
51
- // the nicer glyph). The evaluator normalises `× ÷` back to `* /`.
49
+ // Insert chars `sanitizeLive` keeps as they are (`× ÷` and ASCII `+ - * /`; it maps the
50
+ // unicode minus `−` to "-" since 0.51.7, INP-3), so minus inserts the ASCII "-" its
51
+ // field shows while the key shows the nicer glyph. The evaluator normalises `× ÷` back
52
+ // to `* /`.
52
53
  const PAD_KEYS: PadKey[] = [
53
54
  { kind: "ins", label: "7", ins: "7" },
54
55
  { kind: "ins", label: "8", ins: "8" },
@@ -1,5 +1,5 @@
1
1
  import { useContext, useEffect, useId, useRef, useState } from "react";
2
- import type { KeyboardEvent, ReactNode } from "react";
2
+ import type { ReactNode } from "react";
3
3
 
4
4
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
5
5
  import { CurrentPasswordInput, usePromisePending } from "./danger-confirm";
@@ -41,8 +41,7 @@ export interface ReauthDialogProps {
41
41
  onSubmit: (password: string) => void | Promise<unknown>;
42
42
  /**
43
43
  * The dialog has closed: `true` after a resolved `onSubmit`, `false` for Cancel,
44
- * Escape, the backdrop and Back. A dismissal while `onSubmit` is still running also
45
- * answers `false`, and that call's late result is then ignored.
44
+ * Escape, the backdrop and Back — none of which closes it while the check runs.
46
45
  */
47
46
  onClose: (confirmed: boolean) => void;
48
47
  /**
@@ -93,9 +92,9 @@ export interface ReauthDialogProps {
93
92
  *
94
93
  * The field is a `<form>`, so Enter submits once something is typed. Focus starts in
95
94
  * the field and returns to the trigger through `Modal`'s trap. While a check runs,
96
- * the field is read-only, Cancel is disabled and Escape is held: a request that has
97
- * left cannot be cancelled by closing the dialog, and the dialog should not pretend it
98
- * can.
95
+ * the field is read-only, Cancel is disabled and the dialog is `busy` (Escape, the
96
+ * backdrop and Back are held, DUP-3): a request that has left cannot be cancelled by
97
+ * closing the dialog, and the dialog should not pretend it can.
99
98
  */
100
99
  export function ReauthDialog({
101
100
  onSubmit,
@@ -173,10 +172,7 @@ export function ReauthDialog({
173
172
  title={title ?? labels.title}
174
173
  description={description ?? labels.description}
175
174
  onClose={() => onClose(confirmed.current)}
176
- onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
177
- if (busy && e.key === "Escape") e.preventDefault();
178
- }}
179
- aria-busy={busy || undefined}
175
+ busy={busy}
180
176
  actions={
181
177
  <ReauthActions
182
178
  formId={formId}
@@ -187,11 +187,19 @@ export function seriesKey(...parts: (string | number)[]): string {
187
187
  * padded only on the sides that are not zero — bars standing on a frame's bottom edge,
188
188
  * not floating four per cent above it on a band of air nobody asked for.
189
189
  * `undefined` when there is nothing to fit.
190
+ *
191
+ * All-zero data (a quiet period, a zoom window over empty months) is `[0, 1]`: one unit
192
+ * on the free side, the unit {@link padBand} pads a zero extent by. d3 maps every value
193
+ * of a zero-height domain to the range's midpoint, so `[0, 0]` stood the bars on a
194
+ * baseline in the middle of the plot, the "0" tick beside it (audit 2026-10-30 DSP-10;
195
+ * keksdose's weekday pattern drew seven 4 px stubs mid-card for a period with no
196
+ * spending).
190
197
  */
191
198
  export function anchoredBand(extent: readonly [number, number] | undefined): [number, number] | undefined {
192
199
  if (!extent) return undefined;
193
200
  const low = Math.min(0, extent[0]);
194
201
  const high = Math.max(0, extent[1]);
202
+ if (low === 0 && high === 0) return [0, 1];
195
203
  const pad = (high - low) * AUTO_PAD || 1;
196
204
  return [low < 0 ? low - pad : 0, high > 0 ? high + pad : 0];
197
205
  }