| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- /**
- * Phase 17: custom stepper popover for half-step number inputs.
- *
- * 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>.
- *
- * No globals. Single IIFE. Vanilla JS, no jQuery dependency.
- */
- (function () {
- 'use strict';
- const EPS = 1e-9;
- /**
- * Step a value by delta, clamp to [min, max] when finite, and quantise
- * to the nearest `step` increment. Pure — exposed as a local symbol
- * for readability; would be the testable surface if we added a
- * headless-JS test harness.
- */
- function clampToStep(current, delta, step, min, max) {
- if (!(step > 0)) { step = 1; }
- let next = Number(current) + delta;
- if (Number.isFinite(min) && next < min) { next = min; }
- if (Number.isFinite(max) && next > max) { next = max; }
- // Floor with an epsilon tolerance so 0.6/0.05 doesn't quantise down
- // to the wrong side of the grid thanks to binary-float artefacts.
- const quantised = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
- // Round to a sensible number of decimals — 6 is more than enough for
- // any step the app uses (0.5 / 0.05 / 1).
- return Number(quantised.toFixed(6));
- }
- // One popover per document, built lazily on first use.
- let pop = null;
- let elDec = null, elInc = null, elOut = null, elRange = null;
- let boundInput = null;
- 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 = '<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>';
- document.body.appendChild(pop);
- elDec = pop.querySelector('[data-stepper-dec]');
- elInc = pop.querySelector('[data-stepper-inc]');
- elOut = pop.querySelector('[data-stepper-value]');
- elRange = pop.querySelector('[data-stepper-range]');
- elDec.addEventListener('click', function () { step(-1); });
- elInc.addEventListener('click', function () { step(+1); });
- elRange.addEventListener('input', function () {
- if (!boundInput) { return; }
- setValue(Number(elRange.value));
- });
- // Stop pointerdown inside the popover from bubbling to the
- // document-level outside-click handler below.
- pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
- }
- function readBounds() {
- const step = Number(boundInput.step);
- const min = Number(boundInput.min);
- const max = Number(boundInput.max);
- return {
- step: Number.isFinite(step) && step > 0 ? step : 1,
- min: Number.isFinite(min) ? min : NaN,
- max: Number.isFinite(max) ? max : NaN,
- };
- }
- function step(dir) {
- if (!boundInput) { return; }
- const b = readBounds();
- const next = clampToStep(Number(boundInput.value) || 0, dir * b.step, b.step, b.min, b.max);
- setValue(next);
- }
- function setValue(n) {
- if (!boundInput) { return; }
- const b = readBounds();
- const next = clampToStep(n, 0, b.step, b.min, b.max);
- boundInput.value = String(next);
- elOut.textContent = String(next);
- if (!elRange.hidden) { elRange.value = String(next); }
- boundInput.dispatchEvent(new Event('input', { bubbles: true }));
- }
- function position(input) {
- const rect = input.getBoundingClientRect();
- // Measure the popover — temporarily visible-off-screen for layout.
- pop.style.left = '-9999px';
- pop.style.top = '0px';
- pop.hidden = false;
- const pw = pop.offsetWidth;
- const ph = pop.offsetHeight;
- const vh = window.innerHeight;
- const vw = window.innerWidth;
- // Vertical: below unless the input sits in the lower 25% of the
- // viewport, then above.
- const GAP = 4;
- let top;
- if (rect.bottom > vh * 0.75) {
- top = rect.top - ph - GAP;
- } else {
- top = rect.bottom + GAP;
- }
- // Horizontal: align to the input's left edge, clamped to viewport.
- let left = rect.left;
- const MARGIN = 4;
- if (left + pw > vw - MARGIN) { left = vw - pw - MARGIN; }
- if (left < MARGIN) { left = MARGIN; }
- if (top < MARGIN) { top = MARGIN; }
- pop.style.left = left + 'px';
- pop.style.top = top + 'px';
- }
- function open(input) {
- build();
- boundInput = input;
- const b = readBounds();
- const current = clampToStep(Number(input.value) || 0, 0, b.step, b.min, b.max);
- elOut.textContent = String(current);
- if (Number.isFinite(b.min) && Number.isFinite(b.max)) {
- elRange.hidden = false;
- elRange.min = String(b.min);
- elRange.max = String(b.max);
- elRange.step = String(b.step);
- elRange.value = String(current);
- } else {
- elRange.hidden = true;
- }
- position(input);
- }
- function close(returnFocus) {
- if (!pop || pop.hidden) { return; }
- pop.hidden = true;
- const prev = boundInput;
- boundInput = null;
- if (prev) {
- prev.dispatchEvent(new Event('change', { bubbles: true }));
- if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
- }
- }
- // Open on click/focus of a stepper-tagged input.
- function onOpenTrigger(ev) {
- const t = ev.target;
- if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
- if (t.disabled || t.readOnly) { return; }
- open(t);
- }
- document.addEventListener('click', onOpenTrigger);
- document.addEventListener('focusin', onOpenTrigger);
- // Outside-click close — pointerdown, not click, so a scroll gesture
- // starting inside the popover in Safari doesn't dismiss prematurely.
- document.addEventListener('pointerdown', function (ev) {
- if (!pop || pop.hidden) { return; }
- const t = ev.target;
- if (boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t)))) { return; }
- if (pop.contains(t)) { return; }
- close(false);
- });
- // Escape closes + returns focus; Tab that leaves the input + popover
- // closes without focus return.
- document.addEventListener('keydown', function (ev) {
- if (!pop || pop.hidden) { return; }
- if (ev.key === 'Escape') {
- ev.preventDefault();
- close(true);
- return;
- }
- if (ev.key === 'Tab') {
- // After the tab completes, check if focus is still on the input
- // or inside the popover. If not, close.
- setTimeout(function () {
- const active = document.activeElement;
- if (active === boundInput) { return; }
- if (pop.contains(active)) { return; }
- close(false);
- }, 0);
- }
- });
- // While the bound input is focused, ArrowUp/Down step by `step` —
- // restores the shortcut the native spinner lost to Phase 17's CSS reset.
- document.addEventListener('keydown', function (ev) {
- const t = ev.target;
- if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
- if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
- ev.preventDefault();
- if (boundInput !== t) { open(t); }
- step(ev.key === 'ArrowUp' ? +1 : -1);
- });
- })();
|