vanilla-wheel-number-picker 1.0.1 → 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
 
@@ -11,7 +11,20 @@ A small plain JavaScript wheel-style number picker. No framework required.
11
11
  />
12
12
  <script src="https://cdn.jsdelivr.net/npm/vanilla-wheel-number-picker@1/dist/wheel-number-picker.min.js"></script>
13
13
 
14
- <wheel-number-picker min="0" max="99" value="25"></wheel-number-picker>
14
+ <wheel-number-picker
15
+ min="0"
16
+ max="99"
17
+ value="25"
18
+ item-height="56"
19
+ visible-items="3"
20
+ color="#111827"
21
+ muted-color="#9ca3af"
22
+ active-color="#2563eb"
23
+ background="#f8fafc"
24
+ arrows
25
+ arrows-hidden-max-width="480"
26
+ >
27
+ </wheel-number-picker>
15
28
 
16
29
  <script>
17
30
  const picker = document.querySelector("wheel-number-picker");
@@ -41,6 +54,14 @@ A small plain JavaScript wheel-style number picker. No framework required.
41
54
  min: 18,
42
55
  max: 100,
43
56
  value: 30,
57
+ itemHeight: 56,
58
+ visibleItems: 3,
59
+ color: "#111827",
60
+ mutedColor: "#9ca3af",
61
+ activeColor: "#2563eb",
62
+ background: "#f8fafc",
63
+ arrows: true,
64
+ arrowsHiddenMaxWidth: 480,
44
65
  onChange(value) {
45
66
  console.log(value);
46
67
  },
@@ -50,28 +71,99 @@ A small plain JavaScript wheel-style number picker. No framework required.
50
71
 
51
72
  ## Options / attributes
52
73
 
53
- | Option | Attribute | Default | Description |
54
- | -------------- | --------------- | ------: | ---------------------- |
55
- | `min` | `min` | `0` | Minimum value |
56
- | `max` | `max` | `100` | Maximum value |
57
- | `value` | `value` | `min` | Initial value |
58
- | `itemHeight` | `item-height` | `44` | Item height in pixels |
59
- | `visibleItems` | `visible-items` | `5` | Number of visible rows |
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. |
87
+
88
+ ## Dynamic updates
89
+
90
+ These attributes can be changed after the component is mounted:
91
+
92
+ ```js
93
+ const picker = document.querySelector("wheel-number-picker");
94
+
95
+ picker.setAttribute("item-height", "64");
96
+ picker.setAttribute("visible-items", "3");
97
+ picker.setAttribute("active-color", "crimson");
98
+ picker.setAttribute("background", "#fff7ed");
99
+ picker.setAttribute("arrows", "true");
100
+ picker.setAttribute("arrows-hidden-max-width", "480");
101
+ ```
102
+
103
+ The component recalculates its render dimensions and snapping math automatically.
60
104
 
61
- ## Styling
105
+ ## Styling with CSS variables
62
106
 
63
- Override CSS variables:
107
+ You can also style with CSS variables:
64
108
 
65
109
  ```css
66
110
  wheel-number-picker {
67
- --wnp-width: 180px;
68
- --wnp-height: 240px;
69
- --wnp-active-color: #111;
70
- --wnp-muted-color: #aaa;
71
- --wnp-item-height: 44px;
111
+ width: 320px;
112
+ --wnp-item-height: 56px;
113
+ --wnp-height: 168px;
114
+ --wnp-color: #111827;
115
+ --wnp-active-color: #2563eb;
116
+ --wnp-muted-color: #9ca3af;
117
+ --wnp-background: #f8fafc;
72
118
  }
73
119
  ```
74
120
 
121
+ For the most predictable behavior, prefer the `item-height` attribute or `itemHeight` option because that updates both the visual height and the JavaScript snapping calculations.
122
+
75
123
  ## Local demo
76
124
 
77
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
@@ -17,7 +17,7 @@
17
17
  color: #111;
18
18
  }
19
19
  main {
20
- width: min(420px, calc(100vw - 32px));
20
+ width: min(520px, calc(100vw - 32px));
21
21
  padding: 32px;
22
22
  border-radius: 24px;
23
23
  background: white;
@@ -26,18 +26,60 @@
26
26
  }
27
27
  h1 { margin: 0 0 8px; font-size: 24px; }
