| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- /**
- * 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 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.
- */
- (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');
- // 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);
- 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(); });
- // 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() {
- 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 */ } }
- }
- }
- // 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;
- if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
- if (t.disabled || t.readOnly) { return; }
- cancelCloseTimer();
- if (boundInput !== t) { open(t); }
- }
- // 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
- // 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) {
- 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);
- });
- })();
|