number-stepper.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. /**
  2. * Phase 17: custom stepper popover for half-step number inputs.
  3. *
  4. * Activates on any `input[data-stepper]` — opens a small popover next to
  5. * the input with −/+ buttons that step by the input's own step attribute
  6. * (default 1), and — when both min and max are finite — a vertical range
  7. * slider (top = max, bottom = min) for quick gross moves. Mutations
  8. * mirror into input.value and dispatch a bubbling `input` event (so
  9. * sprint-planner.js live-recomputes); on popover close we dispatch
  10. * `change` (so the existing debounced save pipeline fires). Strict-CSP-
  11. * clean: loaded as a standard <script src>.
  12. *
  13. * Trigger model (hover-first): the popover opens when the pointer
  14. * enters the input, and auto-closes ~200 ms after the pointer leaves
  15. * both the input and the popover. Keyboard focus also opens the popover
  16. * for accessibility; Escape closes and returns focus.
  17. *
  18. * No globals. Single IIFE. Vanilla JS, no jQuery dependency.
  19. */
  20. (function () {
  21. 'use strict';
  22. const EPS = 1e-9;
  23. /**
  24. * Step a value by delta, clamp to [min, max] when finite, and quantise
  25. * to the nearest `step` increment. Pure — exposed as a local symbol
  26. * for readability; would be the testable surface if we added a
  27. * headless-JS test harness.
  28. */
  29. function clampToStep(current, delta, step, min, max) {
  30. if (!(step > 0)) { step = 1; }
  31. let next = Number(current) + delta;
  32. if (Number.isFinite(min) && next < min) { next = min; }
  33. if (Number.isFinite(max) && next > max) { next = max; }
  34. // Floor with an epsilon tolerance so 0.6/0.05 doesn't quantise down
  35. // to the wrong side of the grid thanks to binary-float artefacts.
  36. const quantised = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
  37. // Round to a sensible number of decimals — 6 is more than enough for
  38. // any step the app uses (0.5 / 0.05 / 1).
  39. return Number(quantised.toFixed(6));
  40. }
  41. // One popover per document, built lazily on first use.
  42. let pop = null;
  43. let elDec = null, elInc = null, elOut = null, elRange = null;
  44. let boundInput = null;
  45. function build() {
  46. if (pop) { return; }
  47. pop = document.createElement('div');
  48. pop.className = 'stepper-popover';
  49. pop.hidden = true;
  50. pop.setAttribute('role', 'dialog');
  51. pop.setAttribute('aria-label', 'Set value');
  52. // Controls (+ / value / −) stack vertically so the layout stays
  53. // compact next to the optional vertical range slider on the right.
  54. pop.innerHTML =
  55. '<div class="stepper-controls">'
  56. + '<button type="button" data-stepper-inc aria-label="Increase">+</button>'
  57. + '<output data-stepper-value>0</output>'
  58. + '<button type="button" data-stepper-dec aria-label="Decrease">\u2212</button>'
  59. + '</div>'
  60. + '<input type="range" data-stepper-range hidden orient="vertical">';
  61. document.body.appendChild(pop);
  62. elDec = pop.querySelector('[data-stepper-dec]');
  63. elInc = pop.querySelector('[data-stepper-inc]');
  64. elOut = pop.querySelector('[data-stepper-value]');
  65. elRange = pop.querySelector('[data-stepper-range]');
  66. elDec.addEventListener('click', function () { step(-1); });
  67. elInc.addEventListener('click', function () { step(+1); });
  68. elRange.addEventListener('input', function () {
  69. if (!boundInput) { return; }
  70. setValue(Number(elRange.value));
  71. });
  72. // Stop pointerdown inside the popover from bubbling to the
  73. // document-level outside-click handler below.
  74. pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
  75. // Hover bookkeeping — cancelling the pending close when the pointer
  76. // enters the popover, restarting it when the pointer leaves.
  77. pop.addEventListener('pointerenter', cancelCloseTimer);
  78. pop.addEventListener('pointerleave', scheduleClose);
  79. }
  80. function readBounds() {
  81. const step = Number(boundInput.step);
  82. const min = Number(boundInput.min);
  83. const max = Number(boundInput.max);
  84. return {
  85. step: Number.isFinite(step) && step > 0 ? step : 1,
  86. min: Number.isFinite(min) ? min : NaN,
  87. max: Number.isFinite(max) ? max : NaN,
  88. };
  89. }
  90. function step(dir) {
  91. if (!boundInput) { return; }
  92. const b = readBounds();
  93. const next = clampToStep(Number(boundInput.value) || 0, dir * b.step, b.step, b.min, b.max);
  94. setValue(next);
  95. }
  96. function setValue(n) {
  97. if (!boundInput) { return; }
  98. const b = readBounds();
  99. const next = clampToStep(n, 0, b.step, b.min, b.max);
  100. boundInput.value = String(next);
  101. elOut.textContent = String(next);
  102. if (!elRange.hidden) { elRange.value = String(next); }
  103. boundInput.dispatchEvent(new Event('input', { bubbles: true }));
  104. }
  105. function position(input) {
  106. const rect = input.getBoundingClientRect();
  107. // Measure the popover — temporarily visible-off-screen for layout.
  108. pop.style.left = '-9999px';
  109. pop.style.top = '0px';
  110. pop.hidden = false;
  111. const pw = pop.offsetWidth;
  112. const ph = pop.offsetHeight;
  113. const vh = window.innerHeight;
  114. const vw = window.innerWidth;
  115. // Vertical: below unless the input sits in the lower 25% of the
  116. // viewport, then above.
  117. const GAP = 4;
  118. let top;
  119. if (rect.bottom > vh * 0.75) {
  120. top = rect.top - ph - GAP;
  121. } else {
  122. top = rect.bottom + GAP;
  123. }
  124. // Horizontal: align to the input's left edge, clamped to viewport.
  125. let left = rect.left;
  126. const MARGIN = 4;
  127. if (left + pw > vw - MARGIN) { left = vw - pw - MARGIN; }
  128. if (left < MARGIN) { left = MARGIN; }
  129. if (top < MARGIN) { top = MARGIN; }
  130. pop.style.left = left + 'px';
  131. pop.style.top = top + 'px';
  132. }
  133. function open(input) {
  134. build();
  135. boundInput = input;
  136. const b = readBounds();
  137. const current = clampToStep(Number(input.value) || 0, 0, b.step, b.min, b.max);
  138. elOut.textContent = String(current);
  139. if (Number.isFinite(b.min) && Number.isFinite(b.max)) {
  140. elRange.hidden = false;
  141. elRange.min = String(b.min);
  142. elRange.max = String(b.max);
  143. elRange.step = String(b.step);
  144. elRange.value = String(current);
  145. } else {
  146. elRange.hidden = true;
  147. }
  148. position(input);
  149. }
  150. function close(returnFocus) {
  151. if (!pop || pop.hidden) { return; }
  152. pop.hidden = true;
  153. const prev = boundInput;
  154. boundInput = null;
  155. if (prev) {
  156. prev.dispatchEvent(new Event('change', { bubbles: true }));
  157. if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
  158. }
  159. }
  160. // Hover-grace timer so the mouse can transit from input to popover
  161. // (and back) without the popover flickering closed. 200 ms feels
  162. // snappy but forgives normal mouse wobble.
  163. const CLOSE_DELAY_MS = 200;
  164. let closeTimer = null;
  165. function cancelCloseTimer() {
  166. if (closeTimer !== null) {
  167. clearTimeout(closeTimer);
  168. closeTimer = null;
  169. }
  170. }
  171. function scheduleClose() {
  172. cancelCloseTimer();
  173. closeTimer = setTimeout(function () {
  174. closeTimer = null;
  175. close(false);
  176. }, CLOSE_DELAY_MS);
  177. }
  178. // Open on hover (primary) or focus (keyboard a11y path).
  179. function onHoverOrFocus(ev) {
  180. const t = ev.target;
  181. if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
  182. if (t.disabled || t.readOnly) { return; }
  183. cancelCloseTimer();
  184. if (boundInput !== t) { open(t); }
  185. }
  186. // Delegated pointerenter via pointerover (pointerenter doesn't bubble).
  187. document.addEventListener('pointerover', onHoverOrFocus);
  188. document.addEventListener('focusin', onHoverOrFocus);
  189. // Schedule a close when the pointer leaves a stepper input. The
  190. // popover's own pointerenter cancels the timer, so transit is safe.
  191. document.addEventListener('pointerout', function (ev) {
  192. const t = ev.target;
  193. if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
  194. if (!pop || pop.hidden || boundInput !== t) { return; }
  195. // pointerout also fires when the pointer moves between children
  196. // of the input; guard by checking relatedTarget.
  197. const rel = ev.relatedTarget;
  198. if (rel && t.contains && t.contains(rel)) { return; }
  199. scheduleClose();
  200. });
  201. // Outside-click close — pointerdown, not click, so a scroll gesture
  202. // starting inside the popover in Safari doesn't dismiss prematurely.
  203. // Kept as a belt-and-braces for touch devices where hover is spotty.
  204. document.addEventListener('pointerdown', function (ev) {
  205. if (!pop || pop.hidden) { return; }
  206. const t = ev.target;
  207. if (boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t)))) { return; }
  208. if (pop.contains(t)) { return; }
  209. close(false);
  210. });
  211. // Escape closes + returns focus; Tab that leaves the input + popover
  212. // closes without focus return.
  213. document.addEventListener('keydown', function (ev) {
  214. if (!pop || pop.hidden) { return; }
  215. if (ev.key === 'Escape') {
  216. ev.preventDefault();
  217. close(true);
  218. return;
  219. }
  220. if (ev.key === 'Tab') {
  221. // After the tab completes, check if focus is still on the input
  222. // or inside the popover. If not, close.
  223. setTimeout(function () {
  224. const active = document.activeElement;
  225. if (active === boundInput) { return; }
  226. if (pop.contains(active)) { return; }
  227. close(false);
  228. }, 0);
  229. }
  230. });
  231. // While the bound input is focused, ArrowUp/Down step by `step` —
  232. // restores the shortcut the native spinner lost to Phase 17's CSS reset.
  233. document.addEventListener('keydown', function (ev) {
  234. const t = ev.target;
  235. if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
  236. if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
  237. ev.preventDefault();
  238. if (boundInput !== t) { open(t); }
  239. step(ev.key === 'ArrowUp' ? +1 : -1);
  240. });
  241. })();