/* Custom stepper enhancer. * * The native browser spin buttons are tiny, OS-themed, and (on * Firefox/Linux) often look mismatched against the rest of the app. * This module wraps every with class="form-control" * or "form-input" in a glass container and supplies our own up/down * buttons that call stepUp()/stepDown() on the native input — so any * existing change/input handler keeps working unchanged. * * Features: * - Click ↑/↓ to step once. * - Hold ↑/↓ to repeat (delay-then-accelerate). * - Buttons auto-disable at min/max. * - Mouse wheel over the input also steps (with shift = ×10). * - Native spin buttons hidden via CSS (see forms.css). * * Opt-out: data-no-enhance on the input. */ (() => { const ENHANCED = Symbol('customNumberEnhanced'); const ENHANCE_CLASSES = ['form-control', 'form-input']; class CustomNumber { constructor(input) { if (input[ENHANCED]) return; input[ENHANCED] = true; this.input = input; this.build(); this.bind(); this.refresh(); } build() { this.wrapper = document.createElement('div'); this.wrapper.className = 'custom-number'; // Preserve inline width/maxWidth so layout-sensitive callers still work. if (this.input.style.maxWidth) this.wrapper.style.maxWidth = this.input.style.maxWidth; if (this.input.style.width && this.input.style.width !== '100%') { this.wrapper.style.width = this.input.style.width; } this.input.parentNode.insertBefore(this.wrapper, this.input); this.wrapper.appendChild(this.input); this.input.classList.add('custom-number-input'); this.controls = document.createElement('div'); this.controls.className = 'custom-number-controls'; this.upBtn = document.createElement('button'); this.upBtn.type = 'button'; this.upBtn.className = 'custom-number-btn custom-number-up'; this.upBtn.setAttribute('aria-label', 'Increase'); this.upBtn.tabIndex = -1; this.upBtn.innerHTML = ''; this.downBtn = document.createElement('button'); this.downBtn.type = 'button'; this.downBtn.className = 'custom-number-btn custom-number-down'; this.downBtn.setAttribute('aria-label', 'Decrease'); this.downBtn.tabIndex = -1; this.downBtn.innerHTML = ''; this.controls.append(this.upBtn, this.downBtn); this.wrapper.appendChild(this.controls); } refresh() { const v = this.numeric(); const min = this.input.min === '' ? -Infinity : Number(this.input.min); const max = this.input.max === '' ? Infinity : Number(this.input.max); this.upBtn.disabled = !Number.isNaN(v) && v >= max; this.downBtn.disabled = !Number.isNaN(v) && v <= min; this.upBtn.classList.toggle('is-disabled', this.upBtn.disabled); this.downBtn.classList.toggle('is-disabled', this.downBtn.disabled); if (this.input.disabled) { this.wrapper.classList.add('is-disabled'); this.upBtn.disabled = true; this.downBtn.disabled = true; } else { this.wrapper.classList.remove('is-disabled'); } } numeric() { const raw = this.input.value; if (raw === '' || raw == null) return NaN; return Number(raw); } step(direction) { if (this.input.disabled) return; // If the input is empty, seed with min (or 0) on first step so // stepUp/stepDown have somewhere to go. if (this.input.value === '' || this.input.value == null) { const min = this.input.min === '' ? 0 : Number(this.input.min); this.input.value = String(min); } try { if (direction > 0) this.input.stepUp(); else this.input.stepDown(); } catch { // stepUp/stepDown throw if min/max prevents the step. return; } this.input.dispatchEvent(new Event('input', { bubbles: true })); this.input.dispatchEvent(new Event('change', { bubbles: true })); this.refresh(); } holdStart(direction) { this.step(direction); let delay = 400; const tick = () => { this.step(direction); delay = Math.max(40, delay * 0.85); this._holdTimer = setTimeout(tick, delay); }; this._holdTimer = setTimeout(tick, delay); } holdStop() { if (this._holdTimer) { clearTimeout(this._holdTimer); this._holdTimer = null; } } bind() { this.upBtn.addEventListener('mousedown', (e) => { e.preventDefault(); if (this.upBtn.disabled) return; this.holdStart(1); }); this.downBtn.addEventListener('mousedown', (e) => { e.preventDefault(); if (this.downBtn.disabled) return; this.holdStart(-1); }); ['mouseup', 'mouseleave', 'blur'].forEach(ev => { this.upBtn.addEventListener(ev, () => this.holdStop()); this.downBtn.addEventListener(ev, () => this.holdStop()); }); document.addEventListener('mouseup', () => this.holdStop()); // Keyboard support for the buttons. this.upBtn.addEventListener('click', (e) => e.preventDefault()); this.downBtn.addEventListener('click', (e) => e.preventDefault()); // Re-evaluate disabled state on any value change (typing, paste, // programmatic .value = ...). this.input.addEventListener('input', () => this.refresh()); this.input.addEventListener('change', () => this.refresh()); // Wheel-to-step when the input is focused (intentional, non-passive). this.input.addEventListener('wheel', (e) => { if (document.activeElement !== this.input) return; e.preventDefault(); const factor = e.shiftKey ? 10 : 1; for (let i = 0; i < factor; i++) this.step(e.deltaY < 0 ? 1 : -1); }, { passive: false }); // React to min/max/disabled attribute changes (config-shared.js // sometimes flips these after the field is mounted). this.attrObserver = new MutationObserver(() => this.refresh()); this.attrObserver.observe(this.input, { attributes: true, attributeFilter: ['min', 'max', 'disabled', 'step'] }); } } function shouldEnhance(el) { if (!(el instanceof HTMLInputElement)) return false; if (el.type !== 'number') return false; if (el[ENHANCED]) return false; if (el.hasAttribute('data-no-enhance')) return false; return ENHANCE_CLASSES.some(c => el.classList.contains(c)); } function enhanceAll(root = document) { const sel = ENHANCE_CLASSES.map(c => `input[type="number"].${c}`).join(','); root.querySelectorAll(sel).forEach(el => { if (shouldEnhance(el)) new CustomNumber(el); }); } function start() { enhanceAll(); const observer = new MutationObserver((mutations) => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType !== 1) continue; if (shouldEnhance(node)) { new CustomNumber(node); } else { enhanceAll(node); } } } }); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } window.CustomNumber = { enhance: enhanceAll, _Class: CustomNumber }; })();