librelad 875a60f90f LibrePortal v0.1.0 — initial release
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>
2026-05-21 20:37:54 +01:00

213 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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