├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── es5-deprecated ├── index.html ├── src │ ├── bootstrap-input-spinner.js │ └── custom-editors.js └── test │ ├── Test.js │ ├── index.html │ └── test-pages │ ├── native-number-input.html │ ├── test-bug-98.html │ └── test.css ├── favicon.ico ├── index.html ├── package-lock.json ├── package.json └── src ├── InputSpinner.js └── custom-editors.js /.gitattributes: -------------------------------------------------------------------------------- 1 | src/bootstrap-input-spinner.js linguist-vendored=false 2 | index.html linguist-documentation 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.idea 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Stefan Haack - http://shaack.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bootstrap-input-spinner 2 | 3 | A Bootstrap extension to create input spinner elements for number input. 4 | 5 | > Note: bootstrap-input-spinner is now a ES6 module. You find the old ES5 version in the folder `es5-deprecated`. The ES5 version is not maintained anymore and will be removed in the future. 6 | 7 | ![bootstrap-input-spinner](https://shaack.com/projekte/assets/img/bootstrap-input-spinner-floatingpoint-and-i18n.png) 8 | *Examples with floating-point and german localization* 9 | 10 | ## References 11 | 12 | - [Demo page with usage examples](http://shaack.com/projekte/bootstrap-input-spinner/) 13 | - [GitHub repository](https://github.com/shaack/bootstrap-input-spinner/) 14 | - [npm package](https://www.npmjs.com/package/bootstrap-input-spinner) 15 | 16 | ### Older version, Bootstrap 4 compatible 17 | 18 | > The current is compatible with **Bootstrap 5**, but we remain a Bootstrap 4 compatible version with the branch 19 | > bootstrap4-compatible. 20 | > npm package versions 3.x are Bootstrap 5 compatible, versions 2.x Bootstrap 4 compatible. 21 | 22 | - [Bootstrap 4 compatible npm package](https://www.npmjs.com/package/bootstrap-input-spinner/v/2.1.2) 23 | 24 | ## Features 25 | 26 | The Bootstrap InputSpinner 27 | 28 | - is **mobile friendly** and **responsive**, 29 | - automatically changes the value when **holding a button**, 30 | - has **internationalized** number formatting, 31 | - allows setting a **prefix** or a **suffix** text in the input, 32 | - handles **`val()`** like the native element, 33 | - **dynamically handles** changing **attribute values** like `disabled` or `class`, 34 | - supports **templates** and **custom editors**, (*new!*) 35 | - dispatches **`change`** and **`input`** **events on value change** like the native element and 36 | - works **without extra css**, only Bootstrap 5 is needed. 37 | 38 | ## Quickstart 39 | 40 | ### Installation 41 | 42 | Current version, Bootstrap 5 compatible 43 | ```bash 44 | npm install bootstrap-input-spinner 45 | ``` 46 | Bootstrap 4 compatible version 47 | ```bash 48 | npm install bootstrap-input-spinner@2.2.0 49 | ``` 50 | 51 | 52 | Or just download the GitHub repository and include `src/bootstrap-input-spinner.js`. 53 | 54 | ### HTML 55 | 56 | Create the element in HTML. The attributes are compatible to the native `input[type="number"]` element. 57 | 58 | ```html 59 | 60 | ``` 61 | 62 | ### Script 63 | 64 | ```html 65 | 73 | ``` 74 | 75 | That's it. **No extra css needed**, just Bootstrap 5 and jQuery. (Note: jQuery will be removed in the future) 76 | 77 | ## API Reference 78 | 79 | ### HTML Attributes 80 | 81 | ```html 82 | 83 | ``` 84 | 85 | Use these attributes to configure the behaviour 86 | 87 | - `value` // starting value on element creation 88 | - `min` // minimum value when stepping 89 | - `max` // maximum value when stepping 90 | - `step` // step size 91 | - `inputmode` // the "inputmode" of the input, defaults to "decimal" (shows decimal keyboard on touch devices) 92 | - `data-decimals` // shown decimal places 93 | - `data-digit-grouping` // "false" to disable grouping (thousands separator), default is "true" 94 | - `data-prefix` // show a prefix text in the input element 95 | - `data-suffix` // show a suffix text in the input element 96 | 97 | The InputSpinner also handles the standard input attributes `required`, `disabled`, `readonly` and `placeholder`. 98 | 99 | ### Create an instance in JavaScript 100 | 101 | Use JavaScript to create the instance as a jQuery plugin. You may provide additional configuration in an object as a 102 | config parameter. 103 | 104 | ```js 105 | $(element).inputSpinner(config); 106 | ``` 107 | 108 | #### Configuration (props) 109 | 110 | The default configuration is 111 | 112 | ```javascript 113 | var props = { 114 | decrementButton: "", // button text 115 | incrementButton: "+", // .. 116 | groupClass: "", // css class of the resulting input-group 117 | buttonsClass: "btn-outline-secondary", 118 | buttonsWidth: "2.5rem", 119 | textAlign: "center", // alignment of the entered number 120 | autoDelay: 500, // ms threshold before auto value change 121 | autoInterval: 50, // speed of auto value change, set to `undefined` to disable auto-change 122 | buttonsOnly: false, // set this `true` to disable the possibility to enter or paste the number via keyboard 123 | keyboardStepping: true, // set this to `false` to disallow the use of the up and down arrow keys to step 124 | locale: navigator.language, // the locale, per default detected automatically from the browser 125 | editor: I18nEditor, // the editor (parsing and rendering of the input) 126 | template: // the template of the input 127 | '
' + 128 | '' + 129 | '' + 130 | '' + 131 | '
' 132 | } 133 | ``` 134 | 135 | ##### decrementButton, incrementButton 136 | 137 | HTML of the texts inside the buttons. 138 | 139 | ##### groupClass 140 | 141 | Additional css class for the `input-group` of the rendered Bootstrap input. 142 | 143 | ##### buttonsClass 144 | 145 | The css class of the buttons. Use it to style the increment and decrement buttons as 146 | described [here](https://getbootstrap.com/docs/4.0/components/buttons/). Maybe `buttonsClass: btn-primary` 147 | or `btn-success` or whatever type of buttons you want. 148 | 149 | ##### buttonsWidth 150 | 151 | The width of the increment and decrement buttons. 152 | 153 | ##### textAlign 154 | 155 | The text alignment inside the ``. 156 | 157 | ##### autoDelay 158 | 159 | The delay in ms after which the input automatically changes the value, when holding the increment or decrement button. 160 | 161 | ##### autoInterval 162 | 163 | Speed of the value change when holding the button in ms. A lower value makes it faster. 164 | 165 | ##### buttonsOnly 166 | 167 | In `buttonsOnly` mode (set `true`) no direct text input is allowed, the text-input gets the attribute `readonly`, but 168 | the plus and minus buttons still allow to change the value. 169 | 170 | ##### keyboardStepping 171 | 172 | In `keyboardStepping` mode (set `true`) allows the use of the up/down arrow keys to increase/decrease the number by the 173 | step. 174 | 175 | ##### locale 176 | 177 | Used to format the number in the UI. Detected automatically from the user's browser, can be set to "de", "en",… or " 178 | de_DE", "en_GB",…. 179 | 180 | ##### editor (*new!*) 181 | 182 | An Editor defines, how the input is parsed and rendered. The default editor of the spinner is the `I18nEditor`, which 183 | renders and parses an internationalized number value. There are custom editors in `/src/custom-editors.js`. An Editor 184 | must implement the two functions `parse(customValue)`, to parse the input to a number and `render(number)` to render the 185 | number to the spinner input. 186 | 187 | The simplest custom Editor is the `RawEditor`, it renders just the value und parses just the value, without any changes, 188 | like a native number input. It looks like this: 189 | 190 | ```javascript 191 | var RawEditor = function (props, element) { 192 | this.parse = function (customFormat) { 193 | // parse nothing 194 | return customFormat 195 | } 196 | this.render = function (number) { 197 | // render raw 198 | return number 199 | } 200 | } 201 | ``` 202 | 203 | `props` is the configuration of the spinner and `element` is the original HTML element. You can use these values for the 204 | configuration of the Editor, like in `I18nEditor`, which uses `props` for the language and `element` for the attributes. 205 | 206 | The `TimeEditor` renders and parses the number to time in hours and minutes, separated by a colon. 207 | 208 | ![bootstrap-input-spinner](https://shaack.com/projekte/assets/img/time-editor.png) 209 | *Supports custom editors to parse and render everything* 210 | 211 | ##### template 212 | 213 | To modify the look completely, you can use the template parameter. There is an example about this on the 214 | [Demo Page](http://shaack.com/projekte/bootstrap-input-spinner/). 215 | 216 | ### Programmatic change and read of value 217 | 218 | To change or read the value just use the jQuery `val()` function on the input, like this 219 | 220 | ```javascript 221 | var currentValue = $(element).val() // read 222 | $(element).val(newValue) // write 223 | ``` 224 | 225 | > **Hint:** Reading the value in vanilla JS with `element.value` will also work, but to set the value you have to use `element.setValue(newValue)` or `$(element).val(newValue)` 226 | 227 | ### Handling attributes 228 | 229 | The attributes 230 | `min`, `max`, `step`, `decimals`, `placeholder`, `required`, `disabled`, `readonly` and `class` 231 | are handled dynamically. The `class` attribute value is dynamically copied to the input element. 232 | 233 | #### Sizing 234 | 235 | If the original elements class is set to `form-control-sm` of `form-control-lg` the class of the resulting input-group 236 | is dynamically set to `input-group-sm` or `input-group-lg`. 237 | 238 | ### Events 239 | 240 | The InputSpinner handles `input` and `change` events like the native element. 241 | 242 | #### Event handling with vanilla JavaScript 243 | 244 | ```javascript 245 | element.addEventListener("change", function (event) { 246 | newValue = this.value 247 | }) 248 | ``` 249 | 250 | #### Event handling with jQuery syntax 251 | 252 | ```javascript 253 | $(element).on("change", function (event) { 254 | newValue = $(this).val() 255 | }) 256 | ``` 257 | 258 | ### Methods 259 | 260 | Methods are passed as string values instead of the options object. 261 | 262 | #### destroy 263 | 264 | Removes the InputSpinner and shows the original input element. 265 | 266 | ```javascript 267 | $(element).inputSpinner("destroy") 268 | ``` 269 | 270 | ## Minified version 271 | 272 | I don't provide a minified version because I think it should be up to the using programmer to create minified versions, 273 | with all the used script sources concatenated to one file. 274 | 275 | But, if you want it, it is easy to create your minified version with uglify: https://www.npmjs.com/package/uglify-js 276 | 277 | Just install uglify 278 | 279 | ```bash 280 | npm install uglify-js -g 281 | ``` 282 | 283 | and then in the src-folder 284 | 285 | ```bash 286 | uglifyjs bootstrap-input-spinner.js --compress --mangle > bootstrap-input-spinner.min.js 287 | ``` 288 | 289 | Violà! :) 290 | 291 | ## Browser support 292 | 293 | The spinner works in all modern browsers and Internet Explorer. Not tested with IE < 11. 294 | 295 | For older browsers (IE 9 or so), that doesn't support `Intl`, when you get an error message like 296 | **"Intl is not defined"** (See [issue #34](https://github.com/shaack/bootstrap-input-spinner/issues/34)), just use a 297 | shim or polyfill like [Intl.js](https://github.com/andyearnshaw/Intl.js), and it works. 298 | 299 | --- 300 | 301 | Find more high quality modules from [shaack.com](https://shaack.com) 302 | on [our projects page](https://shaack.com/works). 303 | -------------------------------------------------------------------------------- /es5-deprecated/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | bootstrap-input-spinner 7 | 8 | 9 | 10 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |

