@marianmeres/stuic 3.118.0 → 3.119.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.
@@ -0,0 +1,110 @@
1
+ # WheelPicker
2
+
3
+ iOS-style scrolling wheel / drum picker (à la `UIPickerView`) for selecting a value from a
4
+ list — hours, minutes, days, units, anything. Supports **infinite looping** (scroll past the
5
+ last option and the first reappears seamlessly, e.g. minutes 59 → 00).
6
+
7
+ Built entirely on native scroll + CSS scroll-snap, so momentum/inertia feel native on touch
8
+ devices for free — no hand-rolled physics. Looping works by silently teleporting the scroll
9
+ position by a whole number of option-cycles **only at rest**, which is provably invisible
10
+ (the centered value is identical before and after).
11
+
12
+ ## Usage
13
+
14
+ ```svelte
15
+ <script lang="ts">
16
+ import { WheelPicker } from "@marianmeres/stuic";
17
+
18
+ let hour = $state(9);
19
+ const hours = Array.from({ length: 24 }, (_, i) => i);
20
+ </script>
21
+
22
+ <!-- looping wheel of 0..23 -->
23
+ <WheelPicker options={hours} bind:value={hour} loop label="Hour" />
24
+ ```
25
+
26
+ A simple time picker is three wheels side by side:
27
+
28
+ ```svelte
29
+ <script lang="ts">
30
+ let h = $state(9),
31
+ m = $state(30);
32
+ const range = (n: number) => Array.from({ length: n }, (_, i) => i);
33
+ const pad = (n: number) => ({ label: String(n).padStart(2, "0"), value: n });
34
+ </script>
35
+
36
+ <div style="display:flex; gap:0.5rem">
37
+ <WheelPicker options={range(24).map(pad)} bind:value={h} loop label="Hour" />
38
+ <WheelPicker options={range(60).map(pad)} bind:value={m} loop label="Minute" />
39
+ </div>
40
+ ```
41
+
42
+ Options may be bare primitives or full objects:
43
+
44
+ ```svelte
45
+ <WheelPicker
46
+ options={[
47
+ { label: "Small", value: "s" },
48
+ { label: "Medium", value: "m" },
49
+ { label: "Large", value: "l", disabled: true },
50
+ ]}
51
+ bind:value={size}
52
+ />
53
+ ```
54
+
55
+ ## Props
56
+
57
+ | Prop | Type | Default | Description |
58
+ | ------------------- | ---------------------------------------------------- | ------- | --------------------------------------------------------------------------- |
59
+ | `options` | `(string \| number \| WheelPickerOption)[]` | — | Options to scroll through. Primitives become `{ label, value }`. |
60
+ | `value` | `T` | — | **Bindable** selected value (the chosen option's `value`). Primary binding. |
61
+ | `index` | `number` | — | **Bindable** selected real index. Alternative/companion to `value`. |
62
+ | `loop` | `boolean` | `false` | Infinite wrap (59 → 00). When `false`, clamps at the ends. |
63
+ | `itemHeight` | `number` | `36` | Row height in px. Prefer an integer. |
64
+ | `visibleCount` | `number` | `5` | How many rows are visible (forced odd, for an exact center). |
65
+ | `tiles` | `number` | `3` | Loop buffer copies (a floor; auto-grown to guarantee fling runway). |
66
+ | `keyboard` | `boolean` | `true` | Enable Arrow/Page/Home/End navigation. |
67
+ | `announce` | `boolean` | `true` | Announce the committed label via an `aria-live` region. |
68
+ | `label` | `string` | — | Accessible name for the listbox. **Strongly recommended.** |
69
+ | `onchange` | `(option: WheelPickerOption, index: number) => void` | — | Fired when the selection **settles** on a new option (not during a fling). |
70
+ | `unstyled` | `boolean` | `false` | Skip default styling. |
71
+ | `class` | `string` | — | Additional CSS classes on the root. |
72
+ | `classItem` | `string` | — | Extra classes on each row. |
73
+ | `classItemSelected` | `string` | — | Extra classes on the selected row. |
74
+ | `classBand` | `string` | — | Extra classes on the center selection band. |
75
+ | `el` | `HTMLDivElement` | — | **Bindable** root element ref. |
76
+ | `renderItem` | `Snippet<[{ option, index, selected }]>` | — | Custom row renderer (alternative to the plain label). |
77
+
78
+ `WheelPickerOption` is `{ label: string; value: string \| number; disabled?: boolean }`.
79
+
80
+ ## Behavior
81
+
82
+ - **Native momentum**: touch fling, trackpad, mouse wheel and scrollbar all drive the native
83
+ scroll; CSS scroll-snap settles the nearest row under the center band. No custom physics.
84
+ - **Commit on settle**: `value`/`index`/`onchange` update only when scrolling comes to rest,
85
+ not on every intermediate frame of a fling.
86
+ - **Infinite loop** (`loop`): options repeat in both directions. The buffer is recentered by a
87
+ whole-cycle scroll teleport **only at rest**, so the seam is never visible and momentum is
88
+ never interrupted (important on iOS Safari, where `scrollend` is unavailable before 26.2 —
89
+ so the commit/settle path never depends on it).
90
+ - **Keyboard**: ↑/↓ step one, PageUp/PageDown step `visibleCount`, Home/End jump to the
91
+ first/last option (wrapping when `loop`). Disabled options are skipped.
92
+ - **Programmatic set**: changing the bound `value` (e.g. a ticking clock) scrolls the wheel to
93
+ match — deferred while the user is actively scrolling so it never yanks mid-gesture.
94
+ - **Reduced motion**: respects `prefers-reduced-motion` — programmatic scrolls jump instantly.
95
+ - **Accessibility**: the scroll viewport is a `role="listbox"` with `aria-activedescendant`
96
+ tracking the selected option; in loop mode only the canonical copy of each option carries
97
+ `role="option"` (duplicate tiles are `aria-hidden`), so screen readers never hear repeats.
98
+
99
+ ## CSS Tokens
100
+
101
+ Prefix: `--stuic-wheel-picker-*`
102
+
103
+ `item-height`, `visible-count`, `min-width`, `font-size`, `font-family`, `color`,
104
+ `color-selected`, `font-weight-selected`, `band-bg`, `band-border-color`, `band-border-width`,
105
+ `fade` (0–1 edge-fade strength), `ring-width`, `ring-color`, plus optional `radius`, `bg`,
106
+ `transition` overrides.
107
+
108
+ > **Note:** set `item-height` / `visible-count` via the **props**, not CSS — the component
109
+ > writes them inline because the same numbers drive the scroll math. The shared structural
110
+ > token `--stuic-radius-container` is used as the radius fallback.
@@ -0,0 +1,496 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { Snippet } from "svelte";
4
+ import type { WheelPickerOption, WheelPickerOptionInput } from "./utils.js";
5
+
6
+ export type { WheelPickerOption, WheelPickerOptionInput } from "./utils.js";
7
+
8
+ export interface Props<T = string | number> extends Omit<
9
+ HTMLAttributes<HTMLDivElement>,
10
+ "children" | "onchange"
11
+ > {
12
+ /** Options to scroll through. Bare strings/numbers become `{ label, value }`; objects pass through. */
13
+ options: WheelPickerOptionInput<T>[];
14
+
15
+ /** Bindable selected value (the chosen option's `value`). Primary binding. */
16
+ value?: T;
17
+
18
+ /** Bindable selected real index (modulo-reduced). Alternative/companion to `value`. */
19
+ index?: number;
20
+
21
+ /** Infinite wrap (59 → 00). Default false (single copy, clamped at the ends). */
22
+ loop?: boolean;
23
+
24
+ /** Row height in px. Prefer an integer to avoid cross-copy drift. Default 36. */
25
+ itemHeight?: number;
26
+
27
+ /** How many rows are visible (forced odd, so there's an exact center). Default 5. */
28
+ visibleCount?: number;
29
+
30
+ /** Loop buffer copies (floor; auto-grown to guarantee fling runway). Default 3. */
31
+ tiles?: number;
32
+
33
+ /** Enable keyboard navigation (Arrow/Page/Home/End). Default true. */
34
+ keyboard?: boolean;
35
+
36
+ /** Announce the committed label via an aria-live region. Default true. */
37
+ announce?: boolean;
38
+
39
+ /** Accessible name for the listbox. Strongly recommended. */
40
+ label?: string;
41
+
42
+ /** Fired when the selection settles on a new option (not during a fling). */
43
+ onchange?: (option: WheelPickerOption<T>, index: number) => void;
44
+
45
+ /** Skip default styling. */
46
+ unstyled?: boolean;
47
+ /** Additional CSS classes on the root. */
48
+ class?: string;
49
+ /** Extra classes on each row. */
50
+ classItem?: string;
51
+ /** Extra classes on the selected row. */
52
+ classItemSelected?: string;
53
+ /** Extra classes on the center selection band. */
54
+ classBand?: string;
55
+ /** Bindable root element ref. */
56
+ el?: HTMLDivElement;
57
+ /** Custom row renderer (alternative to the plain label). */
58
+ renderItem?: Snippet<
59
+ [{ option: WheelPickerOption<T>; index: number; selected: boolean }]
60
+ >;
61
+ }
62
+
63
+ // Per-instance id base for aria wiring (option ids / activedescendant).
64
+ let _uid = 0;
65
+ </script>
66
+
67
+ <script lang="ts" generics="T = string | number">
68
+ import { untrack } from "svelte";
69
+ import { twMerge } from "../../utils/tw-merge.js";
70
+ import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
71
+ import {
72
+ normalizeOptions,
73
+ forceOdd,
74
+ clamp,
75
+ mod,
76
+ centerGlobalIndex,
77
+ realIndexFromGlobal,
78
+ resolveTiles,
79
+ homeBase,
80
+ driftTiles,
81
+ nextEnabledIndex,
82
+ indexOfValue,
83
+ nearestGlobalIndex,
84
+ } from "./utils.js";
85
+
86
+ let {
87
+ options,
88
+ value = $bindable(),
89
+ index = $bindable(),
90
+ loop = false,
91
+ itemHeight = 36,
92
+ visibleCount = 5,
93
+ tiles: tilesProp = 3,
94
+ keyboard = true,
95
+ announce = true,
96
+ label,
97
+ onchange,
98
+ unstyled = false,
99
+ class: classProp,
100
+ classItem,
101
+ classItemSelected,
102
+ classBand,
103
+ el = $bindable(),
104
+ renderItem,
105
+ // pulled out of `rest` so they name the listbox (the semantic control), not the wrapper
106
+ "aria-label": ariaLabel,
107
+ "aria-labelledby": ariaLabelledby,
108
+ ...rest
109
+ }: Props<T> = $props();
110
+
111
+ const instanceId = `stuic-wp-${_uid++}`;
112
+ const optionId = (realIndex: number) => `${instanceId}-opt-${realIndex}`;
113
+
114
+ const reducedMotion = prefersReducedMotion();
115
+
116
+ // 120ms of scroll silence ≈ at rest. The PRIMARY commit path: `scrollend` is
117
+ // absent on iOS Safari < 26.2, so we never depend on it (we also listen for it
118
+ // as a crisp optimization when available).
119
+ const IDLE_MS = 120;
120
+
121
+ // ---- derived geometry (fully deterministic from props — no measurement) ----
122
+ const opts = $derived(normalizeOptions<T>(options));
123
+ const n = $derived(opts.length);
124
+ const vCount = $derived(forceOdd(visibleCount));
125
+ const centerPad = $derived(((vCount - 1) / 2) * itemHeight);
126
+ const containerH = $derived(vCount * itemHeight);
127
+ const tiles = $derived(resolveTiles(tilesProp, n, itemHeight, containerH, loop));
128
+ const tilePx = $derived(n * itemHeight);
129
+ const homeTile = $derived(Math.floor(tiles / 2));
130
+ const homeBaseVal = $derived(homeBase(tiles, n));
131
+
132
+ // Rendered rows: `tiles` copies when looping, one otherwise. Only the home tile
133
+ // carries the real listbox semantics; every other copy is aria-hidden so screen
134
+ // readers never encounter N duplicates of each value.
135
+ const rows = $derived.by(() => {
136
+ const out: {
137
+ gi: number;
138
+ realIndex: number;
139
+ isHome: boolean;
140
+ option: WheelPickerOption<T>;
141
+ }[] = [];
142
+ const total = tiles * n;
143
+ for (let gi = 0; gi < total; gi++) {
144
+ const realIndex = ((gi % n) + n) % n;
145
+ out.push({
146
+ gi,
147
+ realIndex,
148
+ isHome: loop ? Math.floor(gi / n) === homeTile : true,
149
+ option: opts[realIndex],
150
+ });
151
+ }
152
+ return out;
153
+ });
154
+
155
+ // committed selection (initialized once from value/index)
156
+ function initialIndex(): number {
157
+ const o = normalizeOptions<T>(options);
158
+ if (o.length === 0) return 0;
159
+ if (value !== undefined) {
160
+ const i = indexOfValue(o, value);
161
+ if (i >= 0) return i;
162
+ }
163
+ if (index !== undefined && index >= 0) return Math.min(index, o.length - 1);
164
+ return 0;
165
+ }
166
+ let selectedIndex = $state(initialIndex());
167
+
168
+ let scrollEl: HTMLDivElement | undefined = $state();
169
+ let announceText = $state("");
170
+ // Flipped each announce so the live-region text always differs (forces a re-announce
171
+ // even when two consecutive selections share the same label).
172
+ let announceNonce = false;
173
+
174
+ // True while the USER is actively interacting (pointer/touch/wheel) until the next
175
+ // settle — used to defer external `value` changes so we never yank the wheel out from
176
+ // under a drag. Driven by INPUT events (not scroll events), so our own programmatic
177
+ // scrolls (init/teleport/keyboard/value-set) never spuriously trip it.
178
+ let userScrolling = false;
179
+ // A smooth programmatic scroll is animating; coalesces rapid value changes to instant.
180
+ let pendingSmooth = false;
181
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
182
+ let lastScrollTop = 0;
183
+ let lastDir: 1 | -1 = 1;
184
+ // $state so the reconcile effect re-runs when mount flips it (cheap early-return),
185
+ // instead of depending on effect source-order.
186
+ let mounted = $state(false);
187
+
188
+ const selectedOptionId = $derived(n > 0 ? optionId(selectedIndex) : undefined);
189
+
190
+ // Genuine user intent to scroll. armIdle() guarantees a settle() that clears the flag
191
+ // even for a tap/click that doesn't actually move the scroll position.
192
+ function markUserScrolling() {
193
+ userScrolling = true;
194
+ armIdle();
195
+ }
196
+
197
+ /** Position scrollTop so `realIndex` sits centered (instant). */
198
+ function anchorTo(realIndex: number) {
199
+ if (!scrollEl || n === 0) return;
200
+ const gi = loop ? homeBaseVal + realIndex : realIndex;
201
+ scrollEl.scrollTop = gi * itemHeight;
202
+ lastScrollTop = scrollEl.scrollTop;
203
+ }
204
+
205
+ /** Commit a new real index outward (value/index/onchange/announce). No-op if unchanged. */
206
+ function commit(realIndex: number) {
207
+ const opt = opts[realIndex];
208
+ if (!opt) return;
209
+ const changed = realIndex !== selectedIndex || value !== opt.value;
210
+ selectedIndex = realIndex;
211
+ index = realIndex;
212
+ value = opt.value;
213
+ if (changed) {
214
+ if (announce) {
215
+ announceNonce = !announceNonce;
216
+ // trailing zero-width space on alternate commits => text always changes
217
+ announceText = announceNonce ? opt.label : opt.label + "\u200B";
218
+ }
219
+ onchange?.(opt, realIndex);
220
+ }
221
+ }
222
+
223
+ function armIdle() {
224
+ clearTimeout(idleTimer);
225
+ idleTimer = setTimeout(settle, IDLE_MS);
226
+ }
227
+
228
+ function onScroll() {
229
+ if (!scrollEl) return;
230
+ const st = scrollEl.scrollTop;
231
+ if (st !== lastScrollTop) lastDir = st > lastScrollTop ? 1 : -1;
232
+ lastScrollTop = st;
233
+ armIdle();
234
+ }
235
+
236
+ /**
237
+ * Read the settled selection and commit it; recenter the loop buffer. Gated on
238
+ * snap-alignment so we never commit/teleport mid-momentum (a momentum *pause*
239
+ * leaves scrollTop off the snap grid → re-arm and wait for true rest).
240
+ */
241
+ function settle() {
242
+ if (!scrollEl || n === 0) return;
243
+ const raw = scrollEl.scrollTop;
244
+ const snapped = Math.round(raw / itemHeight) * itemHeight;
245
+ if (Math.abs(raw - snapped) > 1.5) {
246
+ armIdle();
247
+ return;
248
+ }
249
+ clearTimeout(idleTimer);
250
+ pendingSmooth = false;
251
+ userScrolling = false;
252
+
253
+ const gi = centerGlobalIndex(raw, itemHeight);
254
+ let real = realIndexFromGlobal(gi, n, loop);
255
+
256
+ // Auto-skip a disabled landing by nudging to the nearest enabled row in the travel
257
+ // direction. anchorTo() lands it in the home tile, so the buffer stays recentered.
258
+ if (opts[real]?.disabled) {
259
+ const target = nextEnabledIndex(real + lastDir, lastDir, opts, loop);
260
+ if (target !== real && !opts[target]?.disabled) {
261
+ anchorTo(target);
262
+ commit(target);
263
+ return;
264
+ }
265
+ }
266
+
267
+ commit(real);
268
+
269
+ // Recenter to the home tile (invisible: an exact whole-tile teleport leaves the
270
+ // centered row and its value unchanged). drift becomes 0 afterwards → no recurse.
271
+ if (loop) {
272
+ const drift = driftTiles(gi, n, homeTile);
273
+ if (drift !== 0) {
274
+ scrollEl.scrollTop = raw - drift * tilePx;
275
+ lastScrollTop = scrollEl.scrollTop;
276
+ }
277
+ }
278
+ }
279
+
280
+ /** Scroll so `realIndex` becomes selected. Keeps within the copy nearest the current
281
+ * position (minimal/zero visible movement). Smooth unless instant/reduced-motion. */
282
+ function scrollToIndex(realIndex: number, instant = false) {
283
+ if (!scrollEl || n === 0) return;
284
+ const r = loop ? mod(realIndex, n) : clamp(realIndex, 0, n - 1);
285
+ let gi: number;
286
+ if (loop) {
287
+ // target the copy of `r` nearest the current position, so a wrap (59 -> 00)
288
+ // advances one step in the natural direction instead of rewinding the long way.
289
+ const curGi = centerGlobalIndex(scrollEl.scrollTop, itemHeight);
290
+ gi = nearestGlobalIndex(curGi, r, n);
291
+ } else {
292
+ gi = r;
293
+ }
294
+ const targetTop = gi * itemHeight;
295
+ // A zero-distance scroll fires no scroll/scrollend events. Commit directly and bail
296
+ // (also covers Arrow/Home/End at a non-loop edge).
297
+ if (Math.abs(scrollEl.scrollTop - targetTop) < 1) {
298
+ commit(r);
299
+ return;
300
+ }
301
+ // Coalesce rapid programmatic changes (e.g. a fast clock binding) to instant to
302
+ // avoid animation pile-up; otherwise animate.
303
+ const behavior: ScrollBehavior =
304
+ instant || reducedMotion.current || pendingSmooth ? "instant" : "smooth";
305
+ pendingSmooth = behavior === "smooth";
306
+ commit(r);
307
+ scrollEl.scrollTo({ top: targetTop, behavior });
308
+ lastScrollTop = targetTop;
309
+ // armIdle guarantees a settle() (clears pendingSmooth) even where `scrollend` is
310
+ // unavailable (iOS < 26.2) or a smooth scroll's end event doesn't fire.
311
+ armIdle();
312
+ }
313
+
314
+ function step(delta: number) {
315
+ if (n === 0) return;
316
+ const target = nextEnabledIndex(
317
+ selectedIndex + delta,
318
+ delta >= 0 ? 1 : -1,
319
+ opts,
320
+ loop
321
+ );
322
+ scrollToIndex(target);
323
+ }
324
+
325
+ function onKeydown(e: KeyboardEvent) {
326
+ if (!keyboard || n === 0) return;
327
+ switch (e.key) {
328
+ case "ArrowDown":
329
+ e.preventDefault();
330
+ step(1);
331
+ break;
332
+ case "ArrowUp":
333
+ e.preventDefault();
334
+ step(-1);
335
+ break;
336
+ case "PageDown":
337
+ e.preventDefault();
338
+ step(vCount);
339
+ break;
340
+ case "PageUp":
341
+ e.preventDefault();
342
+ step(-vCount);
343
+ break;
344
+ case "Home":
345
+ e.preventDefault();
346
+ scrollToIndex(nextEnabledIndex(0, 1, opts, loop));
347
+ break;
348
+ case "End":
349
+ e.preventDefault();
350
+ scrollToIndex(nextEnabledIndex(n - 1, -1, opts, loop));
351
+ break;
352
+ }
353
+ }
354
+
355
+ // Init, re-anchor on geometry change, AND reconcile the committed selection when the
356
+ // options array changes (shrinks/reorders). Structural deps only (incl. `opts` content);
357
+ // `selectedIndex`/`value`/`index` are read untracked so a user commit or an external
358
+ // value tick doesn't retrigger this (the separate reconcile effect owns that).
359
+ $effect(() => {
360
+ void scrollEl;
361
+ void opts;
362
+ void itemHeight;
363
+ void vCount;
364
+ void tiles;
365
+ void loop;
366
+ if (!scrollEl || n === 0) return;
367
+ untrack(() => {
368
+ if (!mounted) {
369
+ // initial: skip a disabled target, position, and make value+index
370
+ // authoritative from frame 1 (covers an unmatched/partial initial binding).
371
+ const sel0 = nextEnabledIndex(clamp(selectedIndex, 0, n - 1), 1, opts, loop);
372
+ selectedIndex = sel0;
373
+ anchorTo(sel0);
374
+ const opt = opts[sel0];
375
+ if (opt) {
376
+ value = opt.value;
377
+ index = sel0;
378
+ }
379
+ mounted = true;
380
+ return;
381
+ }
382
+ if (userScrolling) return; // don't disrupt an in-flight gesture; settle reconciles
383
+ // reconcile a now-invalid committed selection (options shrank/reordered)
384
+ const cur = opts[selectedIndex];
385
+ const stillValid =
386
+ selectedIndex < n && cur && (value === undefined || cur.value === value);
387
+ if (!stillValid) {
388
+ let cand = value !== undefined ? indexOfValue(opts, value) : -1;
389
+ if (cand < 0) cand = clamp(selectedIndex, 0, n - 1);
390
+ commit(nextEnabledIndex(cand, 1, opts, loop));
391
+ }
392
+ anchorTo(clamp(selectedIndex, 0, n - 1));
393
+ });
394
+ });
395
+
396
+ // Reconcile external value/index changes (e.g. a clock binding). Deferred while the
397
+ // user is actively scrolling; no-op when already in sync (prevents a commit→effect loop).
398
+ $effect(() => {
399
+ if (!mounted || !scrollEl || n === 0) return;
400
+ let desired = -1;
401
+ if (value !== undefined) {
402
+ const i = indexOfValue(opts, value);
403
+ if (i >= 0) desired = i;
404
+ } else if (index !== undefined) {
405
+ desired = clamp(index, 0, n - 1);
406
+ }
407
+ if (desired < 0 || desired === selectedIndex) return;
408
+ if (untrack(() => userScrolling)) return;
409
+ untrack(() => scrollToIndex(desired, reducedMotion.current));
410
+ });
411
+
412
+ // A listbox with no accessible name is a WCAG 4.1.2 failure for screen-reader users.
413
+ $effect(() => {
414
+ if (n > 0 && !label && !ariaLabel && !ariaLabelledby) {
415
+ console.warn(
416
+ "[WheelPicker] No `label` provided — the listbox has no accessible name for " +
417
+ "screen readers. Pass `label` (or aria-label/aria-labelledby)."
418
+ );
419
+ }
420
+ });
421
+
422
+ $effect(() => {
423
+ return () => clearTimeout(idleTimer);
424
+ });
425
+
426
+ let _class = $derived(unstyled ? classProp : twMerge("stuic-wheel-picker", classProp));
427
+ </script>
428
+
429
+ <div
430
+ bind:this={el}
431
+ class={_class}
432
+ data-loop={!unstyled && loop ? "true" : undefined}
433
+ style={unstyled
434
+ ? undefined
435
+ : `--stuic-wheel-picker-item-height: ${itemHeight}px; --stuic-wheel-picker-visible-count: ${vCount};`}
436
+ {...rest}
437
+ >
438
+ <div
439
+ bind:this={scrollEl}
440
+ class={unstyled ? undefined : "stuic-wheel-picker-scroll"}
441
+ role="listbox"
442
+ tabindex={keyboard ? 0 : undefined}
443
+ aria-label={label ?? ariaLabel}
444
+ aria-labelledby={ariaLabelledby}
445
+ aria-activedescendant={selectedOptionId}
446
+ onscroll={onScroll}
447
+ onscrollend={settle}
448
+ onpointerdown={markUserScrolling}
449
+ ontouchstart={markUserScrolling}
450
+ onwheel={markUserScrolling}
451
+ onkeydown={onKeydown}
452
+ >
453
+ <div
454
+ class={unstyled ? undefined : "stuic-wheel-picker-spacer"}
455
+ aria-hidden="true"
456
+ ></div>
457
+ {#each rows as row (row.gi)}
458
+ {@const selected = row.isHome && row.realIndex === selectedIndex}
459
+ <div
460
+ class={twMerge(
461
+ !unstyled && "stuic-wheel-picker-item",
462
+ classItem,
463
+ selected && classItemSelected
464
+ )}
465
+ id={row.isHome ? optionId(row.realIndex) : undefined}
466
+ role={row.isHome ? "option" : undefined}
467
+ aria-hidden={row.isHome ? undefined : "true"}
468
+ aria-selected={row.isHome ? (selected ? "true" : "false") : undefined}
469
+ aria-disabled={row.option?.disabled ? "true" : undefined}
470
+ data-selected={selected ? "true" : undefined}
471
+ data-disabled={row.option?.disabled ? "true" : undefined}
472
+ data-label={row.option?.label}
473
+ >
474
+ {#if renderItem}
475
+ {@render renderItem({ option: row.option, index: row.realIndex, selected })}
476
+ {:else}
477
+ {row.option?.label}
478
+ {/if}
479
+ </div>
480
+ {/each}
481
+ <div
482
+ class={unstyled ? undefined : "stuic-wheel-picker-spacer"}
483
+ aria-hidden="true"
484
+ ></div>
485
+ </div>
486
+
487
+ {#if !unstyled}
488
+ <div class={twMerge("stuic-wheel-picker-band", classBand)} aria-hidden="true"></div>
489
+ {/if}
490
+
491
+ {#if announce}
492
+ <div class="stuic-wheel-picker-sr" aria-live="polite" aria-atomic="true">
493
+ {announceText}
494
+ </div>
495
+ {/if}
496
+ </div>
@@ -0,0 +1,72 @@
1
+ import type { HTMLAttributes } from "svelte/elements";
2
+ import type { Snippet } from "svelte";
3
+ import type { WheelPickerOption, WheelPickerOptionInput } from "./utils.js";
4
+ export type { WheelPickerOption, WheelPickerOptionInput } from "./utils.js";
5
+ export interface Props<T = string | number> extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onchange"> {
6
+ /** Options to scroll through. Bare strings/numbers become `{ label, value }`; objects pass through. */
7
+ options: WheelPickerOptionInput<T>[];
8
+ /** Bindable selected value (the chosen option's `value`). Primary binding. */
9
+ value?: T;
10
+ /** Bindable selected real index (modulo-reduced). Alternative/companion to `value`. */
11
+ index?: number;
12
+ /** Infinite wrap (59 → 00). Default false (single copy, clamped at the ends). */
13
+ loop?: boolean;
14
+ /** Row height in px. Prefer an integer to avoid cross-copy drift. Default 36. */
15
+ itemHeight?: number;
16
+ /** How many rows are visible (forced odd, so there's an exact center). Default 5. */
17
+ visibleCount?: number;
18
+ /** Loop buffer copies (floor; auto-grown to guarantee fling runway). Default 3. */
19
+ tiles?: number;
20
+ /** Enable keyboard navigation (Arrow/Page/Home/End). Default true. */
21
+ keyboard?: boolean;
22
+ /** Announce the committed label via an aria-live region. Default true. */
23
+ announce?: boolean;
24
+ /** Accessible name for the listbox. Strongly recommended. */
25
+ label?: string;
26
+ /** Fired when the selection settles on a new option (not during a fling). */
27
+ onchange?: (option: WheelPickerOption<T>, index: number) => void;
28
+ /** Skip default styling. */
29
+ unstyled?: boolean;
30
+ /** Additional CSS classes on the root. */
31
+ class?: string;
32
+ /** Extra classes on each row. */
33
+ classItem?: string;
34
+ /** Extra classes on the selected row. */
35
+ classItemSelected?: string;
36
+ /** Extra classes on the center selection band. */
37
+ classBand?: string;
38
+ /** Bindable root element ref. */
39
+ el?: HTMLDivElement;
40
+ /** Custom row renderer (alternative to the plain label). */
41
+ renderItem?: Snippet<[
42
+ {
43
+ option: WheelPickerOption<T>;
44
+ index: number;
45
+ selected: boolean;
46
+ }
47
+ ]>;
48
+ }
49
+ declare function $$render<T = string | number>(): {
50
+ props: Props<T>;
51
+ exports: {};
52
+ bindings: "el" | "value" | "index";
53
+ slots: {};
54
+ events: {};
55
+ };
56
+ declare class __sveltets_Render<T = string | number> {
57
+ props(): ReturnType<typeof $$render<T>>['props'];
58
+ events(): ReturnType<typeof $$render<T>>['events'];
59
+ slots(): ReturnType<typeof $$render<T>>['slots'];
60
+ bindings(): "el" | "value" | "index";
61
+ exports(): {};
62
+ }
63
+ interface $$IsomorphicComponent {
64
+ new <T = string | number>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
65
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
66
+ } & ReturnType<__sveltets_Render<T>['exports']>;
67
+ <T = string | number>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
68
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
69
+ }
70
+ declare const WheelPicker: $$IsomorphicComponent;
71
+ type WheelPicker<T = string | number> = InstanceType<typeof WheelPicker<T>>;
72
+ export default WheelPicker;
@@ -0,0 +1,201 @@
1
+ /* ============================================================================
2
+ WHEEL PICKER COMPONENT TOKENS
3
+ iOS-style scrolling wheel / drum picker (UIPickerView).
4
+ Override globally: :root { --stuic-wheel-picker-color-selected: #f00; }
5
+ Override locally: <WheelPicker style="--stuic-wheel-picker-band-bg: #0001;">
6
+
7
+ NOTE: geometry (item-height / visible-count) is set via the `itemHeight`/`visibleCount`
8
+ PROPS, not CSS — the component writes those vars inline (they also drive the JS scroll
9
+ math), so a CSS override of them is shadowed by the inline style. Set them via props.
10
+ ============================================================================ */
11
+
12
+ :root {
13
+ /* Geometry (defaults; component overrides inline from props) */
14
+ --stuic-wheel-picker-item-height: 36px;
15
+ --stuic-wheel-picker-visible-count: 5;
16
+ --stuic-wheel-picker-min-width: 3rem;
17
+
18
+ /* Typography / color */
19
+ --stuic-wheel-picker-font-size: 1.125rem;
20
+ --stuic-wheel-picker-font-family: var(--font-sans);
21
+ --stuic-wheel-picker-color: var(--stuic-color-muted-foreground);
22
+ --stuic-wheel-picker-color-selected: var(--stuic-color-foreground);
23
+ --stuic-wheel-picker-font-weight-selected: 600;
24
+
25
+ /* Center selection band */
26
+ --stuic-wheel-picker-band-bg: color-mix(
27
+ in srgb,
28
+ var(--stuic-color-foreground) 4%,
29
+ transparent
30
+ );
31
+ --stuic-wheel-picker-band-border-color: var(--stuic-color-border);
32
+
33
+ /* Edge fade strength (0 = no fade, 1 = full fade to transparent at the rim) */
34
+ --stuic-wheel-picker-fade: 1;
35
+
36
+ /* Focus ring */
37
+ --stuic-wheel-picker-ring-width: 2px;
38
+ --stuic-wheel-picker-ring-color: var(--stuic-color-ring);
39
+ }
40
+
41
+ @layer components {
42
+ /* ========================================================================
43
+ ROOT
44
+ ======================================================================== */
45
+ .stuic-wheel-picker {
46
+ position: relative;
47
+ display: inline-block;
48
+ min-width: var(--stuic-wheel-picker-min-width);
49
+ font-family: var(--stuic-wheel-picker-font-family);
50
+ -webkit-tap-highlight-color: transparent;
51
+ }
52
+
53
+ /* ========================================================================
54
+ SCROLL VIEWPORT
55
+ The only scrollable element. Native overflow scroll + CSS scroll-snap do
56
+ the momentum + settling; JS only reads/repositions at rest.
57
+ ======================================================================== */
58
+ .stuic-wheel-picker-scroll {
59
+ height: calc(
60
+ var(--stuic-wheel-picker-visible-count) * var(--stuic-wheel-picker-item-height)
61
+ );
62
+ overflow-x: hidden;
63
+ overflow-y: auto;
64
+
65
+ scroll-snap-type: y mandatory;
66
+ overscroll-behavior: contain; /* stop iOS rubber-band bouncing the page at the ends */
67
+ -webkit-overflow-scrolling: touch;
68
+ touch-action: pan-y;
69
+
70
+ border-radius: var(--stuic-wheel-picker-radius, var(--stuic-radius-container));
71
+ background: var(--stuic-wheel-picker-bg, transparent);
72
+
73
+ /* Hide scrollbar (the band + fade convey position) */
74
+ scrollbar-width: none;
75
+ -ms-overflow-style: none;
76
+
77
+ /* Edge fade — center stays fully opaque, rows dim toward the rim. The clear
78
+ band is ~1 row tall (1 / visible-count of the height) around the center. */
79
+ --_fade-edge: calc(
80
+ (50% - 0.5 * var(--stuic-wheel-picker-item-height)) * var(--stuic-wheel-picker-fade)
81
+ );
82
+ -webkit-mask-image: linear-gradient(
83
+ to bottom,
84
+ transparent 0,
85
+ #000 var(--_fade-edge),
86
+ #000 calc(100% - var(--_fade-edge)),
87
+ transparent 100%
88
+ );
89
+ mask-image: linear-gradient(
90
+ to bottom,
91
+ transparent 0,
92
+ #000 var(--_fade-edge),
93
+ #000 calc(100% - var(--_fade-edge)),
94
+ transparent 100%
95
+ );
96
+ }
97
+
98
+ .stuic-wheel-picker-scroll::-webkit-scrollbar {
99
+ display: none;
100
+ }
101
+
102
+ .stuic-wheel-picker-scroll:focus {
103
+ outline: none;
104
+ }
105
+
106
+ .stuic-wheel-picker-scroll:focus-visible {
107
+ outline: var(--stuic-wheel-picker-ring-width) solid
108
+ var(--stuic-wheel-picker-ring-color);
109
+ outline-offset: 2px;
110
+ }
111
+
112
+ /* ========================================================================
113
+ SPACERS — let the first/last real row reach the vertical center
114
+ ======================================================================== */
115
+ .stuic-wheel-picker-spacer {
116
+ flex: 0 0 auto;
117
+ height: calc(
118
+ (var(--stuic-wheel-picker-visible-count) - 1) / 2 *
119
+ var(--stuic-wheel-picker-item-height)
120
+ );
121
+ }
122
+
123
+ /* ========================================================================
124
+ ROWS
125
+ ======================================================================== */
126
+ .stuic-wheel-picker-item {
127
+ display: flex;
128
+ flex-direction: column; /* stack the bold-width ghost (below) with no row-width sum */
129
+ align-items: center;
130
+ justify-content: center;
131
+ height: var(--stuic-wheel-picker-item-height);
132
+ box-sizing: border-box;
133
+ padding-inline: 0.75rem;
134
+
135
+ scroll-snap-align: center;
136
+ scroll-snap-stop: always; /* best-effort; correctness never relies on it */
137
+
138
+ font-size: var(--stuic-wheel-picker-font-size);
139
+ line-height: 1;
140
+ color: var(--stuic-wheel-picker-color);
141
+ white-space: nowrap;
142
+ user-select: none;
143
+ -webkit-user-select: none;
144
+ transition: color var(--stuic-wheel-picker-transition, var(--stuic-transition));
145
+ }
146
+
147
+ /* Reserve the SELECTED (bold) label width on EVERY row via a zero-height, hidden
148
+ ghost, so committing a wider/narrower bold word never resizes the shrink-to-fit
149
+ wheel (prevents the 1–2px container width jitter while scrolling). */
150
+ .stuic-wheel-picker-item::after {
151
+ content: attr(data-label);
152
+ height: 0;
153
+ overflow: hidden;
154
+ visibility: hidden;
155
+ pointer-events: none;
156
+ font-weight: var(--stuic-wheel-picker-font-weight-selected);
157
+ }
158
+
159
+ .stuic-wheel-picker-item[data-selected="true"] {
160
+ color: var(--stuic-wheel-picker-color-selected);
161
+ font-weight: var(--stuic-wheel-picker-font-weight-selected);
162
+ }
163
+
164
+ .stuic-wheel-picker-item[data-disabled="true"] {
165
+ opacity: 0.4;
166
+ }
167
+
168
+ /* ========================================================================
169
+ CENTER SELECTION BAND (purely visual)
170
+ ======================================================================== */
171
+ .stuic-wheel-picker-band {
172
+ position: absolute;
173
+ left: 0;
174
+ right: 0;
175
+ top: 50%;
176
+ height: var(--stuic-wheel-picker-item-height);
177
+ transform: translateY(-50%);
178
+ pointer-events: none;
179
+ background: var(--stuic-wheel-picker-band-bg);
180
+ border-top: var(--stuic-wheel-picker-band-border-width, var(--stuic-border-width))
181
+ solid var(--stuic-wheel-picker-band-border-color);
182
+ border-bottom: var(--stuic-wheel-picker-band-border-width, var(--stuic-border-width))
183
+ solid var(--stuic-wheel-picker-band-border-color);
184
+ }
185
+
186
+ /* ========================================================================
187
+ SCREEN-READER LIVE REGION (visually hidden)
188
+ ======================================================================== */
189
+ .stuic-wheel-picker-sr {
190
+ position: absolute;
191
+ width: 1px;
192
+ height: 1px;
193
+ padding: 0;
194
+ margin: -1px;
195
+ overflow: hidden;
196
+ clip: rect(0 0 0 0);
197
+ clip-path: inset(50%);
198
+ white-space: nowrap;
199
+ border: 0;
200
+ }
201
+ }
@@ -0,0 +1,3 @@
1
+ export { default as WheelPicker } from "./WheelPicker.svelte";
2
+ export type { Props as WheelPickerProps } from "./WheelPicker.svelte";
3
+ export type { WheelPickerOption, WheelPickerOptionInput } from "./utils.js";
@@ -0,0 +1 @@
1
+ export { default as WheelPicker } from "./WheelPicker.svelte";
@@ -0,0 +1,62 @@
1
+ export interface WheelPickerOption<T = string | number> {
2
+ /** Visible text for the row (and what gets announced to screen readers). */
3
+ label: string;
4
+ /** The value committed to `bind:value` when this row is selected. */
5
+ value: T;
6
+ /** When true the row cannot be selected (auto-skipped on settle/keyboard). */
7
+ disabled?: boolean;
8
+ }
9
+ /** What callers may pass: a bare primitive (used as both label and value) or a full option. */
10
+ export type WheelPickerOptionInput<T = string | number> = string | number | WheelPickerOption<T>;
11
+ /** Px runway (each direction from the home tile) the loop buffer must guarantee so a
12
+ * realistic fling never runs off the rendered content before it comes to rest. */
13
+ export declare const MIN_RUNWAY_PX = 2400;
14
+ /** Positive modulo — `((n % m) + m) % m` — so negative indices wrap correctly. */
15
+ export declare function mod(n: number, m: number): number;
16
+ export declare function clamp(n: number, lo: number, hi: number): number;
17
+ /** Force an integer >= 1 and odd, so there is always a single exact center row. */
18
+ export declare function forceOdd(n: number): number;
19
+ /** Normalize mixed input to `{ label, value, disabled }`. */
20
+ export declare function normalizeOptions<T = string | number>(options: WheelPickerOptionInput<T>[]): WheelPickerOption<T>[];
21
+ /**
22
+ * The rendered row whose center currently sits under the selection band.
23
+ * With the top/bottom center spacer, row `j` is centered exactly when
24
+ * `scrollTop === j * itemHeight`, so the global (buffered) index is a rounded ratio.
25
+ */
26
+ export declare function centerGlobalIndex(scrollTop: number, itemHeight: number): number;
27
+ /** Map a global (buffered) index to the real option index — wrap when looping, clamp otherwise. */
28
+ export declare function realIndexFromGlobal(globalIndex: number, n: number, loop: boolean): number;
29
+ /**
30
+ * Number of identical tiles to render for the infinite buffer. Auto-grows (staying
31
+ * odd, min 3) until the runway from the home tile to either end covers both the
32
+ * viewport and `MIN_RUNWAY_PX`, so a single fling can't exhaust the buffer at rest.
33
+ * Returns 1 when not looping (single copy, clamped).
34
+ */
35
+ export declare function resolveTiles(requested: number, n: number, itemHeight: number, containerHeight: number, loop: boolean, minRunwayPx?: number): number;
36
+ /** Global index of real index 0 in the middle (home) tile. */
37
+ export declare function homeBase(tiles: number, n: number): number;
38
+ /**
39
+ * How many whole tiles the centered row has drifted from the home tile.
40
+ * Floor-based (which tile are we in), NOT round-based: after teleporting by this
41
+ * many tiles the center lands back in the home tile with drift 0 — no oscillation,
42
+ * and the real index (`gi mod n`) is unchanged, so the teleport is invisible.
43
+ */
44
+ export declare function driftTiles(globalIndex: number, n: number, homeTile: number): number;
45
+ /**
46
+ * Nearest selectable (non-disabled) real index starting at `start`, searching in
47
+ * `dir`. Returns `start` (reduced) if it is already enabled. Loop wraps; non-loop
48
+ * stops at the edge and returns the last index reached even if disabled. If every
49
+ * option is disabled, returns the reduced start.
50
+ */
51
+ export declare function nextEnabledIndex(start: number, dir: 1 | -1, options: readonly {
52
+ disabled?: boolean;
53
+ }[], loop: boolean): number;
54
+ /** Index of the option matching `value` (strict equality), or -1. */
55
+ export declare function indexOfValue<T>(options: WheelPickerOption<T>[], value: T): number;
56
+ /**
57
+ * Global index of the copy of `realIndex` NEAREST `currentGlobal` (shortest signed path,
58
+ * ties resolve forward). This is what makes a wrap travel naturally: from 59 with n=60,
59
+ * targeting real 0 returns currentGlobal+1 (one step down) rather than rewinding 59 rows
60
+ * up to the same tile's 0. Used for programmatic/keyboard moves in loop mode.
61
+ */
62
+ export declare function nearestGlobalIndex(currentGlobal: number, realIndex: number, n: number): number;
@@ -0,0 +1,139 @@
1
+ // ============================================================================
2
+ // WheelPicker — framework-free index math
3
+ //
4
+ // The component leans entirely on native scroll + CSS scroll-snap. ALL of its
5
+ // correctness-critical logic is the pure index arithmetic below: turning a
6
+ // scrollTop into a selected option, wrapping/clamping indices, and computing the
7
+ // "teleport" that makes the loop infinite. Keeping it here (no Svelte, no DOM)
8
+ // means it is exhaustively unit-testable in the fast node project (utils.test.ts).
9
+ // ============================================================================
10
+ /** Px runway (each direction from the home tile) the loop buffer must guarantee so a
11
+ * realistic fling never runs off the rendered content before it comes to rest. */
12
+ export const MIN_RUNWAY_PX = 2400;
13
+ /** Positive modulo — `((n % m) + m) % m` — so negative indices wrap correctly. */
14
+ export function mod(n, m) {
15
+ if (m <= 0)
16
+ return 0;
17
+ return ((n % m) + m) % m;
18
+ }
19
+ export function clamp(n, lo, hi) {
20
+ if (hi < lo)
21
+ return lo;
22
+ return Math.min(hi, Math.max(lo, n));
23
+ }
24
+ /** Force an integer >= 1 and odd, so there is always a single exact center row. */
25
+ export function forceOdd(n) {
26
+ let v = Math.max(1, Math.round(n));
27
+ if (v % 2 === 0)
28
+ v += 1;
29
+ return v;
30
+ }
31
+ /** Normalize mixed input to `{ label, value, disabled }`. */
32
+ export function normalizeOptions(options) {
33
+ return (options ?? []).map((o) => {
34
+ if (o !== null && typeof o === "object" && "value" in o) {
35
+ const opt = o;
36
+ return {
37
+ label: String(opt.label ?? opt.value),
38
+ value: opt.value,
39
+ disabled: !!opt.disabled,
40
+ };
41
+ }
42
+ return { label: String(o), value: o };
43
+ });
44
+ }
45
+ /**
46
+ * The rendered row whose center currently sits under the selection band.
47
+ * With the top/bottom center spacer, row `j` is centered exactly when
48
+ * `scrollTop === j * itemHeight`, so the global (buffered) index is a rounded ratio.
49
+ */
50
+ export function centerGlobalIndex(scrollTop, itemHeight) {
51
+ if (itemHeight <= 0)
52
+ return 0;
53
+ return Math.round(scrollTop / itemHeight);
54
+ }
55
+ /** Map a global (buffered) index to the real option index — wrap when looping, clamp otherwise. */
56
+ export function realIndexFromGlobal(globalIndex, n, loop) {
57
+ if (n <= 0)
58
+ return 0;
59
+ return loop ? mod(globalIndex, n) : clamp(globalIndex, 0, n - 1);
60
+ }
61
+ /**
62
+ * Number of identical tiles to render for the infinite buffer. Auto-grows (staying
63
+ * odd, min 3) until the runway from the home tile to either end covers both the
64
+ * viewport and `MIN_RUNWAY_PX`, so a single fling can't exhaust the buffer at rest.
65
+ * Returns 1 when not looping (single copy, clamped).
66
+ */
67
+ export function resolveTiles(requested, n, itemHeight, containerHeight, loop, minRunwayPx = MIN_RUNWAY_PX) {
68
+ if (!loop || n <= 0)
69
+ return 1;
70
+ let tiles = Math.max(3, Math.round(requested));
71
+ if (tiles % 2 === 0)
72
+ tiles += 1;
73
+ const tilePx = n * itemHeight;
74
+ if (tilePx > 0) {
75
+ const need = Math.max(containerHeight, minRunwayPx);
76
+ // runway on each side = floor(tiles/2) full tiles
77
+ while (Math.floor(tiles / 2) * tilePx < need)
78
+ tiles += 2;
79
+ }
80
+ return tiles;
81
+ }
82
+ /** Global index of real index 0 in the middle (home) tile. */
83
+ export function homeBase(tiles, n) {
84
+ return Math.floor(tiles / 2) * n;
85
+ }
86
+ /**
87
+ * How many whole tiles the centered row has drifted from the home tile.
88
+ * Floor-based (which tile are we in), NOT round-based: after teleporting by this
89
+ * many tiles the center lands back in the home tile with drift 0 — no oscillation,
90
+ * and the real index (`gi mod n`) is unchanged, so the teleport is invisible.
91
+ */
92
+ export function driftTiles(globalIndex, n, homeTile) {
93
+ if (n <= 0)
94
+ return 0;
95
+ return Math.floor(globalIndex / n) - homeTile;
96
+ }
97
+ /**
98
+ * Nearest selectable (non-disabled) real index starting at `start`, searching in
99
+ * `dir`. Returns `start` (reduced) if it is already enabled. Loop wraps; non-loop
100
+ * stops at the edge and returns the last index reached even if disabled. If every
101
+ * option is disabled, returns the reduced start.
102
+ */
103
+ export function nextEnabledIndex(start, dir, options, loop) {
104
+ const n = options.length;
105
+ if (n === 0)
106
+ return start;
107
+ let i = loop ? mod(start, n) : clamp(start, 0, n - 1);
108
+ for (let step = 0; step < n; step++) {
109
+ if (!options[i]?.disabled)
110
+ return i;
111
+ let next = i + dir;
112
+ if (loop) {
113
+ next = mod(next, n);
114
+ }
115
+ else if (next < 0 || next > n - 1) {
116
+ return i; // hit the edge, give up
117
+ }
118
+ i = next;
119
+ }
120
+ return i; // all disabled
121
+ }
122
+ /** Index of the option matching `value` (strict equality), or -1. */
123
+ export function indexOfValue(options, value) {
124
+ return options.findIndex((o) => o.value === value);
125
+ }
126
+ /**
127
+ * Global index of the copy of `realIndex` NEAREST `currentGlobal` (shortest signed path,
128
+ * ties resolve forward). This is what makes a wrap travel naturally: from 59 with n=60,
129
+ * targeting real 0 returns currentGlobal+1 (one step down) rather than rewinding 59 rows
130
+ * up to the same tile's 0. Used for programmatic/keyboard moves in loop mode.
131
+ */
132
+ export function nearestGlobalIndex(currentGlobal, realIndex, n) {
133
+ if (n <= 0)
134
+ return currentGlobal;
135
+ let delta = mod(realIndex - mod(currentGlobal, n), n); // 0..n-1
136
+ if (delta > n / 2)
137
+ delta -= n; // fold into (-n/2, n/2]
138
+ return currentGlobal + delta;
139
+ }
package/dist/index.css CHANGED
@@ -100,6 +100,7 @@ In practice:
100
100
  @import "./components/Tree/index.css";
101
101
  @import "./components/TwCheck/index.css";
102
102
  @import "./components/UserAvatarMenu/index.css";
103
+ @import "./components/WheelPicker/index.css";
103
104
  @import "./components/WithSidePanel/index.css";
104
105
  @import "./components/X/index.css";
105
106
 
package/dist/index.d.ts CHANGED
@@ -75,6 +75,7 @@ export * from "./components/Tree/index.js";
75
75
  export * from "./components/TwCheck/index.js";
76
76
  export * from "./components/TypeaheadInput/index.js";
77
77
  export * from "./components/UserAvatarMenu/index.js";
78
+ export * from "./components/WheelPicker/index.js";
78
79
  export * from "./components/WithSidePanel/index.js";
79
80
  export * from "./components/X/index.js";
80
81
  export * from "./utils/index.js";
package/dist/index.js CHANGED
@@ -76,6 +76,7 @@ export * from "./components/Tree/index.js";
76
76
  export * from "./components/TwCheck/index.js";
77
77
  export * from "./components/TypeaheadInput/index.js";
78
78
  export * from "./components/UserAvatarMenu/index.js";
79
+ export * from "./components/WheelPicker/index.js";
79
80
  export * from "./components/WithSidePanel/index.js";
80
81
  export * from "./components/X/index.js";
81
82
  // utils
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.118.0",
3
+ "version": "3.119.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -140,12 +140,12 @@
140
140
  "@milkdown/transformer": "^7.21.2",
141
141
  "@milkdown/utils": "^7.21.2",
142
142
  "@sveltejs/adapter-auto": "^4.0.0",
143
- "@sveltejs/kit": "^2.63.0",
143
+ "@sveltejs/kit": "^2.64.0",
144
144
  "@sveltejs/package": "^2.5.8",
145
145
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
146
146
  "@tailwindcss/cli": "^4.3.0",
147
147
  "@tailwindcss/forms": "^0.5.11",
148
- "@tailwindcss/typography": "^0.5.19",
148
+ "@tailwindcss/typography": "^0.5.20",
149
149
  "@tailwindcss/vite": "^4.3.0",
150
150
  "@types/node": "^25.9.2",
151
151
  "@vitest/browser-playwright": "^4.1.8",
@@ -156,12 +156,12 @@
156
156
  "prettier": "^3.8.3",
157
157
  "prettier-plugin-svelte": "^3.5.2",
158
158
  "publint": "^0.3.21",
159
- "svelte": "^5.56.2",
159
+ "svelte": "^5.56.3",
160
160
  "svelte-check": "^4.6.0",
161
161
  "tailwindcss": "^4.3.0",
162
162
  "tsx": "^4.22.4",
163
163
  "typescript": "^5.9.3",
164
- "typescript-eslint": "^8.60.1",
164
+ "typescript-eslint": "^8.61.0",
165
165
  "vite": "^7.3.5",
166
166
  "vitest": "^4.1.8",
167
167
  "vitest-browser-svelte": "^2.1.1"