فهرست منبع

Fix: stepper popover closes on mouse-drift + outside-click (belt-and-braces)

Follow-up to the blur-close fix (f189ef7). User reported that neither
"mouse leaves" nor "outside click" reliably closed the popover after
the slider-only rewrite (15b2d24). Two root causes:

1. The dismissal gate required the cursor to physically enter the
   popover rectangle at least once (`popoverEntered` latch). If the
   user clicked a cell and moved the cursor back toward the table,
   or to scroll, or to another part of the page — anywhere that
   skipped the popover rect — the latch stayed false and the popup
   lingered forever.

2. The outside-close only listened for `pointerdown` in the bubble
   phase. Any descendant handler that called stopPropagation would
   silently trap us open.

Replaced the latch with a **pointer-position tracker** on `document`.
While the popover is open, `pointermove` events check whether the
cursor is over the bound input or the popover; if neither, start a
150 ms close timer; re-entering either element cancels it. A brief
250 ms grace window after `open()` suppresses the check so a click
whose cursor happens to land in the gap between input and popover
doesn't close immediately. Also: `pointerleave` on `document` closes
when the cursor exits the viewport entirely.

Outside-click close now fires in **both** bubble (`pointerdown`) and
capture (`click`, `{capture:true}`) phases, via a shared handler. A
downstream stopPropagation in either phase can't strand the popup.

Removed `popoverEntered` entirely — superseded by the tracker. Kept
focusout, Escape, Arrow-key paths unchanged; kept the popover-level
pointerenter/pointerleave as a belt-and-braces alongside the tracker
so gap transits feel snappy.

phpunit: 88 / 208 unchanged. node --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 هفته پیش
والد
کامیت
8d79f96e46
1فایلهای تغییر یافته به همراه79 افزوده شده و 23 حذف شده
  1. 79 23
      public/assets/js/number-stepper.js

+ 79 - 23
public/assets/js/number-stepper.js

@@ -11,11 +11,16 @@
  * recomputes live.
  *
  * Close triggers:
- *   1. Pointer leaves the popover after having entered it once (the
- *      primary dismissal gesture — "hover-in, drag, hover-out");
- *   2. Outside pointerdown (touch devices + mis-click escape hatch);
- *   3. Escape keypress (focus returns to the input);
- *   4. Tab that moves focus out of both input and popover.
+ *   1. Pointer drifts outside BOTH the input and the popover for
+ *      more than a short grace window (primary dismissal — no
+ *      "enter-then-leave" gate, so moving the cursor away after a
+ *      click-to-open closes us);
+ *   2. Outside click / pointerdown (belt-and-braces via both bubble
+ *      and capture phases, so a downstream stopPropagation can't
+ *      silently leave the popover hanging);
+ *   3. The bound input loses focus to anything outside our realm
+ *      (Tab-out, devtools steal, etc.);
+ *   4. Escape keypress (focus returns to the input).
  *
  * Applies to every `input[type="number"]` across every view — day
  * cells, RTB cells, task-assignment cells, week-count field, reserve
@@ -42,11 +47,31 @@
     let pop = null;
     let elRange = null;
     let boundInput = null;
