瀏覽代碼

Stepper popover: hover-to-open + vertical range slider

Tweak to Phase 17 (`b457896`) after live use. Two changes:

1. Trigger model is now hover-first. The popover opens when the
   pointer enters a [data-stepper] input (delegated via pointerover,
   since pointerenter doesn't bubble) and closes ~200 ms after the
   pointer leaves both the input and the popover. The popover's own
   pointerenter / pointerleave cancel / restart that 200 ms timer so
   normal mouse transit between the two doesn't flicker the popover
   closed. focusin still opens the popover for keyboard users, and
   Escape still closes + returns focus. The pointerdown outside-
   click path is kept as a touch-device belt-and-braces.

2. The optional range slider is now vertical (top = max, bottom =
   min). Achieved via the standard `writing-mode: vertical-lr;
   direction: rtl;` combo (Chrome 111+, Firefox 110+, Safari 16.4+)
   with `-webkit-appearance: slider-vertical` and an `orient="vertical"`
   attribute as fallbacks for older WebKit / Firefox. Popover layout
   reshuffled to a horizontal row of two columns: +/value/− buttons
   stacked on the left, the vertical slider on the right.

No HTML/view changes, no schema/routes/audit changes. sprint-planner.js
continues to see the same bubbling `input` / `change` events from the
stepper, so the debounced save pipeline is unaffected.

phpunit: 88/88, 208 assertions (unchanged). node --check clean on
number-stepper.js.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 周之前
父節點
當前提交
c07af1c3ea
共有 2 個文件被更改,包括 83 次插入16 次删除
  1. 19 2
      assets/css/input.css
  2. 64 14
      public/assets/js/number-stepper.js

+ 19 - 2
assets/css/input.css

@@ -59,14 +59,21 @@
     /* 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. */
+       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). */
     .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
                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 text-slate-700
+        @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 {
@@ -75,5 +82,15 @@
     }
     .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: 6rem;
     }
 }

+ 64 - 14
public/assets/js/number-stepper.js

@@ -3,11 +3,17 @@
  *
  * 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 range slider
- * 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>.
+ * (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>.
+ *
+ * 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.
  *
  * No globals. Single IIFE. Vanilla JS, no jQuery dependency.
  */
@@ -47,10 +53,15 @@
         pop.hidden = true;
         pop.setAttribute('role', 'dialog');
         pop.setAttribute('aria-label', 'Set value');
-        pop.innerHTML = '<button type="button" data-stepper-dec aria-label="Decrease">\u2212</button>'
-            + '<output data-stepper-value>0</output>'
-            + '<button type="button" data-stepper-inc aria-label="Increase">+</button>'
-            + '<input type="range" data-stepper-range hidden>';
+        // Controls (+ / value / −) stack vertically so the layout stays
+        // compact next to the optional vertical range slider on the right.
+        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">';
         document.body.appendChild(pop);
         elDec   = pop.querySelector('[data-stepper-dec]');
         elInc   = pop.querySelector('[data-stepper-inc]');
@@ -66,6 +77,10 @@
         // 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);
     }
 
     function readBounds() {
@@ -157,18 +172,53 @@
         }
     }
 
-    // Open on click/focus of a stepper-tagged input.
-    function onOpenTrigger(ev) {
+    // 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);
+    }
+
+    // Open on hover (primary) or focus (keyboard a11y path).
+    function onHoverOrFocus(ev) {
         const t = ev.target;
         if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
         if (t.disabled || t.readOnly) { return; }
-        open(t);
+        cancelCloseTimer();
+        if (boundInput !== t) { open(t); }
     }
-    document.addEventListener('click', onOpenTrigger);
-    document.addEventListener('focusin', onOpenTrigger);
+    // 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) {
+        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();
+    });
 
     // 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.
     document.addEventListener('pointerdown', function (ev) {
         if (!pop || pop.hidden) { return; }
         const t = ev.target;