28
28
  p { color: #666; }
29
+ wheel-number-picker { width: 320px; max-width: 100%; }
29
30
  .selected { margin-top: 24px; font-size: 18px; }
30
31
  .selected strong { font-size: 28px; }
32
+ .controls {
33
+ margin-top: 24px;
34
+ display: grid;
35
+ gap: 12px;
36
+ text-align: left;
37
+ }
38
+ label {
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ gap: 16px;
43
+ color: #444;
44
+ }
45
+ input { width: 120px; }
31
46
  </style>
32
47
  </head>
33
48
  <body>
34
49
  <main>
35
50
  <h1>Wheel Number Picker</h1>
36
- <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>
37
52
 
38
- <wheel-number-picker min="0" max="99" value="25"></wheel-number-picker>
53
+ <wheel-number-picker
54
+ min="0"
55
+ max="99"
56
+ value="25"
57
+ item-height="56"
58
+ visible-items="3"
59
+ color="#111827"
60
+ muted-color="#9ca3af"
61
+ active-color="#2563eb"
62
+ background="#f8fafc"
63
+ arrows
64
+ arrows-hidden-max-width="480">
65
+ </wheel-number-picker>
39
66
 
40
67
  <div class="selected">Selected: <strong id="selectedValue">25</strong></div>
68
+
69
+ <div class="controls">
70
+ <label>
71
+ Item height
72
+ <input id="itemHeight" type="range" min="36" max="80" value="56">
73
+ </label>
74
+ <label>
75
+ Active color
76
+ <input id="activeColor" type="color" value="#2563eb">
77
+ </label>
78
+ <label>
79
+ Background
80
+ <input id="background" type="color" value="#f8fafc">
81
+ </label>
82
+ </div>
41
83
  </main>
42
84
 
43
85
  <script src="../dist/wheel-number-picker.min.js"></script>
@@ -48,6 +90,18 @@
48
90
  picker.addEventListener('change', event => {
49
91
  selectedValue.textContent = event.detail.value;
50
92
  });
93
+
94
+ document.getElementById('itemHeight').addEventListener('input', event => {
95
+ picker.setAttribute('item-height', event.target.value);
96
+ });
97
+
98
+ document.getElementById('activeColor').addEventListener('input', event => {
99
+ picker.setAttribute('active-color', event.target.value);
100
+ });
101
+
102
+ document.getElementById('background').addEventListener('input', event => {
103
+ picker.setAttribute('background', event.target.value);
104
+ });
51
105
  </script>
52
106
  </body>
53
107
  </html>
@@ -6,11 +6,12 @@ wheel-number-picker,
6
6
  touch-action: none;
7
7
  outline: none;
8
8
  font-family: var(--wnp-font-family, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
9
+ color: var(--wnp-color, #111);
9
10
  }
10
11
 
11
12
  .wheel-number-picker__window {
12
13
  position: relative;
13
- height: var(--wnp-height, 220px);
14
+ height: var(--wnp-height, calc(var(--wnp-item-height, 44px) * var(--wnp-visible-items, 5)));
14
15
  overflow: hidden;
15
16
  border-radius: var(--wnp-radius, 20px);
16
17
  background: var(
@@ -25,17 +26,21 @@ wheel-number-picker,
25
26
  }
26
27
 
27
28
  .wheel-number-picker__item {
29
+ box-sizing: border-box;
28
30
  height: var(--wnp-item-height, 44px);
31
+ min-height: var(--wnp-item-height, 44px);
29
32
  display: grid;
30
33
  place-items: center;
31
- 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));
32
36
  color: var(--wnp-muted-color, #aaa);
33
37
  transition: color 120ms ease, font-size 120ms ease, opacity 120ms ease;
34
38
  }
35
39
 
36
40
  .wheel-number-picker__item--active {
37
- color: var(--wnp-active-color, #111);
38
- font-size: var(--wnp-active-font-size, 34px);
41
+ color: var(--wnp-active-color, var(--wnp-color, #111));
42
+ line-height: 1;
43
+ font-size: var(--wnp-active-font-size, clamp(16px, calc(var(--wnp-item-height, 44px) * 0.72), 34px));
39
44
  font-weight: var(--wnp-active-font-weight, 750);
40
45
  }
41
46
 
@@ -57,3 +62,36 @@ wheel-number-picker,
57
62
  pointer-events: none;
58
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%));
59
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
+ }
@@ -1,17 +1,45 @@
1
1
  (function (global) {
2
2
  'use strict';
3
3
 
4
+ const DEFAULTS = {
5
+ min: 0,
6
+ max: 100,
7
+ value: 0,
8
+ itemHeight: 44,
9
+ visibleItems: 5,
10
+ color: '',
11
+ activeColor: '',
12
+ mutedColor: '',
13
+ background: '',
14
+ arrows: false,
15
+ arrowsHiddenMaxWidth: ''
16
+ };
17
+
18
+ function readOption(root, options, optionName, attributeName, fallback) {
19
+ return options[optionName] ?? root.getAttribute(attributeName) ?? fallback;
20
+ }
21
+
4
22
  class WheelNumberPicker {
5
23
  constructor(root, options = {}) {
6
24
  this.root = typeof root === 'string' ? document.querySelector(root) : root;
7
25
  if (!this.root) throw new Error('WheelNumberPicker root element was not found.');
8
26
 
9
- this.min = Number(options.min ?? this.root.getAttribute('min') ?? 0);
10
- this.max = Number(options.max ?? this.root.getAttribute('max') ?? 100);
11
- this.value = this.clamp(options.value ?? this.root.getAttribute('value') ?? this.min);
27
+ this.min = Number(readOption(this.root, options, 'min', 'min', DEFAULTS.min));
28
+ this.max = Number(readOption(this.root, options, 'max', 'max', DEFAULTS.max));
29
+ this.itemHeight = Number(readOption(this.root, options, 'itemHeight', 'item-height', DEFAULTS.itemHeight));
30
+ this.visibleItems = Number(readOption(this.root, options, 'visibleItems', 'visible-items', DEFAULTS.visibleItems));
31
+ this.value = this.clamp(readOption(this.root, options, 'value', 'value', this.min));
12
32
  this.onChange = options.onChange ?? function () {};
13
- this.itemHeight = Number(options.itemHeight ?? this.root.getAttribute('item-height') ?? 44);
14
- this.visibleItems = Number(options.visibleItems ?? this.root.getAttribute('visible-items') ?? 5);
33
+
34
+ this.colors = {
35
+ color: readOption(this.root, options, 'color', 'color', DEFAULTS.color),
36
+ activeColor: readOption(this.root, options, 'activeColor', 'active-color', DEFAULTS.activeColor),
37
+ mutedColor: readOption(this.root, options, 'mutedColor', 'muted-color', DEFAULTS.mutedColor),
38
+ background: readOption(this.root, options, 'background', 'background', DEFAULTS.background)
39
+ };
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);
15
43
 
16
44
  this.isDragging = false;
17
45
  this.startY = 0;
@@ -22,6 +50,7 @@
22
50
  this.velocity = 0;
23
51
  this.animationFrame = null;
24
52
 
53
+ this.normalizeConfiguration();
25
54
  this.render();
26
55
  this.bindEvents();
27
56
  this.snapToValue(this.value, false);
@@ -34,42 +63,122 @@
34
63
  if (!this.root.getAttribute('aria-label')) this.root.setAttribute('aria-label', 'Number picker');
35
64
 
36
65
  this.root.innerHTML = `
66
+ <button class="wheel-number-picker__arrow wheel-number-picker__arrow--up" type="button" aria-label="Decrease value">▲</button>
37
67
  <div class="wheel-number-picker__window">
38
68
  <div class="wheel-number-picker__list"></div>
39
69
  <div class="wheel-number-picker__highlight"></div>
40
70
  <div class="wheel-number-picker__fade"></div>
41
71
  </div>
72
+ <button class="wheel-number-picker__arrow wheel-number-picker__arrow--down" type="button" aria-label="Increase value">▼</button>
42
73
  `;
43
74
 
44
75
  this.list = this.root.querySelector('.wheel-number-picker__list');
45
- const spacerCount = Math.floor(this.visibleItems / 2);
76
+ this.upArrow = this.root.querySelector('.wheel-number-picker__arrow--up');
77
+ this.downArrow = this.root.querySelector('.wheel-number-picker__arrow--down');
78
+ this.applyConfiguration();
79
+ this.rebuildItems();
80
+ }
81
+
82
+ rebuildItems() {
83
+ if (!this.list) return;
84
+ this.list.innerHTML = '';
85
+ this.spacerCount = Math.floor(this.visibleItems / 2);
46
86
 
47
- 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(''));
48
88
  for (let value = this.min; value <= this.max; value++) this.list.appendChild(this.createItem(value));
49
- 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(''));
50
90
  }
51
91
 
52
92
  createItem(value) {
53
93
  const item = document.createElement('div');
54
94
  item.className = 'wheel-number-picker__item';
55
- item.style.height = `${this.itemHeight}px`;
56
95
  item.textContent = value;
57
96
  if (value !== '') item.dataset.value = value;
58
97
  return item;
59
98
  }
60
99
 
100
+ normalizeConfiguration() {
101
+ if (!Number.isFinite(this.itemHeight) || this.itemHeight <= 0) this.itemHeight = DEFAULTS.itemHeight;
102
+ if (!Number.isFinite(this.visibleItems) || this.visibleItems < 1) this.visibleItems = DEFAULTS.visibleItems;
103
+
104
+ this.visibleItems = Math.round(this.visibleItems);
105
+ if (this.visibleItems % 2 === 0) this.visibleItems += 1;
106
+ this.spacerCount = Math.floor(this.visibleItems / 2);
107
+ }
108
+
109
+ applyConfiguration() {
110
+ this.normalizeConfiguration();
111
+
112
+ this.root.style.setProperty('--wnp-item-height', `${this.itemHeight}px`);
113
+ this.root.style.setProperty('--wnp-visible-items', String(this.visibleItems));
114
+ this.root.style.setProperty('--wnp-height', `${this.itemHeight * this.visibleItems}px`);
115
+
116
+ this.setCssVariableFromValue('--wnp-color', this.colors.color);
117
+ this.setCssVariableFromValue('--wnp-active-color', this.colors.activeColor);
118
+ this.setCssVariableFromValue('--wnp-muted-color', this.colors.mutedColor);
119
+ this.setCssVariableFromValue('--wnp-background', this.colors.background);
120
+
121
+ this.root.setAttribute('item-height', String(this.itemHeight));
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();
126
+ }
127
+
128
+ setCssVariableFromValue(name, value) {
129
+ if (value === undefined || value === null || value === '') return;
130
+ this.root.style.setProperty(name, String(value));
131
+ }
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
+
61
163
  bindEvents() {
62
164
  this.onPointerDownBound = this.onPointerDown.bind(this);
63
165
  this.onPointerMoveBound = this.onPointerMove.bind(this);
64
166
  this.onPointerUpBound = this.onPointerUp.bind(this);
65
167
  this.onWheelBound = this.onWheel.bind(this);
66
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();
67
172
 
68
173
  this.root.addEventListener('pointerdown', this.onPointerDownBound);
69
174
  window.addEventListener('pointermove', this.onPointerMoveBound);
70
175
  window.addEventListener('pointerup', this.onPointerUpBound);
71
176
  this.root.addEventListener('wheel', this.onWheelBound, { passive: false });
72
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);
73
182
  }
74
183
 
75
184
  destroy() {
@@ -79,6 +188,12 @@
79
188
  window.removeEventListener('pointerup', this.onPointerUpBound);
80
189
  this.root.removeEventListener('wheel', this.onWheelBound);
81
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;
82
197
  this.root.innerHTML = '';
83
198
  }
84
199
 
@@ -161,7 +276,7 @@
161
276
  }
