Explorar o código

Stepper popover: slider-only, click-to-open, close on leave-popup

Third iteration on the Phase 17 popover (b457896 / c07af1c / 832b256)
after use. The team wants a lighter, faster interaction model:

- **Show on click (not hover).** focusin is still honoured for keyboard
  navigation.
- **Close when the mouse leaves the popover**, after having entered it
  once. A `popoverEntered` latch means a click whose cursor already
  sits inside the popover rectangle doesn't immediately dismiss on
  the next micro-movement.
- **Slider only.** The +/− buttons and the numeric output are gone;
  the input itself shows the value.
- **Anchored right of the input at its vertical midpoint**, with a
  flip-to-left fallback when the right edge would clip the viewport.
- **Universal selector.** The popover now activates for every
  `input[type="number"]` on every page — day / RTB / assignment cells
  on show.php and present.php, plus the reserve-percent and week-count
  fields on settings and new-sprint. `data-stepper` is no longer
  required (still harmless on existing markup).
- **Adaptive slider max.** Task-assignment inputs declare `min="0"`
  but no `max`; the popover now picks `max(current + 5, 10)` so the
  slider is always usable. Typing beyond the slider max still works
  normally (the native input has no hard upper bound).
- **Live recompute.** Every slider tick dispatches `change` on the
  bound input. sprint-planner.js's existing 400 ms debounce coalesces
  the flurry into one server write, and its capacity recompute runs
  on each tick — Available / Ressourcen / ≤ reserves update
  smoothly while dragging.

CSS: `.stepper-controls` / button / output rules gone; `.stepper-popover`
reduced to a flex container for the vertical range slider (height 8rem,
width 1.5rem). No changes to the view files — the same JS attribute
that was stamped by the Phase 17 agent still works, but it's no longer
required.

No schema / routes / audit changes. phpunit: 88 / 208 unchanged.
node --check clean on number-stepper.js.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa hai 2 semanas
pai
achega
15b2d2408f
Modificáronse 2 ficheiros con 152 adicións e 182 borrados
  1. 9 22
      assets/css/input.css
  2. 143 160
      public/assets/js/number-stepper.js

+ 9 - 22
assets/css/input.css

@@ -56,30 +56,17 @@
         padding: 0.5rem 0.25rem;
     }
 
-    /* Phase 17: floating stepper popover. Anchored to the active
-       [data-stepper] input in JS; uses `position: fixed` so it never
-       flickers when scrolling the container table. Dark-mode siblings
-       match the Phase 16 palette.
-       As of the hover follow-up: the popover opens on pointerenter over
-       the input and auto-closes shortly after the pointer leaves both
-       input and popover — no click needed. The optional range slider
-       now renders vertically (top = max, bottom = min). */
+    /* Phase 17: floating slider popover for number inputs. Shows a
+       vertical range slider (top = max, bottom = min) anchored to the
+       right of the active input at its vertical midpoint; click-to-
+       open, close when the pointer leaves the popover after having
+       entered it (see public/assets/js/number-stepper.js). Dark-mode
+       siblings match the Phase 16 palette. */
     .stepper-popover {
-        @apply fixed z-50 flex items-center gap-2 rounded-md border
-               border-slate-200 bg-white px-2 py-1 shadow-lg
+        @apply fixed z-50 flex items-center justify-center rounded-md
+               border border-slate-200 bg-white px-1 py-2 shadow-lg
                dark:border-slate-600 dark:bg-slate-800;
     }
