@uinstinct/svelte-wheel-picker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +304 -0
- package/dist/WheelPicker.svelte +349 -0
- package/dist/WheelPicker.svelte.d.ts +4 -0
- package/dist/WheelPickerWrapper.svelte +10 -0
- package/dist/WheelPickerWrapper.svelte.d.ts +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/dist/use-controllable-state.svelte.d.ts +21 -0
- package/dist/use-controllable-state.svelte.js +42 -0
- package/dist/use-typeahead-search.svelte.d.ts +8 -0
- package/dist/use-typeahead-search.svelte.js +59 -0
- package/dist/use-wheel-physics.svelte.d.ts +86 -0
- package/dist/use-wheel-physics.svelte.js +337 -0
- package/dist/wheel-physics-utils.d.ts +141 -0
- package/dist/wheel-physics-utils.js +198 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aditya Mitra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# @uinstinct/svelte-wheel-picker
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@uinstinct/svelte-wheel-picker)
|
|
4
|
+
[](https://github.com/uinstinct/svelte-wheel-picker/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
iOS-style wheel picker for Svelte 5 with smooth inertia scrolling, infinite loop support, keyboard navigation, and cylindrical 3D effect.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- Svelte 5 runes-based reactivity
|
|
11
|
+
- Smooth inertia scrolling with spring physics
|
|
12
|
+
- Infinite loop mode
|
|
13
|
+
- Cylindrical/drum 3D visual effect
|
|
14
|
+
- Full keyboard navigation (arrow keys, Home/End, type-ahead search)
|
|
15
|
+
- Controlled and uncontrolled modes
|
|
16
|
+
- Disabled options support
|
|
17
|
+
- `WheelPickerWrapper` for multi-wheel layouts (time picker, date picker)
|
|
18
|
+
- Headless/unstyled with data attributes for CSS targeting
|
|
19
|
+
- Zero runtime dependencies
|
|
20
|
+
- TypeScript types included
|
|
21
|
+
- SSR safe
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
### npm
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @uinstinct/svelte-wheel-picker
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnpm add @uinstinct/svelte-wheel-picker
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
yarn add @uinstinct/svelte-wheel-picker
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### shadcn-svelte
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx shadcn-svelte@latest add https://svelte-wheel-picker.vercel.app/r/wheel-picker.json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This copies the component source directly into your project under `src/lib/components/ui/wheel-picker/`.
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
```svelte
|
|
50
|
+
<script lang="ts">
|
|
51
|
+
import { WheelPicker } from '@uinstinct/svelte-wheel-picker';
|
|
52
|
+
|
|
53
|
+
const fruits = [
|
|
54
|
+
{ value: 'apple', label: 'Apple' },
|
|
55
|
+
{ value: 'banana', label: 'Banana' },
|
|
56
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
57
|
+
{ value: 'date', label: 'Date' },
|
|
58
|
+
{ value: 'fig', label: 'Fig' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
let selected = $state('cherry');
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<WheelPicker
|
|
65
|
+
options={fruits}
|
|
66
|
+
value={selected}
|
|
67
|
+
onValueChange={(v) => { selected = v; }}
|
|
68
|
+
/>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Examples
|
|
72
|
+
|
|
73
|
+
### Basic (controlled mode)
|
|
74
|
+
|
|
75
|
+
```svelte
|
|
76
|
+
<script lang="ts">
|
|
77
|
+
import { WheelPicker } from '@uinstinct/svelte-wheel-picker';
|
|
78
|
+
|
|
79
|
+
const options = [
|
|
80
|
+
{ value: 'a', label: 'Option A' },
|
|
81
|
+
{ value: 'b', label: 'Option B' },
|
|
82
|
+
{ value: 'c', label: 'Option C' },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
let value = $state('a');
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<WheelPicker
|
|
89
|
+
{options}
|
|
90
|
+
{value}
|
|
91
|
+
onValueChange={(v) => { value = v; }}
|
|
92
|
+
/>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Infinite loop
|
|
96
|
+
|
|
97
|
+
```svelte
|
|
98
|
+
<script lang="ts">
|
|
99
|
+
import { WheelPicker } from '@uinstinct/svelte-wheel-picker';
|
|
100
|
+
|
|
101
|
+
const fruits = [
|
|
102
|
+
{ value: 'apple', label: 'Apple' },
|
|
103
|
+
{ value: 'banana', label: 'Banana' },
|
|
104
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
let selected = $state('apple');
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<WheelPicker
|
|
111
|
+
options={fruits}
|
|
112
|
+
value={selected}
|
|
113
|
+
onValueChange={(v) => { selected = v; }}
|
|
114
|
+
infinite={true}
|
|
115
|
+
/>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Cylindrical/drum effect
|
|
119
|
+
|
|
120
|
+
```svelte
|
|
121
|
+
<script lang="ts">
|
|
122
|
+
import { WheelPicker } from '@uinstinct/svelte-wheel-picker';
|
|
123
|
+
|
|
124
|
+
const fruits = [
|
|
125
|
+
{ value: 'apple', label: 'Apple' },
|
|
126
|
+
{ value: 'banana', label: 'Banana' },
|
|
127
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
let selected = $state('apple');
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<WheelPicker
|
|
134
|
+
options={fruits}
|
|
135
|
+
value={selected}
|
|
136
|
+
onValueChange={(v) => { selected = v; }}
|
|
137
|
+
cylindrical={true}
|
|
138
|
+
/>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Multi-wheel with WheelPickerWrapper
|
|
142
|
+
|
|
143
|
+
```svelte
|
|
144
|
+
<script lang="ts">
|
|
145
|
+
import { WheelPicker, WheelPickerWrapper } from '@uinstinct/svelte-wheel-picker';
|
|
146
|
+
|
|
147
|
+
const hourOptions = Array.from({ length: 12 }, (_, i) => ({
|
|
148
|
+
value: String(i + 1).padStart(2, '0'),
|
|
149
|
+
label: String(i + 1).padStart(2, '0'),
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
const minuteOptions = Array.from({ length: 60 }, (_, i) => ({
|
|
153
|
+
value: String(i).padStart(2, '0'),
|
|
154
|
+
label: String(i).padStart(2, '0'),
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
let hour = $state('12');
|
|
158
|
+
let minute = $state('00');
|
|
159
|
+
</script>
|
|
160
|
+
|
|
161
|
+
<WheelPickerWrapper classNames={{ group: 'time-picker' }}>
|
|
162
|
+
<WheelPicker
|
|
163
|
+
options={hourOptions}
|
|
164
|
+
value={hour}
|
|
165
|
+
onValueChange={(v) => { hour = v; }}
|
|
166
|
+
/>
|
|
167
|
+
<WheelPicker
|
|
168
|
+
options={minuteOptions}
|
|
169
|
+
value={minute}
|
|
170
|
+
onValueChange={(v) => { minute = v; }}
|
|
171
|
+
/>
|
|
172
|
+
</WheelPickerWrapper>
|
|
173
|
+
|
|
174
|
+
<style>
|
|
175
|
+
:global([data-swp-group].time-picker) {
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: row;
|
|
178
|
+
align-items: stretch;
|
|
179
|
+
}
|
|
180
|
+
</style>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## API Reference
|
|
184
|
+
|
|
185
|
+
### `WheelPickerProps<T>`
|
|
186
|
+
|
|
187
|
+
| Prop | Type | Default | Description |
|
|
188
|
+
|------|------|---------|-------------|
|
|
189
|
+
| `options` | `WheelPickerOption<T>[]` | — | The list of selectable options. Required. |
|
|
190
|
+
| `value` | `T` | `undefined` | Current value (controlled mode). `undefined` means no option selected. |
|
|
191
|
+
| `defaultValue` | `T` | `undefined` | Initial value (uncontrolled mode). |
|
|
192
|
+
| `onValueChange` | `(value: T) => void` | `undefined` | Callback when value changes. Presence signals controlled mode. |
|
|
193
|
+
| `classNames` | `WheelPickerClassNames` | `{}` | Per-element CSS class overrides. |
|
|
194
|
+
| `visibleCount` | `number` | `5` | Number of visible option rows. Must be odd. |
|
|
195
|
+
| `optionItemHeight` | `number` | `30` | Height in pixels of each option row. |
|
|
196
|
+
| `dragSensitivity` | `number` | `3` | Pointer drag delta multiplier (affects inertia deceleration). |
|
|
197
|
+
| `scrollSensitivity` | `number` | `5` | Scroll wheel delta multiplier (affects snap animation duration). |
|
|
198
|
+
| `infinite` | `boolean` | `false` | Enable infinite loop scrolling (wraps at both ends). |
|
|
199
|
+
| `cylindrical` | `boolean` | `false` | Enable rotating drum/cylinder visual style with faux-3D scaleY compression. |
|
|
200
|
+
|
|
201
|
+
### `WheelPickerOption<T>`
|
|
202
|
+
|
|
203
|
+
| Field | Type | Description |
|
|
204
|
+
|-------|------|-------------|
|
|
205
|
+
| `value` | `T` | The option's value. Must be `string` or `number`. |
|
|
206
|
+
| `label` | `string` | Display text rendered in the wheel. |
|
|
207
|
+
| `textValue` | `string` (optional) | Fallback text for type-ahead search when `label` is not plain text. |
|
|
208
|
+
| `disabled` | `boolean` (optional) | Whether this option is disabled (skipped in navigation). |
|
|
209
|
+
|
|
210
|
+
### `WheelPickerWrapperProps`
|
|
211
|
+
|
|
212
|
+
| Prop | Type | Description |
|
|
213
|
+
|------|------|-------------|
|
|
214
|
+
| `classNames` | `WheelPickerWrapperClassNames` | Per-element CSS class overrides for the wrapper group container. |
|
|
215
|
+
|
|
216
|
+
**`WheelPickerWrapperClassNames`**: `{ group?: string }` — optional CSS class for the outer group container div.
|
|
217
|
+
|
|
218
|
+
## Styling
|
|
219
|
+
|
|
220
|
+
The library ships no CSS. It is fully headless. Use the `classNames` prop to assign your own CSS classes, then target elements with your stylesheet.
|
|
221
|
+
|
|
222
|
+
### Data Attributes
|
|
223
|
+
|
|
224
|
+
All elements expose data attributes for CSS targeting:
|
|
225
|
+
|
|
226
|
+
| Attribute | Element | Notes |
|
|
227
|
+
|-----------|---------|-------|
|
|
228
|
+
| `data-swp-wrapper` | Outer container of `WheelPicker` | Always present |
|
|
229
|
+
| `data-swp-option` | Each option row | Always present |
|
|
230
|
+
| `data-swp-option-text` | Text span inside each option | Always present |
|
|
231
|
+
| `data-swp-selection` | Center selection highlight overlay | Always present |
|
|
232
|
+
| `data-swp-selected` | Option row | Present with value `"true"` when the option is currently selected |
|
|
233
|
+
| `data-swp-disabled` | Option row | Present with value `"true"` when the option is disabled |
|
|
234
|
+
| `data-swp-group` | Outer container of `WheelPickerWrapper` | Always present |
|
|
235
|
+
|
|
236
|
+
### CSS Example
|
|
237
|
+
|
|
238
|
+
```css
|
|
239
|
+
/* Make the wheel container draggable */
|
|
240
|
+
[data-swp-wrapper].my-wheel {
|
|
241
|
+
width: 200px;
|
|
242
|
+
cursor: grab;
|
|
243
|
+
user-select: none;
|
|
244
|
+
touch-action: none;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
[data-swp-wrapper].my-wheel:active {
|
|
248
|
+
cursor: grabbing;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* Style the selection highlight */
|
|
252
|
+
[data-swp-selection].my-selection {
|
|
253
|
+
background: rgba(59, 130, 246, 0.15);
|
|
254
|
+
border-top: 1px solid rgba(59, 130, 246, 0.3);
|
|
255
|
+
border-bottom: 1px solid rgba(59, 130, 246, 0.3);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Style each option row */
|
|
259
|
+
[data-swp-option].my-option {
|
|
260
|
+
font-size: 16px;
|
|
261
|
+
transition: opacity 0.15s;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* Dim disabled options */
|
|
265
|
+
[data-swp-option][data-swp-disabled='true'] {
|
|
266
|
+
opacity: 0.3;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* Bold the selected option */
|
|
270
|
+
[data-swp-option][data-swp-selected='true'] {
|
|
271
|
+
font-weight: 600;
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Pass your class names via the `classNames` prop:
|
|
276
|
+
|
|
277
|
+
```svelte
|
|
278
|
+
<WheelPicker
|
|
279
|
+
{options}
|
|
280
|
+
{value}
|
|
281
|
+
onValueChange={(v) => { value = v; }}
|
|
282
|
+
classNames={{
|
|
283
|
+
wrapper: 'my-wheel',
|
|
284
|
+
selection: 'my-selection',
|
|
285
|
+
option: 'my-option',
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Keyboard Navigation
|
|
291
|
+
|
|
292
|
+
When the wheel picker is focused:
|
|
293
|
+
|
|
294
|
+
| Key | Action |
|
|
295
|
+
|-----|--------|
|
|
296
|
+
| `Arrow Up` | Move selection up by one |
|
|
297
|
+
| `Arrow Down` | Move selection down by one |
|
|
298
|
+
| `Home` | Jump to first option |
|
|
299
|
+
| `End` | Jump to last option |
|
|
300
|
+
| Type a character | Type-ahead search: jump to the next option whose label (or `textValue`) starts with the typed character(s) |
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
MIT
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import type { WheelPickerProps } from './types.js';
|
|
4
|
+
import { WheelPhysics } from './use-wheel-physics.svelte.js';
|
|
5
|
+
import { useControllableState } from './use-controllable-state.svelte.js';
|
|
6
|
+
import { useTypeaheadSearch } from './use-typeahead-search.svelte.js';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_VISIBLE_COUNT,
|
|
9
|
+
DEFAULT_ITEM_HEIGHT,
|
|
10
|
+
DEFAULT_DRAG_SENSITIVITY,
|
|
11
|
+
DEFAULT_SCROLL_SENSITIVITY,
|
|
12
|
+
cylindricalScaleY,
|
|
13
|
+
} from './wheel-physics-utils.js';
|
|
14
|
+
import { wrapIndex } from './wheel-physics-utils.js';
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
options,
|
|
18
|
+
value,
|
|
19
|
+
defaultValue,
|
|
20
|
+
onValueChange,
|
|
21
|
+
classNames,
|
|
22
|
+
visibleCount: rawVisibleCount = DEFAULT_VISIBLE_COUNT,
|
|
23
|
+
optionItemHeight = DEFAULT_ITEM_HEIGHT,
|
|
24
|
+
dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
|
|
25
|
+
scrollSensitivity = DEFAULT_SCROLL_SENSITIVITY,
|
|
26
|
+
infinite = false,
|
|
27
|
+
cylindrical = false,
|
|
28
|
+
}: WheelPickerProps = $props();
|
|
29
|
+
|
|
30
|
+
// D-07: visibleCount must be odd — warn and round up if even
|
|
31
|
+
const visibleCount = $derived.by(() => {
|
|
32
|
+
if (rawVisibleCount % 2 === 0) {
|
|
33
|
+
console.warn(
|
|
34
|
+
`[WheelPicker] visibleCount must be an odd number. Received ${rawVisibleCount}, rounding up to ${rawVisibleCount + 1}.`,
|
|
35
|
+
);
|
|
36
|
+
return rawVisibleCount + 1;
|
|
37
|
+
}
|
|
38
|
+
return rawVisibleCount;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Controlled/uncontrolled state management
|
|
42
|
+
const state = useControllableState({
|
|
43
|
+
value,
|
|
44
|
+
defaultValue,
|
|
45
|
+
onChange: onValueChange,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Derive the currently selected index
|
|
49
|
+
const selectedIndex = $derived(options.findIndex((o) => o.value === state.current));
|
|
50
|
+
|
|
51
|
+
// Determine initial index — use selectedIndex if found, otherwise first enabled
|
|
52
|
+
const initialIndex = $derived.by(() => {
|
|
53
|
+
if (selectedIndex >= 0) return selectedIndex;
|
|
54
|
+
const first = options.findIndex((o) => !o.disabled);
|
|
55
|
+
return first >= 0 ? first : 0;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Instantiate the physics engine
|
|
59
|
+
const physics = new WheelPhysics({
|
|
60
|
+
itemHeight: optionItemHeight,
|
|
61
|
+
visibleCount: visibleCount,
|
|
62
|
+
dragSensitivity,
|
|
63
|
+
scrollSensitivity,
|
|
64
|
+
options,
|
|
65
|
+
initialIndex,
|
|
66
|
+
infinite,
|
|
67
|
+
onSnap: (index: number) => {
|
|
68
|
+
console.log('[onSnap] index=', index, 'infinite=', infinite, 'offset=', physics.offset);
|
|
69
|
+
if (infinite) {
|
|
70
|
+
// D-04: Normalize offset on snap settle
|
|
71
|
+
const wrappedIndex = wrapIndex(index, options.length);
|
|
72
|
+
physics.jumpTo(wrappedIndex);
|
|
73
|
+
const opt = options[wrappedIndex];
|
|
74
|
+
console.log(
|
|
75
|
+
'[onSnap] wrappedIndex=',
|
|
76
|
+
wrappedIndex,
|
|
77
|
+
'opt=',
|
|
78
|
+
opt?.value,
|
|
79
|
+
'jumpTo offset=',
|
|
80
|
+
physics.offset,
|
|
81
|
+
);
|
|
82
|
+
if (opt && !opt.disabled) {
|
|
83
|
+
state.current = opt.value;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
const opt = options[index];
|
|
87
|
+
if (opt && !opt.disabled) {
|
|
88
|
+
state.current = opt.value;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Typeahead search instance
|
|
95
|
+
const typeahead = useTypeaheadSearch();
|
|
96
|
+
|
|
97
|
+
// Cleanup on component destroy
|
|
98
|
+
$effect(() => {
|
|
99
|
+
return () => {
|
|
100
|
+
physics.destroy();
|
|
101
|
+
typeahead.destroy();
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Keep state.current reactive in controlled mode — update tracked value when value prop changes
|
|
106
|
+
$effect(() => {
|
|
107
|
+
state.updateControlledValue(value);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// React to external `value` prop changes (D-05: cancel mid-flight, jump to new position)
|
|
111
|
+
// Guard: only animate if the target index differs from the current visual position.
|
|
112
|
+
// IMPORTANT: physics.currentIndex reads physics.offset ($state) — must be untracked to prevent
|
|
113
|
+
// this effect from re-running on every pointer-move or animation tick.
|
|
114
|
+
$effect(() => {
|
|
115
|
+
const v = value;
|
|
116
|
+
if (v === undefined) return;
|
|
117
|
+
const idx = options.findIndex((o) => o.value === v);
|
|
118
|
+
if (idx >= 0 && idx !== untrack(() => physics.currentIndex)) {
|
|
119
|
+
physics.cancelAnimation();
|
|
120
|
+
physics.animateTo(idx);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Helper: animate the wheel to a given index and update state
|
|
125
|
+
function setValue(index: number) {
|
|
126
|
+
physics.animateTo(index);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Keyboard navigation (per RESEARCH Pattern 5)
|
|
130
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
131
|
+
const currentIdx = selectedIndex >= 0 ? selectedIndex : 0;
|
|
132
|
+
switch (e.key) {
|
|
133
|
+
case 'ArrowDown': {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
if (infinite) {
|
|
136
|
+
let next = currentIdx + 1;
|
|
137
|
+
let guard = 0;
|
|
138
|
+
while (options[wrapIndex(next, options.length)]?.disabled && guard < options.length) {
|
|
139
|
+
next++;
|
|
140
|
+
guard++;
|
|
141
|
+
}
|
|
142
|
+
if (guard < options.length) {
|
|
143
|
+
// Animate to the extended index (ghost position) for correct direction.
|
|
144
|
+
// onSnap will normalize back to [0, N-1] via wrapIndex + jumpTo.
|
|
145
|
+
setValue(next);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
let next = currentIdx + 1;
|
|
149
|
+
while (next < options.length && options[next].disabled) next++;
|
|
150
|
+
if (next < options.length) setValue(next);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'ArrowUp': {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
if (infinite) {
|
|
157
|
+
let next = currentIdx - 1;
|
|
158
|
+
let guard = 0;
|
|
159
|
+
while (options[wrapIndex(next, options.length)]?.disabled && guard < options.length) {
|
|
160
|
+
next--;
|
|
161
|
+
guard++;
|
|
162
|
+
}
|
|
163
|
+
if (guard < options.length) {
|
|
164
|
+
// Animate to the extended index (ghost position) for correct direction.
|
|
165
|
+
setValue(next);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
let next = currentIdx - 1;
|
|
169
|
+
while (next >= 0 && options[next].disabled) next--;
|
|
170
|
+
if (next >= 0) setValue(next);
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'Home': {
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
const first = options.findIndex((o) => !o.disabled);
|
|
177
|
+
if (first !== -1) setValue(first);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case 'End': {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
for (let i = options.length - 1; i >= 0; i--) {
|
|
183
|
+
if (!options[i].disabled) {
|
|
184
|
+
setValue(i);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
default: {
|
|
191
|
+
const result = typeahead.search(e.key, options, currentIdx);
|
|
192
|
+
if (result !== -1) setValue(result);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Pointer event handlers (Pattern 2: Pointer Capture for reliable drag tracking)
|
|
198
|
+
function onPointerDown(e: PointerEvent) {
|
|
199
|
+
console.log(
|
|
200
|
+
'[onPointerDown] type=',
|
|
201
|
+
e.type,
|
|
202
|
+
'currentTarget=',
|
|
203
|
+
e.currentTarget,
|
|
204
|
+
'target=',
|
|
205
|
+
e.target,
|
|
206
|
+
);
|
|
207
|
+
const el = e.currentTarget as HTMLElement;
|
|
208
|
+
el.setPointerCapture(e.pointerId);
|
|
209
|
+
console.log(
|
|
210
|
+
'[onPointerDown] setPointerCapture called, hasCapture=',
|
|
211
|
+
el.hasPointerCapture(e.pointerId),
|
|
212
|
+
);
|
|
213
|
+
physics.startDrag(e.clientY);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function onPointerMove(e: PointerEvent) {
|
|
217
|
+
physics.moveDrag(e.clientY);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function onPointerUp(e: PointerEvent) {
|
|
221
|
+
console.log(
|
|
222
|
+
'[onPointerUp] type=',
|
|
223
|
+
e.type,
|
|
224
|
+
'currentTarget=',
|
|
225
|
+
e.currentTarget,
|
|
226
|
+
'target=',
|
|
227
|
+
e.target,
|
|
228
|
+
'clientY=',
|
|
229
|
+
e.clientY,
|
|
230
|
+
);
|
|
231
|
+
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
232
|
+
physics.endDrag();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Wheel/trackpad scroll handler — one item per scroll event
|
|
236
|
+
function onWheel(e: WheelEvent) {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
physics.handleWheel(e.deltaY);
|
|
239
|
+
}
|
|
240
|
+
</script>
|
|
241
|
+
|
|
242
|
+
<div
|
|
243
|
+
data-swp-wrapper
|
|
244
|
+
data-swp-cylindrical={cylindrical ? 'true' : undefined}
|
|
245
|
+
class={classNames?.wrapper ?? undefined}
|
|
246
|
+
style:height="{visibleCount * optionItemHeight}px"
|
|
247
|
+
style:overflow="hidden"
|
|
248
|
+
style:position="relative"
|
|
249
|
+
tabindex="0"
|
|
250
|
+
role="listbox"
|
|
251
|
+
onpointerdown={onPointerDown}
|
|
252
|
+
onpointermove={onPointerMove}
|
|
253
|
+
onpointerup={onPointerUp}
|
|
254
|
+
onpointercancel={onPointerUp}
|
|
255
|
+
onwheel={onWheel}
|
|
256
|
+
onkeydown={handleKeydown}
|
|
257
|
+
>
|
|
258
|
+
<!-- Selection overlay — absolutely positioned center row indicator -->
|
|
259
|
+
<div
|
|
260
|
+
data-swp-selection
|
|
261
|
+
class={classNames?.selection ?? undefined}
|
|
262
|
+
style:position="absolute"
|
|
263
|
+
style:top="{Math.floor(visibleCount / 2) * optionItemHeight}px"
|
|
264
|
+
style:left="0"
|
|
265
|
+
style:right="0"
|
|
266
|
+
style:height="{optionItemHeight}px"
|
|
267
|
+
style:pointer-events="none"
|
|
268
|
+
></div>
|
|
269
|
+
|
|
270
|
+
<!-- Options container — translated by physics offset -->
|
|
271
|
+
<div style:transform="translateY({physics.offset}px)">
|
|
272
|
+
{#if infinite}
|
|
273
|
+
<!-- Before-ghosts: reversed so options[N-1] appears just above real section (Pitfall 3) -->
|
|
274
|
+
{#each [...options].reverse() as option, g}
|
|
275
|
+
{@const scale = cylindrical
|
|
276
|
+
? cylindricalScaleY(g - options.length, physics.offset, optionItemHeight, visibleCount)
|
|
277
|
+
: undefined}
|
|
278
|
+
<div
|
|
279
|
+
data-swp-option
|
|
280
|
+
data-swp-disabled={option.disabled ? 'true' : undefined}
|
|
281
|
+
class={classNames?.option ?? undefined}
|
|
282
|
+
style:height="{optionItemHeight}px"
|
|
283
|
+
style:display="flex"
|
|
284
|
+
style:align-items="center"
|
|
285
|
+
style:justify-content="center"
|
|
286
|
+
style:transform={scale !== undefined ? `scaleY(${scale})` : undefined}
|
|
287
|
+
style:opacity={scale}
|
|
288
|
+
role="option"
|
|
289
|
+
aria-selected={false}
|
|
290
|
+
>
|
|
291
|
+
<span data-swp-option-text class={classNames?.optionText ?? undefined}>
|
|
292
|
+
{option.label}
|
|
293
|
+
</span>
|
|
294
|
+
</div>
|
|
295
|
+
{/each}
|
|
296
|
+
{/if}
|
|
297
|
+
|
|
298
|
+
<!-- Real items section -->
|
|
299
|
+
{#each options as option, i}
|
|
300
|
+
{@const scale = cylindrical
|
|
301
|
+
? cylindricalScaleY(i, physics.offset, optionItemHeight, visibleCount)
|
|
302
|
+
: undefined}
|
|
303
|
+
<div
|
|
304
|
+
data-swp-option
|
|
305
|
+
data-swp-selected={selectedIndex === i ? 'true' : undefined}
|
|
306
|
+
data-swp-disabled={option.disabled ? 'true' : undefined}
|
|
307
|
+
class={classNames?.option ?? undefined}
|
|
308
|
+
style:height="{optionItemHeight}px"
|
|
309
|
+
style:display="flex"
|
|
310
|
+
style:align-items="center"
|
|
311
|
+
style:justify-content="center"
|
|
312
|
+
style:transform={scale !== undefined ? `scaleY(${scale})` : undefined}
|
|
313
|
+
style:opacity={scale}
|
|
314
|
+
role="option"
|
|
315
|
+
aria-selected={selectedIndex === i}
|
|
316
|
+
>
|
|
317
|
+
<span data-swp-option-text class={classNames?.optionText ?? undefined}>
|
|
318
|
+
{option.label}
|
|
319
|
+
</span>
|
|
320
|
+
</div>
|
|
321
|
+
{/each}
|
|
322
|
+
|
|
323
|
+
{#if infinite}
|
|
324
|
+
<!-- After-ghosts: same order as real items -->
|
|
325
|
+
{#each options as option, j}
|
|
326
|
+
{@const scale = cylindrical
|
|
327
|
+
? cylindricalScaleY(options.length + j, physics.offset, optionItemHeight, visibleCount)
|
|
328
|
+
: undefined}
|
|
329
|
+
<div
|
|
330
|
+
data-swp-option
|
|
331
|
+
data-swp-disabled={option.disabled ? 'true' : undefined}
|
|
332
|
+
class={classNames?.option ?? undefined}
|
|
333
|
+
style:height="{optionItemHeight}px"
|
|
334
|
+
style:display="flex"
|
|
335
|
+
style:align-items="center"
|
|
336
|
+
style:justify-content="center"
|
|
337
|
+
style:transform={scale !== undefined ? `scaleY(${scale})` : undefined}
|
|
338
|
+
style:opacity={scale}
|
|
339
|
+
role="option"
|
|
340
|
+
aria-selected={false}
|
|
341
|
+
>
|
|
342
|
+
<span data-swp-option-text class={classNames?.optionText ?? undefined}>
|
|
343
|
+
{option.label}
|
|
344
|
+
</span>
|
|
345
|
+
</div>
|
|
346
|
+
{/each}
|
|
347
|
+
{/if}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { WheelPickerWrapperProps } from './types.js';
|
|
4
|
+
|
|
5
|
+
let { classNames, children }: WheelPickerWrapperProps & { children?: Snippet } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<div data-swp-group class={classNames?.group ?? undefined}>
|
|
9
|
+
{@render children?.()}
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { WheelPickerWrapperProps } from './types.js';
|
|
3
|
+
type $$ComponentProps = WheelPickerWrapperProps & {
|
|
4
|
+
children?: Snippet;
|
|
5
|
+
};
|
|
6
|
+
declare const WheelPickerWrapper: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type WheelPickerWrapper = ReturnType<typeof WheelPickerWrapper>;
|
|
8
|
+
export default WheelPickerWrapper;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { default as WheelPicker } from './WheelPicker.svelte';
|
|
2
|
+
export { default as WheelPickerWrapper } from './WheelPickerWrapper.svelte';
|
|
3
|
+
export type { WheelPickerOption, WheelPickerProps, WheelPickerClassNames, WheelPickerWrapperProps, WheelPickerWrapperClassNames, } from './types.js';
|
package/dist/index.js
ADDED