/* 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 };
})();