|
|
@@ -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).
|