|
|
@@ -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); }
|
|
|
- });
|
|
|
-})();
|