/* eslint-disable */
/**
* Author and copyright: Stefan Haack (https://shaack.com)
* Repository: https://github.com/shaack/bootstrap-input-spinner
* License: MIT, see file 'LICENSE'
*
* Vanilla DOM rewrite — the original was a jQuery plugin. The public surface
* preserved: `new InputSpinner(originalInputElement, props?)`, plus the
* `setValue(v)` and `destroyInputSpinner()` methods we attach to the original
* input. External callers that previously did `$(input).val(x)` to drive the
* spinner should now either invoke `input.setValue(x)` directly, or write
* `input.value = x` and dispatch an `input` event — the latter still flows
* through the spinner via the native `value`-property interceptor below.
*/
// the default editor for parsing and rendering
const I18nEditor = function (
this: any,
props,
element,
) {
const locale = props.locale || 'en-US';
this.parse = function (customFormat) {
const numberFormat = new Intl.NumberFormat(locale);
const thousandSeparator = numberFormat.format(11111).replace(/1/g, '') || '.';
const decimalSeparator = numberFormat.format(1.1).replace(/1/g, '');
return parseFloat(customFormat
.replace(new RegExp(' ', 'g'), '')
.replace(new RegExp(`\\${thousandSeparator}`, 'g'), '')
.replace(new RegExp(`\\${decimalSeparator}`), '.'));
};
this.render = function (number) {
const decimals = parseInt(element.getAttribute('data-decimals')) || 0;
const digitGrouping = !(element.getAttribute('data-digit-grouping') === 'false');
const numberFormat = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: digitGrouping,
});
return numberFormat.format(number);
};
};
let triggerKeyPressed = false;
// Build the inner DOM tree from an HTML template string.
function fragmentFromHtml(html: string): HTMLElement {
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstElementChild as HTMLElement;
}
export class InputSpinner {
constructor(element: HTMLInputElement, props?: any) {
const self = this as any;
self.element = element;
self.props = {
decrementButton: '−',
incrementButton: '+',
groupClass: '',
buttonsClass: 'btn-outline-secondary',
buttonsWidth: '2.5rem',
textAlign: 'center',
autoDelay: 500,
autoInterval: 50,
buttonsOnly: false,
keyboardStepping: true,
locale: navigator.language,
editor: I18nEditor,
template:
'
'
+ ''
+ ''
+ ''
+ '
',
};
Object.assign(self.props, props);
const html = self.props.template
.replace(/\$\{groupClass\}/g, self.props.groupClass)
.replace(/\$\{buttonsWidth\}/g, self.props.buttonsWidth)
.replace(/\$\{buttonsClass\}/g, self.props.buttonsClass)
.replace(/\$\{decrementButton\}/g, self.props.decrementButton)
.replace(/\$\{incrementButton\}/g, self.props.incrementButton)
.replace(/\$\{textAlign\}/g, self.props.textAlign);
if ((self.element as any)['bootstrap-input-spinner']) {
console.warn(
'element',
self.element,
'is already a bootstrap-input-spinner',
);
return;
}
self.original = self.element as HTMLInputElement;
(self.original as any)['bootstrap-input-spinner'] = true;
self.original.style.display = 'none';
(self.original as any).inputSpinnerEditor = new self.props.editor(self.props, self.element);
self.autoDelayHandler = null;
self.autoIntervalHandler = null;
self.inputGroup = fragmentFromHtml(html);
self.buttonDecrement = self.inputGroup.querySelector('.btn-decrement') as HTMLButtonElement;
self.buttonIncrement = self.inputGroup.querySelector('.btn-increment') as HTMLButtonElement;
self.input = self.inputGroup.querySelector('input') as HTMLInputElement;
self.label = document.querySelector(`label[for='${self.original.id}']`);
if (self.label == null) {
self.label = self.original.closest('label') as HTMLLabelElement | null;
}
self.min = null;
self.max = null;
self.step = null;
updateAttributes();
self.value = parseFloat(self.original.value);
let pointerState = false;
const prefix = self.original.getAttribute('data-prefix') || '';
const suffix = self.original.getAttribute('data-suffix') || '';
if (prefix) {
const prefixElement = fragmentFromHtml(`${prefix}`);
self.inputGroup.insertBefore(prefixElement, self.input);
}
if (suffix) {
const suffixElement = fragmentFromHtml(`${suffix}`);
self.input.parentNode?.insertBefore(suffixElement, self.input.nextSibling);
}
(self.original as any).setValue = function (newValue: any) {
setValue(newValue);
};
(self.original as any).destroyInputSpinner = function () {
destroy();
};
// Intercept direct writes to original.value (e.g. `input.value = '5'` from
// Vue updates) so the visible spinner input stays in sync without callers
// having to know about `setValue`.
//
// QA_GO-429 Bug B: short-circuit the callback when (a) the value the
// caller wrote is what the spinner already cached, OR (b) the write
// originated from the spinner's own `setValue()` (re-entry guard).
// Without these guards every `self.original.value = X` inside `setValue`
// re-queues another `setTimeout(setValue(X))`, an infinite setTimeout
// recursion that fires hundreds of times per second and clobbers the
// user's typed value every frame — see the Bug B trace in QA_GO-429.
installValueInterceptor(self.original, (newVal) => {
if (self.inSetValue === true) {
return;
}
const parsed = parseFloat(newVal);
if (!isNaN(parsed) && parsed === self.value) {
return;
}
if ((newVal === '' || newVal == null) && isNaN(self.value)) {
return;
}
setTimeout(() => setValue(newVal));
});
self.observer = new MutationObserver(() => {
updateAttributes();
setValue(self.value, true);
});
self.observer.observe(self.original, { attributes: true });
self.original.parentNode?.insertBefore(self.inputGroup, self.original.nextSibling);
setValue(self.value);
const onInputAnyChange = (event: Event) => {
let newValue: any = self.input.value;
const focusOut = event.type === 'focusout';
if (!self.props.buttonsOnly) {
newValue = (self.original as any).inputSpinnerEditor.parse(newValue);
setValue(newValue, focusOut);
dispatchEvent(self.original, event.type);
}
if (self.props.keyboardStepping && focusOut) {
resetTimer();
}
};
self.input.addEventListener('paste', onInputAnyChange);
self.input.addEventListener('input', onInputAnyChange);
self.input.addEventListener('change', onInputAnyChange);
self.input.addEventListener('focusout', onInputAnyChange);
self.input.addEventListener('keydown', (event: KeyboardEvent) => {
if (!self.props.keyboardStepping) {
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (!self.buttonDecrement.disabled) {
stepHandling(self.step);
}
} else if (event.key === 'ArrowDown') {
event.preventDefault();
if (!self.buttonIncrement.disabled) {
stepHandling(-self.step);
}
}
});
self.input.addEventListener('keyup', (event: KeyboardEvent) => {
if (self.props.keyboardStepping && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
event.preventDefault();
resetTimer();
}
});
// decrement button
onPointerDown(self.buttonDecrement, () => {
if (!self.buttonDecrement.disabled) {
pointerState = true;
stepHandling(-self.step);
}
});
// increment button
onPointerDown(self.buttonIncrement, () => {
if (!self.buttonIncrement.disabled) {
pointerState = true;
stepHandling(self.step);
}
});
onPointerUp(document.body, () => {
if (pointerState === true) {
resetTimer();
dispatchEvent(self.original, 'change');
pointerState = false;
}
});
function setValue(newValue: any, updateInput?: boolean) {
if (updateInput === undefined) {
updateInput = true;
}
// QA_GO-429 Bug B: mark re-entry so installValueInterceptor's callback
// skips the setTimeout(setValue) it would otherwise queue for every
// `self.original.value = ...` we do below. Without this flag the
// interceptor would queue an infinite setTimeout cascade that keeps
// firing setValue with stale values and clobbers user typing.
self.inSetValue = true;
try {
if (isNaN(newValue) || newValue === '') {
self.original.value = '';
if (updateInput) {
self.input.value = '';
}
self.value = NaN;
} else {
newValue = parseFloat(newValue);
newValue = Math.min(Math.max(newValue, self.min), self.max);
self.original.value = newValue;
if (updateInput) {
self.input.value = (self.original as any).inputSpinnerEditor.render(newValue);
}
self.value = newValue;
}
} finally {
self.inSetValue = false;
}
}
function destroy() {
if (self.input.required) {
self.original.required = true;
}
self.observer.disconnect();
resetTimer();
self.input.removeEventListener('paste', onInputAnyChange);
self.input.removeEventListener('input', onInputAnyChange);
self.input.removeEventListener('change', onInputAnyChange);
self.input.removeEventListener('focusout', onInputAnyChange);
self.inputGroup.remove();
self.original.style.display = '';
delete (self.original as any)['bootstrap-input-spinner'];
if (self.label != null) {
self.label.setAttribute('for', self.original.id);
}
}
function dispatchEvent(target: HTMLElement, type: string) {
if (!type) {
return;
}
setTimeout(() => {
const event = new Event(type, { bubbles: true });
target.dispatchEvent(event);
});
}
function stepHandling(step: number) {
calcStep(step);
resetTimer();
if (self.props.autoInterval !== undefined) {
self.autoDelayHandler = setTimeout(() => {
self.autoIntervalHandler = setInterval(() => {
calcStep(step);
}, self.props.autoInterval);
}, self.props.autoDelay);
}
}
function calcStep(step: number) {
if (isNaN(self.value)) {
self.value = 0;
}
setValue(Math.round(self.value / step) * step + step);
dispatchEvent(self.original, 'input');
}
function resetTimer() {
clearTimeout(self.autoDelayHandler);
clearTimeout(self.autoIntervalHandler);
}
function updateAttributes() {
// copy properties from original to the new input
if (self.original.required) {
self.input.required = true;
self.original.removeAttribute('required');
}
self.input.placeholder = self.original.placeholder;
self.input.setAttribute('inputmode', self.original.getAttribute('inputmode') || 'decimal');
const disabled = self.original.disabled;
const readonly = self.original.readOnly;
self.input.disabled = disabled;
self.input.readOnly = readonly || self.props.buttonsOnly;
self.buttonIncrement.disabled = disabled || readonly;
self.buttonDecrement.disabled = disabled || readonly;
if (disabled || readonly) {
resetTimer();
}
const originalClass = self.original.className;
let groupClass = '';
// sizing
if (/form-control-sm/.test(originalClass)) {
groupClass = 'input-group-sm';
} else if (/form-control-lg/.test(originalClass)) {
groupClass = 'input-group-lg';
}
const inputClass = originalClass.replace(/form-control(-(sm|lg))?/g, '');
self.inputGroup.className = `input-group ${groupClass} ${self.props.groupClass}`;
self.input.className = `form-control ${inputClass}`;
// update the main attributes
const minAttr = self.original.getAttribute('min');
self.min = (minAttr == null || minAttr === '' || isNaN(Number(minAttr))) ? -Infinity : parseFloat(minAttr);
const maxAttr = self.original.getAttribute('max');
self.max = (maxAttr == null || maxAttr === '' || isNaN(Number(maxAttr))) ? Infinity : parseFloat(maxAttr);
self.step = parseFloat(self.original.getAttribute('step') || '') || 1;
const hiddenAttr = self.original.getAttribute('hidden');
if (hiddenAttr != null) {
self.inputGroup.setAttribute('hidden', hiddenAttr);
} else {
self.inputGroup.removeAttribute('hidden');
}
if (self.original.id) {
self.input.id = `${self.original.id}:input_spinner`;
if (self.label != null) {
self.label.setAttribute('for', self.input.id);
}
}
// a11y: mirror the accessibility attributes the host framework set on
// the original (now display:none) input onto the visible spinner input,
// so the control the user actually operates carries its name,
// description and validation state. Without this, aria-label /
// aria-invalid / aria-describedby / aria-required land on the hidden
// input and never reach the user (WCAG 4.1.2, 3.3.1). Runs again on
// every observed attribute mutation, so dynamic validation changes
// propagate live.
['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-invalid', 'aria-required'].forEach((attr) => {
const ariaVal = self.original.getAttribute(attr);
if (ariaVal != null) {
self.input.setAttribute(attr, ariaVal);
} else {
self.input.removeAttribute(attr);
}
});
}
function onPointerUp(element: HTMLElement, callback: (e: Event) => void) {
element.addEventListener('mouseup', (e) => {
callback(e);
});
element.addEventListener('touchend', (e) => {
callback(e);
});
element.addEventListener('keyup', (e: KeyboardEvent) => {
if ((e.keyCode === 32 || e.keyCode === 13)) {
triggerKeyPressed = false;
callback(e);
}
});
}
function onPointerDown(element: HTMLElement, callback: (e: Event) => void) {
element.addEventListener('mousedown', (e) => {
if ((e as MouseEvent).button === 0) {
e.preventDefault();
callback(e);
}
});
element.addEventListener(
'touchstart',
(e) => {
if (e.cancelable) {
e.preventDefault();
}
callback(e);
},
{ passive: false },
);
element.addEventListener('keydown', (e: KeyboardEvent) => {
if ((e.keyCode === 32 || e.keyCode === 13) && !triggerKeyPressed) {
triggerKeyPressed = true;
callback(e);
}
});
}
}
}
/**
* Replaces the `.value` accessor on an HTMLInputElement with a wrapper that
* still delegates to the original prototype getter/setter, but additionally
* fires `onSet` on every write. Lets the spinner stay in sync with code that
* mutates `input.value` directly instead of going through `setValue()`.
*
* Idempotent per element — installs once.
*/
function installValueInterceptor(input: HTMLInputElement, onSet: (value: any) => void): void {
if ((input as any).__spinnerValueIntercepted) {
return;
}
const proto = Object.getPrototypeOf(input);
const desc = Object.getOwnPropertyDescriptor(proto, 'value')
?? Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
if (desc == null || desc.get == null || desc.set == null) {
return;
}
const originalGet = desc.get;
const originalSet = desc.set;
Object.defineProperty(
input,
'value',
{
configurable: true,
get() {
return originalGet.call(this);
},
set(val) {
originalSet.call(this, val);
try {
onSet(val);
} catch { /* swallow — must not break value writes */ }
},
},
);
(input as any).__spinnerValueIntercepted = true;
}