| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- /**
- * 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); }
- });
- })();
|