bootstrap-input-spinner

34 |

35 | A Bootstrap / jQuery plugin to create input spinner elements for number input, by 36 | shaack.com engineering. For now it needs jQuery, but I am working on it. 37 |

38 |

This version is compatible with Bootstrap 5, but we remain a Bootstrap 4 compatible version with the branch 39 | bootstrap4-compatible. npm versions 3.x are Bootstrap 5 compatible, versions 2.x Bootstrap 4 compatible.

40 |

41 | License: MIT 42 |

43 |

Features

44 |

The Bootstrap InputSpinner

45 | 73 |

Usage

74 |

75 | This script enables the InputSpinner for all inputs with type='number'. 76 | No extra css needed, just Bootstrap 5. 77 |

78 |
<script src="./src/bootstrap-input-spinner.js"></script>
 79 | <script>
 80 |     $("input[type='number']").inputSpinner()
 81 | </script>
82 |

Repository, documentation and npm package

83 |

Find the source code, more documentation and the npm package at

84 | 88 |

Examples

89 |

The following contains examples of the InputSpinner's main features

90 | 91 |

No attributes

92 |

93 | 94 |

95 |
<input type="number"/>
96 |

Simple Integer

97 |

98 | 99 |

100 |
<input type="number" value="500" min="0" max="1000" step="10"/>
102 |

Floating Point

103 |

104 | 105 |

106 |
<input type="number" value="4.5" data-decimals="2" min="0" max="9" step="0.1"/>
108 | 109 |

Handle change and input events and 110 | read the value from JavaScript 111 | with val()

112 |

113 | Type in a number to see the difference between change and input events. 114 |

115 |

116 | 117 |

118 |

119 | Value on input:
120 | Value on change: 121 |

122 | 137 |
var $changedInput = $("#changedInput")
138 | var $valueOnInput = $("#valueOnInput")
139 | var $valueOnChange = $("#valueOnChange")
140 | $changedInput.on("input", function (event) {
141 |     $valueOnInput.html($(event.target).val())
142 |     // or $valueOnInput.html(event.target.value) // in vanilla js
143 |     // or $valueOnInput.html($changedInput.val())
144 | })
145 | $changedInput.on("change", function (event) {
146 |     $valueOnChange.html($(event.target).val())
147 | })
148 |

Programmatic changing the value with val()

149 |

150 | 151 | 152 |

153 |

154 | 155 | 156 |

157 | 168 |
$inputNet.on("input", function (event) {
169 |     $inputGross.val($(event.target).val() * 1.19)
170 |     // or $inputGross[0].setValue(event.target.value * 1.19) // in vanilla js
171 |     // or $inputGross.val($inputNet.val() * 1.19)
172 |     // do all the same
173 | })
174 | $inputGross.on("input", function (event) {
175 |     $inputNet.val($(event.target).val() / 1.19)
176 | })
177 |

Attributes placeholder and required

178 |
179 |

180 | 181 |

182 |
<input placeholder="Enter a number" required type="number" value="" min="-100" max="100"/>
184 | 185 |
186 | 187 |

Attribute disabled, dynamically changing

188 |

Attributes are handled dynamically.

189 |
190 |

191 | 192 |

193 |
194 | 195 | 196 |
197 | 204 |
<input id="inputDisabled" disabled type="number" value="50"/>
205 | <div class="form-check">
206 |     <input type="checkbox" checked class="form-check-input" id="disabledSwitch"/>
207 |     <label class="form-check-label" for="disabledSwitch">Disabled</label>
208 | </div>
209 | <script>
210 |     var $inputDisabled = $("#inputDisabled")
211 |     var $disabledSwitch = $("#disabledSwitch")
212 |     $disabledSwitch.on("change", function () {
213 |         $inputDisabled.prop("disabled", $(this).prop("checked"))
214 |     })
215 | </script>
216 |
217 | 218 |

buttonsOnly mode and disabled autoInterval

219 |

220 | In buttonsOnly mode no direct text input is allowed, the text-input 221 | gets the attribute readonly. But the plus and minus buttons still allow to change the value. 222 |
autoInterval: undefined additionally disables the auto increase/decrease, when you hold the button. 223 |

224 |

225 | 226 |

227 |
$(".buttons-only").inputSpinner({buttonsOnly: true, autoInterval: undefined})
228 | 229 |

Dynamically handling of the class attribute

230 |

231 | 232 |

233 |

234 | 235 | 236 | Try to change the class to "is-invalid" or "text-info". 237 |

238 | 245 |
<input id="inputChangeClass" class="is-valid" type="number" value="50"/>
246 | <label for="classInput">CSS Class</label>
247 | <input id="classInput" type="text" class="form-control" value="is-valid"/>
248 | <script>
249 |     var $inputChangeClass = $("#inputChangeClass")
250 |     var $classInput = $("#classInput")
251 |     $classInput.on("input", function() {
252 |         $inputChangeClass.prop("class", this.value);
253 |     })
254 | </script>
255 | 256 |

Sizing

257 |

Sizing works out of the box. Just set the original inputs class to form-control-sm or 258 | form-control-lg, and 259 | the resulting group gets the class input-group-sm or input-group-lg.

