A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
213 lines
7.6 KiB
JavaScript
213 lines
7.6 KiB
JavaScript
/* Custom <input type="number"> 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 <input type="number"> 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 = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
|
||
|
||
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 = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||
|
||
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 };
|
||
})();
|