@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.
- package/README.md +5 -3
- package/dist/admin/admin-action-confirm.js +2 -4
- package/dist/admin/admin-action-confirm.js.map +1 -1
- package/dist/auth/complete-name-dialog.d.ts +3 -2
- package/dist/auth/complete-name-dialog.js +1 -4
- package/dist/auth/complete-name-dialog.js.map +1 -1
- package/dist/billing/cancel-request.d.ts +11 -2
- package/dist/billing/cancel-request.js +15 -3
- package/dist/billing/cancel-request.js.map +1 -1
- package/dist/billing/cancel-subscription.d.ts +2 -2
- package/dist/billing/cancel-subscription.js +2 -7
- package/dist/billing/cancel-subscription.js.map +1 -1
- package/dist/billing/plan-switch.d.ts +2 -2
- package/dist/billing/plan-switch.js +2 -4
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/components/amount-input.d.ts +9 -4
- package/dist/components/amount-input.js +2 -3
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +21 -2
- package/dist/components/calculator.js +53 -14
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/combobox-core.d.ts +8 -5
- package/dist/components/combobox-core.js +3 -4
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js +2 -2
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/data-table-mobile.d.ts +24 -0
- package/dist/components/data-table-mobile.js +20 -1
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table.d.ts +13 -5
- package/dist/components/data-table.js +35 -9
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +2 -1
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/entity-combobox.js +4 -2
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +4 -3
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/list.js +1 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/modal.d.ts +24 -0
- package/dist/components/modal.js +9 -3
- package/dist/components/modal.js.map +1 -1
- package/dist/components/money-draft.js +7 -5
- package/dist/components/money-draft.js.map +1 -1
- package/dist/components/money-field.d.ts +2 -2
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +2 -1
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +6 -4
- package/dist/components/number-field.js +3 -2
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +4 -1
- package/dist/components/number-input.js +3 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +4 -5
- package/dist/components/reauth-dialog.js +1 -4
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart-axes.d.ts +7 -0
- package/dist/components/series-chart-axes.js +1 -0
- package/dist/components/series-chart-axes.js.map +1 -1
- package/dist/components/tabs.js +12 -10
- package/dist/components/tabs.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-overlay-history.d.ts +7 -1
- package/dist/hooks/use-overlay-history.js +16 -2
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/lib/calc.d.ts +7 -1
- package/dist/lib/calc.js +3 -1
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/clipboard-minus.d.ts +2 -2
- package/dist/lib/clipboard-minus.js +1 -1
- package/dist/lib/clipboard-minus.js.map +1 -1
- package/dist/lib/decimal-marks.d.ts +7 -1
- package/dist/lib/decimal-marks.js +5 -2
- package/dist/lib/decimal-marks.js.map +1 -1
- package/dist/lib/format.d.ts +5 -3
- package/dist/lib/format.js +12 -0
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/text-match.d.ts +9 -0
- package/dist/lib/text-match.js +4 -0
- package/dist/lib/text-match.js.map +1 -1
- package/dist/tour/tour.d.ts +3 -1
- package/dist/tour/tour.js +9 -3
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.js +2 -2
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/package.json +3 -3
- package/src/admin/admin-action-confirm.tsx +4 -5
- package/src/auth/complete-name-dialog.tsx +5 -7
- package/src/billing/cancel-request.ts +28 -6
- package/src/billing/cancel-subscription.tsx +4 -11
- package/src/billing/plan-switch.tsx +5 -8
- package/src/components/amount-input.tsx +14 -7
- package/src/components/calculator.tsx +98 -17
- package/src/components/combobox-core.tsx +10 -7
- package/src/components/combobox.tsx +5 -6
- package/src/components/data-table-mobile.tsx +49 -1
- package/src/components/data-table.tsx +66 -11
- package/src/components/dialog-frame.tsx +2 -1
- package/src/components/entity-combobox.tsx +4 -1
- package/src/components/inline-entity-combobox.tsx +5 -3
- package/src/components/list.tsx +4 -1
- package/src/components/modal.tsx +39 -3
- package/src/components/money-draft.ts +16 -5
- package/src/components/money-field.tsx +2 -2
- package/src/components/multi-entity-combobox.tsx +1 -0
- package/src/components/number-field.tsx +14 -7
- package/src/components/number-input.tsx +9 -3
- package/src/components/numpad-sheet.tsx +4 -3
- package/src/components/reauth-dialog.tsx +6 -10
- package/src/components/series-chart-axes.ts +8 -0
- package/src/components/tabs.tsx +33 -17
- package/src/hooks/use-dismiss.ts +3 -2
- package/src/hooks/use-overlay-history.ts +38 -2
- package/src/lib/calc.ts +13 -2
- package/src/lib/clipboard-minus.ts +14 -4
- package/src/lib/decimal-marks.ts +12 -3
- package/src/lib/format.ts +30 -3
- package/src/lib/text-match.ts +12 -0
- package/src/tour/tour.tsx +21 -4
- 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
|
-
*
|
|
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.
|
|
633
|
-
*
|
|
634
|
-
*
|
|
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
|
-
|
|
1202
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/components/list.tsx
CHANGED
|
@@ -117,7 +117,10 @@ export function List({
|
|
|
117
117
|
className,
|
|
118
118
|
)}
|
|
119
119
|
>
|
|
120
|
-
{
|
|
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
|
package/src/components/modal.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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>(
|
|
55
|
-
if (seen
|
|
56
|
-
setSeen(
|
|
57
|
-
const same = valueAs === "string" ? draft === external : readAmount(draft) === amountOf(
|
|
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
|
|
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.
|
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
50
|
-
//
|
|
51
|
-
// the nicer glyph
|
|
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 {
|
|
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
|
|
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
|
|
97
|
-
*
|
|
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
|
-
|
|
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
|
}
|