260 |

261 | 262 | 263 |

264 |
<input class="form-control-sm" type="number" value="0.0" data-decimals="4" min="-1" max="1" step="0.0001"/>
265 |

266 | 267 | 268 |

269 |
<input class="form-control-lg" type="number" value="1000000" data-decimals="0" min="0" max="2000000" step="1"/>
270 | 271 | 272 |

Dynamically handling of min, max, 273 | step and data-decimals

274 |
275 |
276 |

277 | 278 | 279 |

280 |
281 |
282 |

283 | 284 | 285 |

286 |
287 |
288 |

289 | 290 | 291 |

292 |
293 |
294 |

295 | 296 | 297 |

298 |
299 |
300 |

301 | 302 | 303 |

304 | 327 |
var $minInput = $("#minInput")
328 | var $maxInput = $("#maxInput")
329 | var $stepInput = $("#stepInput")
330 | var $dataDecimalsInput = $("#dataDecimalsInput")
331 | var $minMaxTester = $("#minMaxTester")
332 | $minInput.on("change", function (event) {
333 |     $minMaxTester.attr("min", $minInput.val())
334 | })
335 | $maxInput.on("change", function (event) {
336 |     $minMaxTester.attr("max", $maxInput.val())
337 | })
338 | $stepInput.on("change", function (event) {
339 |     $minMaxTester.attr("step", $stepInput.val())
340 | })
341 | $dataDecimalsInput.on("change", function (event) {
342 |     $minMaxTester.attr("data-decimals", $dataDecimalsInput.val())
343 | })
344 | 
345 | 346 |

Prefix and Suffix

347 |

348 | 349 | 350 |

351 |
<input data-prefix="$" value="100.0" data-decimals="2" min="0" max="1000" step="0.1" type="number" />
352 |

353 | 354 | 355 |

356 |
<input data-suffix="°C" value="50" min="0" max="100" type="number" />
358 | 359 | 360 |

Looping the value

361 |

This input starts from 0 when reaching 360.

362 |

363 | 364 |

365 | 373 |
<input step="10" type="number" id="inputLoop" value="0" data-decimals="0" min="-10" max="360"/>
374 |

"Loop" the value between 0 and 360 with the change event in JavaScript.

375 |
var $inputLoop = $("#inputLoop")
376 | $inputLoop.on("input", function(event) {
377 |     var value = $inputLoop.val()
378 |     value = (value < 0) ? 360 + parseInt(value, 10) : value % 360
379 |     $inputLoop.val(value)
380 | })
381 | 382 |

Custom Editors

383 | 384 |

An Editor defines, how the input is parsed and rendered. The inputSpinner is shipped with some custom Editors in 385 | /src/custom-editors.js.

386 | 387 |

RawEditor

388 | 389 |

The simplest custom Editor is the RawEditor, it renders just the value und parses just the value, 390 | without any 391 | changes, like a native number input. No internationalization, no digit grouping.

392 |

393 | 394 |

395 | 398 |
$("#rawEditor").inputSpinner({editor: customEditors.RawEditor})
400 | 401 |

TimeEditor

402 | 403 |

The TimeEditor renders the number as time in hours and minutes, separated by a colon.

404 | 405 |
value:
406 | 407 | 415 |
$("#rawEditor").inputSpinner({editor: customEditors.TimeEditor})
417 | 418 |

Styling with templates (new!)

419 |

With the new templating feature, you can almost do anything, when it comes to layout.

420 |
How about... buttons right
421 |

422 | 423 |

424 |

425 | This is the template for "buttons right": 426 |

427 | 437 |
<div class="input-group ${groupClass}">
438 | <input type="text" inputmode="decimal" style="text-align: ${textAlign}" class="form-control"/>
439 | <button style="min-width: ${buttonsWidth}" class="btn btn-decrement ${buttonsClass}" type="button">${decrementButton}</button>
440 | <button style="min-width: ${buttonsWidth}" class="btn btn-increment ${buttonsClass}" type="button">${incrementButton}</button>
441 | </div>
442 |

You can... or must use the following variables in your template:

443 | 451 |

Provide the template as configuration parameter:

452 |
$(element).inputSpinner({template: '<div class...'})
453 | 454 |

Destroying the spinner

455 |

To Remove the InputSpinner and show the original input element, use

456 |
$(element).inputSpinner("destroy")
457 |
458 | 459 | 460 |
461 | 462 | 463 | 478 | 479 |
480 | 481 |
482 |

More Bootstrap components (from shaack.com)

483 | You may want to check out our further Bootstrap extensions, 484 | bootstrap-show-modal and 485 | bootstrap-detect-breakpoint. 486 |
487 |
488 |
489 |

If you find bugs or have suggestions, you may write an 490 | issue.

491 |

