Prechádzať zdrojové kódy

Remove number-stepper slider popover

Drops the click-to-open vertical-slider popover that Phase 17
added for [data-stepper] number inputs. The popover never
behaved reliably across the seven follow-up fixes, and the
team prefers plain typed entry.

Removed:
- public/assets/js/number-stepper.js (~272 lines)
- <script src="/assets/js/number-stepper.js"> from
  views/layout.php and views/sprints/present.php
- .stepper-popover CSS block in assets/css/input.css
- data-stepper attributes from views/sprints/{show,settings,
  present}.php and the JS-built cell in sprint-planner.js
  buildTaskRow

Kept (unchanged behaviour):
- The @layer base rule that hides native -webkit/-moz number
  spinner arrows app-wide. ArrowUp/ArrowDown on focused number
  inputs still works (browser default).

Smoke: php -l on every touched view file passes. No schema,
route, or PHP changes; PHPUnit untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 3 dní pred
rodič
commit
e551705334

+ 3 - 28
assets/css/input.css

@@ -3,9 +3,9 @@
 @tailwind utilities;
 
 @layer base {
-    /* Phase 17: hide native spinner buttons on every number input app-wide.
-       The team prefers either keyboard typing or the custom
-       [data-stepper] popover over the tiny, inconsistent UA controls. */
+    /* Hide native spinner buttons on every number input app-wide. The
+       team prefers keyboard typing over the tiny, inconsistent UA
+       controls. */
     input[type="number"]::-webkit-outer-spin-button,
     input[type="number"]::-webkit-inner-spin-button {
         -webkit-appearance: none;
@@ -55,29 +55,4 @@
         transform: rotate(180deg);
         padding: 0.5rem 0.25rem;
     }
-
-    /* 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 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 input[type="range"] {
-        @apply accent-slate-600 dark:accent-slate-400;
-        /* Vertical orientation. Modern browsers (Chrome 111+, Firefox
-           110+, Safari 16.4+) honour writing-mode on <input type="range">;
-           the -webkit-appearance fallback keeps older WebKit correct.
-           The `orient` attribute set in JS handles older Firefox. */
-        writing-mode: vertical-lr;
-        direction: rtl;
-        -webkit-appearance: slider-vertical;
-        appearance: slider-vertical;
-        width: 1.5rem;
-        height: 8rem;
-    }
 }

+ 0 - 272
public/assets/js/number-stepper.js