162
277
 
163
278
  snapToNearest() {
164
- const rawIndex = -this.offset / this.itemHeight;
279
+ const rawIndex = this.offsetToIndex(this.offset);
165
280
  const value = this.clamp(this.min + Math.round(rawIndex));
166
281
  this.setValue(value);
167
282
  }
@@ -190,12 +305,43 @@
190
305
  this.root.dispatchEvent(new CustomEvent('change', { detail: { value: this.value } }));
191
306
  }
192
307
 
308
+ setItemHeight(itemHeight) {
309
+ const next = Number(itemHeight);
310
+ if (!Number.isFinite(next) || next <= 0) return;
311
+ this.itemHeight = next;
312
+ this.applyConfiguration();
313
+ this.snapToValue(this.value, false);
314
+ }
315
+
316
+ setVisibleItems(visibleItems) {
317
+ const next = Number(visibleItems);
318
+ if (!Number.isFinite(next) || next < 1) return;
319
+ this.visibleItems = Math.round(next);
320
+ this.applyConfiguration();
321
+ this.rebuildItems();
322
+ this.snapToValue(this.value, false);
323
+ }
324
+
325
+ setColors(colors = {}) {
326
+ this.colors = { ...this.colors, ...colors };
327
+ this.applyConfiguration();
328
+ }
329
+
193
330
  getValue() {
194
331
  return this.value;
195
332
  }