492 |
493 | 494 | 498 | 499 | 500 | -------------------------------------------------------------------------------- /es5-deprecated/src/bootstrap-input-spinner.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Author and copyright: Stefan Haack (https://shaack.com) 3 | * Repository: https://github.com/shaack/bootstrap-input-spinner 4 | * License: MIT, see file 'LICENSE' 5 | */ 6 | 7 | ;(function ($) { 8 | "use strict" 9 | 10 | // the default editor for parsing and rendering 11 | const I18nEditor = function (props, element) { 12 | const locale = props.locale || "en-US" 13 | 14 | this.parse = function (customFormat) { 15 | const numberFormat = new Intl.NumberFormat(locale) 16 | const thousandSeparator = numberFormat.format(11111).replace(/1/g, '') || '.' 17 | const decimalSeparator = numberFormat.format(1.1).replace(/1/g, '') 18 | return parseFloat(customFormat 19 | .replace(new RegExp(' ', 'g'), '') 20 | .replace(new RegExp('\\' + thousandSeparator, 'g'), '') 21 | .replace(new RegExp('\\' + decimalSeparator), '.') 22 | ) 23 | } 24 | 25 | this.render = function (number) { 26 | const decimals = parseInt(element.getAttribute("data-decimals")) || 0 27 | const digitGrouping = !(element.getAttribute("data-digit-grouping") === "false") 28 | const numberFormat = new Intl.NumberFormat(locale, { 29 | minimumFractionDigits: decimals, 30 | maximumFractionDigits: decimals, 31 | useGrouping: digitGrouping 32 | }) 33 | return numberFormat.format(number) 34 | } 35 | } 36 | 37 | let triggerKeyPressed = false 38 | const originalVal = $.fn.val 39 | $.fn.val = function (value) { 40 | if (arguments.length >= 1) { 41 | for (let i = 0; i < this.length; i++) { 42 | if (this[i]["bootstrap-input-spinner"] && this[i].setValue) { 43 | const element = this[i] 44 | setTimeout(function () { 45 | element.setValue(value) 46 | }) 47 | } 48 | } 49 | } 50 | return originalVal.apply(this, arguments) 51 | } 52 | 53 | $.fn.inputSpinner = function (methodOrProps) { 54 | 55 | if (methodOrProps === "destroy") { 56 | this.each(function () { 57 | if (this["bootstrap-input-spinner"]) { 58 | this.destroyInputSpinner() 59 | } else { 60 | console.warn("element", this, "is no bootstrap-input-spinner") 61 | } 62 | }) 63 | return this 64 | } 65 | 66 | const props = { 67 | decrementButton: "", // button text 68 | incrementButton: "+", // .. 69 | groupClass: "", // css class of the resulting input-group 70 | buttonsClass: "btn-outline-secondary", 71 | buttonsWidth: "2.5rem", 72 | textAlign: "center", // alignment of the entered number 73 | autoDelay: 500, // ms threshold before auto value change 74 | autoInterval: 50, // speed of auto value change, set to `undefined` to disable auto-change 75 | buttonsOnly: false, // set this `true` to disable the possibility to enter or paste the number via keyboard 76 | keyboardStepping: true, // set this to `false` to disallow the use of the up and down arrow keys to step 77 | locale: navigator.language, // the locale, per default detected automatically from the browser 78 | editor: I18nEditor, // the editor (parsing and rendering of the input) 79 | template: // the template of the input 80 | '
' + 81 | '' + 82 | '' + 83 | '' + 84 | '
' 85 | } 86 | 87 | for (let option in methodOrProps) { 88 | // noinspection JSUnfilteredForInLoop 89 | props[option] = methodOrProps[option] 90 | } 91 | 92 | const html = props.template 93 | .replace(/\${groupClass}/g, props.groupClass) 94 | .replace(/\${buttonsWidth}/g, props.buttonsWidth) 95 | .replace(/\${buttonsClass}/g, props.buttonsClass) 96 | .replace(/\${decrementButton}/g, props.decrementButton) 97 | .replace(/\${incrementButton}/g, props.incrementButton) 98 | .replace(/\${textAlign}/g, props.textAlign) 99 | 100 | this.each(function () { 101 | 102 | if (this["bootstrap-input-spinner"]) { 103 | console.warn("element", this, "is already a bootstrap-input-spinner") 104 | } else { 105 | 106 | var $original = $(this) 107 | $original[0]["bootstrap-input-spinner"] = true 108 | $original.hide() 109 | $original[0].inputSpinnerEditor = new props.editor(props, this) 110 | 111 | var autoDelayHandler = null 112 | var autoIntervalHandler = null 113 | 114 | var $inputGroup = $(html) 115 | var $buttonDecrement = $inputGroup.find(".btn-decrement") 116 | var $buttonIncrement = $inputGroup.find(".btn-increment") 117 | var $input = $inputGroup.find("input") 118 | var $label = $("label[for='" + $original.attr("id") + "']") 119 | if (!$label[0]) { 120 | $label = $original.closest("label") 121 | } 122 | 123 | var min = null 124 | var max = null 125 | var step = null 126 | 127 | updateAttributes() 128 | 129 | var value = parseFloat($original[0].value) 130 | let pointerState = false 131 | 132 | const prefix = $original.attr("data-prefix") || "" 133 | const suffix = $original.attr("data-suffix") || "" 134 | 135 | if (prefix) { 136 | const prefixElement = $('' + prefix + '') 137 | $inputGroup.find("input").before(prefixElement) 138 | } 139 | if (suffix) { 140 | const suffixElement = $('' + suffix + '') 141 | $inputGroup.find("input").after(suffixElement) 142 | } 143 | 144 | $original[0].setValue = function (newValue) { 145 | setValue(newValue) 146 | } 147 | $original[0].destroyInputSpinner = function () { 148 | destroy() 149 | } 150 | 151 | var observer = new MutationObserver(function () { 152 | updateAttributes() 153 | setValue(value, true) 154 | }) 155 | observer.observe($original[0], {attributes: true}) 156 | 157 | $original.after($inputGroup) 158 | 159 | setValue(value) 160 | 161 | $input.on("paste input change focusout", function (event) { 162 | let newValue = $input[0].value 163 | const focusOut = event.type === "focusout" 164 | newValue = $original[0].inputSpinnerEditor.parse(newValue) 165 | setValue(newValue, focusOut) 166 | dispatchEvent($original, event.type) 167 | if (props.keyboardStepping && focusOut) { // stop stepping 168 | resetTimer() 169 | } 170 | }).on("keydown", function (event) { 171 | if (props.keyboardStepping) { 172 | if (event.which === 38) { // up arrow pressed 173 | event.preventDefault() 174 | if (!$buttonDecrement.prop("disabled")) { 175 | stepHandling(step) 176 | } 177 | } else if (event.which === 40) { // down arrow pressed 178 | event.preventDefault() 179 | if (!$buttonIncrement.prop("disabled")) { 180 | stepHandling(-step) 181 | } 182 | } 183 | } 184 | }).on("keyup", function (event) { 185 | // up/down arrow released 186 | if (props.keyboardStepping && (event.which === 38 || event.which === 40)) { 187 | event.preventDefault() 188 | resetTimer() 189 | } 190 | }) 191 | 192 | // decrement button 193 | onPointerDown($buttonDecrement[0], function () { 194 | if (!$buttonDecrement.prop("disabled")) { 195 | pointerState = true 196 | stepHandling(-step) 197 | } 198 | }) 199 | // increment button 200 | onPointerDown($buttonIncrement[0], function () { 201 | if (!$buttonIncrement.prop("disabled")) { 202 | pointerState = true 203 | stepHandling(step) 204 | } 205 | }) 206 | onPointerUp(document.body, function () { 207 | if(pointerState === true) { 208 | resetTimer() 209 | dispatchEvent($original, "change") 210 | pointerState = false 211 | } 212 | }) 213 | } 214 | 215 | function setValue(newValue, updateInput) { 216 | if (updateInput === undefined) { 217 | updateInput = true 218 | } 219 | if (isNaN(newValue) || newValue === "") { 220 | $original[0].value = "" 221 | if (updateInput) { 222 | $input[0].value = "" 223 | } 224 | value = NaN 225 | } else { 226 | newValue = parseFloat(newValue) 227 | newValue = Math.min(Math.max(newValue, min), max) 228 | $original[0].value = newValue 229 | if (updateInput) { 230 | $input[0].value = $original[0].inputSpinnerEditor.render(newValue) 231 | } 232 | value = newValue 233 | } 234 | } 235 | 236 | function destroy() { 237 | $original.prop("required", $input.prop("required")) 238 | observer.disconnect() 239 | resetTimer() 240 | $input.off("paste input change focusout") 241 | $inputGroup.remove() 242 | $original.show() 243 | $original[0]["bootstrap-input-spinner"] = undefined 244 | if ($label[0]) { 245 | $label.attr("for", $original.attr("id")) 246 | } 247 | } 248 | 249 | function dispatchEvent($element, type) { 250 | if (type) { 251 | setTimeout(function () { 252 | let event 253 | if (typeof (Event) === 'function') { 254 | event = new Event(type, {bubbles: true}) 255 | } else { // IE 256 | event = document.createEvent('Event') 257 | event.initEvent(type, true, true) 258 | } 259 | $element[0].dispatchEvent(event) 260 | }) 261 | } 262 | } 263 | 264 | function stepHandling(step) { 265 | calcStep(step) 266 | resetTimer() 267 | if(props.autoInterval !== undefined) { 268 | autoDelayHandler = setTimeout(function () { 269 | autoIntervalHandler = setInterval(function () { 270 | calcStep(step) 271 | }, props.autoInterval) 272 | }, props.autoDelay) 273 | } 274 | } 275 | 276 | function calcStep(step) { 277 | if (isNaN(value)) { 278 | value = 0 279 | } 280 | setValue(Math.round(value / step) * step + step) 281 | dispatchEvent($original, "input") 282 | } 283 | 284 | function resetTimer() { 285 | clearTimeout(autoDelayHandler) 286 | clearTimeout(autoIntervalHandler) 287 | } 288 | 289 | function updateAttributes() { 290 | // copy properties from original to the new input 291 | if ($original.prop("required")) { 292 | $input.prop("required", $original.prop("required")) 293 | $original.removeAttr('required') 294 | } 295 | $input.prop("placeholder", $original.prop("placeholder")) 296 | $input.attr("inputmode", $original.attr("inputmode") || "decimal") 297 | const disabled = $original.prop("disabled") 298 | const readonly = $original.prop("readonly") 299 | $input.prop("disabled", disabled) 300 | $input.prop("readonly", readonly || props.buttonsOnly) 301 | $buttonIncrement.prop("disabled", disabled || readonly) 302 | $buttonDecrement.prop("disabled", disabled || readonly) 303 | if (disabled || readonly) { 304 | resetTimer() 305 | } 306 | const originalClass = $original.prop("class") 307 | let groupClass = "" 308 | // sizing 309 | if (/form-control-sm/g.test(originalClass)) { 310 | groupClass = "input-group-sm" 311 | } else if (/form-control-lg/g.test(originalClass)) { 312 | groupClass = "input-group-lg" 313 | } 314 | const inputClass = originalClass.replace(/form-control(-(sm|lg))?/g, "") 315 | $inputGroup.prop("class", "input-group " + groupClass + " " + props.groupClass) 316 | $input.prop("class", "form-control " + inputClass) 317 | 318 | // update the main attributes 319 | min = isNaN($original.prop("min")) || $original.prop("min") === "" ? -Infinity : parseFloat($original.prop("min")) 320 | max = isNaN($original.prop("max")) || $original.prop("max") === "" ? Infinity : parseFloat($original.prop("max")) 321 | step = parseFloat($original.prop("step")) || 1 322 | if ($original.attr("hidden")) { 323 | $inputGroup.attr("hidden", $original.attr("hidden")) 324 | } else { 325 | $inputGroup.removeAttr("hidden") 326 | } 327 | if ($original.attr("id")) { 328 | $input.attr("id", $original.attr("id") + ":input_spinner") // give the spinner a unique id... 329 | if ($label[0]) { 330 | $label.attr("for", $input.attr("id")) // ...to rewire the label 331 | } 332 | } 333 | } 334 | }) 335 | 336 | return this 337 | } 338 | 339 | function onPointerUp(element, callback) { 340 | element.addEventListener("mouseup", function (e) { 341 | callback(e) 342 | }) 343 | element.addEventListener("touchend", function (e) { 344 | callback(e) 345 | }) 346 | element.addEventListener("keyup", function (e) { 347 | if ((e.keyCode === 32 || e.keyCode === 13)) { 348 | triggerKeyPressed = false 349 | callback(e) 350 | } 351 | }) 352 | } 353 | 354 | function onPointerDown(element, callback) { 355 | element.addEventListener("mousedown", function (e) { 356 | if (e.button === 0) { 357 | e.preventDefault() 358 | callback(e) 359 | } 360 | }) 361 | element.addEventListener("touchstart", function (e) { 362 | if (e.cancelable) { 363 | e.preventDefault() 364 | } 365 | callback(e) 366 | }, {passive: false}) 367 | element.addEventListener("keydown", function (e) { 368 | if ((e.keyCode === 32 || e.keyCode === 13) && !triggerKeyPressed) { 369 | triggerKeyPressed = true 370 | callback(e) 371 | } 372 | }) 373 | } 374 | 375 | }(jQuery)) 376 | -------------------------------------------------------------------------------- /es5-deprecated/src/custom-editors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Author and copyright: Stefan Haack (https://shaack.com) 3 | * Repository: https://github.com/shaack/bootstrap-input-spinner 4 | * License: MIT, see file 'LICENSE' 5 | */ 6 | const customEditors = { 7 | RawEditor: function (props, element) { 8 | this.parse = function (customFormat) { 9 | // parse nothing 10 | return customFormat 11 | } 12 | this.render = function (number) { 13 | // render raw 14 | return number 15 | } 16 | }, 17 | TimeEditor: function (props, element) { 18 | // could be implemented more elegant maybe, but works 19 | this.parse = function (customFormat) { 20 | let trimmed = customFormat.trim() 21 | let sign = 1 22 | if (trimmed.charAt(0) === "-") { 23 | sign = -1 24 | trimmed = trimmed.replace("-", "") 25 | } 26 | const parts = trimmed.split(":") 27 | let hours = 0, minutes 28 | if (parts[1]) { 29 | hours = parseInt(parts[0], 10) 30 | minutes = parseInt(parts[1], 10) 31 | } else { 32 | minutes = parseInt(parts[0], 10) 33 | } 34 | return (hours * 60 + minutes) * sign 35 | } 36 | this.render = function (number) { 37 | let minutes = Math.abs(number % 60) 38 | if (minutes < 10) { 39 | minutes = "0" + minutes 40 | } 41 | let hours 42 | if (number >= 0) { 43 | hours = Math.floor(number / 60) 44 | return hours + ":" + minutes 45 | } else { 46 | hours = Math.ceil(number / 60) 47 | return "-" + Math.abs(hours) + ":" + minutes 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /es5-deprecated/test/Test.js: -------------------------------------------------------------------------------- 1 | import {describe, it, assert} from "../node_modules/teevi/src/teevi.js" 2 | 3 | describe('bootstrap-input-spinner', function () { 4 | it('Should display and destroy the spinner', function () { 5 | addInput() 6 | const $input = $("input[type='number']") 7 | $input.inputSpinner() 8 | $input.inputSpinner("destroy") 9 | }) 10 | }) 11 | 12 | function addInput() { 13 | var testContainer = document.getElementById("testContainer") 14 | var input = document.createElement("input") 15 | input.type = "number" 16 | testContainer.append(input) 17 | } 18 | -------------------------------------------------------------------------------- /es5-deprecated/test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | bootstrap-input-spinner test 7 | 8 | 9 |
10 | 11 | 12 | 13 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /es5-deprecated/test/test-pages/native-number-input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | input type="number" 5 | 6 | 7 | 8 | 9 |

This is for testing the behaviour of the native number input, compared to the bootstrap-input-spinner.

10 | First: Native Element, second: bootstrap-input-spinner. 11 | 12 |

No attributes

13 |

14 | 15 | 16 |

17 | 18 | 19 |

step 1, value 1.01

20 |

21 | 22 | 23 |

24 | 25 |

step 2, value 6, min 5.01

26 |

27 | 28 | 29 |

30 | 31 |

step 2, min 0, max 9.5

32 |

33 | 34 | 35 |

36 | 37 |

step 10, min 1, max 49

38 |

39 | 40 | 41 |

42 |

step 20, min -99, max 100

43 |

44 | 45 | 46 |

47 | 48 | 49 | 50 | 51 | 54 | 55 | -------------------------------------------------------------------------------- /es5-deprecated/test/test-pages/test-bug-98.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test bug #98 5 | 6 | 7 | 8 | 9 | 10 |

Test page for fixing issue #98

11 |
12 |
13 | 14 |
15 | 16 |
17 |
18 |
19 |
20 | 21 |
22 | 23 |
24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 |
32 |
33 | 34 | 35 | 36 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /es5-deprecated/test/test-pages/test.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 2rem; 3 | } 4 | 5 | h1 { 6 | margin-bottom: 2rem; 7 | } 8 | 9 | h3 { 10 | margin-top: 2rem; 11 | } 12 | 13 | .input-group, input.test-value-input { 14 | max-width: 250px; 15 | } 16 | 17 | p input:first-child { 18 | margin-bottom: 0.5rem; 19 | width: 250px; 20 | } -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaack/bootstrap-input-spinner/b6adcf3a85b1134313ed843d050811a7c1b9e4e8/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | bootstrap-input-spinner 7 | 8 | 9 | 10 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 |
34 |

