vanilla-wheel-number-picker 1.1.0 → 1.1.2

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Plain Wheel Number Picker
1
+ # Vanilla JS Wheel Number Picker
2
2
 
3
3
  A small plain JavaScript wheel-style number picker. No framework required.
4
4
 
@@ -21,6 +21,8 @@ A small plain JavaScript wheel-style number picker. No framework required.
21
21
  muted-color="#9ca3af"
22
22
  active-color="#2563eb"
23
23
  background="#f8fafc"
24
+ arrows
25
+ arrows-hidden-max-width="480"
24
26
  >
25
27
  </wheel-number-picker>
26
28
 
@@ -58,6 +60,8 @@ A small plain JavaScript wheel-style number picker. No framework required.
58
60
  mutedColor: "#9ca3af",
59
61
  activeColor: "#2563eb",
60
62
  background: "#f8fafc",
63
+ arrows: true,
64
+ arrowsHiddenMaxWidth: 480,
61
65
  onChange(value) {
62
66
  console.log(value);
63
67
  },
@@ -67,17 +71,19 @@ A small plain JavaScript wheel-style number picker. No framework required.
67
71
 
68
72
  ## Options / attributes
69
73
 
70
- | Option | Attribute | Default | Description |
71
- | -------------- | --------------- | -------: | -------------------------------------------------------------------------------------------------------- |
72
- | `min` | `min` | `0` | Minimum value |
73
- | `max` | `max` | `100` | Maximum value |
74
- | `value` | `value` | `min` | Initial/current value |
75
- | `itemHeight` | `item-height` | `44` | Row height in pixels. Controls row height, highlight height, snap distance, and total picker height. |
76
- | `visibleItems` | `visible-items` | `5` | Number of visible rows. Even numbers are rounded up to the next odd number so one row can stay centered. |
77
- | `color` | `color` | `#111` | Base/active text color fallback |
78
- | `mutedColor` | `muted-color` | `#aaa` | Non-selected item text color |
79
- | `activeColor` | `active-color` | `color` | Selected item text color |
80
- | `background` | `background` | gradient | Picker window background. Accepts any valid CSS background value. |
74
+ | Option | Attribute | Default | Description |
75
+ | ---------------------- | ------------------------- | -------: | ------------------------------------------------------------------------------------------------------------- |
76
+ | `min` | `min` | `0` | Minimum value |
77
+ | `max` | `max` | `100` | Maximum value |
78
+ | `value` | `value` | `min` | Initial/current value |
79
+ | `itemHeight` | `item-height` | `44` | Row height in pixels. This now controls row height, highlight height, snap distance, and total picker height. |
80
+ | `visibleItems` | `visible-items` | `5` | Number of visible rows. Even numbers are rounded up to the next odd number so one row can stay centered. |
81
+ | `color` | `color` | `#111` | Base/active text color fallback |
82
+ | `mutedColor` | `muted-color` | `#aaa` | Non-selected item text color |
83
+ | `activeColor` | `active-color` | `color` | Selected item text color |
84
+ | `background` | `background` | gradient | Picker window background. Accepts any valid CSS background value. |
85
+ | `arrows` | `arrows` | `false` | Shows clickable top/bottom arrow buttons that decrement/increment the value. |
86
+ | `arrowsHiddenMaxWidth` | `arrows-hidden-max-width` | empty | Optional responsive breakpoint in pixels. At screen widths up to this value, the arrow buttons are hidden. |
81
87
 
82
88
  ## Dynamic updates
83
89
 
@@ -90,6 +96,8 @@ picker.setAttribute("item-height", "64");
90
96
  picker.setAttribute("visible-items", "3");
91
97
  picker.setAttribute("active-color", "crimson");
92
98
  picker.setAttribute("background", "#fff7ed");
99
+ picker.setAttribute("arrows", "true");
100
+ picker.setAttribute("arrows-hidden-max-width", "480");
93
101
  ```
94
102
 
95
103
  The component recalculates its render dimensions and snapping math automatically.
@@ -115,3 +123,47 @@ For the most predictable behavior, prefer the `item-height` attribute or `itemHe
115
123
  ## Local demo
116
124
 
117
125
  Open `demo/index.html` in a browser.
