Ver código fonte

Rewrite: number-stepper popover from scratch

The stepper file had grown through seven iterations and was carrying
contradictory event plumbing (hover-to-open + click-to-open, enter-
once latch + position tracker, single-phase + capture-phase outside
close) that in practice no longer closed reliably, left the input
blurred mid-drag, and never followed the anchor input during page
scroll. User asked for a clean rebuild: keep the CSS look, throw out
everything else.

This commit replaces public/assets/js/number-stepper.js with a
~250-line single IIFE implementing exactly four concerns:

**Open.** `click` or `focusin` on any `input[type="number"]` calls
open(): configures the slider's min/max/step/value (with the adaptive
`max(cur+5, 10)` fallback for task-assignment cells) and positions
the popover to the right of the input at its vertical midpoint
(flips left if it'd clip the viewport; clamps both axes). If the
same input is re-clicked, the popover just re-anchors.

**Slider → input sync.** One `input` event listener on the range
slider mirrors elRange.value → boundInput.value and dispatches
bubbling `input` + `change` events on the bound input. That drives
sprint-planner.js's existing blur/change handler, which runs the
live capacity recompute and the 400 ms-debounced server save.

**Close.** Any of: (a) pointer is off both the input and the popover
for >200 ms past a 300 ms open-grace window (close timer reschedules
itself during the grace so an early cursor drift doesn't dismiss);
(b) pointer leaves the viewport; (c) `pointerdown` lands outside
both — registered in **capture** phase so a downstream
stopPropagation can't strand us open; (d) Escape keypress (returns
focus to the input). Everything else — the previous hover latch,
Tab-specific handlers, focusout handler, stopPropagation on the
popover's pointerdown, pointerenter/leave wiring on the popover
element — is gone. Close paths now test live bounding rects, so
repositioned popovers don't leave stale "thought it was over"
holes.

**Scroll anchoring.** `window.addEventListener('scroll', ...,
{capture:true})` + `resize` schedule a rAF-throttled reposition, so
the popup tracks the input as the Arbeitstage grid, task list, or
main page scrolls. If the input's bounding rect collapses to 0×0
(detached), we close.

Bonus: keyboard ArrowUp/Down on the focused input still steps by
the `step` attribute (replaces the native spinner arrows suppressed
in Phase 17). Dispatches `change` on the input; if the popover is
already open on that input, syncs the slider thumb.

phpunit: 88 / 208 unchanged. node --check clean. CSS untouched —
the visual style is identical to the previous iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 semanas atrás
pai
commit
ff807c29f8
1 arquivos alterados com 173 adições e 226 exclusões
  1. 173 226
      public/assets/js/number-stepper.js

+ 173 - 226
public/assets/js/number-stepper.js

@@ -1,77 +1,48 @@
 /**
- * Phase 17: floating slider popover for number inputs.
+ * Floating vertical-slider popover for number inputs.
  *
- * Activates on click or keyboard focus of any `input[type="number"]` —
- * pops a compact vertical range slider to the right of the input, at
- * the input's vertical midpoint. The popover shows *only* the slider;
- * there are no +/− buttons and no value readout. Users read the
- * current value from the input itself; they drag the slider to set a
- * new value. Dragging fires `change` on the bound input (debounced
- * saves in sprint-planner.js coalesce the flurry), so capacity
- * recomputes live.
+ * Click (or focus) any `<input type="number">` → a compact popover
+ * appears to the right of the input with a single vertical range
+ * slider. Drag the slider → the input's value updates live and
+ * sprint-planner.js's debounced save + capacity recompute fire via
+ * the `change` event on the bound input.
  *
- * Close triggers:
- *   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.);
+ * Close triggers (any one fires):
+ *   1. Pointer leaves both the input and the popover for >200 ms
+ *      (past a 300 ms open-grace window that forgives post-click
+ *      cursor wobble).
+ *   2. Pointer leaves the viewport.
+ *   3. pointerdown lands anywhere outside both input and popover —
+ *      registered in the *capture* phase so a descendant
+ *      stopPropagation can't trap the popup open.
  *   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
- * percent. For inputs without a `max` attribute (task assignments),
- * the slider picks an adaptive upper bound so it still feels useful.
+ * Position tracking: on every scroll (any ancestor, captured at
+ * window) and on window resize, a rAF-throttled reposition runs so
+ * the popover stays anchored to the input as content moves. If the
+ * input's bounding rect goes to 0×0 (removed from DOM) we close.
  *
- * Strict-CSP-clean: loaded as a standard <script src>. No globals.
- * Single IIFE. Vanilla JS — no jQuery dependency.
+ * Strict-CSP-clean (standard <script src>, no inline handlers). No
+ * globals. Vanilla JS — no jQuery.
  */
 (function () {
     'use strict';
 
-    const EPS = 1e-9;
+    const OPEN_GRACE_MS  = 300;  // min lifetime of a newly-opened popover
+    const CLOSE_DELAY_MS = 200;  // grace for the cursor to re-enter
 
-    function quantise(next, step, min, max) {
-        if (!(step > 0)) { step = 1; }
-        if (Number.isFinite(min) && next < min) { next = min; }
-        if (Number.isFinite(max) && next > max) { next = max; }
-        const q = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
-        return Number(q.toFixed(6));
-    }
-
-    // Single shared popover, built lazily on first use.
-    let pop = null;
-    let elRange = null;
+    let pop        = null;
+    let elRange    = null;
     let boundInput = null;
-    // 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 openedAt   = 0;
     let closeTimer = null;
+    let rafId      = null;
 
     function now() {
-        return typeof performance !== 'undefined' && performance.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; }
@@ -80,243 +51,219 @@
         pop.hidden = true;
         pop.setAttribute('role', 'dialog');
         pop.setAttribute('aria-label', 'Set value');
-        // `orient="vertical"` is a belt-and-braces fallback for older
-        // Firefox; modern layout comes from the writing-mode CSS in
-        // assets/css/input.css.
-        pop.innerHTML =
-            '<input type="range" data-stepper-range orient="vertical">';
+        // `orient="vertical"` is the legacy Firefox attribute; modern
+        // browsers pick up vertical orientation from the writing-mode
+        // CSS in assets/css/input.css. Both are present so the slider
+        // renders vertically everywhere.
+        pop.innerHTML = '<input type="range" orient="vertical">';
         document.body.appendChild(pop);
-        elRange = pop.querySelector('[data-stepper-range]');
+        elRange = pop.querySelector('input[type="range"]');
 
-        // Live recompute during drag — dispatch `change` (not `input`)
-        // because sprint-planner.js listens for `blur change` on the
-        // bound inputs. Its queueCell() is 400 ms-debounced, so
-        // firing `change` on every slider tick coalesces into one
-        // server write after the drag pauses.
+        // Mirror every slider tick into the bound input. Fire both
+        // `input` (live) and `change` (save + recompute) so
+        // sprint-planner.js picks it up; its 400 ms debounce
+        // coalesces the flurry into one write.
         elRange.addEventListener('input', function () {
             if (!boundInput) { return; }
             boundInput.value = elRange.value;
+            boundInput.dispatchEvent(new Event('input',  { bubbles: true }));
             boundInput.dispatchEvent(new Event('change', { bubbles: true }));
         });
-
-        // Cancel bubbling of pointerdown inside the popover so the
-        // document-level outside-click handler doesn't close us on a
-        // drag-start in Safari.
-        pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
-
-        // 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) {
-        const raw = input.getAttribute(name);
+    function readNum(input, attr) {
+        const raw = input.getAttribute(attr);
         if (raw === null || raw === '') { return NaN; }
         const n = Number(raw);
         return Number.isFinite(n) ? n : NaN;
     }
 
-    function position(input) {
-        const rect = input.getBoundingClientRect();
-        // Measure off-screen so the browser has real dimensions to
-        // work with before we anchor.
-        pop.style.left = '-9999px';
-        pop.style.top  = '0px';
-        pop.hidden = false;
-        const pw = pop.offsetWidth;
-        const ph = pop.offsetHeight;
-        const vh = window.innerHeight;
-        const vw = window.innerWidth;
+    function reposition() {
+        if (!pop || pop.hidden || !boundInput) { return; }
+        const r = boundInput.getBoundingClientRect();
+        // Input is detached or fully collapsed — get out.
+        if (r.width === 0 && r.height === 0) { close(); return; }
 
+        const pw     = pop.offsetWidth;
+        const ph     = pop.offsetHeight;
+        const vw     = window.innerWidth;
+        const vh     = window.innerHeight;
         const GAP    = 6;
         const MARGIN = 4;
 
-        // Preferred: right of the input, vertically centred on it.
-        let left = rect.right + GAP;
-        let top  = rect.top + (rect.height / 2) - (ph / 2);
-
-        // If we'd run off the right edge, flip to the left of the input.
-        if (left + pw > vw - MARGIN) {
-            left = rect.left - pw - GAP;
-        }
-        // Then clamp both axes into the viewport.
-        if (left < MARGIN)            { left = MARGIN; }
-        if (left + pw > vw - MARGIN)  { left = vw - pw - MARGIN; }
-        if (top < MARGIN)             { top  = MARGIN; }
-        if (top + ph > vh - MARGIN)   { top  = vh - ph - MARGIN; }
+        let left = r.right + GAP;
+        let top  = r.top + (r.height - ph) / 2;
+        if (left + pw > vw - MARGIN) { left = r.left - pw - GAP; }
+        left = Math.max(MARGIN, Math.min(left, vw - pw - MARGIN));
+        top  = Math.max(MARGIN, Math.min(top,  vh - ph - MARGIN));
 
         pop.style.left = left + 'px';
         pop.style.top  = top  + 'px';
     }
+    function scheduleReposition() {
+        if (rafId !== null) { return; }
+        rafId = requestAnimationFrame(function () {
+            rafId = null;
+            reposition();
+        });
+    }
+
+    function isEligible(el) {
+        return !!(el
+            && el.matches
+            && el.matches('input[type="number"]')
+            && !el.disabled
+            && !el.readOnly);
+    }
 
     function open(input) {
         build();
+        if (boundInput === input && !pop.hidden) {
+            scheduleReposition();
+            return;
+        }
         boundInput = input;
-        cancelClose();
-        graceUntil = now() + OPEN_GRACE_MS;
+        openedAt   = now();
+        cancelCloseTimer();
 
-        const stepAttr = readAttrNumber(input, 'step');
-        const minAttr  = readAttrNumber(input, 'min');
-        const maxAttr  = readAttrNumber(input, 'max');
-        const step     = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
-        const current  = quantise(Number(input.value) || 0, step, minAttr, maxAttr);
+        const step = readNum(input, 'step');
+        const min  = readNum(input, 'min');
+        const max  = readNum(input, 'max');
+        const eff  = Number.isFinite(step) && step > 0 ? step : 1;
+        const cur  = Number(input.value) || 0;
+        // Slider needs a finite min+max. Fall back sensibly when the
+        // input leaves them open (task-assignment cells: min=0 but no
+        // max) so there's always usable overhead on the slider.
+        const sMin = Number.isFinite(min) ? min : 0;
+        const sMax = Number.isFinite(max) ? max : Math.max(cur + 5, 10);
 
-        // Slider requires a finite min and max. Fall back sensibly
-        // when the input leaves them open — task-assignment cells
-        // declare min="0" but no max, so we pick max = max(current+5, 10)
-        // so there's always usable overhead on the slider.
-        const sliderMin = Number.isFinite(minAttr) ? minAttr : 0;
-        const sliderMax = Number.isFinite(maxAttr)
-            ? maxAttr
-            : Math.max(current + 5, 10);
+        elRange.min   = String(sMin);
+        elRange.max   = String(sMax);
+        elRange.step  = String(eff);
+        elRange.value = String(Math.max(sMin, Math.min(sMax, cur)));
 
-        elRange.min  = String(sliderMin);
-        elRange.max  = String(sliderMax);
-        elRange.step = String(step);
-        elRange.value = String(Math.max(sliderMin, Math.min(sliderMax, current)));
-
-        position(input);
+        pop.hidden = false;
+        reposition();
     }
 
-    function close(returnFocus) {
+    function close() {
         if (!pop || pop.hidden) { return; }
         pop.hidden = true;
-        cancelClose();
+        cancelCloseTimer();
         const prev = boundInput;
         boundInput = null;
-        graceUntil = 0;
         if (prev) {
-            // Final `change` so the debounced save captures the last
-            // slider position. Harmless no-op if the slider wasn't
-            // touched (value unchanged).
+            // Final change so the last slider position is saved via the
+            // existing debounced pipeline. Harmless no-op if the value
+            // didn't actually change.
             prev.dispatchEvent(new Event('change', { bubbles: true }));
-            if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
         }
     }
 
-    // ------------------------------------------------------------------
-    // Triggers
-    // ------------------------------------------------------------------
+    function cancelCloseTimer() {
+        if (closeTimer !== null) { clearTimeout(closeTimer); closeTimer = null; }
+    }
+    function scheduleClose() {
+        if (closeTimer !== null) { return; }
+        closeTimer = setTimeout(function () {
+            closeTimer = null;
+            // Respect the open-grace window — if the cursor was outside
+            // both rects in the first 300 ms (common: click-to-open
+            // followed by any mouse drift), reschedule instead of
+            // closing instantly.
+            if (now() - openedAt < OPEN_GRACE_MS) { scheduleClose(); return; }
+            close();
+        }, CLOSE_DELAY_MS);
+    }
 
-    function isEligible(el) {
-        return !!(el
-            && el.matches
-            && el.matches('input[type="number"]')
-            && !el.disabled
-            && !el.readOnly);
+    function pointInRect(x, y, r) {
+        return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
     }
 
-    // Click-to-open. Covers the primary mouse path. Reposition when
-    // the click lands on a different eligible input than the currently
-    // bound one.
+    // ------------------------------------------------------------------
+    // Open triggers
+    // ------------------------------------------------------------------
+
     document.addEventListener('click', function (ev) {
-        const t = ev.target;
-        if (!isEligible(t)) { return; }
-        if (boundInput !== t) { open(t); }
+        if (isEligible(ev.target)) { open(ev.target); }
     });
-
-    // focusin covers keyboard navigation (Tab to field). Don't reopen
-    // if we're already bound to that input — focus may re-fire on
-    // clicks that land on the input.
     document.addEventListener('focusin', function (ev) {
-        const t = ev.target;
-        if (!isEligible(t)) { return; }
-        if (boundInput !== t) { open(t); }
+        if (isEligible(ev.target)) { open(ev.target); }
     });
 
-    // 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.
+    // ------------------------------------------------------------------
+    // Close triggers
+    // ------------------------------------------------------------------
+
+    // Pointer-position tracker. Uses clientX/Y vs live bounding rects
+    // so a mid-drag reposition (via the scroll listener below) is
+    // picked up on the next move — no stale "thought it was over"
+    // hits.
     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(); }
+        if (!pop || pop.hidden || !boundInput) { return; }
+        const ir = boundInput.getBoundingClientRect();
+        const pr = pop.getBoundingClientRect();
+        const over = pointInRect(ev.clientX, ev.clientY, ir)
+                  || pointInRect(ev.clientX, ev.clientY, pr);
+        if (over) { cancelCloseTimer(); }
+        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.
+    // Pointer exits the viewport (mouse into browser chrome, etc.).
     document.addEventListener('pointerleave', function () {
-        if (!pop || pop.hidden) { return; }
-        scheduleClose();
+        if (pop && !pop.hidden) { 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) {
+    // Outside pointerdown — capture phase so a downstream
+    // stopPropagation can't silently leave us hanging.
+    document.addEventListener('pointerdown', function (ev) {
         if (!pop || pop.hidden) { return; }
         const t = ev.target;
-        if (boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t)))) { return; }
+        if (t === boundInput || (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).
-    // If focus is moving into the popover itself, or onto another
-    // eligible input (which the click/focusin handlers will rebind us
-    // to), we stay open. Any other destination — another cell, the
-    // page body, a different page element — closes us. This plugs
-    // the gap where a click elsewhere on the page blurred the input
-    // but never landed on a registered "outside" target.
-    function isOurs(el) {
-        return !!el && (el === boundInput || (pop && pop.contains && pop.contains(el)));
-    }
-    document.addEventListener('focusout', function (ev) {
-        if (!pop || pop.hidden) { return; }
-        if (!isOurs(ev.target)) { return; }
-        const next = ev.relatedTarget;
-        if (isOurs(next))      { return; }  // staying inside our realm
-        if (isEligible(next))  { return; }  // another number input — will rebind
-        close(false);
-    });
+        close();
+    }, true);
 
-    // Escape closes and returns focus. Tab is now handled via the
-    // generic focusout listener above.
+    // Escape closes + returns focus to the input.
     document.addEventListener('keydown', function (ev) {
-        if (!pop || pop.hidden) { return; }
-        if (ev.key === 'Escape') {
-            ev.preventDefault();
-            close(true);
-        }
+        if (!pop || pop.hidden || ev.key !== 'Escape') { return; }
+        ev.preventDefault();
+        const prev = boundInput;
+        close();
+        if (prev) { try { prev.focus(); } catch (_) { /* ignore */ } }
     });
 
-    // ArrowUp/Down while the input has keyboard focus — step by the
-    // input's `step` attribute. Restores the shortcut the native
-    // spinner lost to Phase 17's CSS reset; works even when the
-    // popover isn't open.
+    // ------------------------------------------------------------------
+    // Stay anchored on scroll / resize
+    // ------------------------------------------------------------------
+
+    // Capture phase catches scroll events on any scrollable ancestor
+    // (the Arbeitstage grid's `overflow-x: auto` container, the task
+    // list's overflow div, the main page). rAF-throttled so we never
+    // fight the browser during a rapid wheel burst.
+    window.addEventListener('scroll', scheduleReposition, true);
+    window.addEventListener('resize', scheduleReposition);
+
+    // ------------------------------------------------------------------
+    // Bonus: keyboard nudge on the focused input (replaces the native
+    // spinner arrows Phase 17 suppressed via CSS).
+    // ------------------------------------------------------------------
+
     document.addEventListener('keydown', function (ev) {
         const t = ev.target;
         if (!isEligible(t)) { return; }
         if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
         ev.preventDefault();
-        const step = readAttrNumber(t, 'step');
-        const minA = readAttrNumber(t, 'min');
-        const maxA = readAttrNumber(t, 'max');
-        const effStep = Number.isFinite(step) && step > 0 ? step : 1;
-        const next = quantise(
-            (Number(t.value) || 0) + (ev.key === 'ArrowUp' ? effStep : -effStep),
-            effStep, minA, maxA,
-        );
+        const step = readNum(t, 'step');
+        const min  = readNum(t, 'min');
+        const max  = readNum(t, 'max');
+        const eff  = Number.isFinite(step) && step > 0 ? step : 1;
+        let next = (Number(t.value) || 0) + (ev.key === 'ArrowUp' ? eff : -eff);
+        if (Number.isFinite(min) && next < min) { next = min; }
+        if (Number.isFinite(max) && next > max) { next = max; }
+        next = Number((Math.round(next / eff) * eff).toFixed(6));
         t.value = String(next);
         t.dispatchEvent(new Event('change', { bubbles: true }));
-        if (!pop || pop.hidden || boundInput !== t) { return; }
-        elRange.value = String(next);
+        if (pop && !pop.hidden && boundInput === t) { elRange.value = String(next); }
     });
 })();