196
333
 
197
334
  valueToOffset(value) {
198
- 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;
199
345
  }
200
346
 
201
347
  clamp(value) {
@@ -213,7 +359,7 @@
213
359
  }
214
360
 
215
361
  updateActiveFromOffset() {
216
- const rawIndex = -this.offset / this.itemHeight;
362
+ const rawIndex = this.offsetToIndex(this.offset);
217
363
  const value = this.clamp(this.min + Math.round(rawIndex));
218
364
  this.updateActive(value);
219
365
  }
@@ -240,17 +386,23 @@
240
386
 
241
387
  class WheelNumberPickerElement extends HTMLElement {
242
388
  static get observedAttributes() {
243
- return ['value'];
389
+ return ['value', 'item-height', 'visible-items', 'color', 'active-color', 'muted-color', 'background', 'arrows', 'arrows-hidden-max-width'];
244
390
  }
245
391
 
246
392
  connectedCallback() {
247
393
  if (this.picker) return;
248
394
  this.picker = new WheelNumberPicker(this, {
249
- min: this.getAttribute('min') ?? 0,
250
- max: this.getAttribute('max') ?? 100,
251
- value: this.getAttribute('value') ?? this.getAttribute('min') ?? 0,
252
- itemHeight: this.getAttribute('item-height') ?? 44,
253
- visibleItems: this.getAttribute('visible-items') ?? 5
395
+ min: this.getAttribute('min') ?? DEFAULTS.min,
396
+ max: this.getAttribute('max') ?? DEFAULTS.max,
397
+ value: this.getAttribute('value') ?? this.getAttribute('min') ?? DEFAULTS.min,
398
+ itemHeight: this.getAttribute('item-height') ?? DEFAULTS.itemHeight,
399
+ visibleItems: this.getAttribute('visible-items') ?? DEFAULTS.visibleItems,
400
+ color: this.getAttribute('color') ?? DEFAULTS.color,
401
+ activeColor: this.getAttribute('active-color') ?? DEFAULTS.activeColor,
402
+ mutedColor: this.getAttribute('muted-color') ?? DEFAULTS.mutedColor,
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
254
406
  });
255
407
  }
256
408
 
@@ -260,9 +412,17 @@
260
412
  }
261
413
 
262
414
  attributeChangedCallback(name, oldValue, newValue) {
263
- if (name === 'value' && this.picker && oldValue !== newValue) {
264
- this.picker.setValue(Number(newValue));
265
- }
415
+ if (!this.picker || oldValue === newValue) return;
416
+
417
+ if (name === 'value') this.picker.setValue(Number(newValue));
418
+ if (name === 'item-height') this.picker.setItemHeight(Number(newValue));
419
+ if (name === 'visible-items') this.picker.setVisibleItems(Number(newValue));
420
+ if (name === 'color') this.picker.setColors({ color: newValue });
421
+ if (name === 'active-color') this.picker.setColors({ activeColor: newValue });
422
+ if (name === 'muted-color') this.picker.setColors({ mutedColor: newValue });
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);
266
426
  }
267
427
 