126
+
127
+ ## 1.1.1 item-height alignment fix
128
+
129
+ `item-height` now drives the offset math from the center line of the picker, not from an implicit zero-offset assumption. Font sizes also scale down with small item heights unless you explicitly override `--wnp-item-font-size` or `--wnp-active-font-size`.
130
+
131
+ Example:
132
+
133
+ ```html
134
+ <wheel-number-picker item-height="28" visible-items="3"></wheel-number-picker>
135
+ ```
136
+
137
+ ## 1.1.2 responsive arrows
138
+
139
+ Enable clickable arrows with the `arrows` attribute. Each click steps the picker by one value.
140
+
141
+ ```html
142
+ <wheel-number-picker
143
+ min="0"
144
+ max="10"
145
+ value="5"
146
+ arrows
147
+ arrows-hidden-max-width="480"
148
+ >
149
+ </wheel-number-picker>
150
+ ```
151
+
152
+ `arrows-hidden-max-width="480"` injects a component-scoped media query equivalent to:
153
+
154
+ ```css
155
+ @media (max-width: 480px) {
156
+ /* this component's arrows are hidden */
157
+ }
158
+ ```
159
+
160
+ You can style the buttons with CSS variables:
161
+
162
+ ```css
163
+ wheel-number-picker {
164
+ --wnp-arrow-height: 36px;
165
+ --wnp-arrow-gap: 6px;
166
+ --wnp-arrow-background: #f8fafc;
167
+ --wnp-arrow-color: #2563eb;
168
+ }
169
+ ```
package/demo/index.html CHANGED
@@ -48,7 +48,7 @@
48
48
  <body>
49
49
  <main>
50
50
  <h1>Wheel Number Picker</h1>
51
- <p>Drag, scroll, or use keyboard arrows.</p>
51
+ <p>Drag, scroll, use keyboard arrows, or click the arrow buttons. Resize below 480px to hide arrows.</p>
52
52
 
53
53
  <wheel-number-picker
54
54
  min="0"
@@ -59,7 +59,9 @@
59
59
  color="#111827"
60
60
  muted-color="#9ca3af"
61
61
  active-color="#2563eb"
62
- background="#f8fafc">
62
+ background="#f8fafc"
63
+ arrows
64
+ arrows-hidden-max-width="480">
63
65
  </wheel-number-picker>
64
66
 
65
67
  <div class="selected">Selected: <strong id="selectedValue">25</strong></div>
@@ -26,17 +26,21 @@ wheel-number-picker,
26
26
  }
27
27
 
