bootstrap-input-spinner 4.1.0 → 4.1.1

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": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "A Bootstrap 5 / jQuery plugin to create input spinner elements for number input.",
5
5
  "browser": "./src/bootstrap-input-spinner.js",
6
6
  "scripts": {
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "homepage": "https://shaack.com/en/open-source-components",
27
27
  "devDependencies": {
28
- "prismjs": "^1.29.0",
29
- "teevi": "^2.2.4"
28
+ "prismjs": "^1.30.0",
29
+ "teevi": "^2.3.0"
30
30
  }
31
31
  }
@@ -3,49 +3,69 @@
3
3
  * Repository: https://github.com/shaack/bootstrap-input-spinner
4
4
  * License: MIT, see file 'LICENSE'
5
5
  */
6
- const customEditors = {
7
- RawEditor: function (props, element) {
8
- this.parse = function (customFormat) {
9
- // parse nothing
10
- return customFormat
6
+
7
+ export const 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
+
18
+ export const TimeEditor = function (props, element) {
19
+ // could be implemented more elegant maybe, but works
20
+ this.parse = function (customFormat) {
21
+ let trimmed = customFormat.trim()
22
+ let sign = 1
23
+ if (trimmed.charAt(0) === "-") {
24
+ sign = -1
25
+ trimmed = trimmed.replace("-", "")
11
26
  }
12
- this.render = function (number) {
13
- // render raw
14
- return number
27
+ const parts = trimmed.split(":")
28
+ let hours = 0, minutes
29
+ if (parts[1]) {
30
+ hours = parseInt(parts[0], 10)
31
+ minutes = parseInt(parts[1], 10)
32
+ } else {
33
+ minutes = parseInt(parts[0], 10)
15
34
  }
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
+ return (hours * 60 + minutes) * sign
36
+ }
37
+ this.render = function (number) {
38
+ let minutes = Math.abs(number % 60)
39
+ if (minutes < 10) {
40
+ minutes = "0" + minutes
35
41
  }
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
- }
42
+ let hours
43
+ if (number >= 0) {
44
+ hours = Math.floor(number / 60)
45
+ return hours + ":" + minutes
46
+ } else {
47
+ hours = Math.ceil(number / 60)
48
+ return "-" + Math.abs(hours) + ":" + minutes
49
49
  }
50
50
  }
51
51
  }
52
+
53
+ // Deprecated: global `window.customEditors` is kept for backwards compatibility
54
+ // with users who load this file via a classic <script> tag. Prefer the named
55
+ // ES module exports above.
56
+ if (typeof window !== "undefined") {
57
+ let warned = false
58
+ const editors = {RawEditor, TimeEditor}
59
+ window.customEditors = new Proxy(editors, {
60
+ get(target, prop) {
61
+ if (!warned && prop in target) {
62
+ warned = true
63
+ console.warn(
64
+ "bootstrap-input-spinner: window.customEditors is deprecated, " +
65
+ "import {RawEditor, TimeEditor} from 'bootstrap-input-spinner/src/custom-editors.js' instead."
66
+ )
67
+ }
68
+ return target[prop]
69
+ }
70
+ })
71
+ }
@@ -0,0 +1,54 @@
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
+ import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
7
+ import {RawEditor, TimeEditor} from "../src/custom-editors.js"
8
+
9
+ describe("RawEditor", () => {
10
+ it("parses input unchanged", () => {
11
+ const editor = new RawEditor({}, document.createElement("input"))
12
+ assert.equal(editor.parse("42"), "42")
13
+ assert.equal(editor.parse("abc"), "abc")
14
+ })
15
+ it("renders number unchanged", () => {
16
+ const editor = new RawEditor({}, document.createElement("input"))
17
+ assert.equal(editor.render(3.14), 3.14)
18
+ assert.equal(editor.render(0), 0)
19
+ })
20
+ })
21
+
22
+ describe("TimeEditor", () => {
23
+ const editor = new TimeEditor({}, document.createElement("input"))
24
+
25
+ it("parses H:MM into total minutes", () => {
26
+ assert.equal(editor.parse("1:30"), 90)
27
+ assert.equal(editor.parse("0:05"), 5)
28
+ assert.equal(editor.parse("10:00"), 600)
29
+ })
30
+ it("parses bare minutes", () => {
31
+ assert.equal(editor.parse("45"), 45)
32
+ })
33
+ it("parses negative times", () => {
34
+ assert.equal(editor.parse("-1:30"), -90)
35
+ assert.equal(editor.parse("-0:15"), -15)
36
+ })
37
+ it("renders positive total minutes as H:MM", () => {
38
+ assert.equal(editor.render(90), "1:30")
39
+ assert.equal(editor.render(5), "0:05")
40
+ assert.equal(editor.render(600), "10:00")
41
+ })
42
+ it("renders zero", () => {
43
+ assert.equal(editor.render(0), "0:00")
44
+ })
45
+ it("renders negative total minutes with leading minus", () => {
46
+ assert.equal(editor.render(-90), "-1:30")
47
+ assert.equal(editor.render(-15), "-0:15")
48
+ })
49
+ it("is round-trip stable for positive values", () => {
50
+ for (const v of [0, 5, 59, 60, 61, 125, 600]) {
51
+ assert.equal(editor.parse(editor.render(v)), v)
52
+ }
53
+ })
54
+ })
@@ -0,0 +1,301 @@
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
+ import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
7
+ import {InputSpinner} from "../src/InputSpinner.js"
8
+ import {RawEditor} from "../src/custom-editors.js"
9
+
10
+ const fixture = document.getElementById("fixture")
11
+
12
+ function createInput(attrs = {}) {
13
+ const input = document.createElement("input")
14
+ input.type = "number"
15
+ for (const [k, v] of Object.entries(attrs)) {
16
+ input.setAttribute(k, String(v))
17
+ }
18
+ fixture.appendChild(input)
19
+ return input
20
+ }
21
+
22
+ function spin(attrs = {}, props) {
23
+ const el = createInput(attrs)
24
+ const spinner = new InputSpinner(el, props)
25
+ return {el, spinner, group: el.nextElementSibling}
26
+ }
27
+
28
+ function clear() {
29
+ fixture.innerHTML = ""
30
+ }
31
+
32
+ function wait(ms = 0) {
33
+ return new Promise(r => setTimeout(r, ms))
34
+ }
35
+
36
+ function pressButton(btn) {
37
+ btn.dispatchEvent(new MouseEvent("mousedown", {button: 0, cancelable: true, bubbles: true}))
38
+ document.body.dispatchEvent(new MouseEvent("mouseup", {button: 0, bubbles: true}))
39
+ }
40
+
41
+ describe("InputSpinner construction", () => {
42
+ it("inserts an input-group after the original element", () => {
43
+ const {el, group} = spin({value: "5"})
44
+ assert.true(group !== null)
45
+ assert.true(group.classList.contains("input-group"))
46
+ assert.equal(group.querySelectorAll("button").length, 2)
47
+ assert.equal(group.querySelectorAll("input").length, 1)
48
+ clear()
49
+ })
50
+ it("hides the original input", () => {
51
+ const {el} = spin({value: "5"})
52
+ assert.equal(el.style.display, "none")
53
+ clear()
54
+ })
55
+ it("marks the original element as a spinner", () => {
56
+ const {el} = spin({value: "5"})
57
+ assert.equal(el["bootstrap-input-spinner"], true)
58
+ assert.equal(typeof el.setValue, "function")
59
+ assert.equal(typeof el.destroyInputSpinner, "function")
60
+ clear()
61
+ })
62
+ it("sets the initial value from the value attribute", () => {
63
+ const {el, group} = spin({value: "7"})
64
+ assert.equal(el.value, "7")
65
+ assert.equal(group.querySelector("input").value, "7")
66
+ clear()
67
+ })
68
+ it("leaves value empty (NaN) when no value attribute is given", () => {
69
+ const {el, group} = spin()
70
+ assert.equal(el.value, "")
71
+ assert.equal(group.querySelector("input").value, "")
72
+ clear()
73
+ })
74
+ })
75
+
76
+ describe("InputSpinner I18n rendering (default editor)", () => {
77
+ it("renders with data-decimals", () => {
78
+ const {group} = spin({value: "4.5", "data-decimals": "2"}, {locale: "en-US"})
79
+ assert.equal(group.querySelector("input").value, "4.50")
80
+ clear()
81
+ })
82
+ it("uses thousands grouping by default", () => {
83
+ const {group} = spin({value: "12345"}, {locale: "en-US"})
84
+ assert.equal(group.querySelector("input").value, "12,345")
85
+ clear()
86
+ })
87
+ it("disables grouping when data-digit-grouping=false", () => {
88
+ const {group} = spin({value: "12345", "data-digit-grouping": "false"}, {locale: "en-US"})
89
+ assert.equal(group.querySelector("input").value, "12345")
90
+ clear()
91
+ })
92
+ it("honors the German locale separators", () => {
93
+ const {group} = spin({value: "1234.5", "data-decimals": "1"}, {locale: "de-DE"})
94
+ // German: '.' thousands, ',' decimal
95
+ assert.equal(group.querySelector("input").value, "1.234,5")
96
+ clear()
97
+ })
98
+ })
99
+
100
+ describe("InputSpinner setValue", () => {
101
+ it("clamps above max", () => {
102
+ const {el, group} = spin({value: "5", min: "0", max: "10"})
103
+ el.setValue(99)
104
+ assert.equal(el.value, "10")
105
+ assert.equal(group.querySelector("input").value, "10")
106
+ clear()
107
+ })
108
+ it("clamps below min", () => {
109
+ const {el, group} = spin({value: "5", min: "0", max: "10"})
110
+ el.setValue(-99)
111
+ assert.equal(el.value, "0")
112
+ assert.equal(group.querySelector("input").value, "0")
113
+ clear()
114
+ })
115
+ it("accepts floats", () => {
116
+ const {el} = spin({value: "0", "data-decimals": "2"}, {locale: "en-US"})
117
+ el.setValue(3.14)
118
+ assert.equal(el.value, "3.14")
119
+ clear()
120
+ })
121
+ it("clears value on NaN", () => {
122
+ const {el, group} = spin({value: "5"})
123
+ el.setValue(NaN)
124
+ assert.equal(el.value, "")
125
+ assert.equal(group.querySelector("input").value, "")
126
+ clear()
127
+ })
128
+ it("is reachable via jQuery val() monkey patch", async () => {
129
+ const {el, group} = spin({value: "1", min: "0", max: "100"})
130
+ window.$(el).val(42)
131
+ await wait()
132
+ assert.equal(el.value, "42")
133
+ assert.equal(group.querySelector("input").value, "42")
134
+ clear()
135
+ })
136
+ })
137
+
138
+ describe("InputSpinner stepping", () => {
139
+ it("increments on the plus button", () => {
140
+ const {el, group} = spin({value: "5", min: "0", max: "100", step: "1"})
141
+ pressButton(group.querySelector(".btn-increment"))
142
+ assert.equal(el.value, "6")
143
+ clear()
144
+ })
145
+ it("decrements on the minus button", () => {
146
+ const {el, group} = spin({value: "5", min: "0", max: "100", step: "1"})
147
+ pressButton(group.querySelector(".btn-decrement"))
148
+ assert.equal(el.value, "4")
149
+ clear()
150
+ })
151
+ it("honors a custom step size", () => {
152
+ const {el, group} = spin({value: "0", min: "0", max: "100", step: "10"})
153
+ pressButton(group.querySelector(".btn-increment"))
154
+ assert.equal(el.value, "10")
155
+ pressButton(group.querySelector(".btn-increment"))
156
+ assert.equal(el.value, "20")
157
+ clear()
158
+ })
159
+ it("does not step past max", () => {
160
+ const {el, group} = spin({value: "9", min: "0", max: "10", step: "1"})
161
+ pressButton(group.querySelector(".btn-increment"))
162
+ pressButton(group.querySelector(".btn-increment"))
163
+ assert.equal(el.value, "10")
164
+ clear()
165
+ })
166
+ it("does not step below min", () => {
167
+ const {el, group} = spin({value: "1", min: "0", max: "10", step: "1"})
168
+ pressButton(group.querySelector(".btn-decrement"))
169
+ pressButton(group.querySelector(".btn-decrement"))
170
+ assert.equal(el.value, "0")
171
+ clear()
172
+ })
173
+ it("arrow-up key steps the value", async () => {
174
+ const {el, group} = spin({value: "5", min: "0", max: "10", step: "1"})
175
+ window.$(group.querySelector("input")).trigger(window.$.Event("keydown", {which: 38}))
176
+ assert.equal(el.value, "6")
177
+ clear()
178
+ })
179
+ it("arrow-down key steps the value", async () => {
180
+ const {el, group} = spin({value: "5", min: "0", max: "10", step: "1"})
181
+ window.$(group.querySelector("input")).trigger(window.$.Event("keydown", {which: 40}))
182
+ assert.equal(el.value, "4")
183
+ clear()
184
+ })
185
+ })
186
+
187
+ describe("InputSpinner events", () => {
188
+ it("dispatches 'input' when stepping", async () => {
189
+ const {el, group} = spin({value: "5", min: "0", max: "10"})
190
+ let fired = 0
191
+ el.addEventListener("input", () => fired++)
192
+ pressButton(group.querySelector(".btn-increment"))
193
+ await wait()
194
+ assert.true(fired >= 1)
195
+ clear()
196
+ })
197
+ it("dispatches 'change' on pointer release", async () => {
198
+ const {el, group} = spin({value: "5", min: "0", max: "10"})
199
+ let fired = 0
200
+ el.addEventListener("change", () => fired++)
201
+ pressButton(group.querySelector(".btn-increment"))
202
+ await wait()
203
+ assert.equal(fired, 1)
204
+ clear()
205
+ })
206
+ })
207
+
208
+ describe("InputSpinner attribute observation", () => {
209
+ it("reflects min/max changes", async () => {
210
+ const {el} = spin({value: "5", min: "0", max: "10"})
211
+ el.setAttribute("max", "7")
212
+ await wait()
213
+ el.setValue(99)
214
+ assert.equal(el.value, "7")
215
+ clear()
216
+ })
217
+ it("reflects disabled attribute", async () => {
218
+ const {el, group} = spin({value: "5"})
219
+ el.setAttribute("disabled", "disabled")
220
+ await wait()
221
+ assert.true(group.querySelector(".btn-increment").disabled)
222
+ assert.true(group.querySelector(".btn-decrement").disabled)
223
+ assert.true(group.querySelector("input").disabled)
224
+ clear()
225
+ })
226
+ it("reflects readonly attribute on visible input", async () => {
227
+ const {el, group} = spin({value: "5"})
228
+ el.setAttribute("readonly", "readonly")
229
+ await wait()
230
+ assert.true(group.querySelector("input").readOnly)
231
+ assert.true(group.querySelector(".btn-increment").disabled)
232
+ clear()
233
+ })
234
+ it("maps form-control-sm to input-group-sm", async () => {
235
+ const el = createInput({value: "5"})
236
+ el.className = "form-control form-control-sm"
237
+ new InputSpinner(el)
238
+ await wait()
239
+ assert.true(el.nextElementSibling.classList.contains("input-group-sm"))
240
+ clear()
241
+ })
242
+ it("maps form-control-lg to input-group-lg", async () => {
243
+ const el = createInput({value: "5"})
244
+ el.className = "form-control form-control-lg"
245
+ new InputSpinner(el)
246
+ await wait()
247
+ assert.true(el.nextElementSibling.classList.contains("input-group-lg"))
248
+ clear()
249
+ })
250
+ })
251
+
252
+ describe("InputSpinner prefix/suffix", () => {
253
+ it("renders a prefix element", () => {
254
+ const {group} = spin({value: "5", "data-prefix": "$"})
255
+ const prefix = group.querySelector(".input-group-text")
256
+ assert.true(prefix !== null)
257
+ assert.equal(prefix.textContent, "$")
258
+ clear()
259
+ })
260
+ it("renders a suffix element", () => {
261
+ const {group} = spin({value: "5", "data-suffix": "kg"})
262
+ const suffix = group.querySelector(".input-group-text")
263
+ assert.true(suffix !== null)
264
+ assert.equal(suffix.textContent, "kg")
265
+ clear()
266
+ })
267
+ })
268
+
269
+ describe("InputSpinner buttonsOnly mode", () => {
270
+ it("makes the input readonly", () => {
271
+ const {group} = spin({value: "5"}, {buttonsOnly: true})
272
+ assert.true(group.querySelector("input").readOnly)
273
+ clear()
274
+ })
275
+ it("still allows button stepping", () => {
276
+ const {el, group} = spin({value: "5", min: "0", max: "10"}, {buttonsOnly: true})
277
+ pressButton(group.querySelector(".btn-increment"))
278
+ assert.equal(el.value, "6")
279
+ clear()
280
+ })
281
+ })
282
+
283
+ describe("InputSpinner custom editor", () => {
284
+ it("uses the supplied editor for rendering", () => {
285
+ const {group} = spin({value: "3.14159"}, {editor: RawEditor})
286
+ assert.equal(group.querySelector("input").value, "3.14159")
287
+ clear()
288
+ })
289
+ })
290
+
291
+ describe("InputSpinner destroy", () => {
292
+ it("removes the input-group and un-hides the original", () => {
293
+ const {el, group} = spin({value: "5"})
294
+ assert.true(group.isConnected)
295
+ el.destroyInputSpinner()
296
+ assert.false(group.isConnected)
297
+ assert.notEqual(el.style.display, "none")
298
+ assert.equal(el["bootstrap-input-spinner"], undefined)
299
+ clear()
300
+ })
301
+ })
@@ -0,0 +1,34 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>bootstrap-input-spinner tests</title>
6
+ <link rel="stylesheet"
7
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
8
+ crossorigin="anonymous">
9
+ <script src="https://code.jquery.com/jquery-3.6.0.min.js" crossorigin="anonymous"></script>
10
+ <style>
11
+ body { font-family: sans-serif; padding: 1rem; }
12
+ #fixture { position: absolute; left: -10000px; top: -10000px; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <h1>bootstrap-input-spinner tests</h1>
17
+ <div id="fixture"></div>
18
+
19
+ <script src="https://cdn.jsdelivr.net/npm/es-module-shims@1.7.2/dist/es-module-shims.min.js"></script>
20
+ <script type="importmap">
21
+ {
22
+ "imports": {
23
+ "teevi/": "../node_modules/teevi/"
24
+ }
25
+ }
26
+ </script>
27
+ <script type="module">
28
+ import {teevi} from "teevi/src/teevi.js"
29
+ import "./TestInputSpinner.js"
30
+ import "./TestCustomEditors.js"
31
+ teevi.run()
32
+ </script>
33
+ </body>
34
+ </html>