-    // Set true on the first pointerenter after open(); only then does
-    // pointerleave close the popover. That way a click-to-open whose
-    // cursor already sits inside the popover rectangle doesn't
-    // immediately close on the next micro-movement.
-    let popoverEntered = false;
+    // Brief window after open() during which the pointer-position
+    // tracker ignores "outside both" readings. Without it, a click
+    // that happens while the cursor is momentarily outside both the
+    // input and the (yet-to-render) popover rect would close
+    // immediately. 250 ms lets the browser settle.
+    const OPEN_GRACE_MS  = 250;
+    const CLOSE_DELAY_MS = 150;
+    let graceUntil = 0;
+    let closeTimer = null;
+
+    function now() {
+        return typeof performance !== 'undefined' && performance.now
+            ? performance.now()
+            : Date.now();
+    }
+    function cancelClose() {
+        if (closeTimer !== null) { clearTimeout(closeTimer); closeTimer = null; }
+    }
+    function scheduleClose() {
+        if (closeTimer !== null) { return; }
+        closeTimer = setTimeout(function () {
+            closeTimer = null;
+            close(false);
+        }, CLOSE_DELAY_MS);
+    }
 
     function build() {
         if (pop) { return; }
@@ -79,13 +104,13 @@
         // drag-start in Safari.
         pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
 
-        // The core dismissal gesture: enter-then-leave closes.
-        pop.addEventListener('pointerenter', function () {
-            popoverEntered = true;
-        });
-        pop.addEventListener('pointerleave', function () {
-            if (popoverEntered) { close(false); }
-        });
+        // Keep the popover alive while the pointer is over it. The
+        // document-level pointermove tracker (below) handles the
+        // "outside both" case; these are cheap belt-and-braces so a
+        // quick entry still cancels a pending close scheduled from
+        // the gap between input and popover.
+        pop.addEventListener('pointerenter', cancelClose);
+        pop.addEventListener('pointerleave', scheduleClose);
     }
 
     function readAttrNumber(input, name) {
@@ -131,7 +156,8 @@
     function open(input) {
         build();
         boundInput = input;
-        popoverEntered = false;
+        cancelClose();
+        graceUntil = now() + OPEN_GRACE_MS;
 
         const stepAttr = readAttrNumber(input, 'step');
         const minAttr  = readAttrNumber(input, 'min');
@@ -159,9 +185,10 @@
     function close(returnFocus) {
         if (!pop || pop.hidden) { return; }
         pop.hidden = true;
+        cancelClose();
         const prev = boundInput;
         boundInput = null;
-        popoverEntered = false;
+        graceUntil = 0;
         if (prev) {
             // Final `change` so the debounced save captures the last
             // slider position. Harmless no-op if the slider wasn't
@@ -201,15 +228,44 @@
         if (boundInput !== t) { open(t); }
     });
 
-    // Outside pointerdown closes. Only listen while the popover is
-    // open so we don't pay the cost on every app-wide click.
-    document.addEventListener('pointerdown', function (ev) {
+    // Primary dismissal: the pointer drifts away from both the input
+    // and the popover. Active only while the popover is open and past
+    // the open-grace window. If the pointer comes back over either
+    // element the pending close is cancelled. This replaces the
+    // previous "enter-once-then-leave" gate, which never fired when
+    // the user clicked a cell and moved the cursor somewhere the
+    // popover wasn't — the popup could linger indefinitely.
+    document.addEventListener('pointermove', function (ev) {
+        if (!pop || pop.hidden) { return; }
+        if (now() < graceUntil) { return; }
+        const t = ev.target;
+        const overInput = !!(boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t))));
+        const overPop   = !!(pop.contains && pop.contains(t));
+        if (overInput || overPop) { cancelClose(); }
+        else                      { scheduleClose(); }
+    });
+
+    // Also close if the pointer leaves the viewport entirely — mouse
+    // up to the browser chrome / titlebar shouldn't leave the popup
+    // stranded behind.
+    document.addEventListener('pointerleave', function () {
+        if (!pop || pop.hidden) { return; }
+        scheduleClose();
+    });
+
+    // Outside close — dispatched in BOTH bubble and capture phases so
+    // a descendant handler that calls stopPropagation can't silently
+    // trap us open. Same semantics in both: if the press lands outside
+    // the bound input and outside the popover, close.
+    function outsideCloseHandler(ev) {
         if (!pop || pop.hidden) { return; }
         const t = ev.target;
         if (boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t)))) { return; }
         if (pop.contains(t)) { return; }
         close(false);
-    });
+    }
+    document.addEventListener('pointerdown', outsideCloseHandler);
+    document.addEventListener('click',        outsideCloseHandler, true);
 
     // Blur close. Fires when focus leaves either the bound input or a
     // focusable element inside the popover (e.g. the slider thumb).