28
28
  .wheel-number-picker__item {
29
+ box-sizing: border-box;
29
30
  height: var(--wnp-item-height, 44px);
31
+ min-height: var(--wnp-item-height, 44px);
30
32
  display: grid;
31
33
  place-items: center;
32
- font-size: var(--wnp-item-font-size, 22px);
34
+ line-height: 1;
35
+ font-size: var(--wnp-item-font-size, clamp(12px, calc(var(--wnp-item-height, 44px) * 0.5), 22px));
33
36
  color: var(--wnp-muted-color, #aaa);
34
37
  transition: color 120ms ease, font-size 120ms ease, opacity 120ms ease;
35
38
  }
36
39
 
37
40
  .wheel-number-picker__item--active {
38
41
  color: var(--wnp-active-color, var(--wnp-color, #111));
39
- font-size: var(--wnp-active-font-size, 34px);
42
+ line-height: 1;
43
+ font-size: var(--wnp-active-font-size, clamp(16px, calc(var(--wnp-item-height, 44px) * 0.72), 34px));
40
44
  font-weight: var(--wnp-active-font-weight, 750);
41
45
  }
42
46
 
@@ -58,3 +62,36 @@ wheel-number-picker,
58
62
  pointer-events: none;
59
63
  background: var(--wnp-fade, linear-gradient(to bottom, white 0%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, white 100%));
60
64
  }
65
+
66
+ .wheel-number-picker__arrow {
67
+ width: 100%;
68
+ height: var(--wnp-arrow-height, 32px);
69
+ display: var(--wnp-arrows-display, none);
70
+ place-items: center;
71
+ border: var(--wnp-arrow-border, 1px solid #ddd);
72
+ border-radius: var(--wnp-arrow-radius, 12px);
73
+ background: var(--wnp-arrow-background, var(--wnp-background, #fff));
74
+ color: var(--wnp-arrow-color, var(--wnp-active-color, var(--wnp-color, #111)));
75
+ font: inherit;
76
+ font-size: var(--wnp-arrow-font-size, 14px);
77
+ line-height: 1;
78
+ cursor: pointer;
79
+ user-select: none;
80
+ touch-action: manipulation;
81
+ }
82
+
83
+ .wheel-number-picker__arrow:hover {
84
+ background: var(--wnp-arrow-hover-background, #f5f5f5);
85
+ }
86
+
87
+ .wheel-number-picker__arrow:active {
88
+ transform: translateY(1px);
89
+ }
90
+
91
+ .wheel-number-picker__arrow--up {
92
+ margin-bottom: var(--wnp-arrow-gap, 8px);
93
+ }
94
+
95
+ .wheel-number-picker__arrow--down {
96
+ margin-top: var(--wnp-arrow-gap, 8px);
97
+ }
@@ -10,7 +10,9 @@
10
10
  color: '',
11
11
  activeColor: '',
12
12
  mutedColor: '',
13
- background: ''
13
+ background: '',
14
+ arrows: false,
15
+ arrowsHiddenMaxWidth: ''
14
16
  };
15
17
 
16
18
  function readOption(root, options, optionName, attributeName, fallback) {
@@ -36,6 +38,9 @@
36
38
  background: readOption(this.root, options, 'background', 'background', DEFAULTS.background)
37
39
  };
38
40
 
41
+ this.arrows = this.normalizeBoolean(readOption(this.root, options, 'arrows', 'arrows', DEFAULTS.arrows));
42
+ this.arrowsHiddenMaxWidth = readOption(this.root, options, 'arrowsHiddenMaxWidth', 'arrows-hidden-max-width', DEFAULTS.arrowsHiddenMaxWidth);
43
+
39
44
  this.isDragging = false;
40
45
  this.startY = 0;
41
46
  this.startOffset = 0;
@@ -45,6 +50,7 @@
45
50
  this.velocity = 0;
46
51
  this.animationFrame = null;
47
52
 
53
+ this.normalizeConfiguration();
48
54
  this.render();
49
55
  this.bindEvents();
50
56
  this.snapToValue(this.value, false);
@@ -57,26 +63,30 @@
57
63
  if (!this.root.getAttribute('aria-label')) this.root.setAttribute('aria-label', 'Number picker');
58
64
 
59
65
  this.root.innerHTML = `
66
+ <button class="wheel-number-picker__arrow wheel-number-picker__arrow--up" type="button" aria-label="Decrease value">▲</button>
60
67
  <div class="wheel-number-picker__window">
61
68
  <div class="wheel-number-picker__list"></div>
62
69
  <div class="wheel-number-picker__highlight"></div>
63
70
  <div class="wheel-number-picker__fade"></div>
64
71
  </div>
72
+ <button class="wheel-number-picker__arrow wheel-number-picker__arrow--down" type="button" aria-label="Increase value">▼</button>
65
73
  `;
66
74
 
67
75
  this.list = this.root.querySelector('.wheel-number-picker__list');
68
- this.rebuildItems();
76
+ this.upArrow = this.root.querySelector('.wheel-number-picker__arrow--up');
77
+ this.downArrow = this.root.querySelector('.wheel-number-picker__arrow--down');
69
78
  this.applyConfiguration();
79
+ this.rebuildItems();
70
80
  }
71
81
 
72
82
  rebuildItems() {
73
83
  if (!this.list) return;
74
84
  this.list.innerHTML = '';
75
- const spacerCount = Math.floor(this.visibleItems / 2);
85
+ this.spacerCount = Math.floor(this.visibleItems / 2);
76
86
 
77
- for (let i = 0; i < spacerCount; i++) this.list.appendChild(this.createItem(''));
87
+ for (let i = 0; i < this.spacerCount; i++) this.list.appendChild(this.createItem(''));
78
88
  for (let value = this.min; value <= this.max; value++) this.list.appendChild(this.createItem(value));
79
- for (let i = 0; i < spacerCount; i++) this.list.appendChild(this.createItem(''));
89
+ for (let i = 0; i < this.spacerCount; i++) this.list.appendChild(this.createItem(''));
80
90
  }
81
91
 
82
92
  createItem(value) {
@@ -87,12 +97,17 @@
87
97
  return item;
88
98
  }
89
99
 
90
- applyConfiguration() {
100
+ normalizeConfiguration() {
91
101
  if (!Number.isFinite(this.itemHeight) || this.itemHeight <= 0) this.itemHeight = DEFAULTS.itemHeight;
92
102
  if (!Number.isFinite(this.visibleItems) || this.visibleItems < 1) this.visibleItems = DEFAULTS.visibleItems;
93
103
 
94
104
  this.visibleItems = Math.round(this.visibleItems);
95
105
  if (this.visibleItems % 2 === 0) this.visibleItems += 1;
106
+ this.spacerCount = Math.floor(this.visibleItems / 2);
107
+ }
108
+
109
+ applyConfiguration() {
110
+ this.normalizeConfiguration();
96
111
 
97
112
  this.root.style.setProperty('--wnp-item-height', `${this.itemHeight}px`);
98
113
  this.root.style.setProperty('--wnp-visible-items', String(this.visibleItems));
@@ -105,6 +120,9 @@
105
120
 
106
121
  this.root.setAttribute('item-height', String(this.itemHeight));
107
122
  this.root.setAttribute('visible-items', String(this.visibleItems));
123
+ this.root.classList.toggle('wheel-number-picker--arrows', this.arrows);
124
+ this.root.style.setProperty('--wnp-arrows-display', this.arrows ? 'grid' : 'none');
125
+ this.applyArrowMediaQuery();
108
126
  }
109
127
 
110
128
  setCssVariableFromValue(name, value) {
@@ -112,18 +130,55 @@
112
130
  this.root.style.setProperty(name, String(value));
113
131
  }
114
132
 
133
+ normalizeBoolean(value) {
134
+ if (value === true || value === '') return true;
135
+ if (value === false || value === null || value === undefined) return false;
136
+ return !['false', '0', 'no', 'off'].includes(String(value).toLowerCase());
137
+ }
138
+
139
+ applyArrowMediaQuery() {
140
+ if (this.arrowStyleElement) this.arrowStyleElement.remove();
141
+ if (!this.arrows || this.arrowsHiddenMaxWidth === '' || this.arrowsHiddenMaxWidth === null || this.arrowsHiddenMaxWidth === undefined) return;
142
+
143
+ const maxWidth = Number(this.arrowsHiddenMaxWidth);
144
+ if (!Number.isFinite(maxWidth) || maxWidth < 0) return;
145
+
146
+ if (!this.root.id) this.root.id = `wheel-number-picker-${Math.random().toString(36).slice(2)}`;
147
+ this.arrowStyleElement = document.createElement('style');
148
+ this.arrowStyleElement.textContent = `@media (max-width: ${maxWidth}px) { #${CSS.escape(this.root.id)} .wheel-number-picker__arrow { display: none; } }`;
149
+ document.head.appendChild(this.arrowStyleElement);
150
+ }
151
+
152
+ setArrows(arrows) {
153
+ this.arrows = this.normalizeBoolean(arrows);
154
+ this.applyConfiguration();
155
+ }
156
+
157
+ setArrowsHiddenMaxWidth(value) {
158
+ this.arrowsHiddenMaxWidth = value ?? '';
159
+ this.applyConfiguration();
160
+ }
161
+
162
+
115
163
  bindEvents() {
116
164
  this.onPointerDownBound = this.onPointerDown.bind(this);
117
165
  this.onPointerMoveBound = this.onPointerMove.bind(this);
118
166
  this.onPointerUpBound = this.onPointerUp.bind(this);
119
167
  this.onWheelBound = this.onWheel.bind(this);
120
168
  this.onKeyDownBound = this.onKeyDown.bind(this);
169
+ this.onArrowUpClickBound = () => this.setValue(this.value - 1);
170
+ this.onArrowDownClickBound = () => this.setValue(this.value + 1);
171
+ this.onArrowPointerDownBound = event => event.stopPropagation();
121
172
 
122
173
  this.root.addEventListener('pointerdown', this.onPointerDownBound);
123
174
  window.addEventListener('pointermove', this.onPointerMoveBound);
124
175
  window.addEventListener('pointerup', this.onPointerUpBound);
125
176
  this.root.addEventListener('wheel', this.onWheelBound, { passive: false });
126
177
  this.root.addEventListener('keydown', this.onKeyDownBound);
178
+ this.upArrow.addEventListener('click', this.onArrowUpClickBound);
179
+ this.downArrow.addEventListener('click', this.onArrowDownClickBound);
180
+ this.upArrow.addEventListener('pointerdown', this.onArrowPointerDownBound);
181
+ this.downArrow.addEventListener('pointerdown', this.onArrowPointerDownBound);
127
182
  }
128
183
 
129
184
  destroy() {
@@ -133,6 +188,12 @@
133
188
  window.removeEventListener('pointerup', this.onPointerUpBound);
134
189
  this.root.removeEventListener('wheel', this.onWheelBound);
135
190
  this.root.removeEventListener('keydown', this.onKeyDownBound);
191
+ this.upArrow?.removeEventListener('click', this.onArrowUpClickBound);
192
+ this.downArrow?.removeEventListener('click', this.onArrowDownClickBound);
193
+ this.upArrow?.removeEventListener('pointerdown', this.onArrowPointerDownBound);
194
+ this.downArrow?.removeEventListener('pointerdown', this.onArrowPointerDownBound);
195
+ this.arrowStyleElement?.remove();
196
+ this.arrowStyleElement = null;
136
197
  this.root.innerHTML = '';
137
198
  }
138
199
 
@@ -215,7 +276,7 @@
215
276
  }
216
277
 
217
278
  snapToNearest() {
218
- const rawIndex = -this.offset / this.itemHeight;
279
+ const rawIndex = this.offsetToIndex(this.offset);
219
280
  const value = this.clamp(this.min + Math.round(rawIndex));
220
281
  this.setValue(value);
221
282
  }
@@ -256,9 +317,8 @@
256
317
  const next = Number(visibleItems);
257
318
  if (!Number.isFinite(next) || next < 1) return;
258
319
  this.visibleItems = Math.round(next);
259
- if (this.visibleItems % 2 === 0) this.visibleItems += 1;
260
- this.rebuildItems();
261
320
  this.applyConfiguration();
321
+ this.rebuildItems();
262
322
  this.snapToValue(this.value, false);
263
323
  }
264
324
 
@@ -272,7 +332,16 @@
272
332
  }
273
333
 
274
334
  valueToOffset(value) {
275
- return -(value - this.min) * this.itemHeight;
335
+ const index = Number(value) - this.min;
336
+ const windowCenter = (this.itemHeight * this.visibleItems) / 2;
337
+ const itemCenter = (this.spacerCount + index) * this.itemHeight + this.itemHeight / 2;
338
+ return windowCenter - itemCenter;
339
+ }
340
+
341
+ offsetToIndex(offset) {
342
+ const windowCenter = (this.itemHeight * this.visibleItems) / 2;
343
+ const centeredItemPosition = windowCenter - offset - this.itemHeight / 2;
344
+ return centeredItemPosition / this.itemHeight - this.spacerCount;
276
345
  }
277
346
 
278
347
  clamp(value) {
@@ -290,7 +359,7 @@
290
359
  }
291
360
 
292
361
  updateActiveFromOffset() {
293
- const rawIndex = -this.offset / this.itemHeight;
362
+ const rawIndex = this.offsetToIndex(this.offset);
294
363
  const value = this.clamp(this.min + Math.round(rawIndex));
295
364
  this.updateActive(value);
296
365
  }
@@ -317,7 +386,7 @@
317
386
 
318
387
  class WheelNumberPickerElement extends HTMLElement {
319
388
  static get observedAttributes() {
320
- return ['value', 'item-height', 'visible-items', 'color', 'active-color', 'muted-color', 'background'];
389
+ return ['value', 'item-height', 'visible-items', 'color', 'active-color', 'muted-color', 'background', 'arrows', 'arrows-hidden-max-width'];
321
390
  }
322
391
 
323
392
  connectedCallback() {
@@ -331,7 +400,9 @@
331
400
  color: this.getAttribute('color') ?? DEFAULTS.color,
332
401
  activeColor: this.getAttribute('active-color') ?? DEFAULTS.activeColor,
333
402
  mutedColor: this.getAttribute('muted-color') ?? DEFAULTS.mutedColor,
334
- background: this.getAttribute('background') ?? DEFAULTS.background
403
+ background: this.getAttribute('background') ?? DEFAULTS.background,
404
+ arrows: this.hasAttribute('arrows') ? this.getAttribute('arrows') : DEFAULTS.arrows,
405
+ arrowsHiddenMaxWidth: this.getAttribute('arrows-hidden-max-width') ?? DEFAULTS.arrowsHiddenMaxWidth
335
406
  });
336
407
  }
337
408
 
@@ -350,6 +421,8 @@
350
421
  if (name === 'active-color') this.picker.setColors({ activeColor: newValue });
351
422
  if (name === 'muted-color') this.picker.setColors({ mutedColor: newValue });
352
423
  if (name === 'background') this.picker.setColors({ background: newValue });
424
+ if (name === 'arrows') this.picker.setArrows(newValue !== null && this.picker.normalizeBoolean(newValue));
425
+ if (name === 'arrows-hidden-max-width') this.picker.setArrowsHiddenMaxWidth(newValue);
353
426
  }
354
427
 
355
428
  get value() {
@@ -10,7 +10,9 @@
10
10
  color: '',
11
11
  activeColor: '',
12
12
  mutedColor: '',
13
- background: ''
13
+ background: '',
14
+ arrows: false,
15
+ arrowsHiddenMaxWidth: ''
14
16
  };
15
17
 
16
18
  function readOption(root, options, optionName, attributeName, fallback) {
@@ -36,6 +38,9 @@
36
38
  background: readOption(this.root, options, 'background', 'background', DEFAULTS.background)
37
39
  };
38
40
 
41
+ this.arrows = this.normalizeBoolean(readOption(this.root, options, 'arrows', 'arrows', DEFAULTS.arrows));
42
+ this.arrowsHiddenMaxWidth = readOption(this.root, options, 'arrowsHiddenMaxWidth', 'arrows-hidden-max-width', DEFAULTS.arrowsHiddenMaxWidth);
43
+
39
44
  this.isDragging = false;
40
45
  this.startY = 0;
41
46
  this.startOffset = 0;
@@ -45,6 +50,7 @@
45
50
  this.velocity = 0;
46
51
  this.animationFrame = null;
47
52
 
53
+ this.normalizeConfiguration();
48
54
  this.render();
49
55
  this.bindEvents();
50
56
  this.snapToValue(this.value, false);
@@ -57,26 +63,30 @@
57
63
  if (!this.root.getAttribute('aria-label')) this.root.setAttribute('aria-label', 'Number picker');
58
64
 
59
65
  this.root.innerHTML = `
66
+ <button class="wheel-number-picker__arrow wheel-number-picker__arrow--up" type="button" aria-label="Decrease value">▲</button>
60
67
  <div class="wheel-number-picker__window">
61
68
  <div class="wheel-number-picker__list"></div>
62
69
  <div class="wheel-number-picker__highlight"></div>
63
70
  <div class="wheel-number-picker__fade"></div>
64
71
  </div>
72
+ <button class="wheel-number-picker__arrow wheel-number-picker__arrow--down" type="button" aria-label="Increase value">▼</button>
65
73
  `;
66
74
 
67
75
  this.list = this.root.querySelector('.wheel-number-picker__list');
68
- this.rebuildItems();
76
+ this.upArrow = this.root.querySelector('.wheel-number-picker__arrow--up');
77
+ this.downArrow = this.root.querySelector('.wheel-number-picker__arrow--down');
69
78
  this.applyConfiguration();
79
+ this.rebuildItems();
70
80
  }
71
81
 
72
82
  rebuildItems() {
73
83
  if (!this.list) return;
74
84
  this.list.innerHTML = '';
75
- const spacerCount = Math.floor(this.visibleItems / 2);
85
+ this.spacerCount = Math.floor(this.visibleItems / 2);
76
86
 
77
- for (let i = 0; i < spacerCount; i++) this.list.appendChild(this.createItem(''));
87
+ for (let i = 0; i < this.spacerCount; i++) this.list.appendChild(this.createItem(''));
78
88
  for (let value = this.min; value <= this.max; value++) this.list.appendChild(this.createItem(value));
79
- for (let i = 0; i < spacerCount; i++) this.list.appendChild(this.createItem(''));
89
+ for (let i = 0; i < this.spacerCount; i++) this.list.appendChild(this.createItem(''));
80
90
  }
81
91
 
82
92
  createItem(value) {
@@ -87,12 +97,17 @@
87
97
  return item;
88
98
  }
89
99
 
90
- applyConfiguration() {
100
+ normalizeConfiguration() {
91
101
  if (!Number.isFinite(this.itemHeight) || this.itemHeight <= 0) this.itemHeight = DEFAULTS.itemHeight;
92
102
  if (!Number.isFinite(this.visibleItems) || this.visibleItems < 1) this.visibleItems = DEFAULTS.visibleItems;
93
103
 
94
104
  this.visibleItems = Math.round(this.visibleItems);
95
105
  if (this.visibleItems % 2 === 0) this.visibleItems += 1;
106
+ this.spacerCount = Math.floor(this.visibleItems / 2);
107
+ }
108
+
109
+ applyConfiguration() {
110
+ this.normalizeConfiguration();
96
111
 
97
112
  this.root.style.setProperty('--wnp-item-height', `${this.itemHeight}px`);
98
113
  this.root.style.setProperty('--wnp-visible-items', String(this.visibleItems));
@@ -105,6 +120,9 @@
105
120
 
106
121
  this.root.setAttribute('item-height', String(this.itemHeight));
107
122
  this.root.setAttribute('visible-items', String(this.visibleItems));
123
+ this.root.classList.toggle('wheel-number-picker--arrows', this.arrows);
124
+ this.root.style.setProperty('--wnp-arrows-display', this.arrows ? 'grid' : 'none');
125
+ this.applyArrowMediaQuery();
108
126
  }
109
127
 
110
128
  setCssVariableFromValue(name, value) {
@@ -112,18 +130,55 @@
112
130
  this.root.style.setProperty(name, String(value));
113
131
  }
114
132
 
133
+ normalizeBoolean(value) {
134
+ if (value === true || value === '') return true;
135
+ if (value === false || value === null || value === undefined) return false;
136
+ return !['false', '0', 'no', 'off'].includes(String(value).toLowerCase());
137
+ }
138
+
139
+ applyArrowMediaQuery() {
140
+ if (this.arrowStyleElement) this.arrowStyleElement.remove();
141
+ if (!this.arrows || this.arrowsHiddenMaxWidth === '' || this.arrowsHiddenMaxWidth === null || this.arrowsHiddenMaxWidth === undefined) return;
142
+
143
+ const maxWidth = Number(this.arrowsHiddenMaxWidth);
144
+ if (!Number.isFinite(maxWidth) || maxWidth < 0) return;
145
+
146
+ if (!this.root.id) this.root.id = `wheel-number-picker-${Math.random().toString(36).slice(2)}`;
147
+ this.arrowStyleElement = document.createElement('style');
148
+ this.arrowStyleElement.textContent = `@media (max-width: ${maxWidth}px) { #${CSS.escape(this.root.id)} .wheel-number-picker__arrow { display: none; } }`;
149
+ document.head.appendChild(this.arrowStyleElement);
150
+ }
151
+
152
+ setArrows(arrows) {
153
+ this.arrows = this.normalizeBoolean(arrows);
154
+ this.applyConfiguration();
155
+ }
156
+
157
+ setArrowsHiddenMaxWidth(value) {
158
+ this.arrowsHiddenMaxWidth = value ?? '';
159
+ this.applyConfiguration();
160
+ }
161
+
162
+
115
163
  bindEvents() {
116
164
  this.onPointerDownBound = this.onPointerDown.bind(this);
117
165
  this.onPointerMoveBound = this.onPointerMove.bind(this);
118
166
  this.onPointerUpBound = this.onPointerUp.bind(this);
119
167
  this.onWheelBound = this.onWheel.bind(this);
120
168
  this.onKeyDownBound = this.onKeyDown.bind(this);
169
+ this.onArrowUpClickBound = () => this.setValue(this.value - 1);
170
+ this.onArrowDownClickBound = () => this.setValue(this.value + 1);
171
+ this.onArrowPointerDownBound = event => event.stopPropagation();
121
172
 
122
173
  this.root.addEventListener('pointerdown', this.onPointerDownBound);
123
174
  window.addEventListener('pointermove', this.onPointerMoveBound);
124
175
  window.addEventListener('pointerup', this.onPointerUpBound);
125
176
  this.root.addEventListener('wheel', this.onWheelBound, { passive: false });
126
177
  this.root.addEventListener('keydown', this.onKeyDownBound);
178
+ this.upArrow.addEventListener('click', this.onArrowUpClickBound);
179
+ this.downArrow.addEventListener('click', this.onArrowDownClickBound);
180
+ this.upArrow.addEventListener('pointerdown', this.onArrowPointerDownBound);
181
+ this.downArrow.addEventListener('pointerdown', this.onArrowPointerDownBound);
127
182
  }
128
183
 
129
184
  destroy() {
@@ -133,6 +188,12 @@
133
188
  window.removeEventListener('pointerup', this.onPointerUpBound);
134
189
  this.root.removeEventListener('wheel', this.onWheelBound);
135
190
  this.root.removeEventListener('keydown', this.onKeyDownBound);
191
+ this.upArrow?.removeEventListener('click', this.onArrowUpClickBound);
192
+ this.downArrow?.removeEventListener('click', this.onArrowDownClickBound);
193
+ this.upArrow?.removeEventListener('pointerdown', this.onArrowPointerDownBound);
194
+ this.downArrow?.removeEventListener('pointerdown', this.onArrowPointerDownBound);
195
+ this.arrowStyleElement?.remove();
196
+ this.arrowStyleElement = null;
136
197
  this.root.innerHTML = '';
137
198
  }
138
199
 
@@ -215,7 +276,7 @@
215
276
  }
216
277
 
217
278
  snapToNearest() {
218
- const rawIndex = -this.offset / this.itemHeight;
279
+ const rawIndex = this.offsetToIndex(this.offset);
219
280
  const value = this.clamp(this.min + Math.round(rawIndex));
220
281
  this.setValue(value);
221
282
  }
@@ -256,9 +317,8 @@
256
317
  const next = Number(visibleItems);
257
318
  if (!Number.isFinite(next) || next < 1) return;
258
319
  this.visibleItems = Math.round(next);
259
- if (this.visibleItems % 2 === 0) this.visibleItems += 1;
260
- this.rebuildItems();
261
320
  this.applyConfiguration();
321
+ this.rebuildItems();
262
322
  this.snapToValue(this.value, false);
263
323
  }
264
324
 
@@ -272,7 +332,16 @@
272
332
  }