bootstrap-input-spinner

35 |

36 | A Bootstrap / jQuery plugin to create input spinner elements for number input, by 37 | shaack.com engineering. For now it needs jQuery, but I am working on it. 38 |

39 |

This version is compatible with Bootstrap 5, but we remain a Bootstrap 4 compatible version with the branch 40 | bootstrap4-compatible. 41 | npm versions 3.x are Bootstrap 5 compatible, versions 2.x Bootstrap 4 compatible.

42 |

43 | License: MIT 44 |

45 |

Features

46 |

The Bootstrap InputSpinner

47 | 75 |

Usage

76 |

77 | This script enables the InputSpinner for all inputs with type='number'. 78 | No extra css needed, just Bootstrap 5. 79 |

80 |
<script src="./src/InputSpinner.js"></script>
 81 | <script>
 82 |     $("input[type='number']").inputSpinner()
 83 | </script>
84 |

Repository, documentation and npm package

85 |

Find the source code, more documentation and the npm package at

86 | 90 |

Examples

91 |

The following contains examples of the InputSpinner's main features

92 | 93 |

No attributes

94 |

95 | 96 |

97 |
<input type="number"/>
98 |

Simple Integer

99 |

100 | 101 |

102 |
<input type="number" value="500" min="0" max="1000" step="10"/>
104 |

Floating Point

105 |

106 | 107 |

108 |
<input type="number" value="4.5" data-decimals="2" min="0" max="9" step="0.1"/>
110 | 111 |

Handle change and input events and 112 | read the value from JavaScript 113 | with val()

114 |

115 | Type in a number to see the difference between change and input events. 116 |

117 |

118 | 119 |

120 |

121 | Value on input:
122 | Value on change: 123 |

124 | 139 |
var $changedInput = $("#changedInput")
140 | var $valueOnInput = $("#valueOnInput")
141 | var $valueOnChange = $("#valueOnChange")
142 | $changedInput.on("input", function (event) {
143 |     $valueOnInput.html($(event.target).val())
144 |     // or $valueOnInput.html(event.target.value) // in vanilla js
145 |     // or $valueOnInput.html($changedInput.val())
146 | })
147 | $changedInput.on("change", function (event) {
148 |     $valueOnChange.html($(event.target).val())
149 | })
150 |

Programmatic changing the value with val()

151 |

152 | 153 | 154 |

155 |

156 | 157 | 158 |

159 | 170 |
$inputNet.on("input", function (event) {
171 |     $inputGross.val($(event.target).val() * 1.19)
172 |     // or $inputGross[0].setValue(event.target.value * 1.19) // in vanilla js
173 |     // or $inputGross.val($inputNet.val() * 1.19)
174 |     // do all the same
175 | })
176 | $inputGross.on("input", function (event) {
177 |     $inputNet.val($(event.target).val() / 1.19)
178 | })
179 |