-    .stepper-popover .stepper-controls {
-        @apply flex flex-col items-center gap-1;
-    }
-    .stepper-popover button {
-        @apply rounded px-2 py-1 text-lg font-semibold leading-none text-slate-700
-               hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700;
-    }
-    .stepper-popover output {
-        @apply min-w-[3rem] text-center font-mono text-slate-900
-               dark:text-slate-100;
-    }
     .stepper-popover input[type="range"] {
         @apply accent-slate-600 dark:accent-slate-400;
         /* Vertical orientation. Modern browsers (Chrome 111+, Firefox
@@ -91,6 +78,6 @@
         -webkit-appearance: slider-vertical;
         appearance: slider-vertical;
         width: 1.5rem;
-        height: 6rem;
+        height: 8rem;
     }
 }

+ 143 - 160
public/assets/js/number-stepper.js

@@ -1,50 +1,52 @@
 /**
- * Phase 17: custom stepper popover for half-step number inputs.
+ * Phase 17: floating slider popover for number inputs.
  *
- * Activates on any `input[data-stepper]` — opens a small popover next to
- * the input with −/+ buttons that step by the input's own step attribute
- * (default 1), and — when both min and max are finite — a vertical range
- * slider (top = max, bottom = min) for quick gross moves. Mutations
- * mirror into input.value and dispatch a bubbling `input` event (so
- * sprint-planner.js live-recomputes); on popover close we dispatch
- * `change` (so the existing debounced save pipeline fires). Strict-CSP-
- * clean: loaded as a standard <script src>.
+ * 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.
  *
- * Trigger model (hover-first): the popover opens when the pointer
- * enters the input, and auto-closes ~200 ms after the pointer leaves
- * both the input and the popover. Keyboard focus also opens the popover
- * for accessibility; Escape closes and returns focus.
+ * 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.
  *
- * No globals. Single IIFE. Vanilla JS, no jQuery dependency.
+ * 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.
+ *
+ * Strict-CSP-clean: loaded as a standard <script src>. No globals.
+ * Single IIFE. Vanilla JS — no jQuery dependency.
  */
 (function () {
     'use strict';
 
     const EPS = 1e-9;
 
-    /**
-     * Step a value by delta, clamp to [min, max] when finite, and quantise
-     * to the nearest `step` increment. Pure — exposed as a local symbol
-     * for readability; would be the testable surface if we added a
-     * headless-JS test harness.
-     */
-    function clampToStep(current, delta, step, min, max) {
+    function quantise(next, step, min, max) {
         if (!(step > 0)) { step = 1; }
-        let next = Number(current) + delta;
         if (Number.isFinite(min) && next < min) { next = min; }
         if (Number.isFinite(max) && next > max) { next = max; }
-        // Floor with an epsilon tolerance so 0.6/0.05 doesn't quantise down
-        // to the wrong side of the grid thanks to binary-float artefacts.
-        const quantised = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
-        // Round to a sensible number of decimals — 6 is more than enough for
-        // any step the app uses (0.5 / 0.05 / 1).
-        return Number(quantised.toFixed(6));
+        const q = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
+        return Number(q.toFixed(6));
     }
 
-    // One popover per document, built lazily on first use.
+    // Single shared popover, built lazily on first use.
     let pop = null;
-    let elDec = null, elInc = null, elOut = null, elRange = 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;
 
     function build() {
         if (pop) { return; }
@@ -53,78 +55,50 @@
         pop.hidden = true;
         pop.setAttribute('role', 'dialog');
         pop.setAttribute('aria-label', 'Set value');
-        // Controls (+ / value / −) stack vertically so the layout stays
-        // compact next to the optional vertical range slider on the right.
+        // `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 =
-              '<div class="stepper-controls">'
-            +   '<button type="button" data-stepper-inc aria-label="Increase">+</button>'
-            +   '<output data-stepper-value>0</output>'
-            +   '<button type="button" data-stepper-dec aria-label="Decrease">\u2212</button>'
-            + '</div>'
-            + '<input type="range" data-stepper-range hidden orient="vertical">';
+            '<input type="range" data-stepper-range orient="vertical">';
         document.body.appendChild(pop);
-        elDec   = pop.querySelector('[data-stepper-dec]');
-        elInc   = pop.querySelector('[data-stepper-inc]');
-        elOut   = pop.querySelector('[data-stepper-value]');
         elRange = pop.querySelector('[data-stepper-range]');
 
-        elDec.addEventListener('click', function () { step(-1); });
-        elInc.addEventListener('click', function () { step(+1); });
+        // 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.
         elRange.addEventListener('input', function () {
             if (!boundInput) { return; }
-            setValue(Number(elRange.value));
+            boundInput.value = elRange.value;
+            boundInput.dispatchEvent(new Event('change', { bubbles: true }));
         });
-        // Stop pointerdown inside the popover from bubbling to the
-        // document-level outside-click handler below.
-        pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
-        // Hover bookkeeping — cancelling the pending close when the pointer
-        // enters the popover, restarting it when the pointer leaves.
-        pop.addEventListener('pointerenter', cancelCloseTimer);
-        pop.addEventListener('pointerleave', scheduleClose);
-    }
 
-    // Read bounds via getAttribute so an absent `max` reports as NaN
-    // instead of being coerced through Number("") === 0. Without this,
-    // task-assignment cells (which set min="0" but have no max) would
-    // be clamped to [0, 0] and refuse to increment in any table but
-    // the Arbeitstage grid.
-    function readBounds() {
-        function parseAttr(name) {
-            const raw = boundInput.getAttribute(name);
-            if (raw === null || raw === '') { return NaN; }
-            const n = Number(raw);
-            return Number.isFinite(n) ? n : NaN;
-        }
-        const step = parseAttr('step');
-        const min  = parseAttr('min');
-        const max  = parseAttr('max');
-        return {
-            step: Number.isFinite(step) && step > 0 ? step : 1,
-            min:  min,
-            max:  max,
-        };
-    }
+        // 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(); });
 
-    function step(dir) {
-        if (!boundInput) { return; }
-        const b = readBounds();
-        const next = clampToStep(Number(boundInput.value) || 0, dir * b.step, b.step, b.min, b.max);
-        setValue(next);
+        // The core dismissal gesture: enter-then-leave closes.
+        pop.addEventListener('pointerenter', function () {
+            popoverEntered = true;
+        });
+        pop.addEventListener('pointerleave', function () {
+            if (popoverEntered) { close(false); }
+        });
     }
 
-    function setValue(n) {
-        if (!boundInput) { return; }
-        const b = readBounds();
-        const next = clampToStep(n, 0, b.step, b.min, b.max);
-        boundInput.value = String(next);
-        elOut.textContent = String(next);
-        if (!elRange.hidden) { elRange.value = String(next); }
-        boundInput.dispatchEvent(new Event('input', { bubbles: true }));
+    function readAttrNumber(input, name) {
+        const raw = input.getAttribute(name);
+        if (raw === null || raw === '') { return NaN; }
+        const n = Number(raw);
+        return Number.isFinite(n) ? n : NaN;
     }
 
     function position(input) {
         const rect = input.getBoundingClientRect();
-        // Measure the popover — temporarily visible-off-screen for layout.
+        // 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;
@@ -133,21 +107,22 @@
         const vh = window.innerHeight;
         const vw = window.innerWidth;
 
-        // Vertical: below unless the input sits in the lower 25% of the
-        // viewport, then above.
-        const GAP = 4;
-        let top;
-        if (rect.bottom > vh * 0.75) {
-            top = rect.top - ph - GAP;
-        } else {
-            top = rect.bottom + GAP;
-        }
-        // Horizontal: centre over the input's midpoint, clamped to viewport.
-        let left = rect.left + (rect.width - pw) / 2;
+        const GAP    = 6;
         const MARGIN = 4;
-        if (left + pw > vw - MARGIN) { left = vw - pw - MARGIN; }
-        if (left < MARGIN) { left = MARGIN; }
-        if (top < MARGIN) { top = MARGIN; }
+
+        // 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; }
 
         pop.style.left = left + 'px';
         pop.style.top  = top  + 'px';
@@ -156,19 +131,28 @@
     function open(input) {
         build();
         boundInput = input;
-        const b = readBounds();
-        const current = clampToStep(Number(input.value) || 0, 0, b.step, b.min, b.max);
-        elOut.textContent = String(current);
+        popoverEntered = false;
+
+        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);
+
+        // 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(sliderMin);
+        elRange.max  = String(sliderMax);
+        elRange.step = String(step);
+        elRange.value = String(Math.max(sliderMin, Math.min(sliderMax, current)));
 
-        if (Number.isFinite(b.min) && Number.isFinite(b.max)) {
-            elRange.hidden = false;
-            elRange.min  = String(b.min);
-            elRange.max  = String(b.max);
-            elRange.step = String(b.step);
-            elRange.value = String(current);
-        } else {
-            elRange.hidden = true;
-        }
         position(input);
     }
 
@@ -177,59 +161,48 @@
         pop.hidden = true;
         const prev = boundInput;
         boundInput = null;
+        popoverEntered = false;
         if (prev) {
+            // Final `change` so the debounced save captures the last
+            // slider position. Harmless no-op if the slider wasn't
+            // touched (value unchanged).
             prev.dispatchEvent(new Event('change', { bubbles: true }));
             if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
         }
     }
 
-    // Hover-grace timer so the mouse can transit from input to popover
-    // (and back) without the popover flickering closed. 200 ms feels
-    // snappy but forgives normal mouse wobble.
-    const CLOSE_DELAY_MS = 200;
-    let closeTimer = null;
-    function cancelCloseTimer() {
-        if (closeTimer !== null) {
-            clearTimeout(closeTimer);
-            closeTimer = null;
-        }
-    }
-    function scheduleClose() {
-        cancelCloseTimer();
-        closeTimer = setTimeout(function () {
-            closeTimer = null;
-            close(false);
-        }, CLOSE_DELAY_MS);
+    // ------------------------------------------------------------------
+    // Triggers
+    // ------------------------------------------------------------------
+
+    function isEligible(el) {
+        return !!(el
+            && el.matches
+            && el.matches('input[type="number"]')
+            && !el.disabled
+            && !el.readOnly);
     }
 
-    // Open on hover (primary) or focus (keyboard a11y path).
-    function onHoverOrFocus(ev) {
+    // Click-to-open. Covers the primary mouse path. Reposition when
+    // the click lands on a different eligible input than the currently
+    // bound one.
+    document.addEventListener('click', function (ev) {
         const t = ev.target;
-        if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
-        if (t.disabled || t.readOnly) { return; }
-        cancelCloseTimer();
+        if (!isEligible(t)) { return; }
         if (boundInput !== t) { open(t); }
-    }
-    // Delegated pointerenter via pointerover (pointerenter doesn't bubble).
-    document.addEventListener('pointerover', onHoverOrFocus);
-    document.addEventListener('focusin',     onHoverOrFocus);
+    });
 
-    // Schedule a close when the pointer leaves a stepper input. The
-    // popover's own pointerenter cancels the timer, so transit is safe.
-    document.addEventListener('pointerout', function (ev) {
+    // 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 (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
-        if (!pop || pop.hidden || boundInput !== t) { return; }
-        // pointerout also fires when the pointer moves between children
-        // of the input; guard by checking relatedTarget.
-        const rel = ev.relatedTarget;
-        if (rel && t.contains && t.contains(rel)) { return; }
-        scheduleClose();
+        if (!isEligible(t)) { return; }
+        if (boundInput !== t) { open(t); }
     });
 
-    // Outside-click close — pointerdown, not click, so a scroll gesture
-    // starting inside the popover in Safari doesn't dismiss prematurely.
-    // Kept as a belt-and-braces for touch devices where hover is spotty.
+    // 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) {
         if (!pop || pop.hidden) { return; }
         const t = ev.target;
@@ -238,7 +211,7 @@
         close(false);
     });
 
-    // Escape closes + returns focus; Tab that leaves the input + popover
+    // Escape closes and returns focus; Tab out of the input + popover
     // closes without focus return.
     document.addEventListener('keydown', function (ev) {
         if (!pop || pop.hidden) { return; }
@@ -248,8 +221,6 @@
             return;
         }
         if (ev.key === 'Tab') {
-            // After the tab completes, check if focus is still on the input
-            // or inside the popover. If not, close.
             setTimeout(function () {
                 const active = document.activeElement;
                 if (active === boundInput) { return; }
@@ -259,14 +230,26 @@
         }
     });
 
-    // While the bound input is focused, ArrowUp/Down step by `step` —
-    // restores the shortcut the native spinner lost to Phase 17's CSS reset.
+    // 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.
     document.addEventListener('keydown', function (ev) {
         const t = ev.target;
-        if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
+        if (!isEligible(t)) { return; }
         if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
         ev.preventDefault();
-        if (boundInput !== t) { open(t); }
-        step(ev.key === 'ArrowUp' ? +1 : -1);
+        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,
+        );
+        t.value = String(next);
+        t.dispatchEvent(new Event('change', { bubbles: true }));
+        if (!pop || pop.hidden || boundInput !== t) { return; }
+        elRange.value = String(next);
     });
 })();