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