|
@@ -1,50 +1,52 @@
|
|
|
/**
|
|
/**
|
|
|
- * Phase 17: custom stepper popover for half-step number inputs.
|
|
|
|
|
|
|
+ * Phase 17: floating slider popover for 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>.
|
|
|
|
|
|
|
+ * 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.
|
|
|
*
|
|
*
|
|
|
- * 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.
|
|
|
|
|
|
|
+ * 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.
|
|
|
*
|
|
*
|
|
|
- * No globals. Single IIFE. Vanilla JS, no jQuery dependency.
|
|
|
|
|
|
|
+ * 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 () {
|
|
(function () {
|
|
|
'use strict';
|
|
'use strict';
|
|
|
|
|
|
|
|
const EPS = 1e-9;
|
|
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) {
|
|
|
|
|
|
|
+ function quantise(next, step, min, max) {
|
|
|
if (!(step > 0)) { step = 1; }
|
|
if (!(step > 0)) { step = 1; }
|
|
|
- let next = Number(current) + delta;
|
|
|
|
|
if (Number.isFinite(min) && next < min) { next = min; }
|
|
if (Number.isFinite(min) && next < min) { next = min; }
|
|
|
if (Number.isFinite(max) && next > max) { next = max; }
|
|
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));
|
|
|
|
|
|
|
+ const q = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
|
|
|
|
|
+ return Number(q.toFixed(6));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // One popover per document, built lazily on first use.
|
|
|
|
|
|
|
+ // Single shared popover, built lazily on first use.
|
|
|
let pop = null;
|
|
let pop = null;
|
|
|
- let elDec = null, elInc = null, elOut = null, elRange = null;
|
|
|
|
|
|
|
+ let elRange = null;
|
|
|
let boundInput = 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() {
|
|
function build() {
|
|
|
if (pop) { return; }
|
|
if (pop) { return; }
|
|
@@ -53,78 +55,50 @@
|
|
|
pop.hidden = true;
|
|
pop.hidden = true;
|
|
|
pop.setAttribute('role', 'dialog');
|
|
pop.setAttribute('role', 'dialog');
|
|
|
pop.setAttribute('aria-label', 'Set value');
|
|
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.
|
|
|
|
|
|
|
+ // `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 =
|
|
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">';
|
|
|
|
|
|
|
+ '<input type="range" data-stepper-range orient="vertical">';
|
|
|
document.body.appendChild(pop);
|
|
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]');
|
|
elRange = pop.querySelector('[data-stepper-range]');
|
|
|
|
|
|
|
|
- elDec.addEventListener('click', function () { step(-1); });
|
|
|
|
|
- elInc.addEventListener('click', function () { step(+1); });
|
|
|
|
|
|
|
+ // 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 () {
|
|
elRange.addEventListener('input', function () {
|
|
|
if (!boundInput) { return; }
|
|
if (!boundInput) { return; }
|
|
|
- setValue(Number(elRange.value));
|
|
|
|
|
|
|
+ boundInput.value = elRange.value;
|
|
|
|
|
+ boundInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
});
|
|
});
|
|
|
- // 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);
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- // Read bounds via getAttribute so an absent `max` reports as NaN
|
|
|
|
|
- // instead of being coerced through Number("") === 0. Without this,
|
|
|
|
|
- // task-assignment cells (which set min="0" but have no max) would
|
|
|
|
|
- // be clamped to [0, 0] and refuse to increment in any table but
|
|
|
|
|
- // the Arbeitstage grid.
|
|
|
|
|
- function readBounds() {
|
|
|
|
|
- function parseAttr(name) {
|
|
|
|
|
- const raw = boundInput.getAttribute(name);
|
|
|
|
|
- if (raw === null || raw === '') { return NaN; }
|
|
|
|
|
- const n = Number(raw);
|
|
|
|
|
- return Number.isFinite(n) ? n : NaN;
|
|
|
|
|
- }
|
|
|
|
|
- const step = parseAttr('step');
|
|
|
|
|
- const min = parseAttr('min');
|
|
|
|
|
- const max = parseAttr('max');
|
|
|
|
|
- return {
|
|
|
|
|
- step: Number.isFinite(step) && step > 0 ? step : 1,
|
|
|
|
|
- min: min,
|
|
|
|
|
- max: max,
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 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(); });
|
|
|
|
|
|
|
|
- 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);
|
|
|
|
|
|
|
+ // The core dismissal gesture: enter-then-leave closes.
|
|
|
|
|
+ pop.addEventListener('pointerenter', function () {
|
|
|
|
|
+ popoverEntered = true;
|
|
|
|
|
+ });
|
|
|
|
|
+ pop.addEventListener('pointerleave', function () {
|
|
|
|
|
+ if (popoverEntered) { close(false); }
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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 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) {
|
|
function position(input) {
|
|
|
const rect = input.getBoundingClientRect();
|
|
const rect = input.getBoundingClientRect();
|
|
|
- // Measure the popover — temporarily visible-off-screen for layout.
|
|
|
|
|
|
|
+ // Measure off-screen so the browser has real dimensions to
|
|
|
|
|
+ // work with before we anchor.
|
|
|
pop.style.left = '-9999px';
|
|
pop.style.left = '-9999px';
|
|
|
pop.style.top = '0px';
|
|
pop.style.top = '0px';
|
|
|
pop.hidden = false;
|
|
pop.hidden = false;
|
|
@@ -133,21 +107,22 @@
|
|
|
const vh = window.innerHeight;
|
|
const vh = window.innerHeight;
|
|
|
const vw = window.innerWidth;
|
|
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: centre over the input's midpoint, clamped to viewport.
|
|
|
|
|
- let left = rect.left + (rect.width - pw) / 2;
|
|
|
|
|
|
|
+ const GAP = 6;
|
|
|
const MARGIN = 4;
|
|
const MARGIN = 4;
|
|
|
- if (left + pw > vw - MARGIN) { left = vw - pw - MARGIN; }
|
|
|
|
|
- if (left < MARGIN) { left = MARGIN; }
|
|
|
|
|
- if (top < MARGIN) { top = MARGIN; }
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 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.left = left + 'px';
|
|
|
pop.style.top = top + 'px';
|
|
pop.style.top = top + 'px';
|
|
@@ -156,19 +131,28 @@
|
|
|
function open(input) {
|
|
function open(input) {
|
|
|
build();
|
|
build();
|
|
|
boundInput = input;
|
|
boundInput = input;
|
|
|
- const b = readBounds();
|
|
|
|
|
- const current = clampToStep(Number(input.value) || 0, 0, b.step, b.min, b.max);
|
|
|
|
|
- elOut.textContent = String(current);
|
|
|
|
|
|
|
+ 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)));
|
|
|
|
|
|
|
|
- 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);
|
|
position(input);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -177,59 +161,48 @@
|
|
|
pop.hidden = true;
|
|
pop.hidden = true;
|
|
|
const prev = boundInput;
|
|
const prev = boundInput;
|
|
|
boundInput = null;
|
|
boundInput = null;
|
|
|
|
|
+ popoverEntered = false;
|
|
|
if (prev) {
|
|
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 }));
|
|
prev.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
|
|
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);
|
|
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // Triggers
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ function isEligible(el) {
|
|
|
|
|
+ return !!(el
|
|
|
|
|
+ && el.matches
|
|
|
|
|
+ && el.matches('input[type="number"]')
|
|
|
|
|
+ && !el.disabled
|
|
|
|
|
+ && !el.readOnly);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Open on hover (primary) or focus (keyboard a11y path).
|
|
|
|
|
- function onHoverOrFocus(ev) {
|
|
|
|
|
|
|
+ // 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;
|
|
const t = ev.target;
|
|
|
- if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
|
|
|
|
|
- if (t.disabled || t.readOnly) { return; }
|
|
|
|
|
- cancelCloseTimer();
|
|
|
|
|
|
|
+ if (!isEligible(t)) { return; }
|
|
|
if (boundInput !== t) { open(t); }
|
|
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) {
|
|
|
|
|
|
|
+ // 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;
|
|
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();
|
|
|
|
|
|
|
+ if (!isEligible(t)) { return; }
|
|
|
|
|
+ if (boundInput !== t) { open(t); }
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // 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.
|
|
|
|
|
|
|
+ // 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) {
|
|
document.addEventListener('pointerdown', function (ev) {
|
|
|
if (!pop || pop.hidden) { return; }
|
|
if (!pop || pop.hidden) { return; }
|
|
|
const t = ev.target;
|
|
const t = ev.target;
|
|
@@ -238,7 +211,7 @@
|
|
|
close(false);
|
|
close(false);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Escape closes + returns focus; Tab that leaves the input + popover
|
|
|
|
|
|
|
+ // Escape closes and returns focus; Tab out of the input + popover
|
|
|
// closes without focus return.
|
|
// closes without focus return.
|
|
|
document.addEventListener('keydown', function (ev) {
|
|
document.addEventListener('keydown', function (ev) {
|
|
|
if (!pop || pop.hidden) { return; }
|
|
if (!pop || pop.hidden) { return; }
|
|
@@ -248,8 +221,6 @@
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
if (ev.key === 'Tab') {
|
|
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 () {
|
|
setTimeout(function () {
|
|
|
const active = document.activeElement;
|
|
const active = document.activeElement;
|
|
|
if (active === boundInput) { return; }
|
|
if (active === boundInput) { return; }
|
|
@@ -259,14 +230,26 @@
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // While the bound input is focused, ArrowUp/Down step by `step` —
|
|
|
|
|
- // restores the shortcut the native spinner lost to Phase 17's CSS reset.
|
|
|
|
|
|
|
+ // 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) {
|
|
document.addEventListener('keydown', function (ev) {
|
|
|
const t = ev.target;
|
|
const t = ev.target;
|
|
|
- if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
|
|
|
|
|
|
|
+ if (!isEligible(t)) { return; }
|
|
|
if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
|
|
if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
|
|
|
ev.preventDefault();
|
|
ev.preventDefault();
|
|
|
- if (boundInput !== t) { open(t); }
|
|
|
|
|
- step(ev.key === 'ArrowUp' ? +1 : -1);
|
|
|
|
|
|
|
+ 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);
|
|
|
});
|
|
});
|
|
|
})();
|
|
})();
|