| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- /**
- * Phase 17: floating slider popover for number inputs.
- *
- * Activates on click or keyboard focus of any `input[type="number"]` —
- * pops a compact vertical range slider to the right of the input, at
- * the input's vertical midpoint. The popover shows *only* the slider;
- * there are no +/− buttons and no value readout. Users read the
- * current value from the input itself; they drag the slider to set a
- * new value. Dragging fires `change` on the bound input (debounced
- * saves in sprint-planner.js coalesce the flurry), so capacity
- * recomputes live.
- *
- * Close triggers:
- * 1. Pointer leaves the popover after having entered it once (the
- * primary dismissal gesture — "hover-in, drag, hover-out");
- * 2. Outside pointerdown (touch devices + mis-click escape hatch);
- * 3. Escape keypress (focus returns to the input);
- * 4. Tab that moves focus out of both input and popover.
- *
- * Applies to every `input[type="number"]` across every view — day
- * cells, RTB cells, task-assignment cells, week-count field, reserve
- * percent. For inputs without a `max` attribute (task assignments),
- * the slider picks an adaptive upper bound so it still feels useful.
- *
- * 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;
- function quantise(next, step, min, max) {
- if (!(step > 0)) { step = 1; }
- if (Number.isFinite(min) && next < min) { next = min; }
- if (Number.isFinite(max) && next > max) { next = max; }
- const q = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
- return Number(q.toFixed(6));
- }
- // Single shared popover, built lazily on first use.
- let pop = null;
- let elRange = null;
- let boundInput = null;
- // Set true on the first pointerenter after open(); only then does
- // pointerleave close the popover. That way a click-to-open whose
- // cursor already sits inside the popover rectangle doesn't
- // immediately close on the next micro-movement.
- let popoverEntered = false;
- 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');
- // `orient="vertical"` is a belt-and-braces fallback for older
- // Firefox; modern layout comes from the writing-mode CSS in
- // assets/css/input.css.
- pop.innerHTML =
- '<input type="range" data-stepper-range orient="vertical">';
- document.body.appendChild(pop);
- elRange = pop.querySelector('[data-stepper-range]');
- // Live recompute during drag — dispatch `change` (not `input`)
- // because sprint-planner.js listens for `blur change` on the
- // bound inputs. Its queueCell() is 400 ms-debounced, so
- // firing `change` on every slider tick coalesces into one
- // server write after the drag pauses.
- elRange.addEventListener('input', function () {
- if (!boundInput) { return; }
- boundInput.value = elRange.value;
- boundInput.dispatchEvent(new Event('change', { bubbles: true }));
- });
- // Cancel bubbling of pointerdown inside the popover so the
- // document-level outside-click handler doesn't close us on a
- // drag-start in Safari.
- pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
- // The core dismissal gesture: enter-then-leave closes.
- pop.addEventListener('pointerenter', function () {
- popoverEntered = true;
- });
- pop.addEventListener('pointerleave', function () {
- if (popoverEntered) { close(false); }
- });
- }
- function readAttrNumber(input, name) {
- const raw = input.getAttribute(name);
- if (raw === null || raw === '') { return NaN; }
- const n = Number(raw);
- return Number.isFinite(n) ? n : NaN;
- }
- function position(input) {
- const rect = input.getBoundingClientRect();
- // Measure off-screen so the browser has real dimensions to
- // work with before we anchor.
- 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;
- const GAP = 6;
- const MARGIN = 4;
- // Preferred: right of the input, vertically centred on it.
- let left = rect.right + GAP;
- let top = rect.top + (rect.height / 2) - (ph / 2);
- // If we'd run off the right edge, flip to the left of the input.
- if (left + pw > vw - MARGIN) {
- left = rect.left - pw - GAP;
- }
- // Then clamp both axes into the viewport.
- if (left < MARGIN) { left = MARGIN; }
- if (left + pw > vw - MARGIN) { left = vw - pw - MARGIN; }
- if (top < MARGIN) { top = MARGIN; }
- if (top + ph > vh - MARGIN) { top = vh - ph - MARGIN; }
- pop.style.left = left + 'px';
- pop.style.top = top + 'px';
- }
- function open(input) {
- build();
- boundInput = input;
- popoverEntered = false;
- const stepAttr = readAttrNumber(input, 'step');
- const minAttr = readAttrNumber(input, 'min');
- const maxAttr = readAttrNumber(input, 'max');
- const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
- const current = quantise(Number(input.value) || 0, step, minAttr, maxAttr);
- // Slider requires a finite min and max. Fall back sensibly
- // when the input leaves them open — task-assignment cells
- // declare min="0" but no max, so we pick max = max(current+5, 10)
- // so there's always usable overhead on the slider.
- const sliderMin = Number.isFinite(minAttr) ? minAttr : 0;
- const sliderMax = Number.isFinite(maxAttr)
- ? maxAttr
- : Math.max(current + 5, 10);
- elRange.min = String(sliderMin);
- elRange.max = String(sliderMax);
- elRange.step = String(step);
- elRange.value = String(Math.max(sliderMin, Math.min(sliderMax, current)));
- position(input);
- }
- function close(returnFocus) {
- if (!pop || pop.hidden) { return; }
- pop.hidden = true;
- const prev = boundInput;
- boundInput = null;
- popoverEntered = false;
- if (prev) {
- // Final `change` so the debounced save captures the last
- // slider position. Harmless no-op if the slider wasn't
- // touched (value unchanged).
- prev.dispatchEvent(new Event('change', { bubbles: true }));
- if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
- }
- }
- // ------------------------------------------------------------------
- // Triggers
- // ------------------------------------------------------------------
- function isEligible(el) {
- return !!(el
- && el.matches
- && el.matches('input[type="number"]')
- && !el.disabled
- && !el.readOnly);
- }
- // Click-to-open. Covers the primary mouse path. Reposition when
- // the click lands on a different eligible input than the currently
- // bound one.
- document.addEventListener('click', function (ev) {
- const t = ev.target;
- if (!isEligible(t)) { return; }
- if (boundInput !== t) { open(t); }
- });
- // focusin covers keyboard navigation (Tab to field). Don't reopen
- // if we're already bound to that input — focus may re-fire on
- // clicks that land on the input.
- document.addEventListener('focusin', function (ev) {
- const t = ev.target;
- if (!isEligible(t)) { return; }
- if (boundInput !== t) { open(t); }
- });
- // Outside pointerdown closes. Only listen while the popover is
- // open so we don't pay the cost on every app-wide click.
- 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 and returns focus; Tab out of 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') {
- setTimeout(function () {
- const active = document.activeElement;
- if (active === boundInput) { return; }
- if (pop.contains(active)) { return; }
- close(false);
- }, 0);
- }
- });
- // ArrowUp/Down while the input has keyboard focus — step by the
- // input's `step` attribute. Restores the shortcut the native
- // spinner lost to Phase 17's CSS reset; works even when the
- // popover isn't open.
- 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 = readAttrNumber(t, 'step');
- const minA = readAttrNumber(t, 'min');
- const maxA = readAttrNumber(t, 'max');
- const effStep = Number.isFinite(step) && step > 0 ? step : 1;
- const next = quantise(
- (Number(t.value) || 0) + (ev.key === 'ArrowUp' ? effStep : -effStep),
- effStep, minA, maxA,
- );
- t.value = String(next);
- t.dispatchEvent(new Event('change', { bubbles: true }));
- if (!pop || pop.hidden || boundInput !== t) { return; }
- elRange.value = String(next);
- });
- })();
|