Attributes placeholder and required

180 |
181 |

182 | 183 |

184 |
<input placeholder="Enter a number" required type="number" value="" min="-100" max="100"/>
186 | 187 |
188 | 189 |

Attribute disabled, dynamically changing

190 |

Attributes are handled dynamically.

191 |
192 |

193 | 194 |

195 |
196 | 197 | 198 |
199 | 206 |
<input id="inputDisabled" disabled type="number" value="50"/>
207 | <div class="form-check">
208 |     <input type="checkbox" checked class="form-check-input" id="disabledSwitch"/>
209 |     <label class="form-check-label" for="disabledSwitch">Disabled</label>
210 | </div>
211 | <script>
212 |     var $inputDisabled = $("#inputDisabled")
213 |     var $disabledSwitch = $("#disabledSwitch")
214 |     $disabledSwitch.on("change", function () {
215 |         $inputDisabled.prop("disabled", $(this).prop("checked"))
216 |     })
217 | </script>
218 |
219 | 220 |

buttonsOnly mode and disabled autoInterval

221 |

222 | In buttonsOnly mode no direct text input is allowed, the text-input 223 | gets the attribute readonly. But the plus and minus buttons still allow to change the value. 224 |
autoInterval: undefined additionally disables the auto increase/decrease, when you hold the 225 | button. 226 |

227 |

228 | 229 |

230 | 236 |
$(".buttons-only").inputSpinner({buttonsOnly: true, autoInterval: undefined})
237 | 238 |

Dynamically handling of the class attribute

239 |

240 | 241 |

242 |

243 | 244 | 245 | Try to change the class to "is-invalid" or "text-info". 246 |

247 | 254 |
<input id="inputChangeClass" class="is-valid" type="number" value="50"/>
255 | <label for="classInput">CSS Class</label>
256 | <input id="classInput" type="text" class="form-control" value="is-valid"/>
257 | <script>
258 |     var $inputChangeClass = $("#inputChangeClass")
259 |     var $classInput = $("#classInput")
260 |     $classInput.on("input", function() {
261 |         $inputChangeClass.prop("class", this.value);
262 |     })
263 | </script>
264 | 265 |

Sizing

266 |

Sizing works out of the box. Just set the original inputs class to form-control-sm or 267 | form-control-lg, and 268 | the resulting group gets the class input-group-sm or input-group-lg.

269 |

270 | 271 | 273 |

274 |
<input class="form-control-sm" type="number" value="0.0" data-decimals="4" min="-1" max="1" step="0.0001"/>
275 |

276 | 277 | 279 |

280 |
<input class="form-control-lg" type="number" value="1000000" data-decimals="0" min="0" max="2000000" step="1"/>
281 | 282 | 283 |

Dynamically handling of min, max, 284 | step and data-decimals

285 |
286 |
287 |

288 | 289 | 290 |

291 |
292 |
293 |

294 | 295 | 296 |

297 |
298 |
299 |

300 | 301 | 302 |

303 |
304 |
305 |

306 | 307 | 308 |

309 |
310 |
311 |

312 | 313 | 314 |

315 | 338 |
var $minInput = $("#minInput")
339 | var $maxInput = $("#maxInput")
340 | var $stepInput = $("#stepInput")
341 | var $dataDecimalsInput = $("#dataDecimalsInput")
342 | var $minMaxTester = $("#minMaxTester")
343 | $minInput.on("change", function (event) {
344 |     $minMaxTester.attr("min", $minInput.val())
345 | })
346 | $maxInput.on("change", function (event) {
347 |     $minMaxTester.attr("max", $maxInput.val())
348 | })
349 | $stepInput.on("change", function (event) {
350 |     $minMaxTester.attr("step", $stepInput.val())
351 | })
352 | $dataDecimalsInput.on("change", function (event) {
353 |     $minMaxTester.attr("data-decimals", $dataDecimalsInput.val())
354 | })
355 | 
356 | 357 |

Prefix and Suffix

358 |

359 | 360 | 362 |

363 |
<input data-prefix="$" value="100.0" data-decimals="2" min="0" max="1000" step="0.1" type="number" />
364 |

365 | 366 | 367 |

368 |
<input data-suffix="°C" value="50" min="0" max="100" type="number" />
370 | 371 | 372 |

Looping the value

373 |

This input starts from 0 when reaching 360.

374 |

375 | 376 |

377 | 385 |
<input step="10" type="number" id="inputLoop" value="0" data-decimals="0" min="-10" max="360"/>
386 |

"Loop" the value between 0 and 360 with the change event in JavaScript.

387 |
var $inputLoop = $("#inputLoop")
388 | $inputLoop.on("input", function(event) {
389 |     var value = $inputLoop.val()
390 |     value = (value < 0) ? 360 + parseInt(value, 10) : value % 360
391 |     $inputLoop.val(value)
392 | })
393 | 394 |

Custom Editors

395 | 396 |

An Editor defines, how the input is parsed and rendered. The inputSpinner is shipped with some custom Editors in 397 | /src/custom-editors.js.

398 | 399 |

RawEditor

400 | 401 |

The simplest custom Editor is the RawEditor, it renders just the value und parses just the value, 402 | without any 403 | changes, like a native number input. No internationalization, no digit grouping.

404 |

405 | 406 |

407 | 413 |
$("#rawEditor").inputSpinner({editor: customEditors.RawEditor})
415 | 416 |

TimeEditor

417 | 418 |

The TimeEditor renders the number as time in hours and minutes, separated by a colon.

419 | 420 |
value:
421 | 431 |
$("#rawEditor").inputSpinner({editor: customEditors.TimeEditor})
433 | 434 |

Styling with templates (new!)

435 |

With the new templating feature, you can almost do anything, when it comes to layout.

436 |
How about... buttons right
437 |

438 | 439 |

440 |

441 | This is the template for "buttons right": 442 |

443 | 456 |
<div class="input-group ${groupClass}">
457 | <input type="text" inputmode="decimal" style="text-align: ${textAlign}" class="form-control"/>
458 | <button style="min-width: ${buttonsWidth}" class="btn btn-decrement ${buttonsClass}" type="button">${decrementButton}</button>
459 | <button style="min-width: ${buttonsWidth}" class="btn btn-increment ${buttonsClass}" type="button">${incrementButton}</button>
460 | </div>
461 |

You can... or must use the following variables in your template:

462 | 470 |

Provide the template as configuration parameter:

471 |
$(element).inputSpinner({template: '<div class...'})
472 | 473 |

Destroying the spinner

474 |

To Remove the InputSpinner and show the original input element, use

475 |
$(element).inputSpinner("destroy")
476 |
477 | 478 | 479 |
480 | 481 | 482 | 498 | 499 |
500 | 501 |
502 |

More Bootstrap components (from shaack.com)

503 | You may want to check out our further Bootstrap extensions, 504 | bootstrap-show-modal and 505 | bootstrap-detect-breakpoint. 506 |
507 |
508 |
509 |

If you find bugs or have suggestions, you may write an 510 | issue.

511 |