268
428
  get value() {
@@ -1 +1,443 @@
1
- (function (global) {'use strict';class WheelNumberPicker {constructor(root, options = {}) {this.root = typeof root === 'string' ? document.querySelector(root) : root;if (!this.root) throw new Error('WheelNumberPicker root element was not found.');this.min = Number(options.min ?? this.root.getAttribute('min') ?? 0);this.max = Number(options.max ?? this.root.getAttribute('max') ?? 100);this.value = this.clamp(options.value ?? this.root.getAttribute('value') ?? this.min);this.onChange = options.onChange ?? function () {};this.itemHeight = Number(options.itemHeight ?? this.root.getAttribute('item-height') ?? 44);this.visibleItems = Number(options.visibleItems ?? this.root.getAttribute('visible-items') ?? 5);this.isDragging = false;this.startY = 0;this.startOffset = 0;this.offset = this.valueToOffset(this.value);this.lastY = 0;this.lastTime = 0;this.velocity = 0;this.animationFrame = null;this.render();this.bindEvents();this.snapToValue(this.value, false);}render() {this.root.classList.add('wheel-number-picker');this.root.tabIndex = this.root.tabIndex >= 0 ? this.root.tabIndex : 0;this.root.setAttribute('role', 'spinbutton');if (!this.root.getAttribute('aria-label')) this.root.setAttribute('aria-label', 'Number picker');this.root.innerHTML = `<div class="wheel-number-picker__window"><div class="wheel-number-picker__list"></div><div class="wheel-number-picker__highlight"></div><div class="wheel-number-picker__fade"></div></div>`;this.list = this.root.querySelector('.wheel-number-picker__list');const spacerCount = Math.floor(this.visibleItems / 2);for (let i = 0; i < spacerCount; i++) this.list.appendChild(this.createItem(''));for (let value = this.min; value <= this.max; value++) this.list.appendChild(this.createItem(value));for (let i = 0; i < spacerCount; i++) this.list.appendChild(this.createItem(''));}createItem(value) {const item = document.createElement('div');item.className = 'wheel-number-picker__item';item.style.height = `${this.itemHeight}px`;item.textContent = value;if (value !== '') item.dataset.value = value;return item;}bindEvents() {this.onPointerDownBound = this.onPointerDown.bind(this);this.onPointerMoveBound = this.onPointerMove.bind(this);this.onPointerUpBound = this.onPointerUp.bind(this);this.onWheelBound = this.onWheel.bind(this);this.onKeyDownBound = this.onKeyDown.bind(this);this.root.addEventListener('pointerdown', this.onPointerDownBound);window.addEventListener('pointermove', this.onPointerMoveBound);window.addEventListener('pointerup', this.onPointerUpBound);this.root.addEventListener('wheel', this.onWheelBound, { passive: false });this.root.addEventListener('keydown', this.onKeyDownBound);}destroy() {this.cancelAnimation();this.root.removeEventListener('pointerdown', this.onPointerDownBound);window.removeEventListener('pointermove', this.onPointerMoveBound);window.removeEventListener('pointerup', this.onPointerUpBound);this.root.removeEventListener('wheel', this.onWheelBound);this.root.removeEventListener('keydown', this.onKeyDownBound);this.root.innerHTML = '';}onWheel(event) {event.preventDefault();this.setValue(this.value + Math.sign(event.deltaY));}onKeyDown(event) {if (event.key === 'ArrowUp') {event.preventDefault();this.setValue(this.value - 1);} else if (event.key === 'ArrowDown') {event.preventDefault();this.setValue(this.value + 1);} else if (event.key === 'PageUp') {event.preventDefault();this.setValue(this.value - 10);} else if (event.key === 'PageDown') {event.preventDefault();this.setValue(this.value + 10);} else if (event.key === 'Home') {event.preventDefault();this.setValue(this.min);} else if (event.key === 'End') {event.preventDefault();this.setValue(this.max);}}onPointerDown(event) {this.cancelAnimation();this.isDragging = true;this.root.setPointerCapture?.(event.pointerId);this.startY = event.clientY;this.startOffset = this.offset;this.lastY = event.clientY;this.lastTime = performance.now();this.velocity = 0;}onPointerMove(event) {if (!this.isDragging) return;const currentTime = performance.now();const deltaY = event.clientY - this.startY;const timeDelta = currentTime - this.lastTime;this.offset = this.clampOffset(this.startOffset + deltaY);this.applyOffset();this.updateActiveFromOffset();if (timeDelta > 0) this.velocity = (event.clientY - this.lastY) / timeDelta;this.lastY = event.clientY;this.lastTime = currentTime;}onPointerUp() {if (!this.isDragging) return;this.isDragging = false;this.startMomentum();}startMomentum() {const friction = 0.94;const minVelocity = 0.02;const frame = () => {this.velocity *= friction;this.offset = this.clampOffset(this.offset + this.velocity * 16);this.applyOffset();this.updateActiveFromOffset();if (Math.abs(this.velocity) > minVelocity) {this.animationFrame = requestAnimationFrame(frame);} else {this.snapToNearest();}};this.animationFrame = requestAnimationFrame(frame);}snapToNearest() {const rawIndex = -this.offset / this.itemHeight;const value = this.clamp(this.min + Math.round(rawIndex));this.setValue(value);}snapToValue(value, animated = true) {this.offset = this.valueToOffset(value);this.list.style.transition = animated ? 'transform 160ms ease-out' : 'none';this.applyOffset();window.setTimeout(() => {if (this.list) this.list.style.transition = 'none';}, 170);this.updateActive(value);this.updateAria();}setValue(value) {const next = this.clamp(value);if (next === this.value) {this.snapToValue(next);return;}this.value = next;this.root.setAttribute('value', String(next));this.snapToValue(next);this.onChange(this.value);this.root.dispatchEvent(new CustomEvent('change', { detail: { value: this.value } }));}getValue() {return this.value;}valueToOffset(value) {return -(value - this.min) * this.itemHeight;}clamp(value) {return Math.min(this.max, Math.max(this.min, Number(value)));}clampOffset(offset) {const minOffset = this.valueToOffset(this.max);const maxOffset = this.valueToOffset(this.min);return Math.min(maxOffset, Math.max(minOffset, offset));}applyOffset() {this.list.style.transform = `translateY(${this.offset}px)`;}updateActiveFromOffset() {const rawIndex = -this.offset / this.itemHeight;const value = this.clamp(this.min + Math.round(rawIndex));this.updateActive(value);}updateActive(value) {this.root.querySelectorAll('.wheel-number-picker__item').forEach(item => {item.classList.toggle('wheel-number-picker__item--active', Number(item.dataset.value) === value);});}updateAria() {this.root.setAttribute('aria-valuemin', this.min);this.root.setAttribute('aria-valuemax', this.max);this.root.setAttribute('aria-valuenow', this.value);}cancelAnimation() {if (this.animationFrame) {cancelAnimationFrame(this.animationFrame);this.animationFrame = null;}}}class WheelNumberPickerElement extends HTMLElement {static get observedAttributes() {return ['value'];}connectedCallback() {if (this.picker) return;this.picker = new WheelNumberPicker(this, {min: this.getAttribute('min') ?? 0,max: this.getAttribute('max') ?? 100,value: this.getAttribute('value') ?? this.getAttribute('min') ?? 0,itemHeight: this.getAttribute('item-height') ?? 44,visibleItems: this.getAttribute('visible-items') ?? 5});}disconnectedCallback() {this.picker?.destroy();this.picker = null;}attributeChangedCallback(name, oldValue, newValue) {if (name === 'value' && this.picker && oldValue !== newValue) {this.picker.setValue(Number(newValue));}}get value() {return this.picker ? this.picker.getValue() : Number(this.getAttribute('value'));}set value(value) {if (this.picker) this.picker.setValue(value);else this.setAttribute('value', value);}}if (!global.customElements.get('wheel-number-picker')) {global.customElements.define('wheel-number-picker', WheelNumberPickerElement);}global.WheelNumberPicker = WheelNumberPicker;})(window);
1
+ (function (global) {
2
+ 'use strict';
3
+
4
+ const DEFAULTS = {
5
+ min: 0,
6
+ max: 100,
7
+ value: 0,
8
+ itemHeight: 44,
9
+ visibleItems: 5,
10
+ color: '',
11
+ activeColor: '',
12
+ mutedColor: '',
13
+ background: '',
14
+ arrows: false,
15
+ arrowsHiddenMaxWidth: ''
16
+ };
17
+
18
+ function readOption(root, options, optionName, attributeName, fallback) {
19
+ return options[optionName] ?? root.getAttribute(attributeName) ?? fallback;
20
+ }
21
+
22
+ class WheelNumberPicker {
23
+ constructor(root, options = {}) {
24
+ this.root = typeof root === 'string' ? document.querySelector(root) : root;
25
+ if (!this.root) throw new Error('WheelNumberPicker root element was not found.');
26
+
27
+ this.min = Number(readOption(this.root, options, 'min', 'min', DEFAULTS.min));
28
+ this.max = Number(readOption(this.root, options, 'max', 'max', DEFAULTS.max));
29
+ this.itemHeight = Number(readOption(this.root, options, 'itemHeight', 'item-height', DEFAULTS.itemHeight));
30
+ this.visibleItems = Number(readOption(this.root, options, 'visibleItems', 'visible-items', DEFAULTS.visibleItems));
31
+ this.value = this.clamp(readOption(this.root, options, 'value', 'value', this.min));
32
+ this.onChange = options.onChange ?? function () {};
33
+
34
+ this.colors = {
35
+ color: readOption(this.root, options, 'color', 'color', DEFAULTS.color),
36
+ activeColor: readOption(this.root, options, 'activeColor', 'active-color', DEFAULTS.activeColor),
37
+ mutedColor: readOption(this.root, options, 'mutedColor', 'muted-color', DEFAULTS.mutedColor),
38
+ background: readOption(this.root, options, 'background', 'background', DEFAULTS.background)
39
+ };
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
+
44
+ this.isDragging = false;
45
+ this.startY = 0;
46
+ this.startOffset = 0;
47
+ this.offset = this.valueToOffset(this.value);
48
+ this.lastY = 0;
49
+ this.lastTime = 0;
50
+ this.velocity = 0;
51
+ this.animationFrame = null;
52
+
53
+ this.normalizeConfiguration();
54
+ this.render();
55
+ this.bindEvents();
56
+ this.snapToValue(this.value, false);
57
+ }
58
+
59
+ render() {
60
+ this.root.classList.add('wheel-number-picker');
61
+ this.root.tabIndex = this.root.tabIndex >= 0 ? this.root.tabIndex : 0;
62
+ this.root.setAttribute('role', 'spinbutton');
63
+ if (!this.root.getAttribute('aria-label')) this.root.setAttribute('aria-label', 'Number picker');
64
+
65
+ this.root.innerHTML = `
66
+ <button class="wheel-number-picker__arrow wheel-number-picker__arrow--up" type="button" aria-label="Decrease value">▲</button>
67
+ <div class="wheel-number-picker__window">
68
+ <div class="wheel-number-picker__list"></div>
69
+ <div class="wheel-number-picker__highlight"></div>
70
+ <div class="wheel-number-picker__fade"></div>
71
+ </div>
72
+ <button class="wheel-number-picker__arrow wheel-number-picker__arrow--down" type="button" aria-label="Increase value">▼</button>
73
+ `;
74
+
75
+ this.list = this.root.querySelector('.wheel-number-picker__list');
76
+ this.upArrow = this.root.querySelector('.wheel-number-picker__arrow--up');
77
+ this.downArrow = this.root.querySelector('.wheel-number-picker__arrow--down');
78
+ this.applyConfiguration();
79
+ this.rebuildItems();
80
+ }
81
+
82
+ rebuildItems() {
83
+ if (!this.list) return;
84
+ this.list.innerHTML = '';
85
+ this.spacerCount = Math.floor(this.visibleItems / 2);
86
+
87
+ for (let i = 0; i < this.spacerCount; i++) this.list.appendChild(this.createItem(''));
88
+ for (let value = this.min; value <= this.max; value++) this.list.appendChild(this.createItem(value));
89
+ for (let i = 0; i < this.spacerCount; i++) this.list.appendChild(this.createItem(''));
90
+ }
91
+
92
+ createItem(value) {
93
+ const item = document.createElement('div');
94
+ item.className = 'wheel-number-picker__item';
95
+ item.textContent = value;
96
+ if (value !== '') item.dataset.value = value;
97
+ return item;
98
+ }
99
+
100
+ normalizeConfiguration() {
101
+ if (!Number.isFinite(this.itemHeight) || this.itemHeight <= 0) this.itemHeight = DEFAULTS.itemHeight;
102
+ if (!Number.isFinite(this.visibleItems) || this.visibleItems < 1) this.visibleItems = DEFAULTS.visibleItems;
103
+
104
+ this.visibleItems = Math.round(this.visibleItems);
105
+ if (this.visibleItems % 2 === 0) this.visibleItems += 1;
106
+ this.spacerCount = Math.floor(this.visibleItems / 2);
107
+ }
108
+
109
+ applyConfiguration() {
110
+ this.normalizeConfiguration();
111
+
112
+ this.root.style.setProperty('--wnp-item-height', `${this.itemHeight}px`);
113
+ this.root.style.setProperty('--wnp-visible-items', String(this.visibleItems));
114
+ this.root.style.setProperty('--wnp-height', `${this.itemHeight * this.visibleItems}px`);
115
+
116
+ this.setCssVariableFromValue('--wnp-color', this.colors.color);
117
+ this.setCssVariableFromValue('--wnp-active-color', this.colors.activeColor);
118
+ this.setCssVariableFromValue('--wnp-muted-color', this.colors.mutedColor);
119
+ this.setCssVariableFromValue('--wnp-background', this.colors.background);
120
+
121
+ this.root.setAttribute('item-height', String(this.itemHeight));
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();
126
+ }
127
+
128
+ setCssVariableFromValue(name, value) {
129
+ if (value === undefined || value === null || value === '') return;
130
+ this.root.style.setProperty(name, String(value));
131
+ }
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
+
163
+ bindEvents() {
164
+ this.onPointerDownBound = this.onPointerDown.bind(this);
165
+ this.onPointerMoveBound = this.onPointerMove.bind(this);
166
+ this.onPointerUpBound = this.onPointerUp.bind(this);
167
+ this.onWheelBound = this.onWheel.bind(this);
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();
172
+
173
+ this.root.addEventListener('pointerdown', this.onPointerDownBound);
174
+ window.addEventListener('pointermove', this.onPointerMoveBound);
175
+ window.addEventListener('pointerup', this.onPointerUpBound);
176
+ this.root.addEventListener('wheel', this.onWheelBound, { passive: false });
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);
182
+ }
183
+
184
+ destroy() {
185
+ this.cancelAnimation();
186
+ this.root.removeEventListener('pointerdown', this.onPointerDownBound);
187
+ window.removeEventListener('pointermove', this.onPointerMoveBound);
188
+ window.removeEventListener('pointerup', this.onPointerUpBound);
189
+ this.root.removeEventListener('wheel', this.onWheelBound);
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;
197
+ this.root.innerHTML = '';
198
+ }
199
+
200
+ onWheel(event) {
201
+ event.preventDefault();
202
+ this.setValue(this.value + Math.sign(event.deltaY));
203
+ }
204
+
205
+ onKeyDown(event) {
206
+ if (event.key === 'ArrowUp') {
207
+ event.preventDefault();
208
+ this.setValue(this.value - 1);
209
+ } else if (event.key === 'ArrowDown') {
210
+ event.preventDefault();
211
+ this.setValue(this.value + 1);
212
+ } else if (event.key === 'PageUp') {
213
+ event.preventDefault();
214
+ this.setValue(this.value - 10);
215
+ } else if (event.key === 'PageDown') {
216
+ event.preventDefault();
217
+ this.setValue(this.value + 10);
218
+ } else if (event.key === 'Home') {
219
+ event.preventDefault();
220
+ this.setValue(this.min);
221
+ } else if (event.key === 'End') {
222
+ event.preventDefault();
223
+ this.setValue(this.max);
224
+ }
225
+ }
226
+
227
+ onPointerDown(event) {
228
+ this.cancelAnimation();
229
+ this.isDragging = true;
230
+ this.root.setPointerCapture?.(event.pointerId);
231
+ this.startY = event.clientY;
232
+ this.startOffset = this.offset;
233
+ this.lastY = event.clientY;
234
+ this.lastTime = performance.now();
235
+ this.velocity = 0;
236
+ }
237
+
238
+ onPointerMove(event) {
239
+ if (!this.isDragging) return;
240
+
241
+ const currentTime = performance.now();
242
+ const deltaY = event.clientY - this.startY;
243
+ const timeDelta = currentTime - this.lastTime;
244
+
245
+ this.offset = this.clampOffset(this.startOffset + deltaY);
246
+ this.applyOffset();
247
+ this.updateActiveFromOffset();
248
+
249
+ if (timeDelta > 0) this.velocity = (event.clientY - this.lastY) / timeDelta;
250
+ this.lastY = event.clientY;
251
+ this.lastTime = currentTime;
252
+ }
253
+
254
+ onPointerUp() {
255
+ if (!this.isDragging) return;
256
+ this.isDragging = false;
257
+ this.startMomentum();
258
+ }
259
+
260
+ startMomentum() {
261
+ const friction = 0.94;
262
+ const minVelocity = 0.02;
263
+ const frame = () => {
264
+ this.velocity *= friction;
265
+ this.offset = this.clampOffset(this.offset + this.velocity * 16);
266
+ this.applyOffset();
267
+ this.updateActiveFromOffset();
268
+
269
+ if (Math.abs(this.velocity) > minVelocity) {
270
+ this.animationFrame = requestAnimationFrame(frame);
271
+ } else {
272
+ this.snapToNearest();
273
+ }
274
+ };
275
+ this.animationFrame = requestAnimationFrame(frame);
276
+ }
277
+
278
+ snapToNearest() {
279
+ const rawIndex = this.offsetToIndex(this.offset);
280
+ const value = this.clamp(this.min + Math.round(rawIndex));
281
+ this.setValue(value);
282
+ }
283
+
284
+ snapToValue(value, animated = true) {
285
+ this.offset = this.valueToOffset(value);
286
+ this.list.style.transition = animated ? 'transform 160ms ease-out' : 'none';
287
+ this.applyOffset();
288
+ window.setTimeout(() => {
289
+ if (this.list) this.list.style.transition = 'none';
290
+ }, 170);
291
+ this.updateActive(value);
292
+ this.updateAria();
293
+ }
294
+
295
+ setValue(value) {
296
+ const next = this.clamp(value);
297
+ if (next === this.value) {
298
+ this.snapToValue(next);
299
+ return;
300
+ }
301
+ this.value = next;
302
+ this.root.setAttribute('value', String(next));
303
+ this.snapToValue(next);
304
+ this.onChange(this.value);
305
+ this.root.dispatchEvent(new CustomEvent('change', { detail: { value: this.value } }));
306
+ }
307
+
308
+ setItemHeight(itemHeight) {
309
+ const next = Number(itemHeight);
310
+ if (!Number.isFinite(next) || next <= 0) return;
311
+ this.itemHeight = next;
312
+ this.applyConfiguration();
313
+ this.snapToValue(this.value, false);
314
+ }
315
+
316
+ setVisibleItems(visibleItems) {
317
+ const next = Number(visibleItems);
318
+ if (!Number.isFinite(next) || next < 1) return;
319
+ this.visibleItems = Math.round(next);
320
+ this.applyConfiguration();
321
+ this.rebuildItems();
322
+ this.snapToValue(this.value, false);
323
+ }
324
+
325
+ setColors(colors = {}) {
326
+ this.colors = { ...this.colors, ...colors };
327
+ this.applyConfiguration();
328
+ }
329
+
330
+ getValue() {
331
+ return this.value;
332
+ }
333
+
334
+ valueToOffset(value) {
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;
345
+ }
346
+
347
+ clamp(value) {
348
+ return Math.min(this.max, Math.max(this.min, Number(value)));
349
+ }
350
+
351
+ clampOffset(offset) {
352
+ const minOffset = this.valueToOffset(this.max);
353
+ const maxOffset = this.valueToOffset(this.min);
354
+ return Math.min(maxOffset, Math.max(minOffset, offset));
355
+ }
356
+
357
+ applyOffset() {
358
+ this.list.style.transform = `translateY(${this.offset}px)`;
359
+ }
360
+
361
+ updateActiveFromOffset() {
362
+ const rawIndex = this.offsetToIndex(this.offset);
363
+ const value = this.clamp(this.min + Math.round(rawIndex));
364
+ this.updateActive(value);
365
+ }
366
+
367
+ updateActive(value) {
368
+ this.root.querySelectorAll('.wheel-number-picker__item').forEach(item => {
369
+ item.classList.toggle('wheel-number-picker__item--active', Number(item.dataset.value) === value);
370
+ });
371
+ }
372
+
373
+ updateAria() {
374
+ this.root.setAttribute('aria-valuemin', this.min);
375
+ this.root.setAttribute('aria-valuemax', this.max);
376
+ this.root.setAttribute('aria-valuenow', this.value);
377
+ }
378
+
379
+ cancelAnimation() {
380
+ if (this.animationFrame) {
381
+ cancelAnimationFrame(this.animationFrame);
382
+ this.animationFrame = null;
383
+ }
384
+ }
385
+ }
386
+
387
+ class WheelNumberPickerElement extends HTMLElement {
388
+ static get observedAttributes() {
389
+ return ['value', 'item-height', 'visible-items', 'color', 'active-color', 'muted-color', 'background', 'arrows', 'arrows-hidden-max-width'];
390
+ }
391
+
392
+ connectedCallback() {
393
+ if (this.picker) return;
394
+ this.picker = new WheelNumberPicker(this, {
395
+ min: this.getAttribute('min') ?? DEFAULTS.min,
396
+ max: this.getAttribute('max') ?? DEFAULTS.max,
397
+ value: this.getAttribute('value') ?? this.getAttribute('min') ?? DEFAULTS.min,
398
+ itemHeight: this.getAttribute('item-height') ?? DEFAULTS.itemHeight,
399
+ visibleItems: this.getAttribute('visible-items') ?? DEFAULTS.visibleItems,
400
+ color: this.getAttribute('color') ?? DEFAULTS.color,
401
+ activeColor: this.getAttribute('active-color') ?? DEFAULTS.activeColor,
402
+ mutedColor: this.getAttribute('muted-color') ?? DEFAULTS.mutedColor,
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
406
+ });
407
+ }
408
+
409
+ disconnectedCallback() {
410
+ this.picker?.destroy();
411
+ this.picker = null;
412
+ }
413
+
414
+ attributeChangedCallback(name, oldValue, newValue) {
415
+ if (!this.picker || oldValue === newValue) return;
416
+
417
+ if (name === 'value') this.picker.setValue(Number(newValue));
418
+ if (name === 'item-height') this.picker.setItemHeight(Number(newValue));
419
+ if (name === 'visible-items') this.picker.setVisibleItems(Number(newValue));
420
+ if (name === 'color') this.picker.setColors({ color: newValue });
421
+ if (name === 'active-color') this.picker.setColors({ activeColor: newValue });
422
+ if (name === 'muted-color') this.picker.setColors({ mutedColor: newValue });
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);
426
+ }
427
+
428
+ get value() {
429
+ return this.picker ? this.picker.getValue() : Number(this.getAttribute('value'));
430
+ }
431
+
432
+ set value(value) {
433
+ if (this.picker) this.picker.setValue(value);
434
+ else this.setAttribute('value', value);
435
+ }
436
+ }
437
+
438
+ if (!global.customElements.get('wheel-number-picker')) {
439
+ global.customElements.define('wheel-number-picker', WheelNumberPickerElement);
440
+ }
441
+
442
+ global.WheelNumberPicker = WheelNumberPicker;
443
+ })(window);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vanilla-wheel-number-picker",
3
- "version": "1.0.1",
4
- "description": "A plain JavaScript wheel-style number picker with drag, inertia, snapping, keyboard support, and a custom element API.",
3
+ "version": "1.1.2",
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",
7
7
  "jsdelivr": "dist/wheel-number-picker.min.js",