@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
package/src/components/tabs.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { useLayoutEffect, useMemo, useRef } from "react";
|
|
|
2
2
|
import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
|
|
3
3
|
import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from "react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
|
-
import {
|
|
5
|
+
import { FOCUS_RING } from "./focus-ring";
|
|
6
6
|
import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
|
|
7
7
|
import { horizontalStep } from "../lib/direction";
|
|
8
8
|
import { usePhoneLayout } from "../hooks/use-breakpoint";
|
|
@@ -205,9 +205,7 @@ const TABLIST_CLASSES = "flex gap-1 overflow-x-auto overflow-y-hidden border-b b
|
|
|
205
205
|
// selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the
|
|
206
206
|
// admin page's nav and the app's own sidebar mark "you are here" the same way.
|
|
207
207
|
const TABLIST_VERTICAL_CLASSES = "flex flex-col gap-0.5";
|
|
208
|
-
const TAB_VERTICAL_CLASSES =
|
|
209
|
-
FOCUS_RING_WIDTH +
|
|
210
|
-
" flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong";
|
|
208
|
+
const TAB_VERTICAL_CLASSES = `flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none ${FOCUS_RING}`;
|
|
211
209
|
const TAB_VERTICAL_ACTIVE_CLASSES = "bg-active text-primary";
|
|
212
210
|
const TAB_VERTICAL_INACTIVE_CLASSES = "text-muted hover:bg-hover hover:text-primary";
|
|
213
211
|
|
|
@@ -215,9 +213,14 @@ const TAB_VERTICAL_INACTIVE_CLASSES = "text-muted hover:bg-hover hover:text-prim
|
|
|
215
213
|
// links · … · 31 Punkte") made one tab 448px wide in a 390px strip, and a tab wider
|
|
216
214
|
// than the strip cannot be scrolled into view — its end was simply off screen
|
|
217
215
|
// (lenkbank L3). The label and detail truncate inside it instead; see `inner`.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
//
|
|
217
|
+
// Keyboard focus on a tab, a side-nav row and the add button is the kit's one focus frame
|
|
218
|
+
// (text-size §5), in brand. Until 0.51.7 it rang in `ring-strong`: 2.54:1 on the light
|
|
219
|
+
// page and 2.76:1 on a card, under WCAG 1.4.11's 3:1 (refactor audit 2026-10-30, DSP-8;
|
|
220
|
+
// NavPills made the same move in 0.34, DOC-7). INSET on a tab: the strip scrolls
|
|
221
|
+
// (`overflow-x-auto overflow-y-hidden`), so a ring outside the tab is cut off along its
|
|
222
|
+
// top and bottom, and at a cut end.
|
|
223
|
+
const TAB_CLASSES = `max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none ${FOCUS_RING} focus-visible:ring-inset`;
|
|
221
224
|
const TAB_ACTIVE_CLASSES = "border-[var(--text-primary)] text-primary";
|
|
222
225
|
const TAB_INACTIVE_CLASSES = "border-transparent text-muted hover:text-secondary hover:border-strong";
|
|
223
226
|
|
|
@@ -225,9 +228,7 @@ const TAB_INACTIVE_CLASSES = "border-transparent text-muted hover:text-secondary
|
|
|
225
228
|
// `onRemove`).
|
|
226
229
|
const TAB_REMOVABLE_CLASSES = "pe-8";
|
|
227
230
|
const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-strong px-1.5";
|
|
228
|
-
const TAB_ADD_CLASSES =
|
|
229
|
-
FOCUS_RING_WIDTH +
|
|
230
|
-
" inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none focus-visible:ring-strong disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4";
|
|
231
|
+
const TAB_ADD_CLASSES = `inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none ${FOCUS_RING} disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4`;
|
|
231
232
|
// `relative`: the phone's visually hidden label needs a positioned ancestor (see the
|
|
232
233
|
// sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone "+".
|
|
233
234
|
const TAB_ADD_PHONE_CLASSES = "relative max-md:px-2";
|
|
@@ -262,6 +263,8 @@ export function Tabs<T extends string>({
|
|
|
262
263
|
const cue = overflowCue && !vertical;
|
|
263
264
|
const cueStart = cue && (fade.overflow === "start" || fade.overflow === "both");
|
|
264
265
|
const cueEnd = cue && (fade.overflow === "end" || fade.overflow === "both");
|
|
266
|
+
// The cue gutters (see `stripBox`): there only while the strip overflows at all.
|
|
267
|
+
const gutters = cue && fade.overflow !== undefined;
|
|
265
268
|
// Three quarters of the visible width toward `side`; an RTL strip runs leftward.
|
|
266
269
|
const onCue = (side: "start" | "end") => {
|
|
267
270
|
const strip = stripRef.current;
|
|
@@ -333,15 +336,25 @@ export function Tabs<T extends string>({
|
|
|
333
336
|
// "80 km," under the add button. Only the strip scrolls (`scrollBy` on it, not
|
|
334
337
|
// `scrollIntoView`, which would scroll the page as well), and only as far as needed
|
|
335
338
|
// to clear the fade.
|
|
339
|
+
//
|
|
340
|
+
// And again when the cue gutters appear (refactor audit 2026-10-30, DSP-16). They come a
|
|
341
|
+
// render after the strip is first measured as overflowing — on mount, or with the very
|
|
342
|
+
// tab that tipped it over — and take 24px off each end, so a tab scrolled clear before
|
|
343
|
+
// them ended 24px past the visible end, under the fade and the ›.
|
|
336
344
|
useLayoutEffect(() => {
|
|
337
345
|
const strip = stripRef.current;
|
|
338
346
|
const tab = strip?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
|
|
339
347
|
if (strip && tab) scrollIntoStrip(strip, tab);
|
|
340
|
-
}, [active]);
|
|
348
|
+
}, [active, gutters]);
|
|
341
349
|
const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
|
|
342
350
|
// Whether Delete reaches this tab: every removable tab, or only the open one.
|
|
343
351
|
const deletesOnKey = (tab: TabItem<T>) => isRemovable(tab) && (removeOn === "every" || tab.id === active);
|
|
344
352
|
const nameOf = (tab: TabItem<T>) => tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
|
|
353
|
+
// The strip's one tab stop (see `tabIndex` below): the open tab, or the first while
|
|
354
|
+
// `active` names none. "No sheet open" is a real state — KS's sheet strips pass `""` —
|
|
355
|
+
// and with the stop on the selection alone every tab was -1, so the keyboard could not
|
|
356
|
+
// reach the one control that opens a sheet (refactor audit 2026-10-30, DSP-1).
|
|
357
|
+
const stop = tabs.some((t) => t.id === active) ? active : tabs[0]?.id;
|
|
345
358
|
|
|
346
359
|
// Where focus goes once a removal the strip asked for has happened. The caller
|
|
347
360
|
// owns `tabs` and may take its time (a confirmation, a request), so this waits
|
|
@@ -370,10 +383,14 @@ export function Tabs<T extends string>({
|
|
|
370
383
|
if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;
|
|
371
384
|
// The removed tab was open: focus follows the caller's new selection, which is
|
|
372
385
|
// also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.
|
|
373
|
-
|
|
386
|
+
// The caller opened nothing in its place (KS drops the sheet and the selection in one
|
|
387
|
+
// render): the neighbour too, else the stop — not an index of -1 and the page (DSP-1).
|
|
388
|
+
const present = (id: T | undefined) => tabs.some((t) => t.id === id);
|
|
389
|
+
const target =
|
|
390
|
+
pending.wasActive && present(active) ? active : present(pending.neighbour) ? pending.neighbour : stop;
|
|
374
391
|
const index = tabs.findIndex((t) => t.id === target);
|
|
375
392
|
strip.querySelectorAll<HTMLElement>('[role="tab"]')[index]?.focus();
|
|
376
|
-
}, [tabs, active]);
|
|
393
|
+
}, [tabs, active, stop]);
|
|
377
394
|
|
|
378
395
|
// Arrow keys walk the strip in DOM order (ARIA tabs pattern). Home/End jump
|
|
379
396
|
// to the ends. Focus only — activation stays on click/Enter/Space, because a tab
|
|
@@ -462,8 +479,8 @@ export function Tabs<T extends string>({
|
|
|
462
479
|
// strip back on the next visit in a state they never chose. The cost is that
|
|
463
480
|
// Tab out and back returns to the open tab rather than to the last one
|
|
464
481
|
// looked at; the ARIA pattern allows either, and this one needs no second
|
|
465
|
-
// piece of state shadowing `active`.
|
|
466
|
-
tabIndex:
|
|
482
|
+
// piece of state shadowing `active`. With nothing open, the first tab (`stop`).
|
|
483
|
+
tabIndex: tab.id === stop ? 0 : -1,
|
|
467
484
|
// Only the open tab: see `panelId`.
|
|
468
485
|
"aria-controls": isActive ? panelId : undefined,
|
|
469
486
|
id: isActive && panelId ? `${panelId}-tab` : undefined,
|
|
@@ -612,8 +629,7 @@ export function Tabs<T extends string>({
|
|
|
612
629
|
) : null;
|
|
613
630
|
// The box the cues sit in, ‹ in the gutter before the strip and › in the one after it
|
|
614
631
|
// (see `gutterAt`). There whether or not the strip overflows right now, so a resize
|
|
615
|
-
// never re-parents it; the gutters only while it overflows at all.
|
|
616
|
-
const gutters = cue && fade.overflow !== undefined;
|
|
632
|
+
// never re-parents it; the gutters only while it overflows at all (`gutters`).
|
|
617
633
|
const stripBox = cue ? (
|
|
618
634
|
<div className={cn("relative flex min-w-0", !onAdd && "flex-1", gutters && "px-[24px]")}>
|
|
619
635
|
{tablist}
|
package/src/hooks/use-dismiss.ts
CHANGED
|
@@ -42,8 +42,9 @@ import { isComposing } from "../lib/ime";
|
|
|
42
42
|
* draft, InlineEditField cancelling its edit, the date picker's month grid going back to
|
|
43
43
|
* the days. Those call {@link claimEscape}, and no layer is handed the key. The only
|
|
44
44
|
* other claim is the layer owner's OWN handler — a dialog's `onKeyDown` prop calling
|
|
45
|
-
* `preventDefault()` on Escape (
|
|
46
|
-
*
|
|
45
|
+
* `preventDefault()` on Escape (Kurvenschmiede's gear wizard asking first), through
|
|
46
|
+
* {@link runOwnKeyDown}. A dialog holding itself open while its request runs passes
|
|
47
|
+
* Modal's `busy` instead (0.51.7, DUP-3), which holds the backdrop and Back as well. A `preventDefault()`
|
|
47
48
|
* anywhere else claims nothing: a search box keeping Chromium from clearing itself
|
|
48
49
|
* must not hold a dialog open (FullBleedDialog's 0.8.0 fix, which Modal never had).
|
|
49
50
|
*
|
|
@@ -71,6 +71,8 @@ const SENTINEL_KEY = "__hbUiOverlayHistory";
|
|
|
71
71
|
interface OverlayEntry {
|
|
72
72
|
id: string;
|
|
73
73
|
close: () => void;
|
|
74
|
+
/** The overlay refuses to close right now — see the `held` option. */
|
|
75
|
+
held: () => boolean;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const stack: OverlayEntry[] = [];
|
|
@@ -510,6 +512,11 @@ function closeOnPop() {
|
|
|
510
512
|
// is no longer ours: either the layer below (a different overlay's marker) or
|
|
511
513
|
// the plain page entry (none at all).
|
|
512
514
|
if (currentSentinel() === top.id) return;
|
|
515
|
+
// Checked before anything below moves: a held overlay keeps its place and its record.
|
|
516
|
+
if (top.held()) {
|
|
517
|
+
putBack(top.id);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
513
520
|
stack.pop();
|
|
514
521
|
// The browser consumed this entry, so the overlay's own cleanup has nothing left
|
|
515
522
|
// to unwind and nothing to record as owed.
|
|
@@ -521,6 +528,29 @@ function closeOnPop() {
|
|
|
521
528
|
top.close();
|
|
522
529
|
}
|
|
523
530
|
|
|
531
|
+
/**
|
|
532
|
+
* A Back press the top overlay refuses (a busy `Modal`, DUP-3). A popstate cannot be
|
|
533
|
+
* refused — the browser has already left our entry — so the entry is put back: what the
|
|
534
|
+
* page wrote into the address while the overlay was up is carried down first (#412), as
|
|
535
|
+
* for a close, and the same marker is pushed again over it, as the mount pushed it. Left
|
|
536
|
+
* on the page entry, the overlay would stay up with no entry of its own, and the Back
|
|
537
|
+
* after its request would leave the page under it.
|
|
538
|
+
*
|
|
539
|
+
* A push and not `history.go(1)` back onto the old entry: a traversal lands a task later
|
|
540
|
+
* and fires nothing at all if a listener has pushed meanwhile, and a programmatic pop
|
|
541
|
+
* counted for it would then swallow the user's next real Back.
|
|
542
|
+
*/
|
|
543
|
+
function putBack(id: string): void {
|
|
544
|
+
const record = pushed.find((p) => p.id === id);
|
|
545
|
+
if (record && !record.dead) carryOnto(record.seen, record);
|
|
546
|
+
const prev: unknown = window.history.state;
|
|
547
|
+
window.history.pushState({ ...(prev && typeof prev === "object" ? prev : {}), [SENTINEL_KEY]: id }, "");
|
|
548
|
+
standing = id;
|
|
549
|
+
// The address the entry shows now is the one the user last acted on: the cleanup's
|
|
550
|
+
// owner's-close check (#584) must not read the carried address as a rewrite.
|
|
551
|
+
if (record) record.href = record.seen = currentHref();
|
|
552
|
+
}
|
|
553
|
+
|
|
524
554
|
function ensureListening() {
|
|
525
555
|
if (listening || typeof window === "undefined") return;
|
|
526
556
|
window.addEventListener("popstate", handlePop);
|
|
@@ -536,20 +566,26 @@ function ensureListening() {
|
|
|
536
566
|
* @param open whether the overlay is currently showing. Components that mount
|
|
537
567
|
* only while open (the common case) pass `true`.
|
|
538
568
|
* @param onClose the overlay's own close handler — the same one Escape calls.
|
|
569
|
+
* @param options.held the overlay refuses to close right now (a `Modal` whose request
|
|
570
|
+
* is in flight, DUP-3): Back does not call `onClose`, and the entry the
|
|
571
|
+
* press took is put back, so the overlay still has one for the next
|
|
572
|
+
* Back. Read when Back is pressed. Default false.
|
|
539
573
|
*/
|
|
540
|
-
export function useOverlayHistory(open: boolean, onClose: () => void): void {
|
|
574
|
+
export function useOverlayHistory(open: boolean, onClose: () => void, { held = false }: { held?: boolean } = {}): void {
|
|
541
575
|
// The listener fires outside React's render, so it reads the latest handler
|
|
542
576
|
// through a ref rather than through the closure the effect captured.
|
|
543
577
|
const closeRef = useRef(onClose);
|
|
578
|
+
const heldRef = useRef(held);
|
|
544
579
|
useEffect(() => {
|
|
545
580
|
closeRef.current = onClose;
|
|
581
|
+
heldRef.current = held;
|
|
546
582
|
});
|
|
547
583
|
|
|
548
584
|
useEffect(() => {
|
|
549
585
|
if (!open || typeof window === "undefined") return;
|
|
550
586
|
ensureListening();
|
|
551
587
|
const id = `overlay-${(nextId += 1)}`;
|
|
552
|
-
const entry: OverlayEntry = { id, close: () => closeRef.current() };
|
|
588
|
+
const entry: OverlayEntry = { id, close: () => closeRef.current(), held: () => heldRef.current };
|
|
553
589
|
stack.push(entry);
|
|
554
590
|
const prev = window.history.state;
|
|
555
591
|
const marked = { ...(prev && typeof prev === "object" ? prev : {}), [SENTINEL_KEY]: id };
|
package/src/lib/calc.ts
CHANGED
|
@@ -244,9 +244,20 @@ export function commitExpression(raw: string): string {
|
|
|
244
244
|
* keystroke-by-keystroke but stray characters can't be entered. A lone leading
|
|
245
245
|
* `-` is kept — in a plain field it is simply a negative amount; in a field whose
|
|
246
246
|
* caller owns the sign it is read off again by {@link splitLeadingSign}. The text
|
|
247
|
-
* is resolved to a plain number on blur/Enter via {@link commitExpression}.
|
|
247
|
+
* is resolved to a plain number on blur/Enter via {@link commitExpression}.
|
|
248
|
+
*
|
|
249
|
+
* The typographic minus and the two dashes become "-" first, as in
|
|
250
|
+
* {@link evaluateExpression}: U+2212 is what the kit's own `formatMoney` and
|
|
251
|
+
* `formatNumber` print for every negative since 0.49, and stripping it with the
|
|
252
|
+
* stray characters turned a pasted "−1.234,50 €" into a positive amount (refactor
|
|
253
|
+
* audit 2026-10-30, INP-3). */
|
|
248
254
|
export function sanitizeLive(raw: string): string {
|
|
249
|
-
return dropExtraDots(
|
|
255
|
+
return dropExtraDots(
|
|
256
|
+
raw
|
|
257
|
+
.replace(/[−–—]/g, "-")
|
|
258
|
+
.replace(/,/g, ".")
|
|
259
|
+
.replace(/[^0-9.+\-*/×÷()]/g, ""),
|
|
260
|
+
);
|
|
250
261
|
}
|
|
251
262
|
|
|
252
263
|
/**
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* as a number: a figure copied out of a kit table and pasted into Excel landed as text.
|
|
6
6
|
* So while a selection inside the page is copied, every U+2212 standing directly before a
|
|
7
7
|
* number — optionally after a currency code or symbol and spaces: "−CHF 1,250.50",
|
|
8
|
-
* "−€2,000", "−12%" — becomes the ASCII "-" in the clipboard, in `text/plain` AND
|
|
9
|
-
* `text/html` (Excel pastes the HTML flavour, as cells). The page keeps "−".
|
|
8
|
+
* "−€2,000", "−US$12", "−12%" — becomes the ASCII "-" in the clipboard, in `text/plain` AND
|
|
9
|
+
* in `text/html` (Excel pastes the HTML flavour, as cells). The page keeps "−".
|
|
10
10
|
*
|
|
11
11
|
* A copy with no such minus is left to the browser untouched, and so is any copy out of an
|
|
12
12
|
* input, a textarea or an editable element: what the person typed is theirs.
|
|
@@ -15,8 +15,18 @@
|
|
|
15
15
|
* the one part every app mounts, the signed-out pages included.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* U+2212, then up to five letters, currency symbols or dots, then spaces, then a digit.
|
|
20
|
+
*
|
|
21
|
+
* Five of them, not "a code or one symbol": English and Chinese write a currency with
|
|
22
|
+
* letters before the symbol — "−US$", "−CA$", "−A$", "−NZ$", "−HK$", "−R$", "−MX$",
|
|
23
|
+
* "−JP¥", "−CN¥" — and `narrowSymbol` with letters alone ("−kr", "−zł", "−Kč"). Those kept
|
|
24
|
+
* U+2212 in the clipboard, and Excel pasted them as text: 35 of 351 locale × currency
|
|
25
|
+
* pairs at the default display, every one English or Chinese, and 136 of 1428 with
|
|
26
|
+
* `narrowSymbol` (refactor audit 2026-10-30, DSP-11). A minus with no digit after it
|
|
27
|
+
* ("x−y", "a − b") is still left alone.
|
|
28
|
+
*/
|
|
29
|
+
const MINUS_BEFORE_NUMBER = /\u2212(?=[\p{L}\p{Sc}.]{0,5}[\s\u00a0\u202f]*\d)/gu;
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
32
|
* `text` with each U+2212 that stands before a number (in the sense above) as the ASCII
|
package/src/lib/decimal-marks.ts
CHANGED
|
@@ -37,7 +37,13 @@ export function decimalMark(locale: string | undefined): "," | "." {
|
|
|
37
37
|
* field's settle then made that 1.23, final. This decides per operand, from the
|
|
38
38
|
* typist's own text, before that fold:
|
|
39
39
|
*
|
|
40
|
-
* - both marks present: the one
|
|
40
|
+
* - both marks present: the LAST one is the decimal mark, whichever the locale's is, and
|
|
41
|
+
* the other is grouping and goes. The order says the convention the figure was written
|
|
42
|
+
* in — "1.234,56" German, "1,234.56" English — and a figure pasted from another app or
|
|
43
|
+
* locale keeps it. Taking the non-locale mark as grouping wherever it stood read
|
|
44
|
+
* "1.234,56" in a "." locale (en, de-CH) as 1.23456, which a money field then settled
|
|
45
|
+
* to 1.23 (refactor audit 2026-10-30, INP-3). Typed in the locale's own convention, its
|
|
46
|
+
* mark comes last and nothing changes;
|
|
41
47
|
* - in a "," locale, only "." present: grouping when it cannot be a decimal as a
|
|
42
48
|
* German reader writes it — twice ("1.234.567"), or in thousands shape ("1.234",
|
|
43
49
|
* "12.500") — otherwise a decimal ("1.5", typed by a dot-decimal habit). A first
|
|
@@ -53,10 +59,13 @@ export function decimalMark(locale: string | undefined): "," | "." {
|
|
|
53
59
|
export function normalizeTypedMarks(raw: string, mark: "," | "."): string {
|
|
54
60
|
const other = mark === "," ? "." : ",";
|
|
55
61
|
return raw.replace(/[0-9.,]+/g, (operand) => {
|
|
56
|
-
const hasMark = operand.includes(mark);
|
|
57
62
|
const count = operand.split(other).length - 1;
|
|
58
63
|
if (count === 0) return operand;
|
|
59
|
-
|
|
64
|
+
if (operand.includes(mark)) {
|
|
65
|
+
const grouping = operand.lastIndexOf(mark) > operand.lastIndexOf(other) ? other : mark;
|
|
66
|
+
return operand.split(grouping).join("");
|
|
67
|
+
}
|
|
68
|
+
const grouping = count > 1 || (mark === "," && /^[1-9]\d{0,2}(\.\d{3})+$/.test(operand));
|
|
60
69
|
return grouping ? operand.split(other).join("") : operand;
|
|
61
70
|
});
|
|
62
71
|
}
|
package/src/lib/format.ts
CHANGED
|
@@ -335,8 +335,8 @@ export function formatDate(
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
export interface FormatRelativeTimeOptions extends BaseOptions {
|
|
338
|
-
/** The zone of the date printed past `absoluteAfterDays` (0.46)
|
|
339
|
-
* device's. */
|
|
338
|
+
/** The zone of the date printed past `absoluteAfterDays` (0.46), and (0.51.7) the zone
|
|
339
|
+
* whose calendar days "yesterday" and "2 days ago" count. Left out: the device's. */
|
|
340
340
|
timeZone?: string;
|
|
341
341
|
/** The moment it is measured from. Default: now. */
|
|
342
342
|
now?: DateInput;
|
|
@@ -370,6 +370,20 @@ const RELATIVE_STEPS: Array<[number, Intl.RelativeTimeFormatUnit, number]> = [
|
|
|
370
370
|
[Infinity, "year", 365.25 * DAY],
|
|
371
371
|
];
|
|
372
372
|
|
|
373
|
+
/**
|
|
374
|
+
* `d`'s calendar day in `timeZone` (left out: the device's), as a whole number of days:
|
|
375
|
+
* that day's UTC midnight over a day's length. Two of them subtract to whole calendar
|
|
376
|
+
* days — a 23- or 25-hour DST day still counts one.
|
|
377
|
+
*/
|
|
378
|
+
function dayNumber(d: Date, timeZone: string | undefined): number {
|
|
379
|
+
if (!timeZone) return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY;
|
|
380
|
+
// `en-US`: Gregorian, Latin digits — the parts are read back as numbers.
|
|
381
|
+
const fields: Intl.DateTimeFormatOptions = { timeZone, year: "numeric", month: "numeric", day: "numeric" };
|
|
382
|
+
const parts = dateFormatter("en-US", fields).formatToParts(d);
|
|
383
|
+
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)?.value);
|
|
384
|
+
return Date.UTC(part("year"), part("month") - 1, part("day")) / DAY;
|
|
385
|
+
}
|
|
386
|
+
|
|
373
387
|
const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
|
374
388
|
function relativeFormatter(
|
|
375
389
|
locale: string | undefined,
|
|
@@ -389,7 +403,9 @@ function relativeFormatter(
|
|
|
389
403
|
* How long ago (or until) `value` is, in `locale`'s words, through
|
|
390
404
|
* `Intl.RelativeTimeFormat`: "3 hours ago", "vor 3 Stunden", "il y a 3 heures",
|
|
391
405
|
* "in 2 days", and under 45 seconds "now". The unit is picked by size — seconds,
|
|
392
|
-
* minutes, hours, days, weeks, months, years — and the count is rounded
|
|
406
|
+
* minutes, hours, days, weeks, months, years — and the count is rounded; days are
|
|
407
|
+
* CALENDAR days (0.51.7), in `timeZone` when given: "yesterday" is the day before today,
|
|
408
|
+
* however few hours ago that was.
|
|
393
409
|
*/
|
|
394
410
|
export function formatRelativeTime(value: DateInput, options: FormatRelativeTimeOptions = {}): string {
|
|
395
411
|
const d = toDate(value);
|
|
@@ -402,6 +418,17 @@ export function formatRelativeTime(value: DateInput, options: FormatRelativeTime
|
|
|
402
418
|
}
|
|
403
419
|
const rtf = relativeFormatter(options.locale, options.numeric ?? "auto", options.style ?? "long");
|
|
404
420
|
const [, unit, size] = RELATIVE_STEPS.find(([limit]) => abs < limit)!;
|
|
421
|
+
if (unit === "day") {
|
|
422
|
+
// By the calendar, not in 24-hour units (refactor audit 2026-10-30, DSP-7): rounded
|
|
423
|
+
// elapsed time made 00:10 seen at 23:50 "yesterday" and 01:00 yesterday seen at 23:30
|
|
424
|
+
// "2 days ago", beside an exact date (DateMark's second line, the feedback thread's
|
|
425
|
+
// tooltip) that said otherwise. Counted in `timeZone`, the zone that date is printed
|
|
426
|
+
// in; a bare "YYYY-MM-DD" is the day it names wherever it is read, as in formatDate.
|
|
427
|
+
// Still the same day (22 hours and more since just after midnight): in hours.
|
|
428
|
+
const bareDay = typeof value === "string" && BARE_ISO_DAY.test(value);
|
|
429
|
+
const days = dayNumber(d, bareDay ? undefined : options.timeZone) - dayNumber(now, options.timeZone);
|
|
430
|
+
return days === 0 ? rtf.format(Math.round(diff / HOUR), "hour") : rtf.format(days, "day");
|
|
431
|
+
}
|
|
405
432
|
// Under 45 seconds is "now" rather than "12 seconds ago": a feed that ticks through
|
|
406
433
|
// the seconds is noise, and nothing reads a timestamp to the second.
|
|
407
434
|
const count = unit === "second" ? 0 : Math.round(diff / size);
|
package/src/lib/text-match.ts
CHANGED
|
@@ -32,6 +32,18 @@ export function foldForSearch(text: string, locale?: string): string {
|
|
|
32
32
|
.replace(/ß/g, "ss");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Whether two names are one name as a picker's search reads them: folded
|
|
37
|
+
* ({@link foldForSearch}) and trimmed — "cafe" is Café, " MIGROS " is Migros. For "does
|
|
38
|
+
* the typed text name a record that exists" (a create row, InlineEntityCombobox's blur
|
|
39
|
+
* commit), which compared with plain `toLowerCase` while the list beside it folded, so
|
|
40
|
+
* "cafe" listed Café as a hit and offered "Create “cafe”" too — a duplicate payee or shop
|
|
41
|
+
* chain one Enter away (refactor audit 2026-10-30, DUP-5). Internal: not in the barrel.
|
|
42
|
+
*/
|
|
43
|
+
export function sameName(a: string, b: string, locale?: string): boolean {
|
|
44
|
+
return foldForSearch(a.trim(), locale) === foldForSearch(b.trim(), locale);
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
/**
|
|
36
48
|
* A picker's query as its words, each folded (0.51.4, keksdose's /prices shop picker):
|
|
37
49
|
* "Migros Zürich" → `["migros", "zurich"]`. An empty or blank query has no words.
|
package/src/tour/tour.tsx
CHANGED
|
@@ -140,7 +140,9 @@ export interface TourStep {
|
|
|
140
140
|
/** Runs before the step is shown — e.g. navigate to a route, open a panel.
|
|
141
141
|
* May be async; the step waits for it, then locates the target. Receives the
|
|
142
142
|
* {@link TourStepContext} (0.50); a step entered by "Guide me" sees `via: "at"` and
|
|
143
|
-
* should leave an open form alone.
|
|
143
|
+
* should leave an open form alone. One that throws or rejects is logged to the
|
|
144
|
+
* console, and the step is located and shown anyway. Enter and the arrows wait for
|
|
145
|
+
* it; Escape does not. */
|
|
144
146
|
beforeStep?: (ctx: TourStepContext) => void | Promise<void>;
|
|
145
147
|
}
|
|
146
148
|
|
|
@@ -481,7 +483,10 @@ export function TourProvider({
|
|
|
481
483
|
const last = Math.max(0, s.length - 1);
|
|
482
484
|
floorRef.current = opts?.at === undefined ? 0 : resolveAt(s, opts.at);
|
|
483
485
|
const via: TourStepContext["via"] = opts?.at === undefined ? "start" : "at";
|
|
484
|
-
|
|
486
|
+
// Floored, as `at` is (FLW-9): `steps[1.5]` is undefined, and `forward` threw on
|
|
487
|
+
// its `when` — an edited `?tourStep=2.5` was enough.
|
|
488
|
+
const asked = opts?.startIndex === undefined ? floorRef.current : Math.floor(opts.startIndex);
|
|
489
|
+
const wanted = Math.min(Math.max(floorRef.current, asked), last);
|
|
485
490
|
let to = forward(s, wanted, via);
|
|
486
491
|
if (to < 0) to = backward(s, wanted, via);
|
|
487
492
|
// Not one step of the run is shown: there is nothing to tour.
|
|
@@ -561,7 +566,7 @@ export function TourProvider({
|
|
|
561
566
|
(i: number) => {
|
|
562
567
|
const s = stepsRef.current;
|
|
563
568
|
if (!s || s.length === 0) return;
|
|
564
|
-
const wanted = Math.min(Math.max(floorRef.current, i), s.length - 1);
|
|
569
|
+
const wanted = Math.min(Math.max(floorRef.current, Math.floor(i)), s.length - 1);
|
|
565
570
|
let to = forward(s, wanted, "goto");
|
|
566
571
|
if (to < 0) to = backward(s, wanted, "goto");
|
|
567
572
|
if (to >= 0) arrive(s, to, "goto");
|
|
@@ -990,7 +995,15 @@ function TourOverlay({
|
|
|
990
995
|
setLocated({ index, step, rect: el ? spotRectOf(el, step) : null, dir: dirOf(el), layer, fullScreen });
|
|
991
996
|
};
|
|
992
997
|
void (async () => {
|
|
993
|
-
|
|
998
|
+
try {
|
|
999
|
+
await step.beforeStep?.(stepContext(gates, via));
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
// The step is shown anyway (FLW-5): left unlocated, the tour stayed `active`
|
|
1002
|
+
// with no card, no scrim and no Escape layer, and everything keyed on "a tour
|
|
1003
|
+
// runs" (the `?tour=` mirror, the apps' PWA-update hold) stayed on with it. The
|
|
1004
|
+
// target may well be there regardless; if not, the finder gives up and centres.
|
|
1005
|
+
console.error(error);
|
|
1006
|
+
}
|
|
994
1007
|
if (cancelled) return;
|
|
995
1008
|
if (!step.target) {
|
|
996
1009
|
settle(null);
|
|
@@ -1318,6 +1331,10 @@ function TourOverlay({
|
|
|
1318
1331
|
if (!ready) onSkip();
|
|
1319
1332
|
return;
|
|
1320
1333
|
}
|
|
1334
|
+
// Nothing on screen to move on from: a step whose `beforeStep` is still running
|
|
1335
|
+
// (a route loading) is not passed over by an Enter or a → pressed on the bare
|
|
1336
|
+
// page (FLW-5). Escape, above, still leaves.
|
|
1337
|
+
if (!ready) return;
|
|
1321
1338
|
if (e.key === "Enter") {
|
|
1322
1339
|
if (step.awaitClick || step.awaitInput || ownsKey(e.target, "Enter")) return;
|
|
1323
1340
|
e.preventDefault();
|
|
@@ -94,10 +94,11 @@ const NavigationContext: Context<NavigationProbe> = (() => {
|
|
|
94
94
|
}
|
|
95
95
|
})();
|
|
96
96
|
|
|
97
|
-
/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based.
|
|
97
|
+
/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based.
|
|
98
|
+
* Floored, as the aliases always were (FLW-9): `?tourStep=2.5` was index 1.5. */
|
|
98
99
|
function stepOf(params: URLSearchParams, aliases: readonly string[]): number {
|
|
99
100
|
const step = Number(params.get("tourStep"));
|
|
100
|
-
if (params.has("tourStep")) return Number.isFinite(step) && step
|
|
101
|
+
if (params.has("tourStep")) return Number.isFinite(step) && step >= 1 ? Math.floor(step) - 1 : 0;
|
|
101
102
|
for (const alias of aliases) {
|
|
102
103
|
const value = Number(params.get(alias));
|
|
103
104
|
if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
@@ -166,7 +167,10 @@ export function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTo
|
|
|
166
167
|
launchingRef.current = true;
|
|
167
168
|
void Promise.resolve()
|
|
168
169
|
.then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine))
|
|
169
|
-
|
|
170
|
+
// Said, as `TourProvider`'s own `launch` path says it (FLW-9): swallowed, a launch
|
|
171
|
+
// that failed was a tour that silently did not start — in kastlan after its
|
|
172
|
+
// launcher had already turned practice mode on.
|
|
173
|
+
.catch((error: unknown) => console.error(error))
|
|
170
174
|
.finally(() => {
|
|
171
175
|
launchingRef.current = false;
|
|
172
176
|
setSettled((n) => n + 1);
|