Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
blog
/
assets
/
admin
/
themestrap
:
themestrap.plugin.numberinput.js
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
/** * Themestrap NumberInput * * Replaces the browser's tiny spin arrows with a proper stepper: minus and plus buttons that match * the other Themestrap inputs, hold-to-repeat, keyboard control, bounds, and an optional unit label. * The original <input type="number"> stays in the page and keeps the value, so forms, validation and * code that reads the input keep working. * * Markup: * * <input type="number" name="per_page" value="8" min="1" max="50" step="1" * data-plugin-numberinput * data-plugin-options='{"unit": "posts"}'> * * Layouts: 'split' (default) [ - | 8 posts | + ] * 'stacked' [ 8 posts | up/down ] * * Public API (via stored instance): * const ni = $('#per_page').data('__pluginNumberInput'); * ni.getValue(); // number, or null when empty * ni.setValue(12, silent); // clamps to min and max, silent skips the events * ni.increment(n); // n steps up (default 1) * ni.decrement(n); * ni.setMin(1); ni.setMax(100); ni.setStep(5); * ni.enable(); ni.disable(); * ni.destroy(); * * Events (native CustomEvent, bubbling, dispatched on the input): * ts.numberinput.change detail: { instance, value, previousValue, source } * source is 'button', 'keyboard', 'wheel', 'typing' or 'api' * The input also fires ordinary 'input' and 'change' events, so existing listeners keep working. * * Keyboard (on the input): * Up / Down one step Shift + Up / Down, Page Up / Down ten steps * Home / End minimum / maximum Enter apply the typed value * * CSS custom properties (all optional): * --ts-ni-h, --ts-ni-bg, --ts-ni-border, --ts-ni-border-focus, --ts-ni-radius, --ts-ni-text, * --ts-ni-muted, --ts-ni-btn-bg, --ts-ni-btn-hover, --ts-ni-focus-ring, --ts-ni-width * * Init.js wiring: * if ($.isFunction($.fn['themestrapPluginNumberInput']) && $('[data-plugin-numberinput]').length) { * themestrap.fn.intObsInit('[data-plugin-numberinput]:not(.manual)', 'themestrapPluginNumberInput'); * } */ // Number Input (((themestrap = {}, $) => { const instanceName = '__pluginNumberInput'; const STYLE_ID = 'ts-numberinput-styles'; const EVENT = 'ts.numberinput.change'; let instanceCount = 0; let seq = 0; const ICON = { minus: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>', plus: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>', up: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 15 6-6 6 6"/></svg>', down: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>' }; const LIGHT = ` --ni-h: var(--ts-ni-h, 2.45rem); --ni-bg: var(--ts-ni-bg, #ffffff); --ni-border: var(--ts-ni-border, rgba(0,0,0,.18)); --ni-border-focus: var(--ts-ni-border-focus, #00a89f); --ni-radius: var(--ts-ni-radius, 7px); --ni-text: var(--ts-ni-text, #1f2937); --ni-muted: var(--ts-ni-muted, rgba(31,41,55,.55)); --ni-btn-bg: var(--ts-ni-btn-bg, rgba(0,0,0,.035)); --ni-btn-hover: var(--ts-ni-btn-hover, rgba(0,0,0,.09)); --ni-ring: var(--ts-ni-focus-ring, 0 0 0 3px rgba(0,168,159,.20)); `; const DARK = ` --ni-bg: var(--ts-ni-bg, rgba(255,255,255,.03)); --ni-border: var(--ts-ni-border, rgba(255,255,255,.14)); --ni-border-focus: var(--ts-ni-border-focus, #00c7be); --ni-text: var(--ts-ni-text, #d5dfeb); --ni-muted: var(--ts-ni-muted, rgba(213,223,235,.55)); --ni-btn-bg: var(--ts-ni-btn-bg, rgba(255,255,255,.05)); --ni-btn-hover: var(--ts-ni-btn-hover, rgba(255,255,255,.12)); --ni-ring: var(--ts-ni-focus-ring, 0 0 0 3px rgba(0,199,190,.22)); `; const CSS_TEXT = ` .ts-ni { ${LIGHT} } @media (prefers-color-scheme: dark) { .ts-ni { ${DARK} } } html.dark .ts-ni, [data-bs-theme="dark"] .ts-ni { ${DARK} } [data-bs-theme="light"] .ts-ni { ${LIGHT} } .ts-ni { display: inline-flex; align-items: stretch; width: var(--ts-ni-width, auto); height: var(--ni-h); background: var(--ni-bg); border: 1px solid var(--ni-border); border-radius: var(--ni-radius); color: var(--ni-text); overflow: hidden; transition: border-color .15s, box-shadow .15s; box-sizing: border-box; } .ts-ni:focus-within { border-color: var(--ni-border-focus); box-shadow: var(--ni-ring); } .ts-ni--block { display: flex; width: 100%; } .ts-ni--sm { --ni-h: 2rem; font-size: .85rem; } .ts-ni--lg { --ni-h: 2.9rem; font-size: 1.05rem; } .ts-ni--disabled { opacity: .55; } .ts-ni-field { display: flex; align-items: center; flex: 1 1 auto; min-width: 0; } .ts-ni input.ts-ni-input { flex: 1 1 auto; min-width: 2.6rem; width: var(--ts-ni-input-width, 4.5rem); height: 100%; margin: 0; padding: 0 .4rem; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; text-align: center; box-shadow: none; border-radius: 0; -moz-appearance: textfield; appearance: textfield; } .ts-ni input.ts-ni-input::-webkit-outer-spin-button, .ts-ni input.ts-ni-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } /* In block mode the value field is fluid, and must not bring the input's intrinsic width into the minimum width */ .ts-ni--block input.ts-ni-input { flex: 1 1 0%; width: 0; min-width: 0; } .ts-ni-unit { padding-right: .6rem; color: var(--ni-muted); font-size: .85em; pointer-events: none; white-space: nowrap; } .ts-ni-btn { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 var(--ni-h); width: var(--ni-h); padding: 0; border: 0; background: var(--ni-btn-bg); color: var(--ni-text); cursor: pointer; user-select: none; -webkit-user-select: none; touch-action: manipulation; transition: background-color .12s; } .ts-ni-btn:hover { background: var(--ni-btn-hover); } .ts-ni-btn:active { background: var(--ni-btn-hover); } .ts-ni-btn.is-disabled { opacity: .35; cursor: default; background: var(--ni-btn-bg); } .ts-ni--split .ts-ni-dec { border-right: 1px solid var(--ni-border); } .ts-ni--split .ts-ni-inc { border-left: 1px solid var(--ni-border); } .ts-ni-stack { display: flex; flex-direction: column; flex: 0 0 1.9rem; border-left: 1px solid var(--ni-border); } .ts-ni--stacked .ts-ni-btn { flex: 1 1 50%; width: 100%; height: 50%; } .ts-ni--stacked .ts-ni-inc { border-bottom: 1px solid var(--ni-border); } @media (prefers-reduced-motion: reduce) { .ts-ni, .ts-ni-btn { transition: none; } } `; function decimals(n) { const s = String(n); if (/e-/.test(s)) return parseInt(s.split('e-')[1], 10); const i = s.indexOf('.'); return i === -1 ? 0 : s.length - i - 1; } class PluginNumberInput { constructor($el, opts) { return this.initialize($el, opts); } initialize($el, opts) { if ($el.data(instanceName)) { return this; } this.$el = $el; this._uid = ++seq; this._ns = `.tsni${this._uid}`; this._failed = false; this._hold = null; this .setData() .setOptions(opts) .build() .events(); return this; } setData() { this.$el.data(instanceName, this); return this; } setOptions(opts) { const el = this.$el[0]; const fromAttr = {}; ['min', 'max', 'step'].forEach((k) => { const v = el.getAttribute(k); if (v !== null && v !== '' && !isNaN(parseFloat(v))) fromAttr[k] = parseFloat(v); }); this.options = $.extend(true, {}, PluginNumberInput.defaults, fromAttr, opts, { wrapper: this.$el }); if (!(this.options.step > 0)) this.options.step = 1; this.options.precision = this.options.precision != null ? this.options.precision : Math.max(decimals(this.options.step), isFinite(this.options.min) ? decimals(this.options.min) : 0); return this; } build() { const self = this; const o = self.options; if (!self.$el.is('input')) { console.error('PluginNumberInput: the host element must be an <input>.'); self._failed = true; self.$el.removeData(instanceName); return this; } instanceCount++; if (!document.getElementById(STYLE_ID)) { const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = CSS_TEXT; document.head.appendChild(style); } const el = self.$el[0]; self._origType = el.getAttribute('type'); el.setAttribute('type', 'number'); if (isFinite(o.min)) el.setAttribute('min', o.min); if (isFinite(o.max)) el.setAttribute('max', o.max); el.setAttribute('step', o.step); self.$el.addClass('ts-ni-input'); const layout = o.layout === 'stacked' ? 'stacked' : 'split'; self.$wrap = $(`<div class="ts-ni ts-ni--${layout}" role="group"></div>`); if (o.size === 'sm' || o.size === 'lg') self.$wrap.addClass(`ts-ni--${o.size}`); if (o.block) self.$wrap.addClass('ts-ni--block'); const id = el.id || `ts-ni-${self._uid}`; if (!el.id) el.id = id; const btn = (cls, label, icon) => $(`<button type="button" class="ts-ni-btn ${cls}" tabindex="-1" aria-controls="${id}" aria-label="${label}">${icon}</button>`); self.$dec = btn('ts-ni-dec', o.labels.decrease, layout === 'stacked' ? ICON.down : ICON.minus); self.$inc = btn('ts-ni-inc', o.labels.increase, layout === 'stacked' ? ICON.up : ICON.plus); self.$wrap.insertBefore(self.$el); const $field = $('<div class="ts-ni-field"></div>').append(self.$el); if (o.unit) $field.append($('<span class="ts-ni-unit" aria-hidden="true"></span>').text(o.unit)); if (layout === 'stacked') { self.$wrap.append($field, $('<div class="ts-ni-stack"></div>').append(self.$inc, self.$dec)); } else { self.$wrap.append(self.$dec, $field, self.$inc); } if (el.disabled || el.readOnly) self._applyDisabled(true); self._lastValue = self.getValue(); self._syncButtons(); return this; } events() { const self = this; if (self._failed) return this; const ns = self._ns; // Press, hold to repeat const down = (dir) => (e) => { if (e.type === 'mousedown' && e.button !== 0) return; e.preventDefault(); // keep focus where it is self.$el.trigger('focus'); self._startHold(dir); }; const stop = () => self._stopHold(); const usePointer = typeof window.PointerEvent === 'function'; const downEvt = usePointer ? 'pointerdown' : 'mousedown touchstart'; self.$dec.on(`${downEvt.split(' ').join(ns + ' ')}${ns}`, down(-1)); self.$inc.on(`${downEvt.split(' ').join(ns + ' ')}${ns}`, down(1)); $(document).on(`${usePointer ? 'pointerup pointercancel' : 'mouseup touchend touchcancel'}${ns}`, stop); self.$dec.add(self.$inc).on(`${usePointer ? 'pointerleave' : 'mouseleave'}${ns}`, stop); self.$el .on(`keydown${ns}`, (e) => self._onKeydown(e)) .on(`input${ns}`, () => self._onTyping()) .on(`blur${ns}`, () => self._commit('typing')) .on(`wheel${ns}`, (e) => { if (!self.options.wheel || document.activeElement !== self.$el[0]) return; e.preventDefault(); const up = (e.originalEvent || e).deltaY < 0; self._step(up ? 1 : -1, 'wheel'); }); return this; } // Value helpers _bounds() { return { min: isFinite(this.options.min) ? this.options.min : -Infinity, max: isFinite(this.options.max) ? this.options.max : Infinity }; } _clamp(v) { const { min, max } = this._bounds(); return Math.min(max, Math.max(min, v)); } _format(v) { return v === null || isNaN(v) ? '' : Number(v).toFixed(this.options.precision).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, ''); } getValue() { const raw = String(this.$el.val()).trim().replace(',', '.'); if (raw === '' || isNaN(Number(raw))) return null; return Number(raw); } _write(v) { this.$el.val(this._format(v)); this._lastValue = v; this._syncButtons(); } _syncButtons() { const v = this.getValue(); const { min, max } = this._bounds(); this.$dec.toggleClass('is-disabled', v !== null && v <= min).attr('aria-disabled', v !== null && v <= min ? 'true' : 'false'); this.$inc.toggleClass('is-disabled', v !== null && v >= max).attr('aria-disabled', v !== null && v >= max ? 'true' : 'false'); } _emit(source, previous, silent) { if (silent) return; const el = this.$el[0]; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new CustomEvent(EVENT, { bubbles: true, detail: { instance: this, value: this.getValue(), previousValue: previous, source } })); if (typeof this.options.onChange === 'function') this.options.onChange.call(this, this.getValue()); } // Moves by n steps from the current value (or from the bound when empty) _step(n, source) { if (this._isLocked()) return this; const o = this.options; const prev = this.getValue(); const { min } = this._bounds(); let base = prev; if (base === null) base = isFinite(min) ? min - o.step * Math.sign(n) : 0; if (o.snap) base = Math.round(base / o.step) * o.step; const next = this._clamp(Number((base + n * o.step).toFixed(Math.max(o.precision, 8)))); if (prev !== null && next === prev) { this._syncButtons(); return this; } this._write(next); this._emit(source, prev, false); return this; } increment(n) { return this._step(n === undefined ? 1 : n, 'api'); } decrement(n) { return this._step(-(n === undefined ? 1 : n), 'api'); } setValue(v, silent) { const prev = this.getValue(); const num = v === null || v === '' || isNaN(Number(v)) ? null : this._clamp(Number(v)); this._write(num); if (num !== prev) this._emit('api', prev, !!silent); return this; } setMin(n) { this.options.min = n; this.$el.attr('min', n); this._syncButtons(); return this; } setMax(n) { this.options.max = n; this.$el.attr('max', n); this._syncButtons(); return this; } setStep(n) { if (!(n > 0)) return this; this.options.step = n; this.options.precision = Math.max(decimals(n), isFinite(this.options.min) ? decimals(this.options.min) : 0); this.$el.attr('step', n); return this; } // Typing and committing _onTyping() { const o = this.options; let raw = String(this.$el.val()); if (o.precision === 0 && /[.,]/.test(raw)) raw = raw.replace(/[.,].*$/, ''); if (raw !== String(this.$el.val())) this.$el.val(raw); this._syncButtons(); } // Applies the typed text: parse, clamp, format. An empty field stays empty. _commit(source) { const prev = this._lastValue; const v = this.getValue(); if (v === null) { this._lastValue = null; this._syncButtons(); return; } const next = this._clamp(this.options.snap ? Math.round(v / this.options.step) * this.options.step : v); this._write(next); if (next !== prev) this._emit(source, prev, false); } _onKeydown(e) { const o = this.options; if (e.isComposing) return; const big = e.shiftKey ? 10 : 1; switch (e.key) { case 'ArrowUp': e.preventDefault(); this._step(big, 'keyboard'); break; case 'ArrowDown': e.preventDefault(); this._step(-big, 'keyboard'); break; case 'PageUp': e.preventDefault(); this._step(10, 'keyboard'); break; case 'PageDown': e.preventDefault(); this._step(-10, 'keyboard'); break; case 'Home': if (isFinite(o.min)) { e.preventDefault(); this.setValue(o.min); } break; case 'End': if (isFinite(o.max)) { e.preventDefault(); this.setValue(o.max); } break; case 'Enter': this._commit('typing'); break; } } // Press and hold: first step now, then repeat, speeding up _startHold(dir) { const self = this; self._stopHold(); self._step(dir, 'button'); if (!self.options.repeat) return; let n = 0; const tick = () => { self._step(dir, 'button'); n++; const interval = Math.max(self.options.holdMinInterval, self.options.holdInterval - n * 4); self._hold = setTimeout(tick, interval); }; self._hold = setTimeout(tick, self.options.holdDelay); } _stopHold() { clearTimeout(this._hold); this._hold = null; } _isLocked() { return this.$el[0].disabled || this.$el[0].readOnly; } _applyDisabled(on) { this.$wrap.toggleClass('ts-ni--disabled', !!on); this.$dec.add(this.$inc).attr('aria-disabled', on ? 'true' : 'false'); } enable() { this.$el.prop('disabled', false); this._applyDisabled(false); this._syncButtons(); return this; } disable() { this.$el.prop('disabled', true); this._applyDisabled(true); return this; } destroy() { const self = this; if (self._failed) return this; self._stopHold(); $(document).off(self._ns); self.$el.off(self._ns); self.$dec.off(self._ns).remove(); self.$inc.off(self._ns).remove(); self.$el.removeClass('ts-ni-input').insertBefore(self.$wrap); self.$wrap.remove(); if (self._origType === null) self.$el.removeAttr('type'); else self.$el.attr('type', self._origType); self.$el.removeData(instanceName); instanceCount--; if (instanceCount <= 0) { instanceCount = 0; const styleEl = document.getElementById(STYLE_ID); if (styleEl) styleEl.remove(); } return this; } } PluginNumberInput.defaults = { min: null, // read from the min attribute when not given max: null, step: 1, // read from the step attribute when not given precision: null, // decimals shown. Defaults to those of step and min layout: 'split', // 'split' (minus and plus at the sides) or 'stacked' (arrows at the right) size: 'md', // 'sm' | 'md' | 'lg' block: false, // fill the width of the parent unit: '', // text shown after the value, such as 'posts' repeat: true, // hold a button to repeat holdDelay: 420, // ms before repeating starts holdInterval: 90, // ms between repeats, speeding up holdMinInterval: 25, wheel: false, // change with the mouse wheel while focused snap: false, // round typed values to a multiple of step labels: { increase: 'Increase', decrease: 'Decrease' }, onChange: null // function (value) {} }; $.extend(themestrap, { PluginNumberInput }); $.fn.themestrapPluginNumberInput = function (opts) { const args = Array.prototype.slice.call(arguments, 1); return this.map(function () { const $this = $(this); const inst = $this.data(instanceName); if (inst) { if (typeof opts === 'string' && typeof inst[opts] === 'function') { return inst[opts].apply(inst, args); } return inst; } return new PluginNumberInput($this, typeof opts === 'object' ? opts : undefined); }); }; })).apply(this, [window.themestrap, jQuery]);