@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 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-dismiss.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport { isComposing } from \"../lib/ime\";\n\n/*\n * # One Escape stack (round 0.34 Wave 2, DUP-1)\n *\n * Escape closes the TOPMOST thing on screen, and only that: a list open in a dialog\n * closes and the dialog stays; the next press closes the dialog.\n *\n * \"Who owns Escape\" used to be decided five ways. The anchored pickers listened on the\n * document; Modal, FullBleedDialog, PickerSheet and Popover closed in their own panel's\n * `onKeyDown` and stopped the key; CommandPalette closed without stopping it. A dialog's\n * handler sits earlier on the key's path than any document listener, so Escape in an\n * open list inside a Modal closed the MODAL and threw the form away (BUG-1, sev 4,\n * kastlan's lease dialog), and HoverMenu, the numpad and a tooltip could not be\n * dismissed inside a dialog at all. And Modal and FullBleedDialog disagreed on when a\n * child had \"claimed\" the key: a ChipInput clearing its draft kept a Modal open and\n * closed a FullBleedDialog.\n *\n * Now every overlay and picker pushes a layer while it is open ({@link useDismissLayer}),\n * last in, first out, the way `use-overlay-history` stacks the Back gesture. One\n * capture-phase listener on the document notes which layer is on top as each Escape\n * goes down — before any handler on the page runs, so a layer that closes on the way\n * cannot pass the key on to the one beneath it. That layer, and only that one, is\n * handed the key:\n *\n * - at the first layer element the key passes on its way back up ({@link takeEscape},\n * which Modal's panel, a picker's search box and the rest call from their\n * `onKeyDown`). The key is stopped there, so nothing around the layers — a\n * FloatingPanel, an app's own panel — closes with it;\n * - or, when focus is in none of them (a hover menu, a tooltip, the numpad over its\n * field, a press on the page), at the document once the key has been everywhere else.\n *\n * Layers stack in the order they OPEN, which is the order their effects run. Two that\n * open in the same commit stack child first — the one case that order gets wrong, and\n * one no overlay in the kit does: each opens on a press, after whatever it sits in.\n *\n * ## Claiming the key: one rule\n *\n * An Escape that means \"undo this step\" is not a dismissal: ChipInput clearing its\n * draft, InlineEditField cancelling its edit, the date picker's month grid going back to\n * the days. Those call {@link claimEscape}, and no layer is handed the key. The only\n * other claim is the layer owner's OWN handler — a dialog's `onKeyDown` prop calling\n * `preventDefault()` on Escape (a busy reauth dialog holding itself open, Kurvenschmiede's\n * gear wizard asking first), through {@link runOwnKeyDown}. A `preventDefault()`\n * anywhere else claims nothing: a search box keeping Chromium from clearing itself\n * must not hold a dialog open (FullBleedDialog's 0.8.0 fix, which Modal never had).\n *\n * The owner's handler hears Escape only while its own layer is on top (0.35, KS). It\n * runs before the stack is asked, so with a tooltip, a hover menu or a list open over\n * the dialog and focus on the panel, the gear wizard asked \"Discard this gear?\" and the\n * tooltip stayed. Now the layer above takes that press, and the next one is the\n * owner's. Every other key reaches the handler as before.\n *\n * A handler that STOPS the key before it reaches a layer element has taken it too —\n * that is the DOM, and it is what the kit's cell editors and confirm buttons do.\n */\n\n/** A layer's handler, read through a ref so the stack holds one stable entry per open. */\ninterface Layer {\n escape: (e: KeyboardEvent) => void;\n /** Whether it covers the page — a dialog, a sheet, a popover, a list — rather than\n * annotating it (a tooltip). Counted by {@link useOverlayOpen}. */\n overlay: boolean;\n}\n\n/** What {@link useDismissLayer} hands back: its layer while open, for\n * {@link runOwnKeyDown} to tell whether that layer is the one on top. */\nexport type DismissLayer = RefObject<Layer | null>;\n\n/** Open layers, oldest first. Module-level, because the order is ACROSS instances. */\nconst layers: Layer[] = [];\n\n/** Escapes a claimant has taken — see {@link claimEscape}. Weak: an event is garbage\n * once its dispatch is over. */\nconst claimed = new WeakSet<Event>();\n\n/** The Escape on its way through the page: the layer that was topmost as it went down,\n * and whether that layer has been handed it yet. */\nlet inFlight: { event: KeyboardEvent; layer: Layer; done: boolean } | null = null;\n\nfunction nativeOf(e: KeyboardEvent | ReactKeyboardEvent): KeyboardEvent {\n return \"nativeEvent\" in e ? e.nativeEvent : e;\n}\n\nfunction onCapture(e: KeyboardEvent): void {\n if (e.key !== \"Escape\") return;\n // An Escape inside an IME composition cancels the composition and nothing else.\n const top = isComposing(e) ? undefined : layers[layers.length - 1];\n inFlight = top ? { event: e, layer: top, done: false } : null;\n}\n\n/** The fallback: no layer element was on the key's path, or none took it. */\nfunction onBubble(e: KeyboardEvent): void {\n handOver(e, false);\n}\n\nfunction handOver(e: KeyboardEvent | ReactKeyboardEvent, stop: boolean): boolean {\n const native = nativeOf(e);\n const flight = inFlight;\n if (native.key !== \"Escape\" || !flight || flight.event !== native || flight.done) return false;\n if (claimed.has(native)) return false;\n flight.done = true;\n // Consumed: a type=\"search\" box does not clear itself as well, and a handler further\n // out that checks `defaultPrevented` (FloatingPanel, an app's own panel) leaves it.\n e.preventDefault();\n if (stop) e.stopPropagation();\n // A layer that closed on the way, by a handler of its own, has had its Escape.\n if (layers.includes(flight.layer)) flight.layer.escape(native);\n return true;\n}\n\nfunction push(layer: Layer): void {\n if (layers.length === 0) {\n document.addEventListener(\"keydown\", onCapture, true);\n document.addEventListener(\"keydown\", onBubble);\n }\n layers.push(layer);\n if (layer.overlay) notifyOverlays();\n}\n\nfunction pop(layer: Layer): void {\n const at = layers.indexOf(layer);\n if (at >= 0) layers.splice(at, 1);\n if (at >= 0 && layer.overlay) notifyOverlays();\n if (layers.length > 0) return;\n document.removeEventListener(\"keydown\", onCapture, true);\n document.removeEventListener(\"keydown\", onBubble);\n inFlight = null;\n}\n\n/**\n * An Escape layer on the stack while `open`: the topmost layer is handed the next\n * Escape and calls `onEscape`, every layer under it waits its turn. A list opened inside\n * a dialog is above the dialog; a popover opened from a list, above the list.\n *\n * Where focus can be INSIDE the layer, its element's `onKeyDown` calls\n * {@link takeEscape} too, so the key stops there — see the module note. `onEscape` is\n * read live, so it need not be memoised; the layer is pushed and popped only when\n * `open` flips. The layer it returns is for the owner's {@link runOwnKeyDown}.\n */\nexport function useDismissLayer(\n open: boolean,\n onEscape: (e: KeyboardEvent) => void,\n { overlay = true }: { overlay?: boolean } = {},\n): DismissLayer {\n const handlerRef = useRef(onEscape);\n const layerRef = useRef<Layer | null>(null);\n // Written in an effect, not during render, so it satisfies react-hooks/refs: a\n // render that React starts and then discards — a transition, a Suspense retry —\n // would otherwise leave this holding a handler from a tree that never committed,\n // and the stack would go on to call it. Keydown events fire after the commit, so\n // the ref is always current by the time one arrives.\n useEffect(() => {\n handlerRef.current = onEscape;\n });\n useEffect(() => {\n if (!open || typeof document === \"undefined\") return;\n const layer: Layer = { escape: (e) => handlerRef.current(e), overlay };\n layerRef.current = layer;\n push(layer);\n return () => {\n pop(layer);\n layerRef.current = null;\n };\n }, [open, overlay]);\n return layerRef;\n}\n\n/*\n * Whether an overlay is open (0.49 KIT-11): every layer on the stack but a tooltip's is a\n * dialog, a sheet, a popover, a hover menu or a list covering part of the page, so the\n * stack is also the one place that knows. The floating launchers (`hideWhenOverlay`) step\n * aside while one is open instead of floating over it or beside it.\n */\nconst overlayListeners = new Set<() => void>();\n\nfunction notifyOverlays(): void {\n for (const listener of overlayListeners) listener();\n}\n\nfunction subscribeOverlays(listener: () => void): () => void {\n overlayListeners.add(listener);\n return () => {\n overlayListeners.delete(listener);\n };\n}\n\nfunction anyOverlayOpen(): boolean {\n return layers.some((layer) => layer.overlay);\n}\n\n/** True while a kit dialog, sheet, popover, hover menu or list is open (any layer of the\n * Escape stack but a tooltip's). Internal. */\nexport function useOverlayOpen(enabled = true): boolean {\n const open = useSyncExternalStore(subscribeOverlays, anyOverlayOpen, () => false);\n return enabled && open;\n}\n\n/**\n * For a layer's own element, in its `onKeyDown`: hands an Escape on its way up to the\n * topmost layer — this one, unless something opened over it without taking focus (a\n * tooltip, the numpad) — and stops it here. True when it did; false for any other key,\n * a claimed Escape, or one already handed over.\n */\nexport function takeEscape(e: KeyboardEvent | ReactKeyboardEvent): boolean {\n return handOver(e, true);\n}\n\n/**\n * For an Escape that means \"undo this step\", not \"close\": the key is the caller's, and\n * no layer is handed it. `preventDefault()`s it as well, so a handler further out that\n * checks `defaultPrevented` leaves it alone, as before.\n */\nexport function claimEscape(e: KeyboardEvent | ReactKeyboardEvent): void {\n claimed.add(nativeOf(e));\n e.preventDefault();\n}\n\n/**\n * Runs a layer owner's own key handler — a dialog's `onKeyDown` prop — and turns a\n * `preventDefault()` it makes on Escape into a claim: the one way, beside\n * {@link claimEscape}, to keep a layer open.\n *\n * Given the owner's `layer` (what {@link useDismissLayer} returned), an Escape that went\n * down while another layer was above that one skips the handler: the press is the\n * layer's on top, and the owner hears the next (0.35, KS). Without it, or while the\n * owner's layer is not open, the handler sees every key, as before.\n */\nexport function runOwnKeyDown<E extends ReactKeyboardEvent>(\n e: E,\n handler: ((e: E) => void) | undefined,\n layer?: DismissLayer,\n): void {\n if (!handler) return;\n if (layer && aboveOwner(e, layer.current)) return;\n const before = e.defaultPrevented;\n handler(e);\n if (e.key === \"Escape\" && !before && e.defaultPrevented) claimed.add(nativeOf(e));\n}\n\n/** Whether this Escape went down with a layer above `owner` — the topmost one then was\n * another, and `owner` is on the stack. An Escape the stack did not note (one inside an\n * IME composition) is nobody else's. */\nfunction aboveOwner(e: ReactKeyboardEvent, owner: Layer | null): boolean {\n const flight = inFlight;\n if (e.key !== \"Escape\" || !owner || !flight || flight.event !== nativeOf(e)) return false;\n return flight.layer !== owner && layers.includes(owner);\n}\n\n/**\n * Calls `handler` on an Escape while `enabled` — as an Escape layer. Since 0.34 this is\n * {@link useDismissLayer} with its arguments the other way round: it hears the key only\n * while nothing opened above it, and inside a dialog it no longer loses the key to the\n * dialog (Wave 2, DUP-1). `handler` is read live, so it need not be memoised.\n */\nexport function useEscapeKey(handler: () => void, enabled = true): void {\n useDismissLayer(enabled, handler);\n}\n\n/** Called with each native press an owner's React tree saw — see {@link useOutsideClick}. */\ntype PressMark = (press: Event) => void;\n\n/** The outside-click hooks watching each ref, for {@link pressInside}. */\nconst watchers = new WeakMap<RefObject<HTMLElement | null>, Set<PressMark>>();\n\n/** What an `onPointerDownCapture` hands over: a React event, its press inside. */\ntype ReactPress = { nativeEvent: Event };\n\n/**\n * An `onPointerDownCapture` for a panel whose `ref` an outside-click hook watches but\n * whose element the hook's owner does not render — {@link DropdownPanel} and\n * `ComboboxPanel` take `panelRef` from `useDropdown` / `useComboboxCore`. Every press\n * that reaches the panel through the React tree, a portalled list opened inside it\n * included, is marked inside for each hook watching `ref`. See {@link useOutsideClick}.\n */\nexport function pressInside(ref: RefObject<HTMLElement | null> | undefined): ((e: ReactPress) => void) | undefined {\n if (!ref) return undefined;\n return (e) => {\n watchers.get(ref)?.forEach((mark) => mark(e.nativeEvent));\n };\n}\n\n/**\n * Calls `handler` on a press that lands outside every supplied element, while\n * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one\n * ref or several (trigger + panel); a press inside any of them is ignored. Refs and\n * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and\n * won't churn the listener.\n *\n * ## A panel opened from inside is inside (0.48, keksdose dev#623)\n *\n * A CurrencySelect in a Popover: the currency list is portalled to `<body>`, so in the\n * DOM it is outside the popover's panel, and the first press on a currency closed the\n * POPOVER at pointerdown — the list went with it and the choice never landed. The same\n * for a combobox, a menu, a date picker's popover, a dropdown, at any depth.\n *\n * In the React tree the portal is still where it was opened from, and React passes a\n * press up that tree, through portals. So the hook hands back an\n * `onPointerDownCapture`: put it on the element the press should count as inside of —\n * the kit's Popover panel, HoverMenu, DropdownPanel and ComboboxPanel do — and every\n * press that travels through that element in React, portalled panels opened inside it\n * included, is not outside. React's listeners sit on the root and on each portal's\n * container, so they hear the press before this document listener does. No owner chain\n * to keep: depth is free, and an app's own portal inside a kit panel is covered too. A\n * panel the hook's owner does not render takes {@link pressInside} with the watched ref.\n *\n * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.\n * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED\n * event: the browser emits it for most taps, which is why nothing obviously broke,\n * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands\n * on an element which preventDefaults the touch sequence, may never produce one — and\n * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,\n * inheriting that is worse than choosing it.\n *\n * The one behaviour this changes deliberately: a touch-drag that starts outside an\n * open panel now dismisses it at touch-down, where before it did not dismiss at all\n * (a drag synthesises no mousedown). That matches every other platform surface, and\n * the anchored panels re-measure on scroll precisely because the page moves under\n * them.\n */\nexport function useOutsideClick(\n refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],\n handler: () => void,\n enabled = true,\n): (e: ReactPress) => void {\n const refsRef = useRef(refs);\n const handlerRef = useRef(handler);\n // The last press this owner's React tree saw: the one the document listener is about\n // to hear, when it went through a panel the owner marked.\n const insideRef = useRef<Event | null>(null);\n // In an effect rather than during render — see the note in useDismissLayer.\n useEffect(() => {\n refsRef.current = refs;\n handlerRef.current = handler;\n });\n const markInside = useCallback((e: ReactPress) => {\n insideRef.current = e.nativeEvent;\n }, []);\n useEffect(() => {\n if (!enabled) return;\n const mark: PressMark = (press) => {\n insideRef.current = press;\n };\n const watched = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of watched) {\n const set = watchers.get(r) ?? new Set<PressMark>();\n set.add(mark);\n watchers.set(r, set);\n }\n const onPointerDown = (e: Event) => {\n const inside = insideRef.current === e;\n insideRef.current = null;\n if (inside) return;\n const target = e.target as Node;\n const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of list) {\n if (r.current?.contains(target)) return;\n }\n handlerRef.current();\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointerDown);\n for (const r of watched) watchers.get(r)?.delete(mark);\n };\n }, [enabled]);\n return markInside;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-dismiss.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport { isComposing } from \"../lib/ime\";\n\n/*\n * # One Escape stack (round 0.34 Wave 2, DUP-1)\n *\n * Escape closes the TOPMOST thing on screen, and only that: a list open in a dialog\n * closes and the dialog stays; the next press closes the dialog.\n *\n * \"Who owns Escape\" used to be decided five ways. The anchored pickers listened on the\n * document; Modal, FullBleedDialog, PickerSheet and Popover closed in their own panel's\n * `onKeyDown` and stopped the key; CommandPalette closed without stopping it. A dialog's\n * handler sits earlier on the key's path than any document listener, so Escape in an\n * open list inside a Modal closed the MODAL and threw the form away (BUG-1, sev 4,\n * kastlan's lease dialog), and HoverMenu, the numpad and a tooltip could not be\n * dismissed inside a dialog at all. And Modal and FullBleedDialog disagreed on when a\n * child had \"claimed\" the key: a ChipInput clearing its draft kept a Modal open and\n * closed a FullBleedDialog.\n *\n * Now every overlay and picker pushes a layer while it is open ({@link useDismissLayer}),\n * last in, first out, the way `use-overlay-history` stacks the Back gesture. One\n * capture-phase listener on the document notes which layer is on top as each Escape\n * goes down — before any handler on the page runs, so a layer that closes on the way\n * cannot pass the key on to the one beneath it. That layer, and only that one, is\n * handed the key:\n *\n * - at the first layer element the key passes on its way back up ({@link takeEscape},\n * which Modal's panel, a picker's search box and the rest call from their\n * `onKeyDown`). The key is stopped there, so nothing around the layers — a\n * FloatingPanel, an app's own panel — closes with it;\n * - or, when focus is in none of them (a hover menu, a tooltip, the numpad over its\n * field, a press on the page), at the document once the key has been everywhere else.\n *\n * Layers stack in the order they OPEN, which is the order their effects run. Two that\n * open in the same commit stack child first — the one case that order gets wrong, and\n * one no overlay in the kit does: each opens on a press, after whatever it sits in.\n *\n * ## Claiming the key: one rule\n *\n * An Escape that means \"undo this step\" is not a dismissal: ChipInput clearing its\n * draft, InlineEditField cancelling its edit, the date picker's month grid going back to\n * the days. Those call {@link claimEscape}, and no layer is handed the key. The only\n * other claim is the layer owner's OWN handler — a dialog's `onKeyDown` prop calling\n * `preventDefault()` on Escape (Kurvenschmiede's gear wizard asking first), through\n * {@link runOwnKeyDown}. A dialog holding itself open while its request runs passes\n * Modal's `busy` instead (0.51.7, DUP-3), which holds the backdrop and Back as well. A `preventDefault()`\n * anywhere else claims nothing: a search box keeping Chromium from clearing itself\n * must not hold a dialog open (FullBleedDialog's 0.8.0 fix, which Modal never had).\n *\n * The owner's handler hears Escape only while its own layer is on top (0.35, KS). It\n * runs before the stack is asked, so with a tooltip, a hover menu or a list open over\n * the dialog and focus on the panel, the gear wizard asked \"Discard this gear?\" and the\n * tooltip stayed. Now the layer above takes that press, and the next one is the\n * owner's. Every other key reaches the handler as before.\n *\n * A handler that STOPS the key before it reaches a layer element has taken it too —\n * that is the DOM, and it is what the kit's cell editors and confirm buttons do.\n */\n\n/** A layer's handler, read through a ref so the stack holds one stable entry per open. */\ninterface Layer {\n escape: (e: KeyboardEvent) => void;\n /** Whether it covers the page — a dialog, a sheet, a popover, a list — rather than\n * annotating it (a tooltip). Counted by {@link useOverlayOpen}. */\n overlay: boolean;\n}\n\n/** What {@link useDismissLayer} hands back: its layer while open, for\n * {@link runOwnKeyDown} to tell whether that layer is the one on top. */\nexport type DismissLayer = RefObject<Layer | null>;\n\n/** Open layers, oldest first. Module-level, because the order is ACROSS instances. */\nconst layers: Layer[] = [];\n\n/** Escapes a claimant has taken — see {@link claimEscape}. Weak: an event is garbage\n * once its dispatch is over. */\nconst claimed = new WeakSet<Event>();\n\n/** The Escape on its way through the page: the layer that was topmost as it went down,\n * and whether that layer has been handed it yet. */\nlet inFlight: { event: KeyboardEvent; layer: Layer; done: boolean } | null = null;\n\nfunction nativeOf(e: KeyboardEvent | ReactKeyboardEvent): KeyboardEvent {\n return \"nativeEvent\" in e ? e.nativeEvent : e;\n}\n\nfunction onCapture(e: KeyboardEvent): void {\n if (e.key !== \"Escape\") return;\n // An Escape inside an IME composition cancels the composition and nothing else.\n const top = isComposing(e) ? undefined : layers[layers.length - 1];\n inFlight = top ? { event: e, layer: top, done: false } : null;\n}\n\n/** The fallback: no layer element was on the key's path, or none took it. */\nfunction onBubble(e: KeyboardEvent): void {\n handOver(e, false);\n}\n\nfunction handOver(e: KeyboardEvent | ReactKeyboardEvent, stop: boolean): boolean {\n const native = nativeOf(e);\n const flight = inFlight;\n if (native.key !== \"Escape\" || !flight || flight.event !== native || flight.done) return false;\n if (claimed.has(native)) return false;\n flight.done = true;\n // Consumed: a type=\"search\" box does not clear itself as well, and a handler further\n // out that checks `defaultPrevented` (FloatingPanel, an app's own panel) leaves it.\n e.preventDefault();\n if (stop) e.stopPropagation();\n // A layer that closed on the way, by a handler of its own, has had its Escape.\n if (layers.includes(flight.layer)) flight.layer.escape(native);\n return true;\n}\n\nfunction push(layer: Layer): void {\n if (layers.length === 0) {\n document.addEventListener(\"keydown\", onCapture, true);\n document.addEventListener(\"keydown\", onBubble);\n }\n layers.push(layer);\n if (layer.overlay) notifyOverlays();\n}\n\nfunction pop(layer: Layer): void {\n const at = layers.indexOf(layer);\n if (at >= 0) layers.splice(at, 1);\n if (at >= 0 && layer.overlay) notifyOverlays();\n if (layers.length > 0) return;\n document.removeEventListener(\"keydown\", onCapture, true);\n document.removeEventListener(\"keydown\", onBubble);\n inFlight = null;\n}\n\n/**\n * An Escape layer on the stack while `open`: the topmost layer is handed the next\n * Escape and calls `onEscape`, every layer under it waits its turn. A list opened inside\n * a dialog is above the dialog; a popover opened from a list, above the list.\n *\n * Where focus can be INSIDE the layer, its element's `onKeyDown` calls\n * {@link takeEscape} too, so the key stops there — see the module note. `onEscape` is\n * read live, so it need not be memoised; the layer is pushed and popped only when\n * `open` flips. The layer it returns is for the owner's {@link runOwnKeyDown}.\n */\nexport function useDismissLayer(\n open: boolean,\n onEscape: (e: KeyboardEvent) => void,\n { overlay = true }: { overlay?: boolean } = {},\n): DismissLayer {\n const handlerRef = useRef(onEscape);\n const layerRef = useRef<Layer | null>(null);\n // Written in an effect, not during render, so it satisfies react-hooks/refs: a\n // render that React starts and then discards — a transition, a Suspense retry —\n // would otherwise leave this holding a handler from a tree that never committed,\n // and the stack would go on to call it. Keydown events fire after the commit, so\n // the ref is always current by the time one arrives.\n useEffect(() => {\n handlerRef.current = onEscape;\n });\n useEffect(() => {\n if (!open || typeof document === \"undefined\") return;\n const layer: Layer = { escape: (e) => handlerRef.current(e), overlay };\n layerRef.current = layer;\n push(layer);\n return () => {\n pop(layer);\n layerRef.current = null;\n };\n }, [open, overlay]);\n return layerRef;\n}\n\n/*\n * Whether an overlay is open (0.49 KIT-11): every layer on the stack but a tooltip's is a\n * dialog, a sheet, a popover, a hover menu or a list covering part of the page, so the\n * stack is also the one place that knows. The floating launchers (`hideWhenOverlay`) step\n * aside while one is open instead of floating over it or beside it.\n */\nconst overlayListeners = new Set<() => void>();\n\nfunction notifyOverlays(): void {\n for (const listener of overlayListeners) listener();\n}\n\nfunction subscribeOverlays(listener: () => void): () => void {\n overlayListeners.add(listener);\n return () => {\n overlayListeners.delete(listener);\n };\n}\n\nfunction anyOverlayOpen(): boolean {\n return layers.some((layer) => layer.overlay);\n}\n\n/** True while a kit dialog, sheet, popover, hover menu or list is open (any layer of the\n * Escape stack but a tooltip's). Internal. */\nexport function useOverlayOpen(enabled = true): boolean {\n const open = useSyncExternalStore(subscribeOverlays, anyOverlayOpen, () => false);\n return enabled && open;\n}\n\n/**\n * For a layer's own element, in its `onKeyDown`: hands an Escape on its way up to the\n * topmost layer — this one, unless something opened over it without taking focus (a\n * tooltip, the numpad) — and stops it here. True when it did; false for any other key,\n * a claimed Escape, or one already handed over.\n */\nexport function takeEscape(e: KeyboardEvent | ReactKeyboardEvent): boolean {\n return handOver(e, true);\n}\n\n/**\n * For an Escape that means \"undo this step\", not \"close\": the key is the caller's, and\n * no layer is handed it. `preventDefault()`s it as well, so a handler further out that\n * checks `defaultPrevented` leaves it alone, as before.\n */\nexport function claimEscape(e: KeyboardEvent | ReactKeyboardEvent): void {\n claimed.add(nativeOf(e));\n e.preventDefault();\n}\n\n/**\n * Runs a layer owner's own key handler — a dialog's `onKeyDown` prop — and turns a\n * `preventDefault()` it makes on Escape into a claim: the one way, beside\n * {@link claimEscape}, to keep a layer open.\n *\n * Given the owner's `layer` (what {@link useDismissLayer} returned), an Escape that went\n * down while another layer was above that one skips the handler: the press is the\n * layer's on top, and the owner hears the next (0.35, KS). Without it, or while the\n * owner's layer is not open, the handler sees every key, as before.\n */\nexport function runOwnKeyDown<E extends ReactKeyboardEvent>(\n e: E,\n handler: ((e: E) => void) | undefined,\n layer?: DismissLayer,\n): void {\n if (!handler) return;\n if (layer && aboveOwner(e, layer.current)) return;\n const before = e.defaultPrevented;\n handler(e);\n if (e.key === \"Escape\" && !before && e.defaultPrevented) claimed.add(nativeOf(e));\n}\n\n/** Whether this Escape went down with a layer above `owner` — the topmost one then was\n * another, and `owner` is on the stack. An Escape the stack did not note (one inside an\n * IME composition) is nobody else's. */\nfunction aboveOwner(e: ReactKeyboardEvent, owner: Layer | null): boolean {\n const flight = inFlight;\n if (e.key !== \"Escape\" || !owner || !flight || flight.event !== nativeOf(e)) return false;\n return flight.layer !== owner && layers.includes(owner);\n}\n\n/**\n * Calls `handler` on an Escape while `enabled` — as an Escape layer. Since 0.34 this is\n * {@link useDismissLayer} with its arguments the other way round: it hears the key only\n * while nothing opened above it, and inside a dialog it no longer loses the key to the\n * dialog (Wave 2, DUP-1). `handler` is read live, so it need not be memoised.\n */\nexport function useEscapeKey(handler: () => void, enabled = true): void {\n useDismissLayer(enabled, handler);\n}\n\n/** Called with each native press an owner's React tree saw — see {@link useOutsideClick}. */\ntype PressMark = (press: Event) => void;\n\n/** The outside-click hooks watching each ref, for {@link pressInside}. */\nconst watchers = new WeakMap<RefObject<HTMLElement | null>, Set<PressMark>>();\n\n/** What an `onPointerDownCapture` hands over: a React event, its press inside. */\ntype ReactPress = { nativeEvent: Event };\n\n/**\n * An `onPointerDownCapture` for a panel whose `ref` an outside-click hook watches but\n * whose element the hook's owner does not render — {@link DropdownPanel} and\n * `ComboboxPanel` take `panelRef` from `useDropdown` / `useComboboxCore`. Every press\n * that reaches the panel through the React tree, a portalled list opened inside it\n * included, is marked inside for each hook watching `ref`. See {@link useOutsideClick}.\n */\nexport function pressInside(ref: RefObject<HTMLElement | null> | undefined): ((e: ReactPress) => void) | undefined {\n if (!ref) return undefined;\n return (e) => {\n watchers.get(ref)?.forEach((mark) => mark(e.nativeEvent));\n };\n}\n\n/**\n * Calls `handler` on a press that lands outside every supplied element, while\n * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one\n * ref or several (trigger + panel); a press inside any of them is ignored. Refs and\n * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and\n * won't churn the listener.\n *\n * ## A panel opened from inside is inside (0.48, keksdose dev#623)\n *\n * A CurrencySelect in a Popover: the currency list is portalled to `<body>`, so in the\n * DOM it is outside the popover's panel, and the first press on a currency closed the\n * POPOVER at pointerdown — the list went with it and the choice never landed. The same\n * for a combobox, a menu, a date picker's popover, a dropdown, at any depth.\n *\n * In the React tree the portal is still where it was opened from, and React passes a\n * press up that tree, through portals. So the hook hands back an\n * `onPointerDownCapture`: put it on the element the press should count as inside of —\n * the kit's Popover panel, HoverMenu, DropdownPanel and ComboboxPanel do — and every\n * press that travels through that element in React, portalled panels opened inside it\n * included, is not outside. React's listeners sit on the root and on each portal's\n * container, so they hear the press before this document listener does. No owner chain\n * to keep: depth is free, and an app's own portal inside a kit panel is covered too. A\n * panel the hook's owner does not render takes {@link pressInside} with the watched ref.\n *\n * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.\n * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED\n * event: the browser emits it for most taps, which is why nothing obviously broke,\n * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands\n * on an element which preventDefaults the touch sequence, may never produce one — and\n * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,\n * inheriting that is worse than choosing it.\n *\n * The one behaviour this changes deliberately: a touch-drag that starts outside an\n * open panel now dismisses it at touch-down, where before it did not dismiss at all\n * (a drag synthesises no mousedown). That matches every other platform surface, and\n * the anchored panels re-measure on scroll precisely because the page moves under\n * them.\n */\nexport function useOutsideClick(\n refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],\n handler: () => void,\n enabled = true,\n): (e: ReactPress) => void {\n const refsRef = useRef(refs);\n const handlerRef = useRef(handler);\n // The last press this owner's React tree saw: the one the document listener is about\n // to hear, when it went through a panel the owner marked.\n const insideRef = useRef<Event | null>(null);\n // In an effect rather than during render — see the note in useDismissLayer.\n useEffect(() => {\n refsRef.current = refs;\n handlerRef.current = handler;\n });\n const markInside = useCallback((e: ReactPress) => {\n insideRef.current = e.nativeEvent;\n }, []);\n useEffect(() => {\n if (!enabled) return;\n const mark: PressMark = (press) => {\n insideRef.current = press;\n };\n const watched = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of watched) {\n const set = watchers.get(r) ?? new Set<PressMark>();\n set.add(mark);\n watchers.set(r, set);\n }\n const onPointerDown = (e: Event) => {\n const inside = insideRef.current === e;\n insideRef.current = null;\n if (inside) return;\n const target = e.target as Node;\n const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of list) {\n if (r.current?.contains(target)) return;\n }\n handlerRef.current();\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointerDown);\n for (const r of watched) watchers.get(r)?.delete(mark);\n };\n }, [enabled]);\n return markInside;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -4,5 +4,11 @@
|
|
|
4
4
|
* @param open whether the overlay is currently showing. Components that mount
|
|
5
5
|
* only while open (the common case) pass `true`.
|
|
6
6
|
* @param onClose the overlay's own close handler — the same one Escape calls.
|
|
7
|
+
* @param options.held the overlay refuses to close right now (a `Modal` whose request
|
|
8
|
+
* is in flight, DUP-3): Back does not call `onClose`, and the entry the
|
|
9
|
+
* press took is put back, so the overlay still has one for the next
|
|
10
|
+
* Back. Read when Back is pressed. Default false.
|
|
7
11
|
*/
|
|
8
|
-
export declare function useOverlayHistory(open: boolean, onClose: () => void
|
|
12
|
+
export declare function useOverlayHistory(open: boolean, onClose: () => void, { held }?: {
|
|
13
|
+
held?: boolean;
|
|
14
|
+
}): void;
|
|
@@ -170,6 +170,10 @@ function closeOnPop() {
|
|
|
170
170
|
const top = stack[stack.length - 1];
|
|
171
171
|
if (!top) return;
|
|
172
172
|
if (currentSentinel() === top.id) return;
|
|
173
|
+
if (top.held()) {
|
|
174
|
+
putBack(top.id);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
173
177
|
stack.pop();
|
|
174
178
|
consumed.add(top.id);
|
|
175
179
|
const record = pushed.find((p) => p.id === top.id);
|
|
@@ -177,6 +181,14 @@ function closeOnPop() {
|
|
|
177
181
|
if (record && !record.dead) carryOnto(record.seen, record);
|
|
178
182
|
top.close();
|
|
179
183
|
}
|
|
184
|
+
function putBack(id) {
|
|
185
|
+
const record = pushed.find((p) => p.id === id);
|
|
186
|
+
if (record && !record.dead) carryOnto(record.seen, record);
|
|
187
|
+
const prev = window.history.state;
|
|
188
|
+
window.history.pushState({ ...prev && typeof prev === "object" ? prev : {}, [SENTINEL_KEY]: id }, "");
|
|
189
|
+
standing = id;
|
|
190
|
+
if (record) record.href = record.seen = currentHref();
|
|
191
|
+
}
|
|
180
192
|
function ensureListening() {
|
|
181
193
|
if (listening || typeof window === "undefined") return;
|
|
182
194
|
window.addEventListener("popstate", handlePop);
|
|
@@ -185,16 +197,18 @@ function ensureListening() {
|
|
|
185
197
|
for (const type of ["click", "keyup", "change", "input"]) window.addEventListener(type, noteSeenNowAndLater);
|
|
186
198
|
listening = true;
|
|
187
199
|
}
|
|
188
|
-
function useOverlayHistory(open, onClose) {
|
|
200
|
+
function useOverlayHistory(open, onClose, { held = false } = {}) {
|
|
189
201
|
const closeRef = useRef(onClose);
|
|
202
|
+
const heldRef = useRef(held);
|
|
190
203
|
useEffect(() => {
|
|
191
204
|
closeRef.current = onClose;
|
|
205
|
+
heldRef.current = held;
|
|
192
206
|
});
|
|
193
207
|
useEffect(() => {
|
|
194
208
|
if (!open || typeof window === "undefined") return;
|
|
195
209
|
ensureListening();
|
|
196
210
|
const id = `overlay-${nextId += 1}`;
|
|
197
|
-
const entry = { id, close: () => closeRef.current() };
|
|
211
|
+
const entry = { id, close: () => closeRef.current(), held: () => heldRef.current };
|
|
198
212
|
stack.push(entry);
|
|
199
213
|
const prev = window.history.state;
|
|
200
214
|
const marked = { ...prev && typeof prev === "object" ? prev : {}, [SENTINEL_KEY]: id };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-overlay-history.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\n\n/**\n * Back closes the overlay, not the page (Keksdose feedback #172).\n *\n * On a phone the Back gesture is the universal \"dismiss this\" — so a dialog or a\n * full-screen search surface that ignores it doesn't merely fail to close: the\n * navigation lands somewhere else entirely, and on a route with nothing beneath it\n * that means leaving the app with the overlay still notionally open.\n *\n * The mechanism is one throwaway, SAME-URL history entry pushed while the overlay is\n * up. Back then pops a same-document entry — no route change, no re-render of the\n * page under it — and this hook turns that pop into `onClose()`. Same trick the\n * transactions page already uses for its unsaved-edit trap\n * (`use-soft-leave-guard.ts`); the two coexist because they use different marker\n * keys AND because `handlePop` only claims a pop that actually consumed OUR entry\n * — a foreign sentinel stacked on top of ours pops back ONTO us, and an overlay\n * that closed on that would vanish mid-edit (feedback #424/#426).\n *\n * Not a URL parameter, deliberately. Encoding \"a dialog is open\" in the address bar\n * makes it shareable and restorable, which is wrong for the overlays this covers —\n * nobody wants to send someone a link that opens a confirm dialog — and it would\n * fight the page-owned `?row=` / `f.*` params that ARE meaningful state.\n *\n * ## Ordering\n *\n * The stack is module-level because correctness here is about order ACROSS\n * instances: every mounted overlay hears the same `popstate`, but only the one whose\n * entry was actually popped may close. Overlays nest and unwind last-in-first-out —\n * a dialog opened from a search surface closes before the surface — so the top of\n * this stack is by construction the entry the browser just popped.\n *\n * ## Not undoing a real navigation\n *\n * The cleanup pops our entry when the overlay closes some other way (a button,\n * Escape, a backdrop click) so history doesn't silently accumulate dead entries. It\n * first checks that our marker is still the CURRENT entry: if the user followed a\n * link while the overlay was open, the router has pushed on top of us, and a blind\n * `history.back()` there would undo their navigation.\n *\n * ## Not undoing the owner's own close\n *\n * Nor when the address under the entry changed in the same breath as the close\n * (keksdose dev #584). A page that opens its editor by PUSHING `?row=x` and closes it\n * by REPLACING the clean URL over the top entry rewrites OUR entry — the overlay sits\n * above `?row=x` — and that rewrite is what unmounts the overlay. Going back from there\n * lands on `?row=x` and the router opens the editor that was just closed. See\n * {@link noteAddress}: an address the overlay LIVED THROUGH (a filter the table wrote\n * while its sheet was up) is still unwound, because the user has acted since.\n *\n * ## Not undoing the owner's replaces either\n *\n * An address the overlay lived through is unwound as an ENTRY, never as an address\n * (keksdose live #412). A report that writes each tick of its payee sheet into the query\n * with `setSearchParams(…, { replace: true })` rewrites the overlay's own entry, and the\n * page entry beneath still carries the address from before the sheet opened — so the\n * traversal that took our entry away also took every tick with it. See {@link carryOnto}:\n * whichever way the entry goes (the unwind after a press, or a Back press), the address it\n * last showed is carried onto the entry it lands on, and the router is told.\n *\n * ## The traversal is deferred\n *\n * And it is *scheduled* rather than issued, because `history.go()` lands a task\n * later while everything this module knows is written synchronously. See\n * {@link Unwind}: an overlay opening inside that window adopts the entry instead\n * of stacking a second one on it, which is what makes the hook survive React\n * StrictMode's mount → clean up → mount (steering-design feedback #49).\n */\nconst SENTINEL_KEY = \"__hbUiOverlayHistory\";\n\ninterface OverlayEntry {\n id: string;\n close: () => void;\n}\n\nconst stack: OverlayEntry[] = [];\n\n/**\n * The sentinels we have PUSHED and not yet unwound, oldest first.\n *\n * The stack above tracks live overlays; this tracks live history entries, and the\n * two come apart in exactly one case — which is the bug this list exists for.\n * Nested overlays that close in the same commit unwind parent-first, so the dialog's\n * cleanup runs while the sheet's entry is still on top of its own. It cannot call\n * `history.back()` there (that would consume the SHEET's entry, and the sheet is\n * about to consume it itself), so its entry was simply abandoned: a husk with no\n * live owner sitting between the user and the page.\n *\n * The user paid for it on the next Back press. It popped the husk, `handlePop` found\n * an empty stack and returned, and nothing visible happened — Back had to be pressed\n * twice to leave the page.\n *\n * So an abandoned entry is recorded as `dead` instead, and whichever overlay DOES\n * get to unwind pays the debt with a single `history.go(-n)`. `href` is what keeps\n * that safe: only dead entries pushed at the address we are still sitting at are\n * unwound, so a router navigation that landed between two sentinels is never\n * reversed on the user's behalf.\n */\ninterface PushedEntry {\n id: string;\n /** The full address of the entry (query and hash included) as of the last time the\n * user acted while it was current — see {@link noteAddress}. */\n href: string;\n /** Its overlay is gone but the entry is not — someone above owes this pop. */\n dead: boolean;\n /** The router's `idx` on this entry when the marker went on — see {@link locate}. */\n idx: number | undefined;\n /** The router's `key` copied from the page entry beneath ours. */\n key: string | undefined;\n /** {@link routeOf} the address the marker was placed at. */\n route: string;\n /** The entry's address as last OBSERVED, after the user's last gesture had run its\n * handlers — what a Back press must carry down (see {@link carryOnto}). `href` is\n * the snapshot BEFORE the gesture, which the owner's-close check needs instead. */\n seen: string;\n}\nconst pushed: PushedEntry[] = [];\n\n/**\n * Entries of ours that a ROUTER NAVIGATION made from inside the overlay has left\n * behind — ours, still in the history, but no longer on top and no longer anyone's.\n *\n * Nothing can take an entry out from under a newer one, so they cannot be unwound;\n * what can be done is to not let the user land on one. A traversal that comes to\n * rest on a buried entry is carried straight on over the whole run of them, in the\n * direction it was going (see `skipBuried`): Back from the new page lands on the page\n * the overlay was opened from, Forward from there lands on the new page, and no press\n * is spent on an entry with nothing on screen for it.\n *\n * `below` / `above` count the buried entries of the same run on either side. The\n * router's `idx` tells the direction apart — except after a REPLACE navigation, when\n * the page above the run shares the run's `idx`; the two keys decide there.\n */\ninterface BuriedEntry {\n idx: number;\n /** The `idx` of the router entry that buried the run: higher after a push, the\n * same after a replace. */\n aboveIdx: number;\n /** The key of the page entry directly beneath the run. */\n belowKey: string | undefined;\n /** The key of the router entry that buried the run, when it did. */\n aboveKey: string | undefined;\n below: number;\n above: number;\n}\nconst buried = new Map<string, BuriedEntry>();\n\n/** The router `{idx, key}` of the entry the last traversal (or burial) left us on —\n * what `skipBuried` compares against to know which way the user was going. */\nlet lastSeen: { idx: number | undefined; key: string | undefined } | null = null;\n\n/** Entries a real Back press already consumed. Their overlay's cleanup still has to\n * run, and it must not mistake \"my entry is not current\" for \"my entry is buried\"\n * — it has no entry left at all. */\nconst consumed = new Set<string>();\n\n/** Pops WE caused (a cleanup unwinding its own entries) — their `popstate` is\n * bookkeeping, not a user gesture, and must not close a second overlay. A\n * `history.go(-n)` traversal fires exactly ONE popstate however far it travels. */\nlet pendingProgrammatic = 0;\nlet listening = false;\nlet nextId = 0;\n\n/**\n * The unwind a cleanup has asked for and not yet issued.\n *\n * **`history.go()` is asynchronous and everything above is written\n * synchronously**, which is the seam the whole of this block exists to close.\n * The traversal lands a task later, so an overlay that opens in the *same* tick\n * as one that closed used to push its sentinel on top of the entry that was\n * about to be popped — and the pop then took the NEW entry. The overlay left on\n * screen had no entry of its own (so Back no longer closed it) and the old one\n * stayed behind as a husk between the user and their page.\n *\n * That is not an exotic race. **React StrictMode remounts every effect** — mount,\n * clean up, mount again, all in one commit — so in development *every* dialog\n * anyone opened left a husk behind, the position drifted one entry further from\n * the page on each open, and the Back press that was meant to leave the page was\n * spent on something invisible instead (steering-design feedback #49).\n *\n * So the traversal is deferred by a task, and an overlay mounting inside that\n * window **adopts** the entry instead of pushing a second one (see the effect).\n * Adoption is `replaceState` on an entry that is already there: no traversal, no\n * window in which the count can be wrong, and the StrictMode remount costs\n * exactly nothing.\n */\ninterface Unwind {\n timer: ReturnType<typeof setTimeout>;\n /** The sentinel on the entry to leave — the top of `entries`. */\n id: string;\n href: string;\n /** That entry plus the abandoned ones directly beneath it, oldest first. */\n entries: PushedEntry[];\n}\nlet unwind: Unwind | null = null;\n\n/** Issue the deferred traversal, if the ground has not moved under it.\n *\n * A real Back press or a router navigation during the deferred task leaves us\n * somewhere that is not the entry we meant to leave, and going back from there\n * would take something that is not ours. The entries are then simply forgotten\n * rather than handed back as `dead`: a forgotten entry costs one dead Back\n * press, while an over-counted one costs the user a navigation they did make. */\nfunction runUnwind(): void {\n const job = unwind;\n unwind = null;\n if (!job) return;\n if (currentSentinel() !== job.id || currentHref() !== job.href) {\n // A router navigation landed between the cleanup and this task: the entries\n // are under it (or were replaced by it) rather than simply forgotten.\n if (taggedSentinel() === null) buryUnder(job.entries);\n return;\n }\n const bottom = job.entries[0];\n carry = { href: job.href, idx: bottom.idx, key: bottom.key };\n pendingProgrammatic += 1;\n window.history.go(-job.entries.length);\n}\n\n/** The address an unwind in flight must carry onto the entry it lands on. */\nlet carry: { href: string; idx: number | undefined; key: string | undefined } | null = null;\n\n/**\n * Put `href` — the address the overlay's entry last showed — onto the entry a traversal\n * has just landed on, and tell the router (keksdose live #412).\n *\n * Only the query and the hash can differ: an address naming another page is a\n * navigation, never carried. And only onto the page entry the overlay was opened over,\n * which our push copied the router's `{idx, key}` from — a traversal that came to rest\n * anywhere else is not ours to rewrite.\n *\n * The router heard the traversal land on the OLD address in this same event, so a\n * `replaceState` alone would leave it showing the old query under the new one. A\n * synthetic `popstate` makes it read the address again; both reads happen in one event\n * dispatch, so React commits only the second. Ours absorbs it as programmatic.\n */\nfunction carryOnto(href: string, mark: { idx: number | undefined; key: string | undefined }): void {\n const here = currentHref();\n if (here === href || routeOf(here) !== routeOf(href)) return;\n const now = routerMark();\n if (mark.idx !== undefined && now.idx !== undefined && mark.idx !== now.idx) return;\n if (mark.key !== undefined && now.key !== undefined && mark.key !== now.key) return;\n const state: unknown = window.history.state;\n window.history.replaceState(state, \"\", href);\n pendingProgrammatic += 1;\n window.dispatchEvent(new PopStateEvent(\"popstate\", { state }));\n}\n\nfunction currentHref(): string {\n return typeof window === \"undefined\" ? \"\" : window.location.href;\n}\n\n/** The router's own `{idx, key}` on the current entry, where a router has put them. */\nfunction routerMark(): { idx: number | undefined; key: string | undefined } {\n const state = typeof window !== \"undefined\" ? (window.history.state as unknown) : null;\n if (!state || typeof state !== \"object\") return { idx: undefined, key: undefined };\n const { idx, key } = state as Record<string, unknown>;\n return { idx: typeof idx === \"number\" ? idx : undefined, key: typeof key === \"string\" ? key : undefined };\n}\n\n/**\n * The part of an address that names the PAGE — the query string left out.\n *\n * A `setSearchParams(…, { replace: true })` rewrites the query on the entry the\n * overlay is standing on; that is a wipe to repair, not a move. Anything else changing\n * is a navigation. Under a hash router the page lives in the hash (`#/table?f.q=x`), so\n * a hash that starts with `#/` counts up to its own `?`; an ordinary `#fragment` does\n * not name a page and is left out.\n */\nfunction routeOf(href: string): string {\n try {\n const url = new URL(href);\n const hashRoute = url.hash.startsWith(\"#/\") ? url.hash.split(\"?\")[0] : \"\";\n return url.origin + url.pathname + hashRoute;\n } catch {\n return href;\n }\n}\n\n/**\n * Is the untagged entry we are standing on still `p`'s — merely wiped by the router —\n * or an entry the router NAVIGATED to from inside the overlay?\n *\n * The router's `idx` is the one per-entry fact that separates the two, because it is\n * the one the router itself keeps straight: a replace leaves it as it was, a push adds\n * one. Our own push copies the page's state, so our entry carries the page's `idx` and\n * a router push on top of it carries one more. The key would not do — every replace\n * mints a new one, so the wipe this repair exists for would already fail it. Neither\n * would the address alone: a push to the address we are already at is still a push.\n *\n * `idx` does not see one move, the router REPLACING our entry with a different page\n * (same `idx`), so the page named by the address must also be unchanged. Where there is\n * no `idx` to compare (no router, or one that keeps none) the address is all there is.\n */\nfunction isStillOn(p: PushedEntry): boolean {\n if (routeOf(currentHref()) !== p.route) return false;\n const { idx } = routerMark();\n return idx === undefined || p.idx === undefined || idx === p.idx;\n}\n\n/**\n * The router has navigated while our entries were on top: record which of `records`\n * (ours, oldest first, ending with the top one) it buried and which it replaced, and\n * drop them from `pushed` — none of them can be unwound any more. Returns whether it\n * recognised a navigation at all; when it cannot tell (no router `idx` to go by), it\n * leaves everything as it was and the caller falls back to marking the entry dead.\n */\nfunction buryUnder(records: PushedEntry[]): boolean {\n const top = records[records.length - 1];\n const here = routerMark();\n if (!top || top.idx === undefined || here.idx === undefined) return false;\n let run: PushedEntry[];\n if (here.idx > top.idx) {\n run = [];\n for (let i = records.length - 1; i >= 0 && records[i].idx === top.idx; i -= 1) run.unshift(records[i]);\n } else if (here.idx === top.idx && !isStillOn(top)) {\n // Replaced: the top entry IS the new page now, and gone as ours. Those beneath it\n // are buried under it.\n run = [];\n for (let i = records.length - 2; i >= 0 && records[i].idx === top.idx; i -= 1) run.unshift(records[i]);\n } else {\n return false;\n }\n run.forEach((r, i) => {\n buried.set(r.id, {\n idx: top.idx as number,\n aboveIdx: here.idx as number,\n belowKey: run[0].key,\n aboveKey: here.key,\n below: i,\n above: run.length - 1 - i,\n });\n });\n for (const r of records) forgetPushed(r.id);\n lastSeen = here;\n return true;\n}\n\n/**\n * A traversal came to rest on one of our buried entries: carry it on over the run, the\n * way it was going. Back from above (a higher `idx`, or the very entry that buried us)\n * continues down to the page beneath; Forward from that page continues up. A landing\n * from anywhere this cannot place is left alone — one idle press is the price of not\n * guessing, and a wrong guess would move the user somewhere they did not ask to go.\n */\nfunction skipBuried(from: typeof lastSeen): void {\n const tag = taggedSentinel();\n const rec = tag !== null ? buried.get(tag) : undefined;\n if (!rec || !from || stack.some((e) => e.id === tag)) return;\n let steps = 0;\n if ((from.idx !== undefined && from.idx > rec.idx) || (from.key !== undefined && from.key === rec.aboveKey)) {\n steps = -(rec.below + 1);\n } else if (\n from.idx !== undefined &&\n // Below the run, or level with it when nothing above it is (a push buried it),\n // or level with it and provably the page beneath (a replace buried it). The\n // router's first entry has no key at all, so an absent key is a key here.\n (from.idx < rec.idx || (from.idx === rec.idx && (rec.aboveIdx > rec.idx || from.key === rec.belowKey)))\n ) {\n steps = rec.above + 1;\n }\n if (steps === 0) return;\n pendingProgrammatic += 1;\n window.history.go(steps);\n}\n\nfunction forgetPushed(id: string): void {\n const at = pushed.findIndex((p) => p.id === id);\n if (at >= 0) pushed.splice(at, 1);\n}\n\n/**\n * The sentinel this module believes is on the entry we are standing on.\n *\n * `history.state` is where the tag physically sits, because it is the only\n * per-entry storage a browser has — but it is not this module's storage, so\n * since the 2026-09-22 audit it is no longer read as the last word.\n * The ROUTER owns that object and writes it WHOLE: every\n * `setSearchParams(…, { replace: true })` stamps `{usr, key, idx}` over\n * whatever was there. A `DataTable` with `urlSync` does exactly that, on mount\n * and on every filter change — so an overlay opened from such a table had its\n * tag wiped off the entry it was standing on, and then:\n *\n * * its cleanup read \"not my entry\" and abandoned a live entry as a husk;\n * * the push path below could not find itself in `pushed`, so it discarded\n * every record it had (`splice(0)`) and nothing was ever unwound again;\n * * the StrictMode adoption never matched, so in development every dialog\n * pushed a second entry and the deferred `go(-1)` ate the live one — which\n * is Back no longer closing the overlay at all.\n *\n * So `standing` answers when the tag is gone: a MISSING tag on an entry we have\n * not left is a wipe to repair rather than a verdict. The tag is still read\n * FIRST wherever it is there, because it is the one thing that can see a FOREIGN\n * same-URL entry stacked above ours (feedback #424/#426, below) — module-level\n * bookkeeping cannot, since nobody tells us about someone else's `pushState`.\n */\nlet standing: string | null = null;\n\n/** The tag actually on the current entry, with no repair and no belief. */\nfunction taggedSentinel(): string | null {\n const state = typeof window !== \"undefined\" ? window.history.state : null;\n if (!state || typeof state !== \"object\") return null;\n const id = (state as Record<string, unknown>)[SENTINEL_KEY];\n return typeof id === \"string\" ? id : null;\n}\n\n/** Put our tag back on the current entry, keeping whatever the router has since\n * written there — we are a passenger in that object, not its owner. */\nfunction retag(id: string): void {\n const prev = window.history.state;\n window.history.replaceState({ ...(prev && typeof prev === \"object\" ? prev : {}), [SENTINEL_KEY]: id }, \"\");\n}\n\nfunction currentSentinel(): string | null {\n const tag = taggedSentinel();\n if (tag !== null) {\n standing = tag;\n return tag;\n }\n if (standing === null || typeof window === \"undefined\") return null;\n // Only ever re-stamp an entry we still hold a record for. Without that guard a\n // belief left over from an overlay that is long gone — a tab that has since\n // navigated, a test file that ran another case — would be stamped onto a\n // stranger's entry, and the next cleanup would traverse off it.\n //\n // And only an entry that is still the one the marker was placed on. A row that\n // NAVIGATES (a palette result, a dialog's link) has the router push the new page\n // before the overlay's cleanup runs; that entry carries no tag either, and\n // re-stamping it made the cleanup \"unwind\" the navigation itself — the page\n // changed and changed straight back. See `isStillOn` for how the two are told apart.\n const record = pushed.find((p) => p.id === standing);\n if (!record || !isStillOn(record)) {\n standing = null;\n return null;\n }\n retag(standing);\n return standing;\n}\n\n/**\n * The user is about to act: what the current entry's address is NOW is what the\n * overlay standing on it has lived through.\n *\n * The cleanup needs to tell two same-page query rewrites apart, and the address alone\n * cannot. A `DataTable` with `urlSync` replaces `f.*` over the entry while its filter\n * sheet is up, and the sheet is closed LATER, by a press of its own — that entry is\n * still the sheet's and must be unwound (the audit's drift fix, below). A page whose\n * `?row=` drives the overlay replaces the clean URL over the entry and the overlay\n * unmounts BECAUSE of it (keksdose dev #584) — that entry is the owner's now, and\n * unwinding lands on `?row=` beneath and reopens what was just closed. What separates\n * them is whether the user acted in between: the rewrite that closes an overlay is\n * made by the very press that closes it, so it postdates this snapshot.\n *\n * Capture phase, so the snapshot is taken before any handler of the press runs.\n */\nfunction noteAddress(): void {\n const top = pushed[pushed.length - 1];\n if (!top || top.dead || !stack.some((e) => e.id === top.id)) return;\n const tag = taggedSentinel();\n // Read without `currentSentinel`'s repair: an observation must not write history.\n if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) {\n top.href = currentHref();\n top.seen = top.href;\n }\n}\n\n/** After the user's gesture has run its handlers: the address its entry now shows —\n * what a Back press must carry down (keksdose live #412). Again a little later, for a\n * handler that writes the address a task after the event. */\nfunction noteSeen(): void {\n const top = pushed[pushed.length - 1];\n if (!top || top.dead || !stack.some((e) => e.id === top.id)) return;\n const tag = taggedSentinel();\n if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) top.seen = currentHref();\n}\n\nfunction noteSeenNowAndLater(): void {\n noteSeen();\n setTimeout(noteSeen, 0);\n}\n\nfunction handlePop() {\n // A pop MOVES us, so the belief is stale by definition: re-read it from the\n // entry we have landed on before anything below consults it, or the repair in\n // `currentSentinel` would stamp the entry we just left onto the one we are on.\n standing = taggedSentinel();\n const from = lastSeen;\n lastSeen = routerMark();\n if (pendingProgrammatic > 0) {\n pendingProgrammatic -= 1;\n const job = carry;\n carry = null;\n if (job) carryOnto(job.href, job);\n return;\n }\n closeOnPop();\n skipBuried(from);\n}\n\nfunction closeOnPop() {\n // Nothing open → an ordinary navigation, which is none of our business.\n const top = stack[stack.length - 1];\n if (!top) return;\n // We landed back ON the top overlay's own entry, so that entry was NOT what the\n // pop consumed — something stacked ABOVE it was (Keksdose feedback #424/#426).\n // The app layers a second same-URL sentinel of its own on top of ours for the\n // unsaved-edit trap (`use-soft-leave-guard.ts`), and it pops that sentinel every\n // time the form goes back to clean — twice-toggling one field was enough to\n // close the whole dialog. A pop is only ours when the entry we came to rest on\n // is no longer ours: either the layer below (a different overlay's marker) or\n // the plain page entry (none at all).\n if (currentSentinel() === top.id) return;\n stack.pop();\n // The browser consumed this entry, so the overlay's own cleanup has nothing left\n // to unwind and nothing to record as owed.\n consumed.add(top.id);\n const record = pushed.find((p) => p.id === top.id);\n forgetPushed(top.id);\n // What the page wrote into the address while we were up stays (#412).\n if (record && !record.dead) carryOnto(record.seen, record);\n top.close();\n}\n\nfunction ensureListening() {\n if (listening || typeof window === \"undefined\") return;\n window.addEventListener(\"popstate\", handlePop);\n window.addEventListener(\"pointerdown\", noteAddress, true);\n window.addEventListener(\"keydown\", noteAddress, true);\n for (const type of [\"click\", \"keyup\", \"change\", \"input\"]) window.addEventListener(type, noteSeenNowAndLater);\n listening = true;\n}\n\n/**\n * Make the platform Back gesture dismiss an overlay.\n *\n * @param open whether the overlay is currently showing. Components that mount\n * only while open (the common case) pass `true`.\n * @param onClose the overlay's own close handler — the same one Escape calls.\n */\nexport function useOverlayHistory(open: boolean, onClose: () => void): void {\n // The listener fires outside React's render, so it reads the latest handler\n // through a ref rather than through the closure the effect captured.\n const closeRef = useRef(onClose);\n useEffect(() => {\n closeRef.current = onClose;\n });\n\n useEffect(() => {\n if (!open || typeof window === \"undefined\") return;\n ensureListening();\n const id = `overlay-${(nextId += 1)}`;\n const entry: OverlayEntry = { id, close: () => closeRef.current() };\n stack.push(entry);\n const prev = window.history.state;\n const marked = { ...(prev && typeof prev === \"object\" ? prev : {}), [SENTINEL_KEY]: id };\n // An entry that is on its way out and that we are still standing on is ours\n // to take over: same URL, same slot, only the marker changes. Pushing a\n // second one on top of it is what used to leave a husk on every StrictMode\n // remount — see {@link Unwind}.\n const adopting = unwind && unwind.id === currentSentinel() && unwind.href === currentHref();\n if (adopting && unwind) {\n clearTimeout(unwind.timer);\n const inherited = unwind.entries;\n unwind = null;\n // Preserve react-router's own `{usr,key,idx}` — we only swap the marker,\n // and the entry we are swapping it on is the one the router is already on.\n window.history.replaceState(marked, \"\");\n // The abandoned ones beneath come back owed; ours takes the top slot.\n for (const owed of inherited.slice(0, -1)) pushed.push({ ...owed, dead: true });\n const was = inherited[inherited.length - 1];\n pushed.push({\n id,\n href: currentHref(),\n seen: currentHref(),\n dead: false,\n idx: was.idx,\n key: was.key,\n route: was.route,\n });\n standing = id;\n } else {\n // A push DESTROYS every entry ahead of the one we are on, so anything we\n // still had recorded above our own position is gone. Kept, those would be\n // counted into a later `history.go(-n)` and the overshoot would come out\n // of a navigation the user actually made. Where we cannot tell which of\n // ours are beneath us — we are standing on a plain page entry, or on a\n // husk nobody tracks — none of them can be proven to be, so none survive.\n const under = pushed.findIndex((p) => p.id === currentSentinel());\n pushed.splice(under + 1);\n // Preserve react-router's own `{usr,key,idx}` — we only tack a marker on, and\n // the URL is unchanged, so the router treats popping this as a no-op re-render.\n window.history.pushState(marked, \"\");\n const page = routerMark();\n const at = currentHref();\n pushed.push({ id, href: at, seen: at, dead: false, idx: page.idx, key: page.key, route: routeOf(at) });\n standing = id;\n }\n return () => {\n const at = stack.indexOf(entry);\n if (at >= 0) stack.splice(at, 1);\n\n // A Back press already took this entry; there is nothing to unwind or to owe.\n if (consumed.delete(id)) return;\n\n const mine = pushed.findIndex((p) => p.id === id);\n // Still our entry — same page, same router `idx` — but its address is not the one\n // the user last acted on: the owner rewrote it as part of closing us (see\n // {@link noteAddress}). The entry is the owner's page now, and the one beneath is\n // the address it replaced — going back would restore exactly what was closed.\n // Nothing to unwind; forget the record rather than re-stamp a page we left.\n if (mine >= 0 && currentHref() !== pushed[mine].href) {\n const tag = taggedSentinel();\n if (tag === id || (tag === null && standing === id && isStillOn(pushed[mine]))) {\n pushed.splice(mine, 1);\n if (standing === id) standing = null;\n return;\n }\n }\n // Only unwind an entry that is still THE current one. If a router push landed\n // on top of it, going back would undo the user's navigation instead — and if\n // a SIBLING overlay's sentinel landed on top, that overlay is about to unwind\n // and is the one that can take ours with it.\n if (currentSentinel() !== id) {\n // A router navigation from inside the overlay: nothing to unwind — the\n // entries it left beneath the new page are skipped over instead.\n if (mine >= 0 && taggedSentinel() === null && buryUnder(pushed.slice(0, pushed.length))) return;\n if (mine >= 0) pushed[mine].dead = true;\n return;\n }\n\n // Ours plus every abandoned entry lying directly beneath it at this address.\n // One traversal, so one popstate, so one programmatic pop to absorb.\n const here = currentHref();\n let steps = 1;\n while (steps <= mine && pushed[mine - steps].dead && pushed[mine - steps].href === here) {\n steps += 1;\n }\n const leaving = pushed.splice(mine - (steps - 1), steps);\n // Scheduled, not issued: an overlay opening in this same tick takes the\n // entry over instead, which is what makes the hook survive a StrictMode\n // remount. An unwind already waiting is dropped rather than merged — it\n // was the entry ABOVE ours and something has since moved off it, so its\n // count can no longer be proven, and an over-counted traversal costs a\n // navigation the user made while an under-counted one costs one dead Back.\n if (unwind) clearTimeout(unwind.timer);\n unwind = { timer: setTimeout(runUnwind, 0), id, href: here, entries: leaving };\n };\n }, [open]);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-overlay-history.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\n\n/**\n * Back closes the overlay, not the page (Keksdose feedback #172).\n *\n * On a phone the Back gesture is the universal \"dismiss this\" — so a dialog or a\n * full-screen search surface that ignores it doesn't merely fail to close: the\n * navigation lands somewhere else entirely, and on a route with nothing beneath it\n * that means leaving the app with the overlay still notionally open.\n *\n * The mechanism is one throwaway, SAME-URL history entry pushed while the overlay is\n * up. Back then pops a same-document entry — no route change, no re-render of the\n * page under it — and this hook turns that pop into `onClose()`. Same trick the\n * transactions page already uses for its unsaved-edit trap\n * (`use-soft-leave-guard.ts`); the two coexist because they use different marker\n * keys AND because `handlePop` only claims a pop that actually consumed OUR entry\n * — a foreign sentinel stacked on top of ours pops back ONTO us, and an overlay\n * that closed on that would vanish mid-edit (feedback #424/#426).\n *\n * Not a URL parameter, deliberately. Encoding \"a dialog is open\" in the address bar\n * makes it shareable and restorable, which is wrong for the overlays this covers —\n * nobody wants to send someone a link that opens a confirm dialog — and it would\n * fight the page-owned `?row=` / `f.*` params that ARE meaningful state.\n *\n * ## Ordering\n *\n * The stack is module-level because correctness here is about order ACROSS\n * instances: every mounted overlay hears the same `popstate`, but only the one whose\n * entry was actually popped may close. Overlays nest and unwind last-in-first-out —\n * a dialog opened from a search surface closes before the surface — so the top of\n * this stack is by construction the entry the browser just popped.\n *\n * ## Not undoing a real navigation\n *\n * The cleanup pops our entry when the overlay closes some other way (a button,\n * Escape, a backdrop click) so history doesn't silently accumulate dead entries. It\n * first checks that our marker is still the CURRENT entry: if the user followed a\n * link while the overlay was open, the router has pushed on top of us, and a blind\n * `history.back()` there would undo their navigation.\n *\n * ## Not undoing the owner's own close\n *\n * Nor when the address under the entry changed in the same breath as the close\n * (keksdose dev #584). A page that opens its editor by PUSHING `?row=x` and closes it\n * by REPLACING the clean URL over the top entry rewrites OUR entry — the overlay sits\n * above `?row=x` — and that rewrite is what unmounts the overlay. Going back from there\n * lands on `?row=x` and the router opens the editor that was just closed. See\n * {@link noteAddress}: an address the overlay LIVED THROUGH (a filter the table wrote\n * while its sheet was up) is still unwound, because the user has acted since.\n *\n * ## Not undoing the owner's replaces either\n *\n * An address the overlay lived through is unwound as an ENTRY, never as an address\n * (keksdose live #412). A report that writes each tick of its payee sheet into the query\n * with `setSearchParams(…, { replace: true })` rewrites the overlay's own entry, and the\n * page entry beneath still carries the address from before the sheet opened — so the\n * traversal that took our entry away also took every tick with it. See {@link carryOnto}:\n * whichever way the entry goes (the unwind after a press, or a Back press), the address it\n * last showed is carried onto the entry it lands on, and the router is told.\n *\n * ## The traversal is deferred\n *\n * And it is *scheduled* rather than issued, because `history.go()` lands a task\n * later while everything this module knows is written synchronously. See\n * {@link Unwind}: an overlay opening inside that window adopts the entry instead\n * of stacking a second one on it, which is what makes the hook survive React\n * StrictMode's mount → clean up → mount (steering-design feedback #49).\n */\nconst SENTINEL_KEY = \"__hbUiOverlayHistory\";\n\ninterface OverlayEntry {\n id: string;\n close: () => void;\n /** The overlay refuses to close right now — see the `held` option. */\n held: () => boolean;\n}\n\nconst stack: OverlayEntry[] = [];\n\n/**\n * The sentinels we have PUSHED and not yet unwound, oldest first.\n *\n * The stack above tracks live overlays; this tracks live history entries, and the\n * two come apart in exactly one case — which is the bug this list exists for.\n * Nested overlays that close in the same commit unwind parent-first, so the dialog's\n * cleanup runs while the sheet's entry is still on top of its own. It cannot call\n * `history.back()` there (that would consume the SHEET's entry, and the sheet is\n * about to consume it itself), so its entry was simply abandoned: a husk with no\n * live owner sitting between the user and the page.\n *\n * The user paid for it on the next Back press. It popped the husk, `handlePop` found\n * an empty stack and returned, and nothing visible happened — Back had to be pressed\n * twice to leave the page.\n *\n * So an abandoned entry is recorded as `dead` instead, and whichever overlay DOES\n * get to unwind pays the debt with a single `history.go(-n)`. `href` is what keeps\n * that safe: only dead entries pushed at the address we are still sitting at are\n * unwound, so a router navigation that landed between two sentinels is never\n * reversed on the user's behalf.\n */\ninterface PushedEntry {\n id: string;\n /** The full address of the entry (query and hash included) as of the last time the\n * user acted while it was current — see {@link noteAddress}. */\n href: string;\n /** Its overlay is gone but the entry is not — someone above owes this pop. */\n dead: boolean;\n /** The router's `idx` on this entry when the marker went on — see {@link locate}. */\n idx: number | undefined;\n /** The router's `key` copied from the page entry beneath ours. */\n key: string | undefined;\n /** {@link routeOf} the address the marker was placed at. */\n route: string;\n /** The entry's address as last OBSERVED, after the user's last gesture had run its\n * handlers — what a Back press must carry down (see {@link carryOnto}). `href` is\n * the snapshot BEFORE the gesture, which the owner's-close check needs instead. */\n seen: string;\n}\nconst pushed: PushedEntry[] = [];\n\n/**\n * Entries of ours that a ROUTER NAVIGATION made from inside the overlay has left\n * behind — ours, still in the history, but no longer on top and no longer anyone's.\n *\n * Nothing can take an entry out from under a newer one, so they cannot be unwound;\n * what can be done is to not let the user land on one. A traversal that comes to\n * rest on a buried entry is carried straight on over the whole run of them, in the\n * direction it was going (see `skipBuried`): Back from the new page lands on the page\n * the overlay was opened from, Forward from there lands on the new page, and no press\n * is spent on an entry with nothing on screen for it.\n *\n * `below` / `above` count the buried entries of the same run on either side. The\n * router's `idx` tells the direction apart — except after a REPLACE navigation, when\n * the page above the run shares the run's `idx`; the two keys decide there.\n */\ninterface BuriedEntry {\n idx: number;\n /** The `idx` of the router entry that buried the run: higher after a push, the\n * same after a replace. */\n aboveIdx: number;\n /** The key of the page entry directly beneath the run. */\n belowKey: string | undefined;\n /** The key of the router entry that buried the run, when it did. */\n aboveKey: string | undefined;\n below: number;\n above: number;\n}\nconst buried = new Map<string, BuriedEntry>();\n\n/** The router `{idx, key}` of the entry the last traversal (or burial) left us on —\n * what `skipBuried` compares against to know which way the user was going. */\nlet lastSeen: { idx: number | undefined; key: string | undefined } | null = null;\n\n/** Entries a real Back press already consumed. Their overlay's cleanup still has to\n * run, and it must not mistake \"my entry is not current\" for \"my entry is buried\"\n * — it has no entry left at all. */\nconst consumed = new Set<string>();\n\n/** Pops WE caused (a cleanup unwinding its own entries) — their `popstate` is\n * bookkeeping, not a user gesture, and must not close a second overlay. A\n * `history.go(-n)` traversal fires exactly ONE popstate however far it travels. */\nlet pendingProgrammatic = 0;\nlet listening = false;\nlet nextId = 0;\n\n/**\n * The unwind a cleanup has asked for and not yet issued.\n *\n * **`history.go()` is asynchronous and everything above is written\n * synchronously**, which is the seam the whole of this block exists to close.\n * The traversal lands a task later, so an overlay that opens in the *same* tick\n * as one that closed used to push its sentinel on top of the entry that was\n * about to be popped — and the pop then took the NEW entry. The overlay left on\n * screen had no entry of its own (so Back no longer closed it) and the old one\n * stayed behind as a husk between the user and their page.\n *\n * That is not an exotic race. **React StrictMode remounts every effect** — mount,\n * clean up, mount again, all in one commit — so in development *every* dialog\n * anyone opened left a husk behind, the position drifted one entry further from\n * the page on each open, and the Back press that was meant to leave the page was\n * spent on something invisible instead (steering-design feedback #49).\n *\n * So the traversal is deferred by a task, and an overlay mounting inside that\n * window **adopts** the entry instead of pushing a second one (see the effect).\n * Adoption is `replaceState` on an entry that is already there: no traversal, no\n * window in which the count can be wrong, and the StrictMode remount costs\n * exactly nothing.\n */\ninterface Unwind {\n timer: ReturnType<typeof setTimeout>;\n /** The sentinel on the entry to leave — the top of `entries`. */\n id: string;\n href: string;\n /** That entry plus the abandoned ones directly beneath it, oldest first. */\n entries: PushedEntry[];\n}\nlet unwind: Unwind | null = null;\n\n/** Issue the deferred traversal, if the ground has not moved under it.\n *\n * A real Back press or a router navigation during the deferred task leaves us\n * somewhere that is not the entry we meant to leave, and going back from there\n * would take something that is not ours. The entries are then simply forgotten\n * rather than handed back as `dead`: a forgotten entry costs one dead Back\n * press, while an over-counted one costs the user a navigation they did make. */\nfunction runUnwind(): void {\n const job = unwind;\n unwind = null;\n if (!job) return;\n if (currentSentinel() !== job.id || currentHref() !== job.href) {\n // A router navigation landed between the cleanup and this task: the entries\n // are under it (or were replaced by it) rather than simply forgotten.\n if (taggedSentinel() === null) buryUnder(job.entries);\n return;\n }\n const bottom = job.entries[0];\n carry = { href: job.href, idx: bottom.idx, key: bottom.key };\n pendingProgrammatic += 1;\n window.history.go(-job.entries.length);\n}\n\n/** The address an unwind in flight must carry onto the entry it lands on. */\nlet carry: { href: string; idx: number | undefined; key: string | undefined } | null = null;\n\n/**\n * Put `href` — the address the overlay's entry last showed — onto the entry a traversal\n * has just landed on, and tell the router (keksdose live #412).\n *\n * Only the query and the hash can differ: an address naming another page is a\n * navigation, never carried. And only onto the page entry the overlay was opened over,\n * which our push copied the router's `{idx, key}` from — a traversal that came to rest\n * anywhere else is not ours to rewrite.\n *\n * The router heard the traversal land on the OLD address in this same event, so a\n * `replaceState` alone would leave it showing the old query under the new one. A\n * synthetic `popstate` makes it read the address again; both reads happen in one event\n * dispatch, so React commits only the second. Ours absorbs it as programmatic.\n */\nfunction carryOnto(href: string, mark: { idx: number | undefined; key: string | undefined }): void {\n const here = currentHref();\n if (here === href || routeOf(here) !== routeOf(href)) return;\n const now = routerMark();\n if (mark.idx !== undefined && now.idx !== undefined && mark.idx !== now.idx) return;\n if (mark.key !== undefined && now.key !== undefined && mark.key !== now.key) return;\n const state: unknown = window.history.state;\n window.history.replaceState(state, \"\", href);\n pendingProgrammatic += 1;\n window.dispatchEvent(new PopStateEvent(\"popstate\", { state }));\n}\n\nfunction currentHref(): string {\n return typeof window === \"undefined\" ? \"\" : window.location.href;\n}\n\n/** The router's own `{idx, key}` on the current entry, where a router has put them. */\nfunction routerMark(): { idx: number | undefined; key: string | undefined } {\n const state = typeof window !== \"undefined\" ? (window.history.state as unknown) : null;\n if (!state || typeof state !== \"object\") return { idx: undefined, key: undefined };\n const { idx, key } = state as Record<string, unknown>;\n return { idx: typeof idx === \"number\" ? idx : undefined, key: typeof key === \"string\" ? key : undefined };\n}\n\n/**\n * The part of an address that names the PAGE — the query string left out.\n *\n * A `setSearchParams(…, { replace: true })` rewrites the query on the entry the\n * overlay is standing on; that is a wipe to repair, not a move. Anything else changing\n * is a navigation. Under a hash router the page lives in the hash (`#/table?f.q=x`), so\n * a hash that starts with `#/` counts up to its own `?`; an ordinary `#fragment` does\n * not name a page and is left out.\n */\nfunction routeOf(href: string): string {\n try {\n const url = new URL(href);\n const hashRoute = url.hash.startsWith(\"#/\") ? url.hash.split(\"?\")[0] : \"\";\n return url.origin + url.pathname + hashRoute;\n } catch {\n return href;\n }\n}\n\n/**\n * Is the untagged entry we are standing on still `p`'s — merely wiped by the router —\n * or an entry the router NAVIGATED to from inside the overlay?\n *\n * The router's `idx` is the one per-entry fact that separates the two, because it is\n * the one the router itself keeps straight: a replace leaves it as it was, a push adds\n * one. Our own push copies the page's state, so our entry carries the page's `idx` and\n * a router push on top of it carries one more. The key would not do — every replace\n * mints a new one, so the wipe this repair exists for would already fail it. Neither\n * would the address alone: a push to the address we are already at is still a push.\n *\n * `idx` does not see one move, the router REPLACING our entry with a different page\n * (same `idx`), so the page named by the address must also be unchanged. Where there is\n * no `idx` to compare (no router, or one that keeps none) the address is all there is.\n */\nfunction isStillOn(p: PushedEntry): boolean {\n if (routeOf(currentHref()) !== p.route) return false;\n const { idx } = routerMark();\n return idx === undefined || p.idx === undefined || idx === p.idx;\n}\n\n/**\n * The router has navigated while our entries were on top: record which of `records`\n * (ours, oldest first, ending with the top one) it buried and which it replaced, and\n * drop them from `pushed` — none of them can be unwound any more. Returns whether it\n * recognised a navigation at all; when it cannot tell (no router `idx` to go by), it\n * leaves everything as it was and the caller falls back to marking the entry dead.\n */\nfunction buryUnder(records: PushedEntry[]): boolean {\n const top = records[records.length - 1];\n const here = routerMark();\n if (!top || top.idx === undefined || here.idx === undefined) return false;\n let run: PushedEntry[];\n if (here.idx > top.idx) {\n run = [];\n for (let i = records.length - 1; i >= 0 && records[i].idx === top.idx; i -= 1) run.unshift(records[i]);\n } else if (here.idx === top.idx && !isStillOn(top)) {\n // Replaced: the top entry IS the new page now, and gone as ours. Those beneath it\n // are buried under it.\n run = [];\n for (let i = records.length - 2; i >= 0 && records[i].idx === top.idx; i -= 1) run.unshift(records[i]);\n } else {\n return false;\n }\n run.forEach((r, i) => {\n buried.set(r.id, {\n idx: top.idx as number,\n aboveIdx: here.idx as number,\n belowKey: run[0].key,\n aboveKey: here.key,\n below: i,\n above: run.length - 1 - i,\n });\n });\n for (const r of records) forgetPushed(r.id);\n lastSeen = here;\n return true;\n}\n\n/**\n * A traversal came to rest on one of our buried entries: carry it on over the run, the\n * way it was going. Back from above (a higher `idx`, or the very entry that buried us)\n * continues down to the page beneath; Forward from that page continues up. A landing\n * from anywhere this cannot place is left alone — one idle press is the price of not\n * guessing, and a wrong guess would move the user somewhere they did not ask to go.\n */\nfunction skipBuried(from: typeof lastSeen): void {\n const tag = taggedSentinel();\n const rec = tag !== null ? buried.get(tag) : undefined;\n if (!rec || !from || stack.some((e) => e.id === tag)) return;\n let steps = 0;\n if ((from.idx !== undefined && from.idx > rec.idx) || (from.key !== undefined && from.key === rec.aboveKey)) {\n steps = -(rec.below + 1);\n } else if (\n from.idx !== undefined &&\n // Below the run, or level with it when nothing above it is (a push buried it),\n // or level with it and provably the page beneath (a replace buried it). The\n // router's first entry has no key at all, so an absent key is a key here.\n (from.idx < rec.idx || (from.idx === rec.idx && (rec.aboveIdx > rec.idx || from.key === rec.belowKey)))\n ) {\n steps = rec.above + 1;\n }\n if (steps === 0) return;\n pendingProgrammatic += 1;\n window.history.go(steps);\n}\n\nfunction forgetPushed(id: string): void {\n const at = pushed.findIndex((p) => p.id === id);\n if (at >= 0) pushed.splice(at, 1);\n}\n\n/**\n * The sentinel this module believes is on the entry we are standing on.\n *\n * `history.state` is where the tag physically sits, because it is the only\n * per-entry storage a browser has — but it is not this module's storage, so\n * since the 2026-09-22 audit it is no longer read as the last word.\n * The ROUTER owns that object and writes it WHOLE: every\n * `setSearchParams(…, { replace: true })` stamps `{usr, key, idx}` over\n * whatever was there. A `DataTable` with `urlSync` does exactly that, on mount\n * and on every filter change — so an overlay opened from such a table had its\n * tag wiped off the entry it was standing on, and then:\n *\n * * its cleanup read \"not my entry\" and abandoned a live entry as a husk;\n * * the push path below could not find itself in `pushed`, so it discarded\n * every record it had (`splice(0)`) and nothing was ever unwound again;\n * * the StrictMode adoption never matched, so in development every dialog\n * pushed a second entry and the deferred `go(-1)` ate the live one — which\n * is Back no longer closing the overlay at all.\n *\n * So `standing` answers when the tag is gone: a MISSING tag on an entry we have\n * not left is a wipe to repair rather than a verdict. The tag is still read\n * FIRST wherever it is there, because it is the one thing that can see a FOREIGN\n * same-URL entry stacked above ours (feedback #424/#426, below) — module-level\n * bookkeeping cannot, since nobody tells us about someone else's `pushState`.\n */\nlet standing: string | null = null;\n\n/** The tag actually on the current entry, with no repair and no belief. */\nfunction taggedSentinel(): string | null {\n const state = typeof window !== \"undefined\" ? window.history.state : null;\n if (!state || typeof state !== \"object\") return null;\n const id = (state as Record<string, unknown>)[SENTINEL_KEY];\n return typeof id === \"string\" ? id : null;\n}\n\n/** Put our tag back on the current entry, keeping whatever the router has since\n * written there — we are a passenger in that object, not its owner. */\nfunction retag(id: string): void {\n const prev = window.history.state;\n window.history.replaceState({ ...(prev && typeof prev === \"object\" ? prev : {}), [SENTINEL_KEY]: id }, \"\");\n}\n\nfunction currentSentinel(): string | null {\n const tag = taggedSentinel();\n if (tag !== null) {\n standing = tag;\n return tag;\n }\n if (standing === null || typeof window === \"undefined\") return null;\n // Only ever re-stamp an entry we still hold a record for. Without that guard a\n // belief left over from an overlay that is long gone — a tab that has since\n // navigated, a test file that ran another case — would be stamped onto a\n // stranger's entry, and the next cleanup would traverse off it.\n //\n // And only an entry that is still the one the marker was placed on. A row that\n // NAVIGATES (a palette result, a dialog's link) has the router push the new page\n // before the overlay's cleanup runs; that entry carries no tag either, and\n // re-stamping it made the cleanup \"unwind\" the navigation itself — the page\n // changed and changed straight back. See `isStillOn` for how the two are told apart.\n const record = pushed.find((p) => p.id === standing);\n if (!record || !isStillOn(record)) {\n standing = null;\n return null;\n }\n retag(standing);\n return standing;\n}\n\n/**\n * The user is about to act: what the current entry's address is NOW is what the\n * overlay standing on it has lived through.\n *\n * The cleanup needs to tell two same-page query rewrites apart, and the address alone\n * cannot. A `DataTable` with `urlSync` replaces `f.*` over the entry while its filter\n * sheet is up, and the sheet is closed LATER, by a press of its own — that entry is\n * still the sheet's and must be unwound (the audit's drift fix, below). A page whose\n * `?row=` drives the overlay replaces the clean URL over the entry and the overlay\n * unmounts BECAUSE of it (keksdose dev #584) — that entry is the owner's now, and\n * unwinding lands on `?row=` beneath and reopens what was just closed. What separates\n * them is whether the user acted in between: the rewrite that closes an overlay is\n * made by the very press that closes it, so it postdates this snapshot.\n *\n * Capture phase, so the snapshot is taken before any handler of the press runs.\n */\nfunction noteAddress(): void {\n const top = pushed[pushed.length - 1];\n if (!top || top.dead || !stack.some((e) => e.id === top.id)) return;\n const tag = taggedSentinel();\n // Read without `currentSentinel`'s repair: an observation must not write history.\n if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) {\n top.href = currentHref();\n top.seen = top.href;\n }\n}\n\n/** After the user's gesture has run its handlers: the address its entry now shows —\n * what a Back press must carry down (keksdose live #412). Again a little later, for a\n * handler that writes the address a task after the event. */\nfunction noteSeen(): void {\n const top = pushed[pushed.length - 1];\n if (!top || top.dead || !stack.some((e) => e.id === top.id)) return;\n const tag = taggedSentinel();\n if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) top.seen = currentHref();\n}\n\nfunction noteSeenNowAndLater(): void {\n noteSeen();\n setTimeout(noteSeen, 0);\n}\n\nfunction handlePop() {\n // A pop MOVES us, so the belief is stale by definition: re-read it from the\n // entry we have landed on before anything below consults it, or the repair in\n // `currentSentinel` would stamp the entry we just left onto the one we are on.\n standing = taggedSentinel();\n const from = lastSeen;\n lastSeen = routerMark();\n if (pendingProgrammatic > 0) {\n pendingProgrammatic -= 1;\n const job = carry;\n carry = null;\n if (job) carryOnto(job.href, job);\n return;\n }\n closeOnPop();\n skipBuried(from);\n}\n\nfunction closeOnPop() {\n // Nothing open → an ordinary navigation, which is none of our business.\n const top = stack[stack.length - 1];\n if (!top) return;\n // We landed back ON the top overlay's own entry, so that entry was NOT what the\n // pop consumed — something stacked ABOVE it was (Keksdose feedback #424/#426).\n // The app layers a second same-URL sentinel of its own on top of ours for the\n // unsaved-edit trap (`use-soft-leave-guard.ts`), and it pops that sentinel every\n // time the form goes back to clean — twice-toggling one field was enough to\n // close the whole dialog. A pop is only ours when the entry we came to rest on\n // is no longer ours: either the layer below (a different overlay's marker) or\n // the plain page entry (none at all).\n if (currentSentinel() === top.id) return;\n // Checked before anything below moves: a held overlay keeps its place and its record.\n if (top.held()) {\n putBack(top.id);\n return;\n }\n stack.pop();\n // The browser consumed this entry, so the overlay's own cleanup has nothing left\n // to unwind and nothing to record as owed.\n consumed.add(top.id);\n const record = pushed.find((p) => p.id === top.id);\n forgetPushed(top.id);\n // What the page wrote into the address while we were up stays (#412).\n if (record && !record.dead) carryOnto(record.seen, record);\n top.close();\n}\n\n/**\n * A Back press the top overlay refuses (a busy `Modal`, DUP-3). A popstate cannot be\n * refused — the browser has already left our entry — so the entry is put back: what the\n * page wrote into the address while the overlay was up is carried down first (#412), as\n * for a close, and the same marker is pushed again over it, as the mount pushed it. Left\n * on the page entry, the overlay would stay up with no entry of its own, and the Back\n * after its request would leave the page under it.\n *\n * A push and not `history.go(1)` back onto the old entry: a traversal lands a task later\n * and fires nothing at all if a listener has pushed meanwhile, and a programmatic pop\n * counted for it would then swallow the user's next real Back.\n */\nfunction putBack(id: string): void {\n const record = pushed.find((p) => p.id === id);\n if (record && !record.dead) carryOnto(record.seen, record);\n const prev: unknown = window.history.state;\n window.history.pushState({ ...(prev && typeof prev === \"object\" ? prev : {}), [SENTINEL_KEY]: id }, \"\");\n standing = id;\n // The address the entry shows now is the one the user last acted on: the cleanup's\n // owner's-close check (#584) must not read the carried address as a rewrite.\n if (record) record.href = record.seen = currentHref();\n}\n\nfunction ensureListening() {\n if (listening || typeof window === \"undefined\") return;\n window.addEventListener(\"popstate\", handlePop);\n window.addEventListener(\"pointerdown\", noteAddress, true);\n window.addEventListener(\"keydown\", noteAddress, true);\n for (const type of [\"click\", \"keyup\", \"change\", \"input\"]) window.addEventListener(type, noteSeenNowAndLater);\n listening = true;\n}\n\n/**\n * Make the platform Back gesture dismiss an overlay.\n *\n * @param open whether the overlay is currently showing. Components that mount\n * only while open (the common case) pass `true`.\n * @param onClose the overlay's own close handler — the same one Escape calls.\n * @param options.held the overlay refuses to close right now (a `Modal` whose request\n * is in flight, DUP-3): Back does not call `onClose`, and the entry the\n * press took is put back, so the overlay still has one for the next\n * Back. Read when Back is pressed. Default false.\n */\nexport function useOverlayHistory(open: boolean, onClose: () => void, { held = false }: { held?: boolean } = {}): void {\n // The listener fires outside React's render, so it reads the latest handler\n // through a ref rather than through the closure the effect captured.\n const closeRef = useRef(onClose);\n const heldRef = useRef(held);\n useEffect(() => {\n closeRef.current = onClose;\n heldRef.current = held;\n });\n\n useEffect(() => {\n if (!open || typeof window === \"undefined\") return;\n ensureListening();\n const id = `overlay-${(nextId += 1)}`;\n const entry: OverlayEntry = { id, close: () => closeRef.current(), held: () => heldRef.current };\n stack.push(entry);\n const prev = window.history.state;\n const marked = { ...(prev && typeof prev === \"object\" ? prev : {}), [SENTINEL_KEY]: id };\n // An entry that is on its way out and that we are still standing on is ours\n // to take over: same URL, same slot, only the marker changes. Pushing a\n // second one on top of it is what used to leave a husk on every StrictMode\n // remount — see {@link Unwind}.\n const adopting = unwind && unwind.id === currentSentinel() && unwind.href === currentHref();\n if (adopting && unwind) {\n clearTimeout(unwind.timer);\n const inherited = unwind.entries;\n unwind = null;\n // Preserve react-router's own `{usr,key,idx}` — we only swap the marker,\n // and the entry we are swapping it on is the one the router is already on.\n window.history.replaceState(marked, \"\");\n // The abandoned ones beneath come back owed; ours takes the top slot.\n for (const owed of inherited.slice(0, -1)) pushed.push({ ...owed, dead: true });\n const was = inherited[inherited.length - 1];\n pushed.push({\n id,\n href: currentHref(),\n seen: currentHref(),\n dead: false,\n idx: was.idx,\n key: was.key,\n route: was.route,\n });\n standing = id;\n } else {\n // A push DESTROYS every entry ahead of the one we are on, so anything we\n // still had recorded above our own position is gone. Kept, those would be\n // counted into a later `history.go(-n)` and the overshoot would come out\n // of a navigation the user actually made. Where we cannot tell which of\n // ours are beneath us — we are standing on a plain page entry, or on a\n // husk nobody tracks — none of them can be proven to be, so none survive.\n const under = pushed.findIndex((p) => p.id === currentSentinel());\n pushed.splice(under + 1);\n // Preserve react-router's own `{usr,key,idx}` — we only tack a marker on, and\n // the URL is unchanged, so the router treats popping this as a no-op re-render.\n window.history.pushState(marked, \"\");\n const page = routerMark();\n const at = currentHref();\n pushed.push({ id, href: at, seen: at, dead: false, idx: page.idx, key: page.key, route: routeOf(at) });\n standing = id;\n }\n return () => {\n const at = stack.indexOf(entry);\n if (at >= 0) stack.splice(at, 1);\n\n // A Back press already took this entry; there is nothing to unwind or to owe.\n if (consumed.delete(id)) return;\n\n const mine = pushed.findIndex((p) => p.id === id);\n // Still our entry — same page, same router `idx` — but its address is not the one\n // the user last acted on: the owner rewrote it as part of closing us (see\n // {@link noteAddress}). The entry is the owner's page now, and the one beneath is\n // the address it replaced — going back would restore exactly what was closed.\n // Nothing to unwind; forget the record rather than re-stamp a page we left.\n if (mine >= 0 && currentHref() !== pushed[mine].href) {\n const tag = taggedSentinel();\n if (tag === id || (tag === null && standing === id && isStillOn(pushed[mine]))) {\n pushed.splice(mine, 1);\n if (standing === id) standing = null;\n return;\n }\n }\n // Only unwind an entry that is still THE current one. If a router push landed\n // on top of it, going back would undo the user's navigation instead — and if\n // a SIBLING overlay's sentinel landed on top, that overlay is about to unwind\n // and is the one that can take ours with it.\n if (currentSentinel() !== id) {\n // A router navigation from inside the overlay: nothing to unwind — the\n // entries it left beneath the new page are skipped over instead.\n if (mine >= 0 && taggedSentinel() === null && buryUnder(pushed.slice(0, pushed.length))) return;\n if (mine >= 0) pushed[mine].dead = true;\n return;\n }\n\n // Ours plus every abandoned entry lying directly beneath it at this address.\n // One traversal, so one popstate, so one programmatic pop to absorb.\n const here = currentHref();\n let steps = 1;\n while (steps <= mine && pushed[mine - steps].dead && pushed[mine - steps].href === here) {\n steps += 1;\n }\n const leaving = pushed.splice(mine - (steps - 1), steps);\n // Scheduled, not issued: an overlay opening in this same tick takes the\n // entry over instead, which is what makes the hook survive a StrictMode\n // remount. An unwind already waiting is dropped rather than merged — it\n // was the entry ABOVE ours and something has since moved off it, so its\n // count can no longer be proven, and an over-counted traversal costs a\n // navigation the user made while an under-counted one costs one dead Back.\n if (unwind) clearTimeout(unwind.timer);\n unwind = { timer: setTimeout(runUnwind, 0), id, href: here, entries: leaving };\n };\n }, [open]);\n}\n"],"mappings":"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","names":[]}
|
package/dist/lib/calc.d.ts
CHANGED
|
@@ -61,7 +61,13 @@ export declare function commitExpression(raw: string): string;
|
|
|
61
61
|
* keystroke-by-keystroke but stray characters can't be entered. A lone leading
|
|
62
62
|
* `-` is kept — in a plain field it is simply a negative amount; in a field whose
|
|
63
63
|
* caller owns the sign it is read off again by {@link splitLeadingSign}. The text
|
|
64
|
-
* is resolved to a plain number on blur/Enter via {@link commitExpression}.
|
|
64
|
+
* is resolved to a plain number on blur/Enter via {@link commitExpression}.
|
|
65
|
+
*
|
|
66
|
+
* The typographic minus and the two dashes become "-" first, as in
|
|
67
|
+
* {@link evaluateExpression}: U+2212 is what the kit's own `formatMoney` and
|
|
68
|
+
* `formatNumber` print for every negative since 0.49, and stripping it with the
|
|
69
|
+
* stray characters turned a pasted "−1.234,50 €" into a positive amount (refactor
|
|
70
|
+
* audit 2026-10-30, INP-3). */
|
|
65
71
|
export declare function sanitizeLive(raw: string): string;
|
|
66
72
|
/** A bare, unsigned amount: digits with at most one decimal separator and nothing
|
|
67
73
|
* else — "42", "42.", ".5", "0,5". NOT "" (an empty field has no figure to put a
|
package/dist/lib/calc.js
CHANGED
|
@@ -141,7 +141,9 @@ function commitExpression(raw) {
|
|
|
141
141
|
return cleaned.replace(/^(-?\d*\.?\d*).*$/, "$1");
|
|
142
142
|
}
|
|
143
143
|
function sanitizeLive(raw) {
|
|
144
|
-
return dropExtraDots(
|
|
144
|
+
return dropExtraDots(
|
|
145
|
+
raw.replace(/[−–—]/g, "-").replace(/,/g, ".").replace(/[^0-9.+\-*/×÷()]/g, "")
|
|
146
|
+
);
|
|
145
147
|
}
|
|
146
148
|
function dropExtraDots(s) {
|
|
147
149
|
let out = "";
|
package/dist/lib/calc.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/calc.ts"],"sourcesContent":["/**\n * A tiny, dependency-free arithmetic evaluator for the numeric input fields.\n * It lets users type a calculation (e.g. \"12.50+3.20\" or \"100/3\") straight into\n * an amount field, or build one in the calculator popover, and have it resolve\n * to a number. Deliberately NOT backed by `eval`/`Function` — it's a hand-rolled\n * tokenizer + recursive-descent parser over a closed grammar (digits, decimals,\n * `+ - * /`, unary minus, parentheses), so no arbitrary code can run.\n */\n\ntype Token =\n | { kind: \"num\"; value: number }\n | { kind: \"op\"; value: \"+\" | \"-\" | \"*\" | \"/\" }\n | { kind: \"lparen\" }\n | { kind: \"rparen\" };\n\nfunction tokenize(input: string): Token[] | null {\n const tokens: Token[] = [];\n let i = 0;\n while (i < input.length) {\n const ch = input[i];\n if (ch === \" \") {\n i++;\n continue;\n }\n if (ch === \"+\" || ch === \"-\" || ch === \"*\" || ch === \"/\") {\n tokens.push({ kind: \"op\", value: ch });\n i++;\n continue;\n }\n if (ch === \"(\") {\n tokens.push({ kind: \"lparen\" });\n i++;\n continue;\n }\n if (ch === \")\") {\n tokens.push({ kind: \"rparen\" });\n i++;\n continue;\n }\n if ((ch >= \"0\" && ch <= \"9\") || ch === \".\") {\n let j = i;\n let dotSeen = false;\n while (j < input.length && ((input[j] >= \"0\" && input[j] <= \"9\") || input[j] === \".\")) {\n if (input[j] === \".\") {\n if (dotSeen) return null; // two dots in one number → invalid\n dotSeen = true;\n }\n j++;\n }\n const num = Number(input.slice(i, j));\n if (!Number.isFinite(num)) return null;\n tokens.push({ kind: \"num\", value: num });\n i = j;\n continue;\n }\n return null; // unknown character\n }\n return tokens;\n}\n\n/**\n * Recursive-descent parse honouring `* /` over `+ -` and left-associativity.\n * Returns `null` for any malformed input (the `null` sentinel is safe because a\n * valid result of 0 is distinct from it). Division by zero is treated as invalid.\n *\n * expr := term (('+' | '-') term)*\n * term := factor (('*' | '/') factor)*\n * factor := ('+' | '-') factor | number | '(' expr ')'\n */\nfunction parse(tokens: Token[]): number | null {\n let pos = 0;\n\n function parseExpr(): number | null {\n let value = parseTerm();\n if (value === null) return null;\n while (pos < tokens.length) {\n const tok = tokens[pos];\n if (tok.kind === \"op\" && (tok.value === \"+\" || tok.value === \"-\")) {\n pos++;\n const rhs = parseTerm();\n if (rhs === null) return null;\n value = tok.value === \"+\" ? value + rhs : value - rhs;\n } else break;\n }\n return value;\n }\n\n function parseTerm(): number | null {\n let value = parseFactor();\n if (value === null) return null;\n while (pos < tokens.length) {\n const tok = tokens[pos];\n if (tok.kind === \"op\" && (tok.value === \"*\" || tok.value === \"/\")) {\n pos++;\n const rhs = parseFactor();\n if (rhs === null) return null;\n if (tok.value === \"/\") {\n if (rhs === 0) return null;\n value = value / rhs;\n } else {\n value = value * rhs;\n }\n } else break;\n }\n return value;\n }\n\n function parseFactor(): number | null {\n const tok = tokens[pos];\n if (!tok) return null;\n if (tok.kind === \"op\" && (tok.value === \"+\" || tok.value === \"-\")) {\n pos++;\n const operand = parseFactor();\n if (operand === null) return null;\n return tok.value === \"-\" ? -operand : operand;\n }\n if (tok.kind === \"lparen\") {\n pos++;\n const inner = parseExpr();\n if (inner === null) return null;\n if (tokens[pos]?.kind !== \"rparen\") return null;\n pos++;\n return inner;\n }\n if (tok.kind === \"num\") {\n pos++;\n return tok.value;\n }\n return null;\n }\n\n const result = parseExpr();\n if (result === null) return null;\n if (pos !== tokens.length) return null; // leftover tokens → invalid\n return result;\n}\n\n/** Evaluate an arithmetic string, returning `null` if it isn't a valid sum.\n * Accepts the on-screen operator glyphs (`× ÷ −`) and comma decimals too. */\nexport function evaluateExpression(expr: string): number | null {\n const normalized = expr\n .replace(/×/g, \"*\")\n .replace(/÷/g, \"/\")\n .replace(/[−–—]/g, \"-\") // unicode minus / en / em dash → ascii\n .replace(/,/g, \".\")\n .trim();\n if (normalized === \"\") return null;\n const tokens = tokenize(normalized);\n if (tokens === null || tokens.length === 0) return null;\n const result = parse(tokens);\n if (result === null || !Number.isFinite(result)) return null;\n return result;\n}\n\n/** True when `s` carries arithmetic beyond a single leading minus — i.e. a plain\n * number (or a lone negative like \"-5\") is NOT an expression, but \"12+5\" is.\n * Used to decide whether a field's text should be evaluated on commit.\n *\n * The operator set is the one {@link evaluateExpression} accepts, on-screen glyphs\n * included: the calculator keypad inserts `× ÷ −`, and a `−` that this said was not\n * arithmetic went on to be read as a plain number — where the digits-only fallback\n * in {@link commitExpression} silently ate it, turning \"-42−−50\" (= 8) into a bare\n * 8 with no record that a sum had happened at all. */\nexport function looksLikeExpression(s: string): boolean {\n const t = s.trim();\n if (t === \"\") return false;\n if (/^-?\\d*[.,]?\\d*$/.test(t)) return false; // plain (optionally negative) decimal\n return /[+\\-*/×÷−–—()]/.test(t.replace(/^-/, \"\"));\n}\n\n/** Render an evaluated number back to a field-friendly string, trimming binary\n * float noise (so 0.1 + 0.2 reads \"0.3\", not \"0.30000000000000004\").\n *\n * FIXED notation, always, and that is the whole point of the function rather than a\n * detail of it. This used to end in `parseFloat(n.toFixed(10)).toString()`, and\n * `Number.prototype.toString` switches to EXPONENT notation below 1e-6 and at or\n * above 1e21 — an alphabet containing `e`, `+` and `-`. Neither function that reads a\n * field's text back accepts it: {@link tokenize} rejects `e` as an unknown character\n * and {@link sanitizeLive} deletes it. So {@link commitExpression} fell through to\n * its digits-only cleanup, which stripped the `e` and CONCATENATED the mantissa to\n * the exponent. A field committed its own output as a different number:\n *\n * \"1/10000000\" -> \"1e-7\" -> \"17\" (AmountInput: \"6\")\n * \"999999999999*999999999999\" -> \"9.99999999998e+23\" -> \"9.9999999999823\"\n *\n * Both controls commit on blur AND on Enter, so Enter-then-click-away was enough on\n * its own, it was silent, and it was in a money field.\n *\n * The invariant, which the test asserts directly rather than by example:\n * **`commitExpression(formatResult(n))` equals `formatResult(n)` for every finite\n * `n`.** Anything rendered here has to survive being read back. */\nexport function formatResult(n: number): string {\n if (!Number.isFinite(n)) return \"\";\n // At or above 1e21 `toFixed` gives up and hands back `toString`'s exponent form —\n // but every double that large is an integer, so BigInt renders it exactly.\n if (Math.abs(n) >= 1e21) return BigInt(n).toString();\n return trimTrailingZeros(n.toFixed(10));\n}\n\n/** \"15.7000000000\" -> \"15.7\", \"0.0000000000\" -> \"0\". The ten decimals are where the\n * binary float noise gets rounded off; this is only about not showing the padding. */\nfunction trimTrailingZeros(fixed: string): string {\n if (!fixed.includes(\".\")) return fixed;\n const trimmed = fixed.replace(/0+$/, \"\").replace(/\\.$/, \"\");\n // `toFixed` keeps the sign on a magnitude that rounded away to nothing\n // (\"-0.0000000000\"); `toString` did not, and \"-0\" in an amount field is noise.\n return trimmed === \"-0\" || trimmed === \"\" ? \"0\" : trimmed;\n}\n\n/** The single \"on blur / Enter\" transform a numeric field applies: evaluate the\n * text if it's an expression, otherwise normalise it to a bare number string\n * (comma → dot, dropping stray characters, non-leading minuses and anything after\n * a second decimal point).\n *\n * The last clause is the backstop for Keksdose live #201. A field the user typed in\n * cannot reach here malformed any more — {@link sanitizeLive} runs on every change,\n * including a paste — but a value handed in from elsewhere (a recurring rule, an\n * import, a test) still can, and \"1.2.3\" is not an expression (no operator), so it\n * used to fall straight through unchanged and be read as NaN. Here the text is CUT\n * at the second dot (\"1.2\") rather than joined (\"1.23\"): with no keystroke to\n * ignore there is nothing to say which reading was meant, and the shorter one\n * cannot silently invent a bigger number. */\nexport function commitExpression(raw: string): string {\n const s = raw.trim();\n if (looksLikeExpression(s)) {\n const n = evaluateExpression(s);\n if (n !== null) return formatResult(n);\n // Unparseable (e.g. a half-typed \"12+\"): fall through to a digits-only clean.\n }\n // Global, like sanitizeLive's: these two are the two halves of one rule and must\n // not appear to state it differently. (The `[^0-9.-]` strip on the same line\n // already ate whatever commas a non-global replace missed, so this changes no\n // behaviour — it removes the need for the next reader to run it to find that out.)\n const cleaned = s\n .replace(/,/g, \".\")\n .replace(/[^0-9.-]/g, \"\")\n .replace(/(?!^)-/g, \"\");\n return cleaned.replace(/^(-?\\d*\\.?\\d*).*$/, \"$1\");\n}\n\n/** The live, per-keystroke transform a numeric field applies: comma → dot and\n * drop anything that isn't a digit, decimal point, arithmetic operator (incl.\n * the on-screen `× ÷` glyphs) or paren — so a typed calculation survives\n * keystroke-by-keystroke but stray characters can't be entered. A lone leading\n * `-` is kept — in a plain field it is simply a negative amount; in a field whose\n * caller owns the sign it is read off again by {@link splitLeadingSign}. The text\n * is resolved to a plain number on blur/Enter via {@link commitExpression}. */\nexport function sanitizeLive(raw: string): string {\n return dropExtraDots(raw.replace(/,/g, \".\").replace(/[^0-9.+\\-*/×÷()]/g, \"\"));\n}\n\n/**\n * At most ONE decimal point per number (Keksdose live #201).\n *\n * Per NUMBER, not per string: \"1.5+2.5\" is a legitimate calculation with two dots\n * in two operands, so the counter resets at every operator and parenthesis.\n *\n * A second dot inside one operand is a **no-op keystroke** — the digits after it\n * keep landing, so \"1.2\" + \".\" + \"3\" is \"1.23\". That is what every calculator does\n * with a repeated decimal point, and it is the only option that does not make the\n * field feel broken (\"my 3 disappeared\"). This runs on paste as well as on typing,\n * so the malformed value never reaches the rest of the app: everything downstream\n * reads the field with `Number()`, NaN became a silent 0, and on the create form\n * that 0 was painted with the income green — a figure that was not a number at all\n * asserting money was coming in.\n */\nfunction dropExtraDots(s: string): string {\n let out = \"\";\n let dotInNumber = false;\n for (const ch of s) {\n if (ch === \".\") {\n if (dotInNumber) continue;\n dotInNumber = true;\n } else if (ch < \"0\" || ch > \"9\") {\n // An operator or a paren ends the current operand.\n dotInNumber = false;\n }\n out += ch;\n }\n return out;\n}\n\n/** A bare, unsigned amount: digits with at most one decimal separator and nothing\n * else — \"42\", \"42.\", \".5\", \"0,5\". NOT \"\" (an empty field has no figure to put a\n * sign in front of), not \"-42\", not \"12+5\". It is the only shape a DISPLAYED sign\n * prefix makes sense in front of; an expression carries its own arithmetic and has\n * to survive keystroke-for-keystroke. */\nexport function isBareAmount(s: string): boolean {\n const t = s.trim();\n return t !== \"\" && /^\\d*[.,]?\\d*$/.test(t);\n}\n\n/**\n * Split a LEADING sign off a plain signed number (Keksdose live #201 rework):\n * `\"-42\"` → `{ sign: \"-\", rest: \"42\" }`, everything else → `{ sign: \"\", rest: text }`.\n *\n * Only in front of a bare amount, because a minus with arithmetic after it IS\n * arithmetic: \"12-30\" is a subtraction, and \"-12+5\" is -7, not -(12+5). Those come\n * back untouched and are resolved by {@link commitExpression} on blur — at which\n * point the RESULT is a plain signed number and this function does get to speak.\n *\n * A lone \"-\" splits (rest \"\"): the sign is a statement about the direction the\n * moment it is typed, before any digit exists.\n */\nexport function splitLeadingSign(text: string): { sign: \"\" | \"-\" | \"+\"; rest: string } {\n const trimmed = text.trimStart();\n const sign = trimmed.charAt(0);\n if (sign !== \"-\" && sign !== \"+\") return { sign: \"\", rest: text };\n const rest = trimmed.slice(1);\n if (rest !== \"\" && !isBareAmount(rest)) return { sign: \"\", rest: text };\n return { sign, rest };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/calc.ts"],"sourcesContent":["/**\n * A tiny, dependency-free arithmetic evaluator for the numeric input fields.\n * It lets users type a calculation (e.g. \"12.50+3.20\" or \"100/3\") straight into\n * an amount field, or build one in the calculator popover, and have it resolve\n * to a number. Deliberately NOT backed by `eval`/`Function` — it's a hand-rolled\n * tokenizer + recursive-descent parser over a closed grammar (digits, decimals,\n * `+ - * /`, unary minus, parentheses), so no arbitrary code can run.\n */\n\ntype Token =\n | { kind: \"num\"; value: number }\n | { kind: \"op\"; value: \"+\" | \"-\" | \"*\" | \"/\" }\n | { kind: \"lparen\" }\n | { kind: \"rparen\" };\n\nfunction tokenize(input: string): Token[] | null {\n const tokens: Token[] = [];\n let i = 0;\n while (i < input.length) {\n const ch = input[i];\n if (ch === \" \") {\n i++;\n continue;\n }\n if (ch === \"+\" || ch === \"-\" || ch === \"*\" || ch === \"/\") {\n tokens.push({ kind: \"op\", value: ch });\n i++;\n continue;\n }\n if (ch === \"(\") {\n tokens.push({ kind: \"lparen\" });\n i++;\n continue;\n }\n if (ch === \")\") {\n tokens.push({ kind: \"rparen\" });\n i++;\n continue;\n }\n if ((ch >= \"0\" && ch <= \"9\") || ch === \".\") {\n let j = i;\n let dotSeen = false;\n while (j < input.length && ((input[j] >= \"0\" && input[j] <= \"9\") || input[j] === \".\")) {\n if (input[j] === \".\") {\n if (dotSeen) return null; // two dots in one number → invalid\n dotSeen = true;\n }\n j++;\n }\n const num = Number(input.slice(i, j));\n if (!Number.isFinite(num)) return null;\n tokens.push({ kind: \"num\", value: num });\n i = j;\n continue;\n }\n return null; // unknown character\n }\n return tokens;\n}\n\n/**\n * Recursive-descent parse honouring `* /` over `+ -` and left-associativity.\n * Returns `null` for any malformed input (the `null` sentinel is safe because a\n * valid result of 0 is distinct from it). Division by zero is treated as invalid.\n *\n * expr := term (('+' | '-') term)*\n * term := factor (('*' | '/') factor)*\n * factor := ('+' | '-') factor | number | '(' expr ')'\n */\nfunction parse(tokens: Token[]): number | null {\n let pos = 0;\n\n function parseExpr(): number | null {\n let value = parseTerm();\n if (value === null) return null;\n while (pos < tokens.length) {\n const tok = tokens[pos];\n if (tok.kind === \"op\" && (tok.value === \"+\" || tok.value === \"-\")) {\n pos++;\n const rhs = parseTerm();\n if (rhs === null) return null;\n value = tok.value === \"+\" ? value + rhs : value - rhs;\n } else break;\n }\n return value;\n }\n\n function parseTerm(): number | null {\n let value = parseFactor();\n if (value === null) return null;\n while (pos < tokens.length) {\n const tok = tokens[pos];\n if (tok.kind === \"op\" && (tok.value === \"*\" || tok.value === \"/\")) {\n pos++;\n const rhs = parseFactor();\n if (rhs === null) return null;\n if (tok.value === \"/\") {\n if (rhs === 0) return null;\n value = value / rhs;\n } else {\n value = value * rhs;\n }\n } else break;\n }\n return value;\n }\n\n function parseFactor(): number | null {\n const tok = tokens[pos];\n if (!tok) return null;\n if (tok.kind === \"op\" && (tok.value === \"+\" || tok.value === \"-\")) {\n pos++;\n const operand = parseFactor();\n if (operand === null) return null;\n return tok.value === \"-\" ? -operand : operand;\n }\n if (tok.kind === \"lparen\") {\n pos++;\n const inner = parseExpr();\n if (inner === null) return null;\n if (tokens[pos]?.kind !== \"rparen\") return null;\n pos++;\n return inner;\n }\n if (tok.kind === \"num\") {\n pos++;\n return tok.value;\n }\n return null;\n }\n\n const result = parseExpr();\n if (result === null) return null;\n if (pos !== tokens.length) return null; // leftover tokens → invalid\n return result;\n}\n\n/** Evaluate an arithmetic string, returning `null` if it isn't a valid sum.\n * Accepts the on-screen operator glyphs (`× ÷ −`) and comma decimals too. */\nexport function evaluateExpression(expr: string): number | null {\n const normalized = expr\n .replace(/×/g, \"*\")\n .replace(/÷/g, \"/\")\n .replace(/[−–—]/g, \"-\") // unicode minus / en / em dash → ascii\n .replace(/,/g, \".\")\n .trim();\n if (normalized === \"\") return null;\n const tokens = tokenize(normalized);\n if (tokens === null || tokens.length === 0) return null;\n const result = parse(tokens);\n if (result === null || !Number.isFinite(result)) return null;\n return result;\n}\n\n/** True when `s` carries arithmetic beyond a single leading minus — i.e. a plain\n * number (or a lone negative like \"-5\") is NOT an expression, but \"12+5\" is.\n * Used to decide whether a field's text should be evaluated on commit.\n *\n * The operator set is the one {@link evaluateExpression} accepts, on-screen glyphs\n * included: the calculator keypad inserts `× ÷ −`, and a `−` that this said was not\n * arithmetic went on to be read as a plain number — where the digits-only fallback\n * in {@link commitExpression} silently ate it, turning \"-42−−50\" (= 8) into a bare\n * 8 with no record that a sum had happened at all. */\nexport function looksLikeExpression(s: string): boolean {\n const t = s.trim();\n if (t === \"\") return false;\n if (/^-?\\d*[.,]?\\d*$/.test(t)) return false; // plain (optionally negative) decimal\n return /[+\\-*/×÷−–—()]/.test(t.replace(/^-/, \"\"));\n}\n\n/** Render an evaluated number back to a field-friendly string, trimming binary\n * float noise (so 0.1 + 0.2 reads \"0.3\", not \"0.30000000000000004\").\n *\n * FIXED notation, always, and that is the whole point of the function rather than a\n * detail of it. This used to end in `parseFloat(n.toFixed(10)).toString()`, and\n * `Number.prototype.toString` switches to EXPONENT notation below 1e-6 and at or\n * above 1e21 — an alphabet containing `e`, `+` and `-`. Neither function that reads a\n * field's text back accepts it: {@link tokenize} rejects `e` as an unknown character\n * and {@link sanitizeLive} deletes it. So {@link commitExpression} fell through to\n * its digits-only cleanup, which stripped the `e` and CONCATENATED the mantissa to\n * the exponent. A field committed its own output as a different number:\n *\n * \"1/10000000\" -> \"1e-7\" -> \"17\" (AmountInput: \"6\")\n * \"999999999999*999999999999\" -> \"9.99999999998e+23\" -> \"9.9999999999823\"\n *\n * Both controls commit on blur AND on Enter, so Enter-then-click-away was enough on\n * its own, it was silent, and it was in a money field.\n *\n * The invariant, which the test asserts directly rather than by example:\n * **`commitExpression(formatResult(n))` equals `formatResult(n)` for every finite\n * `n`.** Anything rendered here has to survive being read back. */\nexport function formatResult(n: number): string {\n if (!Number.isFinite(n)) return \"\";\n // At or above 1e21 `toFixed` gives up and hands back `toString`'s exponent form —\n // but every double that large is an integer, so BigInt renders it exactly.\n if (Math.abs(n) >= 1e21) return BigInt(n).toString();\n return trimTrailingZeros(n.toFixed(10));\n}\n\n/** \"15.7000000000\" -> \"15.7\", \"0.0000000000\" -> \"0\". The ten decimals are where the\n * binary float noise gets rounded off; this is only about not showing the padding. */\nfunction trimTrailingZeros(fixed: string): string {\n if (!fixed.includes(\".\")) return fixed;\n const trimmed = fixed.replace(/0+$/, \"\").replace(/\\.$/, \"\");\n // `toFixed` keeps the sign on a magnitude that rounded away to nothing\n // (\"-0.0000000000\"); `toString` did not, and \"-0\" in an amount field is noise.\n return trimmed === \"-0\" || trimmed === \"\" ? \"0\" : trimmed;\n}\n\n/** The single \"on blur / Enter\" transform a numeric field applies: evaluate the\n * text if it's an expression, otherwise normalise it to a bare number string\n * (comma → dot, dropping stray characters, non-leading minuses and anything after\n * a second decimal point).\n *\n * The last clause is the backstop for Keksdose live #201. A field the user typed in\n * cannot reach here malformed any more — {@link sanitizeLive} runs on every change,\n * including a paste — but a value handed in from elsewhere (a recurring rule, an\n * import, a test) still can, and \"1.2.3\" is not an expression (no operator), so it\n * used to fall straight through unchanged and be read as NaN. Here the text is CUT\n * at the second dot (\"1.2\") rather than joined (\"1.23\"): with no keystroke to\n * ignore there is nothing to say which reading was meant, and the shorter one\n * cannot silently invent a bigger number. */\nexport function commitExpression(raw: string): string {\n const s = raw.trim();\n if (looksLikeExpression(s)) {\n const n = evaluateExpression(s);\n if (n !== null) return formatResult(n);\n // Unparseable (e.g. a half-typed \"12+\"): fall through to a digits-only clean.\n }\n // Global, like sanitizeLive's: these two are the two halves of one rule and must\n // not appear to state it differently. (The `[^0-9.-]` strip on the same line\n // already ate whatever commas a non-global replace missed, so this changes no\n // behaviour — it removes the need for the next reader to run it to find that out.)\n const cleaned = s\n .replace(/,/g, \".\")\n .replace(/[^0-9.-]/g, \"\")\n .replace(/(?!^)-/g, \"\");\n return cleaned.replace(/^(-?\\d*\\.?\\d*).*$/, \"$1\");\n}\n\n/** The live, per-keystroke transform a numeric field applies: comma → dot and\n * drop anything that isn't a digit, decimal point, arithmetic operator (incl.\n * the on-screen `× ÷` glyphs) or paren — so a typed calculation survives\n * keystroke-by-keystroke but stray characters can't be entered. A lone leading\n * `-` is kept — in a plain field it is simply a negative amount; in a field whose\n * caller owns the sign it is read off again by {@link splitLeadingSign}. The text\n * is resolved to a plain number on blur/Enter via {@link commitExpression}.\n *\n * The typographic minus and the two dashes become \"-\" first, as in\n * {@link evaluateExpression}: U+2212 is what the kit's own `formatMoney` and\n * `formatNumber` print for every negative since 0.49, and stripping it with the\n * stray characters turned a pasted \"−1.234,50 €\" into a positive amount (refactor\n * audit 2026-10-30, INP-3). */\nexport function sanitizeLive(raw: string): string {\n return dropExtraDots(\n raw\n .replace(/[−–—]/g, \"-\")\n .replace(/,/g, \".\")\n .replace(/[^0-9.+\\-*/×÷()]/g, \"\"),\n );\n}\n\n/**\n * At most ONE decimal point per number (Keksdose live #201).\n *\n * Per NUMBER, not per string: \"1.5+2.5\" is a legitimate calculation with two dots\n * in two operands, so the counter resets at every operator and parenthesis.\n *\n * A second dot inside one operand is a **no-op keystroke** — the digits after it\n * keep landing, so \"1.2\" + \".\" + \"3\" is \"1.23\". That is what every calculator does\n * with a repeated decimal point, and it is the only option that does not make the\n * field feel broken (\"my 3 disappeared\"). This runs on paste as well as on typing,\n * so the malformed value never reaches the rest of the app: everything downstream\n * reads the field with `Number()`, NaN became a silent 0, and on the create form\n * that 0 was painted with the income green — a figure that was not a number at all\n * asserting money was coming in.\n */\nfunction dropExtraDots(s: string): string {\n let out = \"\";\n let dotInNumber = false;\n for (const ch of s) {\n if (ch === \".\") {\n if (dotInNumber) continue;\n dotInNumber = true;\n } else if (ch < \"0\" || ch > \"9\") {\n // An operator or a paren ends the current operand.\n dotInNumber = false;\n }\n out += ch;\n }\n return out;\n}\n\n/** A bare, unsigned amount: digits with at most one decimal separator and nothing\n * else — \"42\", \"42.\", \".5\", \"0,5\". NOT \"\" (an empty field has no figure to put a\n * sign in front of), not \"-42\", not \"12+5\". It is the only shape a DISPLAYED sign\n * prefix makes sense in front of; an expression carries its own arithmetic and has\n * to survive keystroke-for-keystroke. */\nexport function isBareAmount(s: string): boolean {\n const t = s.trim();\n return t !== \"\" && /^\\d*[.,]?\\d*$/.test(t);\n}\n\n/**\n * Split a LEADING sign off a plain signed number (Keksdose live #201 rework):\n * `\"-42\"` → `{ sign: \"-\", rest: \"42\" }`, everything else → `{ sign: \"\", rest: text }`.\n *\n * Only in front of a bare amount, because a minus with arithmetic after it IS\n * arithmetic: \"12-30\" is a subtraction, and \"-12+5\" is -7, not -(12+5). Those come\n * back untouched and are resolved by {@link commitExpression} on blur — at which\n * point the RESULT is a plain signed number and this function does get to speak.\n *\n * A lone \"-\" splits (rest \"\"): the sign is a statement about the direction the\n * moment it is typed, before any digit exists.\n */\nexport function splitLeadingSign(text: string): { sign: \"\" | \"-\" | \"+\"; rest: string } {\n const trimmed = text.trimStart();\n const sign = trimmed.charAt(0);\n if (sign !== \"-\" && sign !== \"+\") return { sign: \"\", rest: text };\n const rest = trimmed.slice(1);\n if (rest !== \"\" && !isBareAmount(rest)) return { sign: \"\", rest: text };\n return { sign, rest };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
const MINUS_BEFORE_NUMBER = /\u2212(?=
|
|
2
|
+
const MINUS_BEFORE_NUMBER = /\u2212(?=[\p{L}\p{Sc}.]{0,5}[\s\u00a0\u202f]*\d)/gu;
|
|
3
3
|
function asciiMinusInNumbers(text) {
|
|
4
4
|
return text.replace(MINUS_BEFORE_NUMBER, "-");
|
|
5
5
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/clipboard-minus.ts"],"sourcesContent":["/**\n * The copy handler for the one minus sign (0.49 KIT-1b, Marcel's follow-up to KIT-1).\n *\n * The kit prints a negative with U+2212 (lib/minus.ts). A spreadsheet does not read that\n * as a number: a figure copied out of a kit table and pasted into Excel landed as text.\n * So while a selection inside the page is copied, every U+2212 standing directly before a\n * number — optionally after a currency code or symbol and spaces: \"−CHF 1,250.50\",\n * \"−€2,000\", \"−12%\" — becomes the ASCII \"-\" in the clipboard, in `text/plain` AND
|
|
1
|
+
{"version":3,"sources":["../../src/lib/clipboard-minus.ts"],"sourcesContent":["/**\n * The copy handler for the one minus sign (0.49 KIT-1b, Marcel's follow-up to KIT-1).\n *\n * The kit prints a negative with U+2212 (lib/minus.ts). A spreadsheet does not read that\n * as a number: a figure copied out of a kit table and pasted into Excel landed as text.\n * So while a selection inside the page is copied, every U+2212 standing directly before a\n * number — optionally after a currency code or symbol and spaces: \"−CHF 1,250.50\",\n * \"−€2,000\", \"−US$12\", \"−12%\" — becomes the ASCII \"-\" in the clipboard, in `text/plain` AND\n * in `text/html` (Excel pastes the HTML flavour, as cells). The page keeps \"−\".\n *\n * A copy with no such minus is left to the browser untouched, and so is any copy out of an\n * input, a textarea or an editable element: what the person typed is theirs.\n *\n * Installed once per document by `UiKitProvider` (`copyAsciiMinus`, default `true`) —\n * the one part every app mounts, the signed-out pages included.\n */\n\n/**\n * U+2212, then up to five letters, currency symbols or dots, then spaces, then a digit.\n *\n * Five of them, not \"a code or one symbol\": English and Chinese write a currency with\n * letters before the symbol — \"−US$\", \"−CA$\", \"−A$\", \"−NZ$\", \"−HK$\", \"−R$\", \"−MX$\",\n * \"−JP¥\", \"−CN¥\" — and `narrowSymbol` with letters alone (\"−kr\", \"−zł\", \"−Kč\"). Those kept\n * U+2212 in the clipboard, and Excel pasted them as text: 35 of 351 locale × currency\n * pairs at the default display, every one English or Chinese, and 136 of 1428 with\n * `narrowSymbol` (refactor audit 2026-10-30, DSP-11). A minus with no digit after it\n * (\"x−y\", \"a − b\") is still left alone.\n */\nconst MINUS_BEFORE_NUMBER = /\\u2212(?=[\\p{L}\\p{Sc}.]{0,5}[\\s\\u00a0\\u202f]*\\d)/gu;\n\n/**\n * `text` with each U+2212 that stands before a number (in the sense above) as the ASCII\n * hyphen-minus, every other character as it was — for text an app hands a machine that\n * did not come from a formatter's `minus: \"ascii\"`.\n */\nexport function asciiMinusInNumbers(text: string): string {\n return text.replace(MINUS_BEFORE_NUMBER, \"-\");\n}\n\nfunction hasMinusBeforeNumber(text: string): boolean {\n MINUS_BEFORE_NUMBER.lastIndex = 0;\n const found = MINUS_BEFORE_NUMBER.test(text);\n MINUS_BEFORE_NUMBER.lastIndex = 0;\n return found;\n}\n\n/** An input, a textarea or an editable element: a copy there is the person's own text. */\nfunction isEditable(node: Node | null | undefined): boolean {\n const el = node instanceof Element ? node : node?.parentElement;\n if (!el) return false;\n if (el.closest(\"input, textarea, select\")) return true;\n return (\n (el as HTMLElement).isContentEditable === true ||\n el.closest('[contenteditable=\"\"], [contenteditable=\"true\"]') !== null\n );\n}\n\n/** Table parts copied alone are wrapped back into a table, which Excel reads as cells. */\nfunction wrapTableParts(container: HTMLElement): string {\n const first = container.firstElementChild?.tagName;\n const html = container.innerHTML;\n if (first === \"TD\" || first === \"TH\") return `<table><tbody><tr>${html}</tr></tbody></table>`;\n if (first === \"TR\") return `<table><tbody>${html}</tbody></table>`;\n if (first === \"TBODY\" || first === \"THEAD\" || first === \"TFOOT\") return `<table>${html}</table>`;\n return html;\n}\n\nfunction onCopy(event: ClipboardEvent): void {\n if (event.defaultPrevented || !event.clipboardData) return;\n const doc = (event.target as Node | null)?.ownerDocument ?? document;\n if (isEditable(event.target as Node | null) || isEditable(doc.activeElement)) return;\n const selection = doc.getSelection();\n if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;\n if (isEditable(selection.anchorNode) || isEditable(selection.focusNode)) return;\n const text = selection.toString();\n if (!hasMinusBeforeNumber(text)) return;\n\n const container = doc.createElement(\"div\");\n for (let i = 0; i < selection.rangeCount; i++) container.appendChild(selection.getRangeAt(i).cloneContents());\n const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n node.nodeValue = asciiMinusInNumbers(node.nodeValue ?? \"\");\n }\n event.clipboardData.setData(\"text/plain\", asciiMinusInNumbers(text));\n event.clipboardData.setData(\"text/html\", wrapTableParts(container));\n event.preventDefault();\n}\n\nconst installs = new WeakMap<Document, number>();\n\n/**\n * Listen for copies on `doc` until the returned function is called. Counted: nested\n * providers install it once, and the last one to go removes it.\n */\nexport function installClipboardMinus(doc: Document = document): () => void {\n const count = installs.get(doc) ?? 0;\n if (count === 0) doc.addEventListener(\"copy\", onCopy);\n installs.set(doc, count + 1);\n let done = false;\n return () => {\n if (done) return;\n done = true;\n const left = (installs.get(doc) ?? 1) - 1;\n installs.set(doc, left);\n if (left === 0) doc.removeEventListener(\"copy\", onCopy);\n };\n}\n"],"mappings":";AA4BA,MAAM,sBAAsB;AAOrB,SAAS,oBAAoB,MAAsB;AACxD,SAAO,KAAK,QAAQ,qBAAqB,GAAG;AAC9C;AAEA,SAAS,qBAAqB,MAAuB;AACnD,sBAAoB,YAAY;AAChC,QAAM,QAAQ,oBAAoB,KAAK,IAAI;AAC3C,sBAAoB,YAAY;AAChC,SAAO;AACT;AAGA,SAAS,WAAW,MAAwC;AAC1D,QAAM,KAAK,gBAAgB,UAAU,OAAO,MAAM;AAClD,MAAI,CAAC,GAAI,QAAO;AAChB,MAAI,GAAG,QAAQ,yBAAyB,EAAG,QAAO;AAClD,SACG,GAAmB,sBAAsB,QAC1C,GAAG,QAAQ,gDAAgD,MAAM;AAErE;AAGA,SAAS,eAAe,WAAgC;AACtD,QAAM,QAAQ,UAAU,mBAAmB;AAC3C,QAAM,OAAO,UAAU;AACvB,MAAI,UAAU,QAAQ,UAAU,KAAM,QAAO,qBAAqB,IAAI;AACtE,MAAI,UAAU,KAAM,QAAO,iBAAiB,IAAI;AAChD,MAAI,UAAU,WAAW,UAAU,WAAW,UAAU,QAAS,QAAO,UAAU,IAAI;AACtF,SAAO;AACT;AAEA,SAAS,OAAO,OAA6B;AAC3C,MAAI,MAAM,oBAAoB,CAAC,MAAM,cAAe;AACpD,QAAM,MAAO,MAAM,QAAwB,iBAAiB;AAC5D,MAAI,WAAW,MAAM,MAAqB,KAAK,WAAW,IAAI,aAAa,EAAG;AAC9E,QAAM,YAAY,IAAI,aAAa;AACnC,MAAI,CAAC,aAAa,UAAU,eAAe,KAAK,UAAU,YAAa;AACvE,MAAI,WAAW,UAAU,UAAU,KAAK,WAAW,UAAU,SAAS,EAAG;AACzE,QAAM,OAAO,UAAU,SAAS;AAChC,MAAI,CAAC,qBAAqB,IAAI,EAAG;AAEjC,QAAM,YAAY,IAAI,cAAc,KAAK;AACzC,WAAS,IAAI,GAAG,IAAI,UAAU,YAAY,IAAK,WAAU,YAAY,UAAU,WAAW,CAAC,EAAE,cAAc,CAAC;AAC5G,QAAM,SAAS,IAAI,iBAAiB,WAAW,WAAW,SAAS;AACnE,WAAS,OAAO,OAAO,SAAS,GAAG,MAAM,OAAO,OAAO,SAAS,GAAG;AACjE,SAAK,YAAY,oBAAoB,KAAK,aAAa,EAAE;AAAA,EAC3D;AACA,QAAM,cAAc,QAAQ,cAAc,oBAAoB,IAAI,CAAC;AACnE,QAAM,cAAc,QAAQ,aAAa,eAAe,SAAS,CAAC;AAClE,QAAM,eAAe;AACvB;AAEA,MAAM,WAAW,oBAAI,QAA0B;AAMxC,SAAS,sBAAsB,MAAgB,UAAsB;AAC1E,QAAM,QAAQ,SAAS,IAAI,GAAG,KAAK;AACnC,MAAI,UAAU,EAAG,KAAI,iBAAiB,QAAQ,MAAM;AACpD,WAAS,IAAI,KAAK,QAAQ,CAAC;AAC3B,MAAI,OAAO;AACX,SAAO,MAAM;AACX,QAAI,KAAM;AACV,WAAO;AACP,UAAM,QAAQ,SAAS,IAAI,GAAG,KAAK,KAAK;AACxC,aAAS,IAAI,KAAK,IAAI;AACtB,QAAI,SAAS,EAAG,KAAI,oBAAoB,QAAQ,MAAM;AAAA,EACxD;AACF;","names":[]}
|
|
@@ -11,7 +11,13 @@ export declare function decimalMark(locale: string | undefined): "," | ".";
|
|
|
11
11
|
* field's settle then made that 1.23, final. This decides per operand, from the
|
|
12
12
|
* typist's own text, before that fold:
|
|
13
13
|
*
|
|
14
|
-
* - both marks present: the one
|
|
14
|
+
* - both marks present: the LAST one is the decimal mark, whichever the locale's is, and
|
|
15
|
+
* the other is grouping and goes. The order says the convention the figure was written
|
|
16
|
+
* in — "1.234,56" German, "1,234.56" English — and a figure pasted from another app or
|
|
17
|
+
* locale keeps it. Taking the non-locale mark as grouping wherever it stood read
|
|
18
|
+
* "1.234,56" in a "." locale (en, de-CH) as 1.23456, which a money field then settled
|
|
19
|
+
* to 1.23 (refactor audit 2026-10-30, INP-3). Typed in the locale's own convention, its
|
|
20
|
+
* mark comes last and nothing changes;
|
|
15
21
|
* - in a "," locale, only "." present: grouping when it cannot be a decimal as a
|
|
16
22
|
* German reader writes it — twice ("1.234.567"), or in thousands shape ("1.234",
|
|
17
23
|
* "12.500") — otherwise a decimal ("1.5", typed by a dot-decimal habit). A first
|
|
@@ -11,10 +11,13 @@ function decimalMark(locale) {
|
|
|
11
11
|
function normalizeTypedMarks(raw, mark) {
|
|
12
12
|
const other = mark === "," ? "." : ",";
|
|
13
13
|
return raw.replace(/[0-9.,]+/g, (operand) => {
|
|
14
|
-
const hasMark = operand.includes(mark);
|
|
15
14
|
const count = operand.split(other).length - 1;
|
|
16
15
|
if (count === 0) return operand;
|
|
17
|
-
|
|
16
|
+
if (operand.includes(mark)) {
|
|
17
|
+
const grouping2 = operand.lastIndexOf(mark) > operand.lastIndexOf(other) ? other : mark;
|
|
18
|
+
return operand.split(grouping2).join("");
|
|
19
|
+
}
|
|
20
|
+
const grouping = count > 1 || mark === "," && /^[1-9]\d{0,2}(\.\d{3})+$/.test(operand);
|
|
18
21
|
return grouping ? operand.split(other).join("") : operand;
|
|
19
22
|
});
|
|
20
23
|
}
|