bootstrap-input-spinner 5.0.6 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bootstrap-input-spinner",
3
- "version": "5.0.6",
3
+ "version": "5.1.0",
4
4
  "description": "A Bootstrap 5 plugin to create input spinner elements for number input.",
5
5
  "browser": "./src/InputSpinner.js",
6
6
  "type": "module",
@@ -46,8 +46,6 @@ const I18nEditor = function (props, element) {
46
46
  }
47
47
  }
48
48
 
49
- let triggerKeyPressed = false
50
-
51
49
  function parseTemplate(html) {
52
50
  const tpl = document.createElement("template")
53
51
  tpl.innerHTML = html.trim()
@@ -159,6 +157,8 @@ export class InputSpinner {
159
157
  destroy()
160
158
  }
161
159
 
160
+ let triggerKeyPressed = false
161
+
162
162
  this.observer = new MutationObserver(function () {
163
163
  updateAttributes()
164
164
  setValue(self.value, true)
@@ -336,7 +336,23 @@ export class InputSpinner {
336
336
  if (isNaN(self.value)) {
337
337
  self.value = 0
338
338
  }
339
- setValue(Math.round(self.value / step) * step + step)
339
+ // Step like the native <input type="number">: the stepping base is
340
+ // `min` if set, otherwise the `value` content attribute, otherwise 0.
341
+ // Valid values are `base + n * step`. On-grid values move exactly one
342
+ // step; off-grid values (e.g. typed manually) snap to the next valid
343
+ // value in the button's direction.
344
+ const direction = step < 0 ? -1 : 1
345
+ const stepSize = Math.abs(step)
346
+ const base = isFinite(self.min) ? self.min : parseNumberAttr(self.original, "value", 0)
347
+ const offset = (self.value - base) / stepSize
348
+ const rounded = Math.round(offset)
349
+ let next
350
+ if (Math.abs(offset - rounded) < 1e-9) {
351
+ next = base + (rounded + direction) * stepSize
352
+ } else {
353
+ next = base + (direction > 0 ? Math.ceil(offset) : Math.floor(offset)) * stepSize
354
+ }
355
+ setValue(next)
340
356
  dispatchEvent(self.original, "input")
341
357
  }
342
358
 
@@ -364,9 +380,9 @@ export class InputSpinner {
364
380
  }
365
381
  const originalClass = self.original.className || ""
366
382
  let groupClass = ""
367
- if (/form-control-sm/g.test(originalClass)) {
383
+ if (/form-control-sm/.test(originalClass)) {
368
384
  groupClass = "input-group-sm"
369
- } else if (/form-control-lg/g.test(originalClass)) {
385
+ } else if (/form-control-lg/.test(originalClass)) {
370
386
  groupClass = "input-group-lg"
371
387
  }
372
388
  const inputClass = originalClass.replace(/form-control(-(sm|lg))?/g, "")
@@ -212,6 +212,37 @@ describe("InputSpinner stepping", () => {
212
212
  assert.equal(el.value, "4")
213
213
  clear()
214
214
  })
215
+ it("uses the value attribute as the stepping base when no min is set (regression for #56)", () => {
216
+ // No min → base is the value attribute (5); with step 2 the grid is
217
+ // ..., 1, 3, 5, 7, ... Native stepDown(5) === 3, not value - step rounded to 4.
218
+ const {el, group} = spin({value: "5", step: "2"})
219
+ pressButton(group.querySelector(".btn-decrement"))
220
+ assert.equal(el.value, "3")
221
+ clear()
222
+ })
223
+ it("steps up from the value-attribute base when no min is set (regression for #56)", () => {
224
+ const {el, group} = spin({value: "5", step: "2"})
225
+ pressButton(group.querySelector(".btn-increment"))
226
+ assert.equal(el.value, "7")
227
+ clear()
228
+ })
229
+ it("uses min as the stepping base and snaps off-grid values (regression for #56)", () => {
230
+ // base -99, step 20 → grid ..., -19, 1, 21, ...; the off-grid 12 snaps
231
+ // down to 1 and up to 21, matching native stepDown/stepUp
232
+ const {el, group} = spin({value: "12", min: "-99", step: "20"})
233
+ pressButton(group.querySelector(".btn-decrement"))
234
+ assert.equal(el.value, "1")
235
+ pressButton(group.querySelector(".btn-increment"))
236
+ assert.equal(el.value, "21")
237
+ clear()
238
+ })
239
+ it("snaps an off-grid value to the min-based grid (regression for #56)", () => {
240
+ // base 1, step 3 → grid 1, 4, 7, 10, ...; the off-grid 5 snaps down to 4
241
+ const {el, group} = spin({value: "5", min: "1", step: "3"})
242
+ pressButton(group.querySelector(".btn-decrement"))
243
+ assert.equal(el.value, "4")
244
+ clear()
245
+ })
215
246
  })
216
247
 
217
248
  describe("InputSpinner dynamic step while holding", () => {
@@ -341,6 +372,28 @@ describe("InputSpinner instance isolation", () => {
341
372
  assert.equal(b, 0)
342
373
  clear()
343
374
  })
375
+ it("two spinners both respond to keyboard activation while the other is held", () => {
376
+ const elA = createInput({value: "0", min: "0", max: "10", step: "1"})
377
+ const elB = createInput({value: "0", min: "0", max: "10", step: "1"})
378
+ new InputSpinner(elA, {autoInterval: undefined})
379
+ new InputSpinner(elB, {autoInterval: undefined})
380
+ const btnA = elA.nextElementSibling.querySelector(".btn-increment")
381
+ const btnB = elB.nextElementSibling.querySelector(".btn-increment")
382
+ const make = (type) => {
383
+ const e = new KeyboardEvent(type, {key: " ", bubbles: true})
384
+ Object.defineProperty(e, "keyCode", {value: 32})
385
+ return e
386
+ }
387
+ const down = () => make("keydown")
388
+ const up = () => make("keyup")
389
+ btnA.dispatchEvent(down())
390
+ assert.equal(elA.value, "1")
391
+ // B's keydown must still register even though A's keyup hasn't fired.
392
+ btnB.dispatchEvent(down())
393
+ assert.equal(elB.value, "1")
394
+ document.body.dispatchEvent(up())
395
+ clear()
396
+ })
344
397
  })
345
398
 
346
399
  describe("InputSpinner attribute observation", () => {
@@ -0,0 +1,136 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>bootstrap-input-spinner vs. native input[type=number]</title>
7
+ <link rel="stylesheet"
8
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
9
+ crossorigin="anonymous">
10
+ <style>
11
+ body { padding: 2rem; max-width: 880px; }
12
+ .case { border: 1px solid var(--bs-border-color); border-radius: .5rem; padding: 1rem 1.25rem; margin-bottom: 1rem; }
13
+ .case h2 { font-size: 1rem; margin: 0 0 .75rem; }
14
+ .case code { color: var(--bs-pink); }
15
+ .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
16
+ .col-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--bs-secondary-color); margin-bottom: .35rem; }
17
+ .native input { max-width: 12rem; }
18
+ .grid-hint { font-size: .8rem; color: var(--bs-secondary-color); margin-top: .4rem; }
19
+ .note { background: var(--bs-light); border-radius: .5rem; padding: .75rem 1rem; font-size: .9rem; }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <h1 class="h4 mb-3">Spinner vs. native <code>input[type=number]</code></h1>
24
+ <p class="text-secondary">
25
+ Click the up/down controls (native on the left, the spinner on the right) and compare.
26
+ Both fields carry the same attributes. The stepping base is <code>min</code>, otherwise the
27
+ <code>value</code> attribute, otherwise 0; valid values are <code>base + n·step</code>.
28
+ </p>
29
+
30
+ <div id="cases"></div>
31
+
32
+ <div class="note">
33
+ <strong>About manual typing:</strong> native Chrome accepts an off-grid value that is typed
34
+ in and only flags it as invalid on form validation. The spinner likewise leaves a typed value
35
+ untouched, but a button click snaps it, like the native arrows, to the nearest valid grid value
36
+ in the click direction.
37
+ </div>
38
+
39
+ <script type="module">
40
+ import {InputSpinner} from "../src/InputSpinner.js"
41
+
42
+ // Each case renders the same attributes once as a native control and once as a spinner.
43
+ const cases = [
44
+ {
45
+ title: "No min: the value attribute is the base (core of #56)",
46
+ desc: "value 5, step 2 → base 5 → grid …,1,3,5,7,…",
47
+ attrs: {value: 5, step: 2},
48
+ grid: "Expected native: down 3, up 7"
49
+ },
50
+ {
51
+ title: "min as the stepping base",
52
+ desc: "value 12, step 20, min -99 → grid …,-19,1,21,41,…",
53
+ attrs: {value: 12, step: 20, min: -99},
54
+ grid: "Expected native: down 1, up 21"
55
+ },
56
+ {
57
+ title: "On-grid default case",
58
+ desc: "value 5, step 1, min 0, max 10",
59
+ attrs: {value: 5, step: 1, min: 0, max: 10},
60
+ grid: "Expected: down 4, up 6"
61
+ },
62
+ {
63
+ title: "On-grid with min",
64
+ desc: "value 7, step 3, min 1 → grid 1,4,7,10,…",
65
+ attrs: {value: 7, step: 3, min: 1},
66
+ grid: "7 is on the grid → down 4, up 10"
67
+ },
68
+ {
69
+ title: "Off-grid with min",
70
+ desc: "value 5, step 3, min 1 → grid 1,4,7,10,…",
71
+ attrs: {value: 5, step: 3, min: 1},
72
+ grid: "5 is off the grid → native down 4, up 7"
73
+ },
74
+ {
75
+ title: "Decimal values",
76
+ desc: "value 0.5, step 0.3, min 0, data-decimals 1 → grid 0,0.3,0.6,…",
77
+ attrs: {value: 0.5, step: 0.3, min: 0, "data-decimals": 1},
78
+ grid: "0.5 is off the grid → native down 0.3, up 0.6"
79
+ }
80
+ ]
81
+
82
+ function makeInput(attrs) {
83
+ const input = document.createElement("input")
84
+ input.type = "number"
85
+ input.className = "form-control"
86
+ for (const [k, v] of Object.entries(attrs)) {
87
+ input.setAttribute(k, String(v))
88
+ }
89
+ return input
90
+ }
91
+
92
+ const container = document.getElementById("cases")
93
+ for (const c of cases) {
94
+ const card = document.createElement("div")
95
+ card.className = "case"
96
+
97
+ const h = document.createElement("h2")
98
+ h.textContent = c.title
99
+ card.appendChild(h)
100
+
101
+ const d = document.createElement("div")
102
+ d.className = "text-secondary mb-3"
103
+ d.style.fontSize = ".9rem"
104
+ d.textContent = c.desc
105
+ card.appendChild(d)
106
+
107
+ const cols = document.createElement("div")
108
+ cols.className = "cols"
109
+
110
+ const nativeCol = document.createElement("div")
111
+ nativeCol.className = "native"
112
+ nativeCol.innerHTML = '<div class="col-label">Native</div>'
113
+ nativeCol.appendChild(makeInput(c.attrs))
114
+
115
+ const spinnerCol = document.createElement("div")
116
+ spinnerCol.innerHTML = '<div class="col-label">Spinner</div>'
117
+ const spinnerInput = makeInput(c.attrs)
118
+ spinnerCol.appendChild(spinnerInput)
119
+
120
+ cols.appendChild(nativeCol)
121
+ cols.appendChild(spinnerCol)
122
+ card.appendChild(cols)
123
+
124
+ const hint = document.createElement("div")
125
+ hint.className = "grid-hint"
126
+ hint.textContent = c.grid
127
+ card.appendChild(hint)
128
+
129
+ container.appendChild(card)
130
+
131
+ // Initialise the spinner after the input is in the DOM.
132
+ new InputSpinner(spinnerInput)
133
+ }
134
+ </script>
135
+ </body>
136
+ </html>