@@ -1,272 +0,0 @@
-/**
- * Floating vertical-slider popover for number inputs.
- *
- * 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 strategy (DIFFERENT from earlier iterations — several prior
- * attempts relied on document-level `pointermove` / `focusout` /
- * `pointerleave` delegation and all silently failed in practice).
- * This version attaches pointer listeners DIRECTLY to the two
- * elements that matter — the bound input and the popover — in
- * open(), and detaches them in close(). That way close behaviour
- * can't be swallowed by anything else on the page.
- *
- *   - `pointerleave` on input → schedule close (200 ms).
- *   - `pointerenter` on popover → cancel pending close.
- *   - `pointerleave` on popover → schedule close.
- *   - `pointerenter` on input  → cancel pending close.
- *   - 300 ms open-grace window: if the close timer fires while still
- *     inside the grace, it reschedules instead of dismissing.
- *
- * Outside-click close: capture-phase `pointerdown` on document, with
- * a 50 ms "just opened" guard so the very click that opened the
- * popup doesn't count as an outside click.
- *
- * Escape closes + returns focus. ArrowUp/Down on the focused input
- * steps by the input's `step` attribute (replaces the native spinner
- * shortcut suppressed in Phase 17).
- *
- * Scroll anchoring: `window` scroll (capture) + resize listeners
- * rAF-throttle a reposition, so the popover follows the input as
- * any scrollable ancestor moves. A 0×0 bounding rect triggers close.
- *
- * Strict-CSP-clean (standard <script src>, no inline handlers). No
- * globals. Vanilla JS — no jQuery.
- */
-(function () {
-    'use strict';
-
-    const OPEN_GRACE_MS  = 300;
-    const CLOSE_DELAY_MS = 200;
-    const OPEN_IGNORE_MS = 50;   // ignore outside-click within this of open
-
-    let pop          = null;
-    let elRange      = null;
-    let boundInput   = null;
-    let openedAt     = 0;
-    let closeTimer   = null;
-    let rafId        = null;
-
-    function now() {
-        return (typeof performance !== 'undefined' && performance.now)
-            ? performance.now()
-            : Date.now();
-    }
-
-    // --------------------------------------------------------------
-    // Open / close
-    // --------------------------------------------------------------
-
-    function build() {
-        if (pop) { return; }
-        pop = document.createElement('div');
-        pop.className = 'stepper-popover';
-        pop.hidden = true;
-        pop.setAttribute('role', 'dialog');
-        pop.setAttribute('aria-label', 'Set value');
-        pop.innerHTML = '<input type="range" orient="vertical">';
-        document.body.appendChild(pop);
-        elRange = pop.querySelector('input[type="range"]');
-
-        // Slider → input sync on every tick.
-        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 }));
-        });
-
-        // Popover-side pointer tracking. Attached once here; input-
-        // side listeners are attached per-open in bindInput() so they
-        // follow whichever input is currently active.
-        pop.addEventListener('pointerenter', cancelCloseTimer);
-        pop.addEventListener('pointerleave', scheduleClose);
-    }
-
-    function bindInput(input) {
-        input.addEventListener('pointerenter', cancelCloseTimer);
-        input.addEventListener('pointerleave', scheduleClose);
-    }
-    function unbindInput(input) {
-        input.removeEventListener('pointerenter', cancelCloseTimer);
-        input.removeEventListener('pointerleave', scheduleClose);
-    }
-
-    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 open(input) {
-        build();
-        if (boundInput === input && !pop.hidden) {
-            scheduleReposition();
-            return;
-        }
-        // Moving between inputs: detach from previous before rebinding.
-        if (boundInput && boundInput !== input) {
-            unbindInput(boundInput);
-        }
-        boundInput = input;
-        openedAt   = now();
-        cancelCloseTimer();
-        bindInput(input);
-
-        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;
-        const sMin = Number.isFinite(min) ? min : 0;
-        const sMax = Number.isFinite(max) ? max : Math.max(cur + 5, 10);
-
-        elRange.min   = String(sMin);
-        elRange.max   = String(sMax);
-        elRange.step  = String(eff);
-        elRange.value = String(Math.max(sMin, Math.min(sMax, cur)));
-
-        pop.hidden = false;
-        reposition();
-    }
-
-    function close() {
-        if (!pop || pop.hidden) { return; }
-        pop.hidden = true;
-        cancelCloseTimer();
-        const prev = boundInput;
-        boundInput = null;
-        if (prev) {
-            unbindInput(prev);
-            prev.dispatchEvent(new Event('change', { bubbles: true }));
-        }
-    }
-
-    function cancelCloseTimer() {
-        if (closeTimer !== null) { clearTimeout(closeTimer); closeTimer = null; }
-    }
-    function scheduleClose() {
-        if (closeTimer !== null) { return; }
-        closeTimer = setTimeout(function () {
-            closeTimer = null;
-            // Don't dismiss during the open-grace window — a click-to-
-            // open whose pointer is wandering in the first 300 ms
-            // shouldn't kill the popup before the user's had a chance
-            // to reach the slider.
-            if (now() - openedAt < OPEN_GRACE_MS) { scheduleClose(); return; }
-            close();
-        }, CLOSE_DELAY_MS);
-    }
-
-    // --------------------------------------------------------------
-    // Positioning
-    // --------------------------------------------------------------
-
-    function reposition() {
-        if (!pop || pop.hidden || !boundInput) { return; }
-        const r = boundInput.getBoundingClientRect();
-        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;
-
-        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();
-        });
-    }
-
-    // --------------------------------------------------------------
-    // Open triggers
-    // --------------------------------------------------------------
-
-    function isEligible(el) {
-        return !!(el
-            && el.matches
-            && el.matches('input[type="number"]')
-            && !el.disabled
-            && !el.readOnly);
-    }
-
-    document.addEventListener('click', function (ev) {
-        if (isEligible(ev.target)) { open(ev.target); }
-    });
-    document.addEventListener('focusin', function (ev) {
-        if (isEligible(ev.target)) { open(ev.target); }
-    });
-
-    // --------------------------------------------------------------
-    // Outside pointerdown → close. Capture phase + open-ignore
-    // window so the opening click doesn't close us.
-    // --------------------------------------------------------------
-
-    document.addEventListener('pointerdown', function (ev) {
-        if (!pop || pop.hidden) { return; }
-        if (now() - openedAt < OPEN_IGNORE_MS) { return; }
-        const t = ev.target;
-        if (t === boundInput || (boundInput && boundInput.contains && boundInput.contains(t))) { return; }
-        if (pop.contains(t)) { return; }
-        close();
-    }, true);
-
-    // --------------------------------------------------------------
-    // Escape closes + returns focus
-    // --------------------------------------------------------------
-
-    document.addEventListener('keydown', function (ev) {
-        if (!pop || pop.hidden || ev.key !== 'Escape') { return; }
-        ev.preventDefault();
-        const prev = boundInput;
-        close();
-        if (prev) { try { prev.focus(); } catch (_) { /* ignore */ } }
-    });
-
-    // --------------------------------------------------------------
-    // Scroll / resize anchoring
-    // --------------------------------------------------------------
-
-    window.addEventListener('scroll', scheduleReposition, true);
-    window.addEventListener('resize', scheduleReposition);
-
-    // --------------------------------------------------------------
-    // Keyboard nudge on the focused input
-    // --------------------------------------------------------------
-
-    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 = 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) { elRange.value = String(next); }
-    });
-})();

