number-stepper.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. /**
  2. * Floating vertical-slider popover for number inputs.
  3. *
  4. * Click (or focus) any `<input type="number">` → a compact popover
  5. * appears to the right of the input with a single vertical range
  6. * slider. Drag the slider → the input's value updates live and
  7. * sprint-planner.js's debounced save + capacity recompute fire via
  8. * the `change` event on the bound input.
  9. *
  10. * Close strategy (DIFFERENT from earlier iterations — several prior
  11. * attempts relied on document-level `pointermove` / `focusout` /
  12. * `pointerleave` delegation and all silently failed in practice).
  13. * This version attaches pointer listeners DIRECTLY to the two
  14. * elements that matter — the bound input and the popover — in
  15. * open(), and detaches them in close(). That way close behaviour
  16. * can't be swallowed by anything else on the page.
  17. *
  18. * - `pointerleave` on input → schedule close (200 ms).
  19. * - `pointerenter` on popover → cancel pending close.
  20. * - `pointerleave` on popover → schedule close.
  21. * - `pointerenter` on input → cancel pending close.
  22. * - 300 ms open-grace window: if the close timer fires while still
  23. * inside the grace, it reschedules instead of dismissing.
  24. *
  25. * Outside-click close: capture-phase `pointerdown` on document, with
  26. * a 50 ms "just opened" guard so the very click that opened the
  27. * popup doesn't count as an outside click.
  28. *
  29. * Escape closes + returns focus. ArrowUp/Down on the focused input
  30. * steps by the input's `step` attribute (replaces the native spinner
  31. * shortcut suppressed in Phase 17).
  32. *
  33. * Scroll anchoring: `window` scroll (capture) + resize listeners
  34. * rAF-throttle a reposition, so the popover follows the input as
  35. * any scrollable ancestor moves. A 0×0 bounding rect triggers close.
  36. *
  37. * Strict-CSP-clean (standard <script src>, no inline handlers). No
  38. * globals. Vanilla JS — no jQuery.
  39. */
  40. (function () {
  41. 'use strict';
  42. const OPEN_GRACE_MS = 300;
  43. const CLOSE_DELAY_MS = 200;
  44. const OPEN_IGNORE_MS = 50; // ignore outside-click within this of open
  45. let pop = null;
  46. let elRange = null;
  47. let boundInput = null;
  48. let openedAt = 0;
  49. let closeTimer = null;
  50. let rafId = null;
  51. function now() {
  52. return (typeof performance !== 'undefined' && performance.now)
  53. ? performance.now()
  54. : Date.now();
  55. }
  56. // --------------------------------------------------------------
  57. // Open / close
  58. // --------------------------------------------------------------
  59. function build() {
  60. if (pop) { return; }
  61. pop = document.createElement('div');
  62. pop.className = 'stepper-popover';
  63. pop.hidden = true;
  64. pop.setAttribute('role', 'dialog');
  65. pop.setAttribute('aria-label', 'Set value');
  66. pop.innerHTML = '<input type="range" orient="vertical">';
  67. document.body.appendChild(pop);
  68. elRange = pop.querySelector('input[type="range"]');
  69. // Slider → input sync on every tick.
  70. elRange.addEventListener('input', function () {
  71. if (!boundInput) { return; }
  72. boundInput.value = elRange.value;
  73. boundInput.dispatchEvent(new Event('input', { bubbles: true }));
  74. boundInput.dispatchEvent(new Event('change', { bubbles: true }));
  75. });
  76. // Popover-side pointer tracking. Attached once here; input-
  77. // side listeners are attached per-open in bindInput() so they
  78. // follow whichever input is currently active.
  79. pop.addEventListener('pointerenter', cancelCloseTimer);
  80. pop.addEventListener('pointerleave', scheduleClose);
  81. }
  82. function bindInput(input) {
  83. input.addEventListener('pointerenter', cancelCloseTimer);
  84. input.addEventListener('pointerleave', scheduleClose);
  85. }
  86. function unbindInput(input) {
  87. input.removeEventListener('pointerenter', cancelCloseTimer);
  88. input.removeEventListener('pointerleave', scheduleClose);
  89. }
  90. function readNum(input, attr) {
  91. const raw = input.getAttribute(attr);
  92. if (raw === null || raw === '') { return NaN; }
  93. const n = Number(raw);
  94. return Number.isFinite(n) ? n : NaN;
  95. }
  96. function open(input) {
  97. build();
  98. if (boundInput === input && !pop.hidden) {
  99. scheduleReposition();
  100. return;
  101. }
  102. // Moving between inputs: detach from previous before rebinding.
  103. if (boundInput && boundInput !== input) {
  104. unbindInput(boundInput);
  105. }
  106. boundInput = input;
  107. openedAt = now();
  108. cancelCloseTimer();
  109. bindInput(input);
  110. const step = readNum(input, 'step');
  111. const min = readNum(input, 'min');
  112. const max = readNum(input, 'max');
  113. const eff = Number.isFinite(step) && step > 0 ? step : 1;
  114. const cur = Number(input.value) || 0;
  115. const sMin = Number.isFinite(min) ? min : 0;
  116. const sMax = Number.isFinite(max) ? max : Math.max(cur + 5, 10);
  117. elRange.min = String(sMin);
  118. elRange.max = String(sMax);
  119. elRange.step = String(eff);
  120. elRange.value = String(Math.max(sMin, Math.min(sMax, cur)));
  121. pop.hidden = false;
  122. reposition();
  123. }
  124. function close() {
  125. if (!pop || pop.hidden) { return; }
  126. pop.hidden = true;
  127. cancelCloseTimer();
  128. const prev = boundInput;
  129. boundInput = null;
  130. if (prev) {
  131. unbindInput(prev);
  132. prev.dispatchEvent(new Event('change', { bubbles: true }));
  133. }
  134. }
  135. function cancelCloseTimer() {
  136. if (closeTimer !== null) { clearTimeout(closeTimer); closeTimer = null; }
  137. }
  138. function scheduleClose() {
  139. if (closeTimer !== null) { return; }
  140. closeTimer = setTimeout(function () {
  141. closeTimer = null;
  142. // Don't dismiss during the open-grace window — a click-to-
  143. // open whose pointer is wandering in the first 300 ms
  144. // shouldn't kill the popup before the user's had a chance
  145. // to reach the slider.
  146. if (now() - openedAt < OPEN_GRACE_MS) { scheduleClose(); return; }
  147. close();
  148. }, CLOSE_DELAY_MS);
  149. }
  150. // --------------------------------------------------------------
  151. // Positioning
  152. // --------------------------------------------------------------
  153. function reposition() {
  154. if (!pop || pop.hidden || !boundInput) { return; }
  155. const r = boundInput.getBoundingClientRect();
  156. if (r.width === 0 && r.height === 0) { close(); return; }
  157. const pw = pop.offsetWidth;
  158. const ph = pop.offsetHeight;
  159. const vw = window.innerWidth;
  160. const vh = window.innerHeight;
  161. const GAP = 6;
  162. const MARGIN = 4;
  163. let left = r.right + GAP;
  164. let top = r.top + (r.height - ph) / 2;
  165. if (left + pw > vw - MARGIN) { left = r.left - pw - GAP; }
  166. left = Math.max(MARGIN, Math.min(left, vw - pw - MARGIN));
  167. top = Math.max(MARGIN, Math.min(top, vh - ph - MARGIN));
  168. pop.style.left = left + 'px';
  169. pop.style.top = top + 'px';
  170. }
  171. function scheduleReposition() {
  172. if (rafId !== null) { return; }
  173. rafId = requestAnimationFrame(function () {
  174. rafId = null;
  175. reposition();
  176. });
  177. }
  178. // --------------------------------------------------------------
  179. // Open triggers
  180. // --------------------------------------------------------------
  181. function isEligible(el) {
  182. return !!(el
  183. && el.matches
  184. && el.matches('input[type="number"]')
  185. && !el.disabled
  186. && !el.readOnly);
  187. }
  188. document.addEventListener('click', function (ev) {
  189. if (isEligible(ev.target)) { open(ev.target); }
  190. });
  191. document.addEventListener('focusin', function (ev) {
  192. if (isEligible(ev.target)) { open(ev.target); }
  193. });
  194. // --------------------------------------------------------------
  195. // Outside pointerdown → close. Capture phase + open-ignore
  196. // window so the opening click doesn't close us.
  197. // --------------------------------------------------------------
  198. document.addEventListener('pointerdown', function (ev) {
  199. if (!pop || pop.hidden) { return; }
  200. if (now() - openedAt < OPEN_IGNORE_MS) { return; }
  201. const t = ev.target;
  202. if (t === boundInput || (boundInput && boundInput.contains && boundInput.contains(t))) { return; }
  203. if (pop.contains(t)) { return; }
  204. close();
  205. }, true);
  206. // --------------------------------------------------------------
  207. // Escape closes + returns focus
  208. // --------------------------------------------------------------
  209. document.addEventListener('keydown', function (ev) {
  210. if (!pop || pop.hidden || ev.key !== 'Escape') { return; }
  211. ev.preventDefault();
  212. const prev = boundInput;
  213. close();
  214. if (prev) { try { prev.focus(); } catch (_) { /* ignore */ } }
  215. });
  216. // --------------------------------------------------------------
  217. // Scroll / resize anchoring
  218. // --------------------------------------------------------------
  219. window.addEventListener('scroll', scheduleReposition, true);
  220. window.addEventListener('resize', scheduleReposition);
  221. // --------------------------------------------------------------
  222. // Keyboard nudge on the focused input
  223. // --------------------------------------------------------------
  224. document.addEventListener('keydown', function (ev) {
  225. const t = ev.target;
  226. if (!isEligible(t)) { return; }
  227. if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
  228. ev.preventDefault();
  229. const step = readNum(t, 'step');
  230. const min = readNum(t, 'min');
  231. const max = readNum(t, 'max');
  232. const eff = Number.isFinite(step) && step > 0 ? step : 1;
  233. let next = (Number(t.value) || 0) + (ev.key === 'ArrowUp' ? eff : -eff);
  234. if (Number.isFinite(min) && next < min) { next = min; }
  235. if (Number.isFinite(max) && next > max) { next = max; }
  236. next = Number((Math.round(next / eff) * eff).toFixed(6));
  237. t.value = String(next);
  238. t.dispatchEvent(new Event('change', { bubbles: true }));
  239. if (pop && !pop.hidden && boundInput === t) { elRange.value = String(next); }
  240. });
  241. })();