|
@@ -0,0 +1,211 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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);
|
|
|
|
|
+ });
|
|
|
|
|
+})();
|