+ 1 - 1
public/assets/js/sprint-planner.js

@@ -387,7 +387,7 @@
                 .attr('data-col', 'sw-' + sw.id)
                 .attr('data-sort-value-sw-' + sw.id, v.toFixed(2));
             $td.append(
-                $('<input type="number" min="0" step="0.5" data-assign data-stepper class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">')
+                $('<input type="number" min="0" step="0.5" data-assign class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">')
                     .val(fmtDays(v))
                     .attr('data-sw-id', sw.id)
             );

+ 0 - 1
views/layout.php

@@ -20,7 +20,6 @@ $csrfToken   = $csrfToken   ?? '';
     <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
     <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js"></script>
     <script src="/assets/js/app.js" defer></script>
-    <script src="/assets/js/number-stepper.js" defer></script>
 </head>
 <body class="bg-slate-100 text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100">
     <header class="border-b bg-white dark:bg-slate-800 dark:border-slate-700">

+ 0 - 2
views/sprints/present.php

@@ -49,7 +49,6 @@ if (!function_exists('fmt_days')) {
     <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
     <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js"></script>
     <script src="/assets/js/sprint-planner.js" defer></script>
-    <script src="/assets/js/number-stepper.js" defer></script>
 </head>
 <body class="bg-white text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100">
 <main class="min-h-screen w-screen overflow-hidden beamer-root"
@@ -299,7 +298,6 @@ if (!function_exists('fmt_days')) {
                                                    value="<?= e(fmt_days($d)) ?>"
                                                    data-assign
                                                    data-sw-id="<?= (int) $sw->id ?>"
-                                                   data-stepper
                                                    class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                         <?php else: ?>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>

+ 0 - 1
views/sprints/settings.php

@@ -158,7 +158,6 @@ use function App\Http\e;
                                 <input type="number" step="0.05" min="0" max="1"
                                        value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
                                        data-rtb
-                                       data-stepper
                                        class="w-20 rounded border border-slate-300 px-2 py-1 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                 <button type="button" data-remove class="text-sm text-red-600 hover:underline dark:text-red-400">Remove</button>
                             </li>

+ 0 - 3
views/sprints/show.php

@@ -138,7 +138,6 @@ if (!function_exists('fmt_days')) {
                                                value="<?= e(fmt_days($v)) ?>"
                                                data-day data-sw-id="<?= (int) $sw->id ?>"
                                                data-week-id="<?= (int) $w->id ?>"
-                                               data-stepper
                                                class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                     <?php else: ?>
                                         <span class="font-mono"><?= e(fmt_days($v)) ?></span>
@@ -154,7 +153,6 @@ if (!function_exists('fmt_days')) {
                                     <input type="number" min="0" max="1" step="0.05"
                                            value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
                                            data-rtb data-sw-id="<?= (int) $sw->id ?>"
-                                           data-stepper
                                            class="w-16 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                 <?php else: ?>
                                     <span class="font-mono"><?= e(number_format($sw->rtb, 2, '.', '')) ?></span>
@@ -428,7 +426,6 @@ if (!function_exists('fmt_days')) {
                                                    value="<?= e(fmt_days($d)) ?>"
                                                    data-assign
                                                    data-sw-id="<?= (int) $sw->id ?>"
-                                                   data-stepper
                                                    class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                         <?php else: ?>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>