|
@@ -3,11 +3,17 @@
|
|
|
*
|
|
*
|
|
|
* Activates on any `input[data-stepper]` — opens a small popover next to
|
|
* 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
|
|
* 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.
|
|
* No globals. Single IIFE. Vanilla JS, no jQuery dependency.
|
|
|
*/
|
|
*/
|
|
@@ -47,10 +53,15 @@
|
|
|
pop.hidden = true;
|
|
pop.hidden = true;
|
|
|
pop.setAttribute('role', 'dialog');
|
|
pop.setAttribute('role', 'dialog');
|
|
|
pop.setAttribute('aria-label', 'Set value');
|
|
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);
|
|
document.body.appendChild(pop);
|
|
|
elDec = pop.querySelector('[data-stepper-dec]');
|
|
elDec = pop.querySelector('[data-stepper-dec]');
|
|
|
elInc = pop.querySelector('[data-stepper-inc]');
|
|
elInc = pop.querySelector('[data-stepper-inc]');
|
|
@@ -66,6 +77,10 @@
|
|
|
// Stop pointerdown inside the popover from bubbling to the
|
|
// Stop pointerdown inside the popover from bubbling to the
|
|
|
// document-level outside-click handler below.
|
|
// document-level outside-click handler below.
|
|
|
pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
|
|
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() {
|
|
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;
|
|
const t = ev.target;
|
|
|
if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
|
|
if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
|
|
|
if (t.disabled || t.readOnly) { 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
|
|
// Outside-click close — pointerdown, not click, so a scroll gesture
|
|
|
// starting inside the popover in Safari doesn't dismiss prematurely.
|
|
// 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) {
|
|
document.addEventListener('pointerdown', function (ev) {
|
|
|
if (!pop || pop.hidden) { return; }
|
|
if (!pop || pop.hidden) { return; }
|
|
|
const t = ev.target;
|
|
const t = ev.target;
|