|
|
@@ -1,77 +1,48 @@
|
|
|
/**
|
|
|
- * Phase 17: floating slider popover for number inputs.
|
|
|
+ * Floating vertical-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.
|
|
|
+ * Click (or focus) any `<input type="number">` → a compact popover
|
|
|
+ * appears to the right of the input with a single vertical range
|
|
|
+ * slider. Drag the slider → the input's value updates live and
|
|
|
+ * sprint-planner.js's debounced save + capacity recompute fire via
|
|
|
+ * the `change` event on the bound input.
|
|
|
*
|
|
|
- * Close triggers:
|
|
|
- * 1. Pointer drifts outside BOTH the input and the popover for
|
|
|
- * more than a short grace window (primary dismissal — no
|
|
|
- * "enter-then-leave" gate, so moving the cursor away after a
|
|
|
- * click-to-open closes us);
|
|
|
- * 2. Outside click / pointerdown (belt-and-braces via both bubble
|
|
|
- * and capture phases, so a downstream stopPropagation can't
|
|
|
- * silently leave the popover hanging);
|
|
|
- * 3. The bound input loses focus to anything outside our realm
|
|
|
- * (Tab-out, devtools steal, etc.);
|
|
|
+ * Close triggers (any one fires):
|
|
|
+ * 1. Pointer leaves both the input and the popover for >200 ms
|
|
|
+ * (past a 300 ms open-grace window that forgives post-click
|
|
|
+ * cursor wobble).
|
|
|
+ * 2. Pointer leaves the viewport.
|
|
|
+ * 3. pointerdown lands anywhere outside both input and popover —
|
|
|
+ * registered in the *capture* phase so a descendant
|
|
|
+ * stopPropagation can't trap the popup open.
|
|
|
* 4. Escape keypress (focus returns to the input).
|
|
|
*
|
|
|
- * 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.
|
|
|
+ * Position tracking: on every scroll (any ancestor, captured at
|
|
|
+ * window) and on window resize, a rAF-throttled reposition runs so
|
|
|
+ * the popover stays anchored to the input as content moves. If the
|
|
|
+ * input's bounding rect goes to 0×0 (removed from DOM) we close.
|
|
|
*
|
|
|
- * Strict-CSP-clean: loaded as a standard <script src>. No globals.
|
|
|
- * Single IIFE. Vanilla JS — no jQuery dependency.
|
|
|
+ * Strict-CSP-clean (standard <script src>, no inline handlers). No
|
|
|
+ * globals. Vanilla JS — no jQuery.
|
|
|
*/
|
|
|
(function () {
|
|
|
'use strict';
|
|
|
|
|
|
- const EPS = 1e-9;
|
|
|
+ const OPEN_GRACE_MS = 300; // min lifetime of a newly-opened popover
|
|
|
+ const CLOSE_DELAY_MS = 200; // grace for the cursor to re-enter
|
|
|
|
|
|
- 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 pop = null;
|
|
|
+ let elRange = null;
|
|
|
let boundInput = null;
|
|
|
- // Brief window after open() during which the pointer-position
|
|
|
- // tracker ignores "outside both" readings. Without it, a click
|
|
|
- // that happens while the cursor is momentarily outside both the
|
|
|
- // input and the (yet-to-render) popover rect would close
|
|
|
- // immediately. 250 ms lets the browser settle.
|
|
|
- const OPEN_GRACE_MS = 250;
|
|
|
- const CLOSE_DELAY_MS = 150;
|
|
|
- let graceUntil = 0;
|
|
|
+ let openedAt = 0;
|
|
|
let closeTimer = null;
|
|
|
+ let rafId = null;
|
|
|
|
|
|
function now() {
|
|
|
- return typeof performance !== 'undefined' && performance.now
|
|
|
+ return (typeof performance !== 'undefined' && performance.now)
|
|
|
? performance.now()
|
|
|
: Date.now();
|
|
|
}
|
|
|
- function cancelClose() {
|
|
|
- if (closeTimer !== null) { clearTimeout(closeTimer); closeTimer = null; }
|
|
|
- }
|
|
|
- function scheduleClose() {
|
|
|
- if (closeTimer !== null) { return; }
|
|
|
- closeTimer = setTimeout(function () {
|
|
|
- closeTimer = null;
|
|
|
- close(false);
|
|
|
- }, CLOSE_DELAY_MS);
|
|
|
- }
|
|
|
|
|
|
function build() {
|
|
|
if (pop) { return; }
|
|
|
@@ -80,243 +51,219 @@
|
|
|
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">';
|
|
|
+ // `orient="vertical"` is the legacy Firefox attribute; modern
|
|
|
+ // browsers pick up vertical orientation from the writing-mode
|
|
|
+ // CSS in assets/css/input.css. Both are present so the slider
|
|
|
+ // renders vertically everywhere.
|
|
|
+ pop.innerHTML = '<input type="range" orient="vertical">';
|
|
|
document.body.appendChild(pop);
|
|
|
- elRange = pop.querySelector('[data-stepper-range]');
|
|
|
+ elRange = pop.querySelector('input[type="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.
|
|
|
+ // Mirror every slider tick into the bound input. Fire both
|
|
|
+ // `input` (live) and `change` (save + recompute) so
|
|
|
+ // sprint-planner.js picks it up; its 400 ms debounce
|
|
|
+ // coalesces the flurry into one write.
|
|
|
elRange.addEventListener('input', function () {
|
|
|
if (!boundInput) { return; }
|
|
|
boundInput.value = elRange.value;
|
|
|
+ boundInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
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(); });
|
|
|
-
|
|
|
- // Keep the popover alive while the pointer is over it. The
|
|
|
- // document-level pointermove tracker (below) handles the
|
|
|
- // "outside both" case; these are cheap belt-and-braces so a
|
|
|
- // quick entry still cancels a pending close scheduled from
|
|
|
- // the gap between input and popover.
|
|
|
- pop.addEventListener('pointerenter', cancelClose);
|
|
|
- pop.addEventListener('pointerleave', scheduleClose);
|
|
|
}
|
|
|
|
|
|
- function readAttrNumber(input, name) {
|
|
|
- const raw = input.getAttribute(name);
|
|
|
+ function readNum(input, attr) {
|
|
|
+ const raw = input.getAttribute(attr);
|
|
|
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;
|
|
|
+ function reposition() {
|
|
|
+ if (!pop || pop.hidden || !boundInput) { return; }
|
|
|
+ const r = boundInput.getBoundingClientRect();
|
|
|
+ // Input is detached or fully collapsed — get out.
|
|
|
+ if (r.width === 0 && r.height === 0) { close(); return; }
|
|
|
|
|
|
+ const pw = pop.offsetWidth;
|
|
|
+ const ph = pop.offsetHeight;
|
|
|
+ const vw = window.innerWidth;
|
|
|
+ const vh = window.innerHeight;
|
|
|
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; }
|
|
|
+ let left = r.right + GAP;
|
|
|
+ let top = r.top + (r.height - ph) / 2;
|
|
|
+ if (left + pw > vw - MARGIN) { left = r.left - pw - GAP; }
|
|
|
+ left = Math.max(MARGIN, Math.min(left, vw - pw - MARGIN));
|
|
|
+ top = Math.max(MARGIN, Math.min(top, vh - ph - MARGIN));
|
|
|
|
|
|
pop.style.left = left + 'px';
|
|
|
pop.style.top = top + 'px';
|
|
|
}
|
|
|
+ function scheduleReposition() {
|
|
|
+ if (rafId !== null) { return; }
|
|
|
+ rafId = requestAnimationFrame(function () {
|
|
|
+ rafId = null;
|
|
|
+ reposition();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function isEligible(el) {
|
|
|
+ return !!(el
|
|
|
+ && el.matches
|
|
|
+ && el.matches('input[type="number"]')
|
|
|
+ && !el.disabled
|
|
|
+ && !el.readOnly);
|
|
|
+ }
|
|
|
|
|
|
function open(input) {
|
|
|
build();
|
|
|
+ if (boundInput === input && !pop.hidden) {
|
|
|
+ scheduleReposition();
|
|
|
+ return;
|
|
|
+ }
|
|
|
boundInput = input;
|
|
|
- cancelClose();
|
|
|
- graceUntil = now() + OPEN_GRACE_MS;
|
|
|
+ openedAt = now();
|
|
|
+ cancelCloseTimer();
|
|
|
|
|
|
- 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);
|
|
|
+ const step = readNum(input, 'step');
|
|
|
+ const min = readNum(input, 'min');
|
|
|
+ const max = readNum(input, 'max');
|
|
|
+ const eff = Number.isFinite(step) && step > 0 ? step : 1;
|
|
|
+ const cur = Number(input.value) || 0;
|
|
|
+ // Slider needs a finite min+max. Fall back sensibly when the
|
|
|
+ // input leaves them open (task-assignment cells: min=0 but no
|
|
|
+ // max) so there's always usable overhead on the slider.
|
|
|
+ const sMin = Number.isFinite(min) ? min : 0;
|
|
|
+ const sMax = Number.isFinite(max) ? max : Math.max(cur + 5, 10);
|
|
|
|
|
|
- // 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(sMin);
|
|
|
+ elRange.max = String(sMax);
|
|
|
+ elRange.step = String(eff);
|
|
|
+ elRange.value = String(Math.max(sMin, Math.min(sMax, cur)));
|
|
|
|
|
|
- elRange.min = String(sliderMin);
|
|
|
- elRange.max = String(sliderMax);
|
|
|
- elRange.step = String(step);
|
|
|
- elRange.value = String(Math.max(sliderMin, Math.min(sliderMax, current)));
|
|
|
-
|
|
|
- position(input);
|
|
|
+ pop.hidden = false;
|
|
|
+ reposition();
|
|
|
}
|
|
|
|
|
|
- function close(returnFocus) {
|
|
|
+ function close() {
|
|
|
if (!pop || pop.hidden) { return; }
|
|
|
pop.hidden = true;
|
|
|
- cancelClose();
|
|
|
+ cancelCloseTimer();
|
|
|
const prev = boundInput;
|
|
|
boundInput = null;
|
|
|
- graceUntil = 0;
|
|
|
if (prev) {
|
|
|
- // Final `change` so the debounced save captures the last
|
|
|
- // slider position. Harmless no-op if the slider wasn't
|
|
|
- // touched (value unchanged).
|
|
|
+ // Final change so the last slider position is saved via the
|
|
|
+ // existing debounced pipeline. Harmless no-op if the value
|
|
|
+ // didn't actually change.
|
|
|
prev.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
- if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // ------------------------------------------------------------------
|
|
|
- // Triggers
|
|
|
- // ------------------------------------------------------------------
|
|
|
+ function cancelCloseTimer() {
|
|
|
+ if (closeTimer !== null) { clearTimeout(closeTimer); closeTimer = null; }
|
|
|
+ }
|
|
|
+ function scheduleClose() {
|
|
|
+ if (closeTimer !== null) { return; }
|
|
|
+ closeTimer = setTimeout(function () {
|
|
|
+ closeTimer = null;
|
|
|
+ // Respect the open-grace window — if the cursor was outside
|
|
|
+ // both rects in the first 300 ms (common: click-to-open
|
|
|
+ // followed by any mouse drift), reschedule instead of
|
|
|
+ // closing instantly.
|
|
|
+ if (now() - openedAt < OPEN_GRACE_MS) { scheduleClose(); return; }
|
|
|
+ close();
|
|
|
+ }, CLOSE_DELAY_MS);
|
|
|
+ }
|
|
|
|
|
|
- function isEligible(el) {
|
|
|
- return !!(el
|
|
|
- && el.matches
|
|
|
- && el.matches('input[type="number"]')
|
|
|
- && !el.disabled
|
|
|
- && !el.readOnly);
|
|
|
+ function pointInRect(x, y, r) {
|
|
|
+ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
|
}
|
|
|
|
|
|
- // Click-to-open. Covers the primary mouse path. Reposition when
|
|
|
- // the click lands on a different eligible input than the currently
|
|
|
- // bound one.
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // Open triggers
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
document.addEventListener('click', function (ev) {
|
|
|
- const t = ev.target;
|
|
|
- if (!isEligible(t)) { return; }
|
|
|
- if (boundInput !== t) { open(t); }
|
|
|
+ if (isEligible(ev.target)) { open(ev.target); }
|
|
|
});
|
|
|
-
|
|
|
- // 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); }
|
|
|
+ if (isEligible(ev.target)) { open(ev.target); }
|
|
|
});
|
|
|
|
|
|
- // Primary dismissal: the pointer drifts away from both the input
|
|
|
- // and the popover. Active only while the popover is open and past
|
|
|
- // the open-grace window. If the pointer comes back over either
|
|
|
- // element the pending close is cancelled. This replaces the
|
|
|
- // previous "enter-once-then-leave" gate, which never fired when
|
|
|
- // the user clicked a cell and moved the cursor somewhere the
|
|
|
- // popover wasn't — the popup could linger indefinitely.
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // Close triggers
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ // Pointer-position tracker. Uses clientX/Y vs live bounding rects
|
|
|
+ // so a mid-drag reposition (via the scroll listener below) is
|
|
|
+ // picked up on the next move — no stale "thought it was over"
|
|
|
+ // hits.
|
|
|
document.addEventListener('pointermove', function (ev) {
|
|
|
- if (!pop || pop.hidden) { return; }
|
|
|
- if (now() < graceUntil) { return; }
|
|
|
- const t = ev.target;
|
|
|
- const overInput = !!(boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t))));
|
|
|
- const overPop = !!(pop.contains && pop.contains(t));
|
|
|
- if (overInput || overPop) { cancelClose(); }
|
|
|
- else { scheduleClose(); }
|
|
|
+ if (!pop || pop.hidden || !boundInput) { return; }
|
|
|
+ const ir = boundInput.getBoundingClientRect();
|
|
|
+ const pr = pop.getBoundingClientRect();
|
|
|
+ const over = pointInRect(ev.clientX, ev.clientY, ir)
|
|
|
+ || pointInRect(ev.clientX, ev.clientY, pr);
|
|
|
+ if (over) { cancelCloseTimer(); }
|
|
|
+ else { scheduleClose(); }
|
|
|
});
|
|
|
|
|
|
- // Also close if the pointer leaves the viewport entirely — mouse
|
|
|
- // up to the browser chrome / titlebar shouldn't leave the popup
|
|
|
- // stranded behind.
|
|
|
+ // Pointer exits the viewport (mouse into browser chrome, etc.).
|
|
|
document.addEventListener('pointerleave', function () {
|
|
|
- if (!pop || pop.hidden) { return; }
|
|
|
- scheduleClose();
|
|
|
+ if (pop && !pop.hidden) { scheduleClose(); }
|
|
|
});
|
|
|
|
|
|
- // Outside close — dispatched in BOTH bubble and capture phases so
|
|
|
- // a descendant handler that calls stopPropagation can't silently
|
|
|
- // trap us open. Same semantics in both: if the press lands outside
|
|
|
- // the bound input and outside the popover, close.
|
|
|
- function outsideCloseHandler(ev) {
|
|
|
+ // Outside pointerdown — capture phase so a downstream
|
|
|
+ // stopPropagation can't silently leave us hanging.
|
|
|
+ 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 (t === boundInput || (boundInput && boundInput.contains && boundInput.contains(t))) { return; }
|
|
|
if (pop.contains(t)) { return; }
|
|
|
- close(false);
|
|
|
- }
|
|
|
- document.addEventListener('pointerdown', outsideCloseHandler);
|
|
|
- document.addEventListener('click', outsideCloseHandler, true);
|
|
|
-
|
|
|
- // Blur close. Fires when focus leaves either the bound input or a
|
|
|
- // focusable element inside the popover (e.g. the slider thumb).
|
|
|
- // If focus is moving into the popover itself, or onto another
|
|
|
- // eligible input (which the click/focusin handlers will rebind us
|
|
|
- // to), we stay open. Any other destination — another cell, the
|
|
|
- // page body, a different page element — closes us. This plugs
|
|
|
- // the gap where a click elsewhere on the page blurred the input
|
|
|
- // but never landed on a registered "outside" target.
|
|
|
- function isOurs(el) {
|
|
|
- return !!el && (el === boundInput || (pop && pop.contains && pop.contains(el)));
|
|
|
- }
|
|
|
- document.addEventListener('focusout', function (ev) {
|
|
|
- if (!pop || pop.hidden) { return; }
|
|
|
- if (!isOurs(ev.target)) { return; }
|
|
|
- const next = ev.relatedTarget;
|
|
|
- if (isOurs(next)) { return; } // staying inside our realm
|
|
|
- if (isEligible(next)) { return; } // another number input — will rebind
|
|
|
- close(false);
|
|
|
- });
|
|
|
+ close();
|
|
|
+ }, true);
|
|
|
|
|
|
- // Escape closes and returns focus. Tab is now handled via the
|
|
|
- // generic focusout listener above.
|
|
|
+ // Escape closes + returns focus to the input.
|
|
|
document.addEventListener('keydown', function (ev) {
|
|
|
- if (!pop || pop.hidden) { return; }
|
|
|
- if (ev.key === 'Escape') {
|
|
|
- ev.preventDefault();
|
|
|
- close(true);
|
|
|
- }
|
|
|
+ if (!pop || pop.hidden || ev.key !== 'Escape') { return; }
|
|
|
+ ev.preventDefault();
|
|
|
+ const prev = boundInput;
|
|
|
+ close();
|
|
|
+ if (prev) { try { prev.focus(); } catch (_) { /* ignore */ } }
|
|
|
});
|
|
|
|
|
|
- // 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.
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // Stay anchored on scroll / resize
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ // Capture phase catches scroll events on any scrollable ancestor
|
|
|
+ // (the Arbeitstage grid's `overflow-x: auto` container, the task
|
|
|
+ // list's overflow div, the main page). rAF-throttled so we never
|
|
|
+ // fight the browser during a rapid wheel burst.
|
|
|
+ window.addEventListener('scroll', scheduleReposition, true);
|
|
|
+ window.addEventListener('resize', scheduleReposition);
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // Bonus: keyboard nudge on the focused input (replaces the native
|
|
|
+ // spinner arrows Phase 17 suppressed via CSS).
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
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,
|
|
|
- );
|
|
|
+ const step = readNum(t, 'step');
|
|
|
+ const min = readNum(t, 'min');
|
|
|
+ const max = readNum(t, 'max');
|
|
|
+ const eff = Number.isFinite(step) && step > 0 ? step : 1;
|
|
|
+ let next = (Number(t.value) || 0) + (ev.key === 'ArrowUp' ? eff : -eff);
|
|
|
+ if (Number.isFinite(min) && next < min) { next = min; }
|
|
|
+ if (Number.isFinite(max) && next > max) { next = max; }
|
|
|
+ next = Number((Math.round(next / eff) * eff).toFixed(6));
|
|
|
t.value = String(next);
|
|
|
t.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
- if (!pop || pop.hidden || boundInput !== t) { return; }
|
|
|
- elRange.value = String(next);
|
|
|
+ if (pop && !pop.hidden && boundInput === t) { elRange.value = String(next); }
|
|
|
});
|
|
|
})();
|