512 |
513 | 514 | 522 | 523 | 524 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bootstrap-input-spinner", 3 | "version": "4.0.4", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "bootstrap-input-spinner", 9 | "version": "4.0.4", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prismjs": "^1.29.0", 13 | "teevi": "^2.2.4" 14 | } 15 | }, 16 | "node_modules/prismjs": { 17 | "version": "1.29.0", 18 | "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", 19 | "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", 20 | "dev": true, 21 | "engines": { 22 | "node": ">=6" 23 | } 24 | }, 25 | "node_modules/teevi": { 26 | "version": "2.2.4", 27 | "resolved": "https://registry.npmjs.org/teevi/-/teevi-2.2.4.tgz", 28 | "integrity": "sha512-dlWBYsYlb2D/CppMenVKXpD/vCiVwlgFwfjofWqunziq8O1OeYhnXK3dnYOLRmNLhimsk/a3h+w5e03BnIExKQ==", 29 | "dev": true 30 | } 31 | }, 32 | "dependencies": { 33 | "prismjs": { 34 | "version": "1.29.0", 35 | "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", 36 | "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", 37 | "dev": true 38 | }, 39 | "teevi": { 40 | "version": "2.2.4", 41 | "resolved": "https://registry.npmjs.org/teevi/-/teevi-2.2.4.tgz", 42 | "integrity": "sha512-dlWBYsYlb2D/CppMenVKXpD/vCiVwlgFwfjofWqunziq8O1OeYhnXK3dnYOLRmNLhimsk/a3h+w5e03BnIExKQ==", 43 | "dev": true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bootstrap-input-spinner", 3 | "version": "4.0.4", 4 | "description": "A Bootstrap 5 / jQuery plugin to create input spinner elements for number input.", 5 | "browser": "./src/bootstrap-input-spinner.js", 6 | "scripts": { 7 | "test": "tput setaf 4;echo open test/index.html in your browser for testing." 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/shaack/bootstrap-input-spinner.git" 12 | }, 13 | "keywords": [ 14 | "Bootstrap 5", 15 | "Bootstrap", 16 | "Widget", 17 | "Html", 18 | "Input", 19 | "UI" 20 | ], 21 | "author": "Stefan Haack (https://shaack.com)", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/shaack/bootstrap-input-spinner/issues" 25 | }, 26 | "homepage": "https://shaack.com/en/open-source-components", 27 | "devDependencies": { 28 | "prismjs": "^1.29.0", 29 | "teevi": "^2.2.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/InputSpinner.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Author and copyright: Stefan Haack (https://shaack.com) 3 | * Repository: https://github.com/shaack/bootstrap-input-spinner 4 | * License: MIT, see file 'LICENSE' 5 | */ 6 | 7 | // the default editor for parsing and rendering 8 | const I18nEditor = function (props, element) { 9 | const locale = props.locale || "en-US" 10 | 11 | this.parse = function (customFormat) { 12 | const numberFormat = new Intl.NumberFormat(locale) 13 | const thousandSeparator = numberFormat.format(11111).replace(/1/g, '') || '.' 14 | const decimalSeparator = numberFormat.format(1.1).replace(/1/g, '') 15 | return parseFloat(customFormat 16 | .replace(new RegExp(' ', 'g'), '') 17 | .replace(new RegExp('\\' + thousandSeparator, 'g'), '') 18 | .replace(new RegExp('\\' + decimalSeparator), '.') 19 | ) 20 | } 21 | 22 | this.render = function (number) { 23 | const decimals = parseInt(element.getAttribute("data-decimals")) || 0 24 | const digitGrouping = !(element.getAttribute("data-digit-grouping") === "false") 25 | const numberFormat = new Intl.NumberFormat(locale, { 26 | minimumFractionDigits: decimals, 27 | maximumFractionDigits: decimals, 28 | useGrouping: digitGrouping 29 | }) 30 | return numberFormat.format(number) 31 | } 32 | } 33 | 34 | let triggerKeyPressed = false 35 | const originalVal = $.fn.val 36 | $.fn.val = function (value) { 37 | if (arguments.length >= 1) { 38 | for (let i = 0; i < this.length; i++) { 39 | if (this[i]["bootstrap-input-spinner"] && this[i].setValue) { 40 | const element = this[i] 41 | setTimeout(function () { 42 | element.setValue(value) 43 | }) 44 | } 45 | } 46 | } 47 | return originalVal.apply(this, arguments) 48 | } 49 | 50 | export class InputSpinner { 51 | 52 | constructor(element, props) { 53 | 54 | const self = this 55 | this.element = element 56 | /* 57 | if (props === "destroy") { // todo replace with method 58 | this.each(function () { 59 | if (this["bootstrap-input-spinner"]) { 60 | this.destroyInputSpinner() 61 | } else { 62 | console.warn("element", this, "is no bootstrap-input-spinner") 63 | } 64 | }) 65 | return this 66 | } 67 | */ 68 | 69 | this.props = { 70 | decrementButton: "", // button text 71 | incrementButton: "+", // .. 72 | groupClass: "", // css class of the resulting input-group 73 | buttonsClass: "btn-outline-secondary", 74 | buttonsWidth: "2.5rem", 75 | textAlign: "center", // alignment of the entered number 76 | autoDelay: 500, // ms threshold before auto value change 77 | autoInterval: 50, // speed of auto value change, set to `undefined` to disable auto-change 78 | buttonsOnly: false, // set this `true` to disable the possibility to enter or paste the number via keyboard 79 | keyboardStepping: true, // set this to `false` to disallow the use of the up and down arrow keys to step 80 | locale: navigator.language, // the locale, per default detected automatically from the browser 81 | editor: I18nEditor, // the editor (parsing and rendering of the input) 82 | template: // the template of the input 83 | '
' + 84 | '' + 85 | '' + 86 | '' + 87 | '
' 88 | } 89 | 90 | Object.assign(this.props, props) 91 | 92 | const html = this.props.template 93 | .replace(/\${groupClass}/g, this.props.groupClass) 94 | .replace(/\${buttonsWidth}/g, this.props.buttonsWidth) 95 | .replace(/\${buttonsClass}/g, this.props.buttonsClass) 96 | .replace(/\${decrementButton}/g, this.props.decrementButton) 97 | .replace(/\${incrementButton}/g, this.props.incrementButton) 98 | .replace(/\${textAlign}/g, this.props.textAlign) 99 | 100 | if (this.element["bootstrap-input-spinner"]) { 101 | console.warn("element", this.element, "is already a bootstrap-input-spinner") 102 | } else { 103 | 104 | this.$original = $(this.element) 105 | this.$original[0]["bootstrap-input-spinner"] = true 106 | this.$original.hide() 107 | this.$original[0].inputSpinnerEditor = new this.props.editor(this.props, this.element) 108 | 109 | this.autoDelayHandler = null 110 | this.autoIntervalHandler = null 111 | 112 | this.$inputGroup = $(html) 113 | this.$buttonDecrement = this.$inputGroup.find(".btn-decrement") 114 | this.$buttonIncrement = this.$inputGroup.find(".btn-increment") 115 | this.$input = this.$inputGroup.find("input") 116 | this.$label = $("label[for='" + this.$original.attr("id") + "']") 117 | if (!this.$label[0]) { 118 | this.$label = this.$original.closest("label") 119 | } 120 | 121 | this.min = null 122 | this.max = null 123 | this.step = null 124 | 125 | updateAttributes() 126 | 127 | this.value = parseFloat(this.$original[0].value) 128 | let pointerState = false 129 | 130 | const prefix = this.$original.attr("data-prefix") || "" 131 | const suffix = this.$original.attr("data-suffix") || "" 132 | 133 | if (prefix) { 134 | const prefixElement = $('' + prefix + '') 135 | this.$inputGroup.find("input").before(prefixElement) 136 | } 137 | if (suffix) { 138 | const suffixElement = $('' + suffix + '') 139 | this.$inputGroup.find("input").after(suffixElement) 140 | } 141 | 142 | this.$original[0].setValue = function (newValue) { 143 | setValue(newValue) 144 | } 145 | this.$original[0].destroyInputSpinner = function () { 146 | destroy() 147 | } 148 | 149 | this.observer = new MutationObserver(function () { 150 | updateAttributes() 151 | setValue(self.value, true) 152 | }) 153 | this.observer.observe(this.$original[0], {attributes: true}) 154 | 155 | this.$original.after(this.$inputGroup) 156 | 157 | setValue(this.value) 158 | 159 | this.$input.on("paste input change focusout", function (event) { 160 | let newValue = self.$input[0].value 161 | const focusOut = event.type === "focusout" 162 | if(!self.props.buttonsOnly) { 163 | newValue = self.$original[0].inputSpinnerEditor.parse(newValue) 164 | setValue(newValue, focusOut) 165 | dispatchEvent(self.$original, event.type) 166 | } 167 | if (self.props.keyboardStepping && focusOut) { // stop stepping 168 | resetTimer() 169 | } 170 | }).on("keydown", function (event) { 171 | if (self.props.keyboardStepping) { 172 | if (event.which === 38) { // up arrow pressed 173 | event.preventDefault() 174 | if (!self.$buttonDecrement.prop("disabled")) { 175 | stepHandling(self.step) 176 | } 177 | } else if (event.which === 40) { // down arrow pressed 178 | event.preventDefault() 179 | if (!self.$buttonIncrement.prop("disabled")) { 180 | stepHandling(-self.step) 181 | } 182 | } 183 | } 184 | }).on("keyup", function (event) { 185 | // up/down arrow released 186 | if (self.props.keyboardStepping && (event.which === 38 || event.which === 40)) { 187 | event.preventDefault() 188 | resetTimer() 189 | } 190 | }) 191 | 192 | // decrement button 193 | onPointerDown(self.$buttonDecrement[0], function () { 194 | if (!self.$buttonDecrement.prop("disabled")) { 195 | pointerState = true 196 | stepHandling(-self.step) 197 | } 198 | }) 199 | // increment button 200 | onPointerDown(self.$buttonIncrement[0], function () { 201 | if (!self.$buttonIncrement.prop("disabled")) { 202 | pointerState = true 203 | stepHandling(self.step) 204 | } 205 | }) 206 | onPointerUp(document.body, function () { 207 | if (pointerState === true) { 208 | resetTimer() 209 | dispatchEvent(self.$original, "change") 210 | pointerState = false 211 | } 212 | }) 213 | } 214 | 215 | function setValue(newValue, updateInput) { 216 | if (updateInput === undefined) { 217 | updateInput = true 218 | } 219 | if (isNaN(newValue) || newValue === "") { 220 | self.$original[0].value = "" 221 | if (updateInput) { 222 | self.$input[0].value = "" 223 | } 224 | self.value = NaN 225 | } else { 226 | newValue = parseFloat(newValue) 227 | newValue = Math.min(Math.max(newValue, self.min), self.max) 228 | self.$original[0].value = newValue 229 | if (updateInput) { 230 | self.$input[0].value = self.$original[0].inputSpinnerEditor.render(newValue) 231 | } 232 | self.value = newValue 233 | } 234 | } 235 | 236 | function destroy() { 237 | self.$original.prop("required", self.$input.prop("required")) 238 | self.observer.disconnect() 239 | resetTimer() 240 | self.$input.off("paste input change focusout") 241 | self.$inputGroup.remove() 242 | self.$original.show() 243 | self.$original[0]["bootstrap-input-spinner"] = undefined 244 | if (self.$label[0]) { 245 | self.$label.attr("for", self.$original.attr("id")) 246 | } 247 | } 248 | 249 | function dispatchEvent($element, type) { 250 | if (type) { 251 | setTimeout(function () { 252 | let event 253 | if (typeof (Event) === 'function') { 254 | event = new Event(type, {bubbles: true}) 255 | } else { // IE todo remove 256 | event = document.createEvent('Event') 257 | event.initEvent(type, true, true) 258 | } 259 | $element[0].dispatchEvent(event) 260 | }) 261 | } 262 | } 263 | 264 | function stepHandling(step) { 265 | calcStep(step) 266 | resetTimer() 267 | if (self.props.autoInterval !== undefined) { 268 | self.autoDelayHandler = setTimeout(function () { 269 | self.autoIntervalHandler = setInterval(function () { 270 | calcStep(step) 271 | }, self.props.autoInterval) 272 | }, self.props.autoDelay) 273 | } 274 | } 275 | 276 | function calcStep(step) { 277 | if (isNaN(self.value)) { 278 | self.value = 0 279 | } 280 | setValue(Math.round(self.value / step) * step + step) 281 | dispatchEvent(self.$original, "input") 282 | } 283 | 284 | function resetTimer() { 285 | clearTimeout(self.autoDelayHandler) 286 | clearTimeout(self.autoIntervalHandler) 287 | } 288 | 289 | function updateAttributes() { 290 | // copy properties from original to the new input 291 | if (self.$original.prop("required")) { 292 | self.$input.prop("required", self.$original.prop("required")) 293 | self.$original.removeAttr('required') 294 | } 295 | self.$input.prop("placeholder", self.$original.prop("placeholder")) 296 | self.$input.attr("inputmode", self.$original.attr("inputmode") || "decimal") 297 | const disabled = self.$original.prop("disabled") 298 | const readonly = self.$original.prop("readonly") 299 | self.$input.prop("disabled", disabled) 300 | self.$input.prop("readonly", readonly || self.props.buttonsOnly) 301 | self.$buttonIncrement.prop("disabled", disabled || readonly) 302 | self.$buttonDecrement.prop("disabled", disabled || readonly) 303 | if (disabled || readonly) { 304 | resetTimer() 305 | } 306 | const originalClass = self.$original.prop("class") 307 | let groupClass = "" 308 | // sizing 309 | if (/form-control-sm/g.test(originalClass)) { 310 | groupClass = "input-group-sm" 311 | } else if (/form-control-lg/g.test(originalClass)) { 312 | groupClass = "input-group-lg" 313 | } 314 | const inputClass = originalClass.replace(/form-control(-(sm|lg))?/g, "") 315 | self.$inputGroup.prop("class", "input-group " + groupClass + " " + self.props.groupClass) 316 | self.$input.prop("class", "form-control " + inputClass) 317 | 318 | // update the main attributes 319 | self.min = isNaN(self.$original.prop("min")) || self.$original.prop("min") === "" ? -Infinity : parseFloat(self.$original.prop("min")) 320 | self.max = isNaN(self.$original.prop("max")) || self.$original.prop("max") === "" ? Infinity : parseFloat(self.$original.prop("max")) 321 | self.step = parseFloat(self.$original.prop("step")) || 1 322 | if (self.$original.attr("hidden")) { 323 | self.$inputGroup.attr("hidden", self.$original.attr("hidden")) 324 | } else { 325 | self.$inputGroup.removeAttr("hidden") 326 | } 327 | if (self.$original.attr("id")) { 328 | self.$input.attr("id", self.$original.attr("id") + ":input_spinner") // give the spinner a unique id... 329 | if (self.$label[0]) { 330 | self.$label.attr("for", self.$input.attr("id")) // ...to rewire the label 331 | } 332 | } 333 | } 334 | 335 | function onPointerUp(element, callback) { 336 | element.addEventListener("mouseup", function (e) { 337 | callback(e) 338 | }) 339 | element.addEventListener("touchend", function (e) { 340 | callback(e) 341 | }) 342 | element.addEventListener("keyup", function (e) { 343 | if ((e.keyCode === 32 || e.keyCode === 13)) { 344 | triggerKeyPressed = false 345 | callback(e) 346 | } 347 | }) 348 | } 349 | 350 | function onPointerDown(element, callback) { 351 | element.addEventListener("mousedown", function (e) { 352 | if (e.button === 0) { 353 | e.preventDefault() 354 | callback(e) 355 | } 356 | }) 357 | element.addEventListener("touchstart", function (e) { 358 | if (e.cancelable) { 359 | e.preventDefault() 360 | } 361 | callback(e) 362 | }, {passive: false}) 363 | element.addEventListener("keydown", function (e) { 364 | if ((e.keyCode === 32 || e.keyCode === 13) && !triggerKeyPressed) { 365 | triggerKeyPressed = true 366 | callback(e) 367 | } 368 | }) 369 | } 370 | } 371 | 372 | } 373 | 374 | -------------------------------------------------------------------------------- /src/custom-editors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Author and copyright: Stefan Haack (https://shaack.com) 3 | * Repository: https://github.com/shaack/bootstrap-input-spinner 4 | * License: MIT, see file 'LICENSE' 5 | */ 6 | const customEditors = { 7 | RawEditor: function (props, element) { 8 | this.parse = function (customFormat) { 9 | // parse nothing 10 | return customFormat 11 | } 12 | this.render = function (number) { 13 | // render raw 14 | return number 15 | } 16 | }, 17 | TimeEditor: function (props, element) { 18 | // could be implemented more elegant maybe, but works 19 | this.parse = function (customFormat) { 20 | let trimmed = customFormat.trim() 21 | let sign = 1 22 | if (trimmed.charAt(0) === "-") { 23 | sign = -1 24 | trimmed = trimmed.replace("-", "") 25 | } 26 | const parts = trimmed.split(":") 27 | let hours = 0, minutes 28 | if (parts[1]) { 29 | hours = parseInt(parts[0], 10) 30 | minutes = parseInt(parts[1], 10) 31 | } else { 32 | minutes = parseInt(parts[0], 10) 33 | } 34 | return (hours * 60 + minutes) * sign 35 | } 36 | this.render = function (number) { 37 | let minutes = Math.abs(number % 60) 38 | if (minutes < 10) { 39 | minutes = "0" + minutes 40 | } 41 | let hours 42 | if (number >= 0) { 43 | hours = Math.floor(number / 60) 44 | return hours + ":" + minutes 45 | } else { 46 | hours = Math.ceil(number / 60) 47 | return "-" + Math.abs(hours) + ":" + minutes 48 | } 49 | } 50 | } 51 | } 52 | --------------------------------------------------------------------------------