273
333
 
274
334
  valueToOffset(value) {
275
- return -(value - this.min) * this.itemHeight;
335
+ const index = Number(value) - this.min;
336
+ const windowCenter = (this.itemHeight * this.visibleItems) / 2;
337
+ const itemCenter = (this.spacerCount + index) * this.itemHeight + this.itemHeight / 2;
338
+ return windowCenter - itemCenter;
339
+ }
340
+
341
+ offsetToIndex(offset) {
342
+ const windowCenter = (this.itemHeight * this.visibleItems) / 2;
343
+ const centeredItemPosition = windowCenter - offset - this.itemHeight / 2;
344
+ return centeredItemPosition / this.itemHeight - this.spacerCount;
276
345
  }
277
346
 
278
347
  clamp(value) {
@@ -290,7 +359,7 @@
290
359
  }
291
360
 
292
361
  updateActiveFromOffset() {
293
- const rawIndex = -this.offset / this.itemHeight;
362
+ const rawIndex = this.offsetToIndex(this.offset);
294
363
  const value = this.clamp(this.min + Math.round(rawIndex));
295
364
  this.updateActive(value);
296
365
  }
@@ -317,7 +386,7 @@
317
386
 
318
387
  class WheelNumberPickerElement extends HTMLElement {
319
388
  static get observedAttributes() {
320
- return ['value', 'item-height', 'visible-items', 'color', 'active-color', 'muted-color', 'background'];
389
+ return ['value', 'item-height', 'visible-items', 'color', 'active-color', 'muted-color', 'background', 'arrows', 'arrows-hidden-max-width'];
321
390
  }
322
391
 
323
392
  connectedCallback() {
@@ -331,7 +400,9 @@
331
400
  color: this.getAttribute('color') ?? DEFAULTS.color,
332
401
  activeColor: this.getAttribute('active-color') ?? DEFAULTS.activeColor,
333
402
  mutedColor: this.getAttribute('muted-color') ?? DEFAULTS.mutedColor,
334
- background: this.getAttribute('background') ?? DEFAULTS.background
403
+ background: this.getAttribute('background') ?? DEFAULTS.background,
404
+ arrows: this.hasAttribute('arrows') ? this.getAttribute('arrows') : DEFAULTS.arrows,
405
+ arrowsHiddenMaxWidth: this.getAttribute('arrows-hidden-max-width') ?? DEFAULTS.arrowsHiddenMaxWidth
335
406
  });
336
407
  }
337
408
 
@@ -350,6 +421,8 @@
350
421
  if (name === 'active-color') this.picker.setColors({ activeColor: newValue });
351
422
  if (name === 'muted-color') this.picker.setColors({ mutedColor: newValue });
352
423
  if (name === 'background') this.picker.setColors({ background: newValue });
424
+ if (name === 'arrows') this.picker.setArrows(newValue !== null && this.picker.normalizeBoolean(newValue));
425
+ if (name === 'arrows-hidden-max-width') this.picker.setArrowsHiddenMaxWidth(newValue);
353
426
  }
354
427
 
355
428
  get value() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-wheel-number-picker",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "A plain JavaScript wheel-style number picker with drag, inertia, snapping, keyboard support, colors, dynamic item height, and a custom element API.",
5
5
  "main": "dist/wheel-number-picker.js",
6
6
  "unpkg": "dist/wheel-number-picker.min.js",