number-stepper.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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 range slider
  7. * for quick gross moves. Mutations mirror into input.value and dispatch
  8. * a bubbling `input` event (so sprint-planner.js live-recomputes); on
  9. * popover close we dispatch `change` (so the existing debounced save
  10. * pipeline fires). Strict-CSP-clean: loaded as a standard <script src>.
  11. *
  12. * No globals. Single IIFE. Vanilla JS, no jQuery dependency.
  13. */
  14. (function () {
  15. 'use strict';
  16. const EPS = 1e-9;
  17. /**
  18. * Step a value by delta, clamp to [min, max] when finite, and quantise
  19. * to the nearest `step` increment. Pure — exposed as a local symbol
  20. * for readability; would be the testable surface if we added a
  21. * headless-JS test harness.
  22. */
  23. function clampToStep(current, delta, step, min, max) {
  24. if (!(step > 0)) { step = 1; }
  25. let next = Number(current) + delta;
  26. if (Number.isFinite(min) && next < min) { next = min; }
  27. if (Number.isFinite(max) && next > max) { next = max; }
  28. // Floor with an epsilon tolerance so 0.6/0.05 doesn't quantise down
  29. // to the wrong side of the grid thanks to binary-float artefacts.
  30. const quantised = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
  31. // Round to a sensible number of decimals — 6 is more than enough for
  32. // any step the app uses (0.5 / 0.05 / 1).
  33. return Number(quantised.toFixed(6));
  34. }
  35. // One popover per document, built lazily on first use.
  36. let pop = null;
  37. let elDec = null, elInc = null, elOut = null, elRange = null;
  38. let boundInput = null;
  39. function build() {
  40. if (pop) { return; }
  41. pop = document.createElement('div');
  42. pop.className = 'stepper-popover';
  43. pop.hidden = true;
  44. pop.setAttribute('role', 'dialog');
  45. pop.setAttribute('aria-label', 'Set value');
  46. pop.innerHTML = '<button type="button" data-stepper-dec aria-label="Decrease">\u2212</button>'
  47. + '<output data-stepper-value>0</output>'
  48. + '<button type="button" data-stepper-inc aria-label="Increase">+</button>'
  49. + '<input type="range" data-stepper-range hidden>';
  50. document.body.appendChild(pop);
  51. elDec = pop.querySelector('[data-stepper-dec]');
  52. elInc = pop.querySelector('[data-stepper-inc]');
  53. elOut = pop.querySelector('[data-stepper-value]');
  54. elRange = pop.querySelector('[data-stepper-range]');
  55. elDec.addEventListener('click', function () { step(-1); });
  56. elInc.addEventListener('click', function () { step(+1); });
  57. elRange.addEventListener('input', function () {
  58. if (!boundInput) { return; }
  59. setValue(Number(elRange.value));
  60. });
  61. // Stop pointerdown inside the popover from bubbling to the
  62. // document-level outside-click handler below.
  63. pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
  64. }
  65. function readBounds() {
  66. const step = Number(boundInput.step);
  67. const min = Number(boundInput.min);
  68. const max = Number(boundInput.max);
  69. return {
  70. step: Number.isFinite(step) && step > 0 ? step : 1,
  71. min: Number.isFinite(min) ? min : NaN,
  72. max: Number.isFinite(max) ? max : NaN,
  73. };
  74. }
  75. function step(dir) {
  76. if (!boundInput) { return; }
  77. const b = readBounds();
  78. const next = clampToStep(Number(boundInput.value) || 0, dir * b.step, b.step, b.min, b.max);
  79. setValue(next);
  80. }
  81. function setValue(n) {
  82. if (!boundInput) { return; }
  83. const b = readBounds();
  84. const next = clampToStep(n, 0, b.step, b.min, b.max);
  85. boundInput.value = String(next);
  86. elOut.textContent = String(next);
  87. if (!elRange.hidden) { elRange.value = String(next); }
  88. boundInput.dispatchEvent(new Event('input', { bubbles: true }));
  89. }
  90. function position(input) {
  91. const rect = input.getBoundingClientRect();
  92. // Measure the popover — temporarily visible-off-screen for layout.
  93. pop.style.left = '-9999px';
  94. pop.style.top = '0px';
  95. pop.hidden = false;
  96. const pw = pop.offsetWidth;
  97. const ph = pop.offsetHeight;
  98. const vh = window.innerHeight;
  99. const vw = window.innerWidth;
  100. // Vertical: below unless the input sits in the lower 25% of the
  101. // viewport, then above.
  102. const GAP = 4;
  103. let top;
  104. if (rect.bottom > vh * 0.75) {
  105. top = rect.top - ph - GAP;
  106. } else {
  107. top = rect.bottom + GAP;
  108. }
  109. // Horizontal: align to the input's left edge, clamped to viewport.
  110. let left = rect.left;
  111. const MARGIN = 4;
  112. if (left + pw > vw - MARGIN) { left = vw - pw - MARGIN; }
  113. if (left < MARGIN) { left = MARGIN; }
  114. if (top < MARGIN) { top = MARGIN; }
  115. pop.style.left = left + 'px';
  116. pop.style.top = top + 'px';
  117. }
  118. function open(input) {
  119. build();
  120. boundInput = input;
  121. const b = readBounds();
  122. const current = clampToStep(Number(input.value) || 0, 0, b.step, b.min, b.max);
  123. elOut.textContent = String(current);
  124. if (Number.isFinite(b.min) && Number.isFinite(b.max)) {
  125. elRange.hidden = false;
  126. elRange.min = String(b.min);
  127. elRange.max = String(b.max);
  128. elRange.step = String(b.step);
  129. elRange.value = String(current);
  130. } else {
  131. elRange.hidden = true;
  132. }
  133. position(input);
  134. }
  135. function close(returnFocus) {
  136. if (!pop || pop.hidden) { return; }
  137. pop.hidden = true;
  138. const prev = boundInput;
  139. boundInput = null;
  140. if (prev) {
  141. prev.dispatchEvent(new Event('change', { bubbles: true }));
  142. if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
  143. }
  144. }
  145. // Open on click/focus of a stepper-tagged input.
  146. function onOpenTrigger(ev) {
  147. const t = ev.target;
  148. if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
  149. if (t.disabled || t.readOnly) { return; }
  150. open(t);
  151. }
  152. document.addEventListener('click', onOpenTrigger);
  153. document.addEventListener('focusin', onOpenTrigger);
  154. // Outside-click close — pointerdown, not click, so a scroll gesture
  155. // starting inside the popover in Safari doesn't dismiss prematurely.
  156. document.addEventListener('pointerdown', function (ev) {
  157. if (!pop || pop.hidden) { return; }
  158. const t = ev.target;
  159. if (boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t)))) { return; }
  160. if (pop.contains(t)) { return; }
  161. close(false);
  162. });
  163. // Escape closes + returns focus; Tab that leaves the input + popover
  164. // closes without focus return.
  165. document.addEventListener('keydown', function (ev) {
  166. if (!pop || pop.hidden) { return; }
  167. if (ev.key === 'Escape') {
  168. ev.preventDefault();
  169. close(true);
  170. return;
  171. }
  172. if (ev.key === 'Tab') {
  173. // After the tab completes, check if focus is still on the input
  174. // or inside the popover. If not, close.
  175. setTimeout(function () {
  176. const active = document.activeElement;
  177. if (active === boundInput) { return; }
  178. if (pop.contains(active)) { return; }
  179. close(false);
  180. }, 0);
  181. }
  182. });
  183. // While the bound input is focused, ArrowUp/Down step by `step` —
  184. // restores the shortcut the native spinner lost to Phase 17's CSS reset.
  185. document.addEventListener('keydown', function (ev) {
  186. const t = ev.target;
  187. if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
  188. if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
  189. ev.preventDefault();
  190. if (boundInput !== t) { open(t); }
  191. step(ev.key === 'ArrowUp' ? +1 : -1);
  192. });
  193. })();