number-stepper.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. /**
  2. * Phase 17: floating slider popover for number inputs.
  3. *
  4. * Activates on click or keyboard focus of any `input[type="number"]` —
  5. * pops a compact vertical range slider to the right of the input, at
  6. * the input's vertical midpoint. The popover shows *only* the slider;
  7. * there are no +/− buttons and no value readout. Users read the
  8. * current value from the input itself; they drag the slider to set a
  9. * new value. Dragging fires `change` on the bound input (debounced
  10. * saves in sprint-planner.js coalesce the flurry), so capacity
  11. * recomputes live.
  12. *
  13. * Close triggers:
  14. * 1. Pointer leaves the popover after having entered it once (the
  15. * primary dismissal gesture — "hover-in, drag, hover-out");
  16. * 2. Outside pointerdown (touch devices + mis-click escape hatch);
  17. * 3. Escape keypress (focus returns to the input);
  18. * 4. Tab that moves focus out of both input and popover.
  19. *
  20. * Applies to every `input[type="number"]` across every view — day
  21. * cells, RTB cells, task-assignment cells, week-count field, reserve
  22. * percent. For inputs without a `max` attribute (task assignments),
  23. * the slider picks an adaptive upper bound so it still feels useful.
  24. *
  25. * Strict-CSP-clean: loaded as a standard <script src>. No globals.
  26. * Single IIFE. Vanilla JS — no jQuery dependency.
  27. */
  28. (function () {
  29. 'use strict';
  30. const EPS = 1e-9;
  31. function quantise(next, step, min, max) {
  32. if (!(step > 0)) { step = 1; }
  33. if (Number.isFinite(min) && next < min) { next = min; }
  34. if (Number.isFinite(max) && next > max) { next = max; }
  35. const q = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
  36. return Number(q.toFixed(6));
  37. }
  38. // Single shared popover, built lazily on first use.
  39. let pop = null;
  40. let elRange = null;
  41. let boundInput = null;
  42. // Set true on the first pointerenter after open(); only then does
  43. // pointerleave close the popover. That way a click-to-open whose
  44. // cursor already sits inside the popover rectangle doesn't
  45. // immediately close on the next micro-movement.
  46. let popoverEntered = false;
  47. function build() {
  48. if (pop) { return; }
  49. pop = document.createElement('div');
  50. pop.className = 'stepper-popover';
  51. pop.hidden = true;
  52. pop.setAttribute('role', 'dialog');
  53. pop.setAttribute('aria-label', 'Set value');
  54. // `orient="vertical"` is a belt-and-braces fallback for older
  55. // Firefox; modern layout comes from the writing-mode CSS in
  56. // assets/css/input.css.
  57. pop.innerHTML =
  58. '<input type="range" data-stepper-range orient="vertical">';
  59. document.body.appendChild(pop);
  60. elRange = pop.querySelector('[data-stepper-range]');
  61. // Live recompute during drag — dispatch `change` (not `input`)
  62. // because sprint-planner.js listens for `blur change` on the
  63. // bound inputs. Its queueCell() is 400 ms-debounced, so
  64. // firing `change` on every slider tick coalesces into one
  65. // server write after the drag pauses.
  66. elRange.addEventListener('input', function () {
  67. if (!boundInput) { return; }
  68. boundInput.value = elRange.value;
  69. boundInput.dispatchEvent(new Event('change', { bubbles: true }));
  70. });
  71. // Cancel bubbling of pointerdown inside the popover so the
  72. // document-level outside-click handler doesn't close us on a
  73. // drag-start in Safari.
  74. pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
  75. // The core dismissal gesture: enter-then-leave closes.
  76. pop.addEventListener('pointerenter', function () {
  77. popoverEntered = true;
  78. });
  79. pop.addEventListener('pointerleave', function () {
  80. if (popoverEntered) { close(false); }
  81. });
  82. }
  83. function readAttrNumber(input, name) {
  84. const raw = input.getAttribute(name);
  85. if (raw === null || raw === '') { return NaN; }
  86. const n = Number(raw);
  87. return Number.isFinite(n) ? n : NaN;
  88. }
  89. function position(input) {
  90. const rect = input.getBoundingClientRect();
  91. // Measure off-screen so the browser has real dimensions to
  92. // work with before we anchor.
  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. const GAP = 6;
  101. const MARGIN = 4;
  102. // Preferred: right of the input, vertically centred on it.
  103. let left = rect.right + GAP;
  104. let top = rect.top + (rect.height / 2) - (ph / 2);
  105. // If we'd run off the right edge, flip to the left of the input.
  106. if (left + pw > vw - MARGIN) {
  107. left = rect.left - pw - GAP;
  108. }
  109. // Then clamp both axes into the viewport.
  110. if (left < MARGIN) { left = MARGIN; }
  111. if (left + pw > vw - MARGIN) { left = vw - pw - MARGIN; }
  112. if (top < MARGIN) { top = MARGIN; }
  113. if (top + ph > vh - MARGIN) { top = vh - ph - MARGIN; }
  114. pop.style.left = left + 'px';
  115. pop.style.top = top + 'px';
  116. }
  117. function open(input) {
  118. build();
  119. boundInput = input;
  120. popoverEntered = false;
  121. const stepAttr = readAttrNumber(input, 'step');
  122. const minAttr = readAttrNumber(input, 'min');
  123. const maxAttr = readAttrNumber(input, 'max');
  124. const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
  125. const current = quantise(Number(input.value) || 0, step, minAttr, maxAttr);
  126. // Slider requires a finite min and max. Fall back sensibly
  127. // when the input leaves them open — task-assignment cells
  128. // declare min="0" but no max, so we pick max = max(current+5, 10)
  129. // so there's always usable overhead on the slider.
  130. const sliderMin = Number.isFinite(minAttr) ? minAttr : 0;
  131. const sliderMax = Number.isFinite(maxAttr)
  132. ? maxAttr
  133. : Math.max(current + 5, 10);
  134. elRange.min = String(sliderMin);
  135. elRange.max = String(sliderMax);
  136. elRange.step = String(step);
  137. elRange.value = String(Math.max(sliderMin, Math.min(sliderMax, current)));
  138. position(input);
  139. }
  140. function close(returnFocus) {
  141. if (!pop || pop.hidden) { return; }
  142. pop.hidden = true;
  143. const prev = boundInput;
  144. boundInput = null;
  145. popoverEntered = false;
  146. if (prev) {
  147. // Final `change` so the debounced save captures the last
  148. // slider position. Harmless no-op if the slider wasn't
  149. // touched (value unchanged).
  150. prev.dispatchEvent(new Event('change', { bubbles: true }));
  151. if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
  152. }
  153. }
  154. // ------------------------------------------------------------------
  155. // Triggers
  156. // ------------------------------------------------------------------
  157. function isEligible(el) {
  158. return !!(el
  159. && el.matches
  160. && el.matches('input[type="number"]')
  161. && !el.disabled
  162. && !el.readOnly);
  163. }
  164. // Click-to-open. Covers the primary mouse path. Reposition when
  165. // the click lands on a different eligible input than the currently
  166. // bound one.
  167. document.addEventListener('click', function (ev) {
  168. const t = ev.target;
  169. if (!isEligible(t)) { return; }
  170. if (boundInput !== t) { open(t); }
  171. });
  172. // focusin covers keyboard navigation (Tab to field). Don't reopen
  173. // if we're already bound to that input — focus may re-fire on
  174. // clicks that land on the input.
  175. document.addEventListener('focusin', function (ev) {
  176. const t = ev.target;
  177. if (!isEligible(t)) { return; }
  178. if (boundInput !== t) { open(t); }
  179. });
  180. // Outside pointerdown closes. Only listen while the popover is
  181. // open so we don't pay the cost on every app-wide click.
  182. document.addEventListener('pointerdown', function (ev) {
  183. if (!pop || pop.hidden) { return; }
  184. const t = ev.target;
  185. if (boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t)))) { return; }
  186. if (pop.contains(t)) { return; }
  187. close(false);
  188. });
  189. // Escape closes and returns focus; Tab out of the input + popover
  190. // closes without focus return.
  191. document.addEventListener('keydown', function (ev) {
  192. if (!pop || pop.hidden) { return; }
  193. if (ev.key === 'Escape') {
  194. ev.preventDefault();
  195. close(true);
  196. return;
  197. }
  198. if (ev.key === 'Tab') {
  199. setTimeout(function () {
  200. const active = document.activeElement;
  201. if (active === boundInput) { return; }
  202. if (pop.contains(active)) { return; }
  203. close(false);
  204. }, 0);
  205. }
  206. });
  207. // ArrowUp/Down while the input has keyboard focus — step by the
  208. // input's `step` attribute. Restores the shortcut the native
  209. // spinner lost to Phase 17's CSS reset; works even when the
  210. // popover isn't open.
  211. document.addEventListener('keydown', function (ev) {
  212. const t = ev.target;
  213. if (!isEligible(t)) { return; }
  214. if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
  215. ev.preventDefault();
  216. const step = readAttrNumber(t, 'step');
  217. const minA = readAttrNumber(t, 'min');
  218. const maxA = readAttrNumber(t, 'max');
  219. const effStep = Number.isFinite(step) && step > 0 ? step : 1;
  220. const next = quantise(
  221. (Number(t.value) || 0) + (ev.key === 'ArrowUp' ? effStep : -effStep),
  222. effStep, minA, maxA,
  223. );
  224. t.value = String(next);
  225. t.dispatchEvent(new Event('change', { bubbles: true }));
  226. if (!pop || pop.hidden || boundInput !== t) { return; }
  227. elRange.value = String(next);
  228. });
  229. })();