JavaScript Wheel Picker

@lemonadejs/wheel · ✓ 16 contract checks · framework-agnostic · zero dependencies

<Wheel /> — iOS-style scroll wheel picker, ported from the v5 plugin

The v5 model, kept: a column of options behind two frosted masks; the row in the middle band is the selection. A mouse-wheel NOTCH steps one row; trackpad deltas glide freely and settle on the nearest row; press and drag scrolls with the pointer (snap suspended while dragging, as v5 toggled lm-wheel-grid) and snaps to the closest row on release.

v6 rebuild: the position is a transform driven from props (rowheight × visible), not native scrollTop — deterministic everywhere (jsdom has no layout) and the snap animation is one CSS transition. v5 leaked a document mousemove/mouseup pair per instance forever and shared one module-global drag flag; v6 arms document listeners per gesture with ONE persistent cleanup (the Modal track pattern), released on pointer up and on unmount.

v5 → v6 mapping: value (the selected OPTION object, two-way) → bind (the selected INDEX — survives attribute coercion and primitive options; api.getValue() still returns the entry itself); onupdate → onchange; options stays options, now also accepting plain strings and numbers (v5 required { title } objects); the fixed 40px/200px geometry becomes rowheight and visible. New: touch dragging, tap to select a row, keyboard arrows/Home/End, disabled.

Example

live
import { html } from 'lemonadejs';
import Wheel from '@lemonadejs/wheel';

const App = (props, { state }) => {
    const hour = state(9);
    const minute = state(6); // index into the options
    const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
    const minutes = Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, '0'));

    return html`<div>
        <div style="display: flex; gap: 8px; align-items: center;">
            <${Wheel} bind="${hour}" options="${hours}" rowheight="32" visible="5" />
            <b>:</b>
            <${Wheel} bind="${minute}" options="${minutes}" rowheight="32" visible="5" />
        </div>
        <p>Alarm set for <b>${() => hours[hour.value] + ':' + minutes[minute.value]}</b></p>
    </div>`;
};

Installation

npm install @lemonadejs/wheel
import Wheel from '@lemonadejs/wheel';
import '@lemonadejs/wheel/style.css';

Three deployment forms, one component:

html`<${Wheel} />`                       // by value (no registration)
setComponents({ Wheel });               // then <Wheel /> by name anywhere
createWebComponent(Wheel);              // <lm-wheel> in plain HTML/any framework

Props

Every declared prop arrives as a live state — pass a value for a snapshot or a state for a two-way live wire. Attribute strings are coerced to the declared type.

PropTypeDefaultDescription
bindnumberTwo-way bound value. .set() fires onchange; plain assignment is silent. two-way selected index (v5: value held the option)
selectednumber0initial index when unbound
optionsarrayentries: strings/numbers or { title } objects (v5)
rowheightnumber40px per row (v5: fixed 40)
visiblenumber5rows in the viewport (v5: fixed 200px / 40)
disabledbooleanfalseblocks interaction (new)
aria-labelstring''

Events

All event names are lowercase (the platform convention — LJS-305 warns otherwise).

  • onchange — (index) on user/component-initiated changes

API

import { ref } from 'lemonadejs';
const wheel = ref();
html`<${Wheel} ref="${wheel}" />`;
// wheel.current.getIndex(...)  ·  wheel.current.setIndex(...)  ·  wheel.current.getValue(...)
  • getIndex()
  • setIndex()
  • getValue()

Styling

All classes follow the lm-wheel-* convention; visual variants are data-* attributes on the root. Override freely — there is no styling engine to fight.

Contract

The machine-readable schema ships with the package:

import contract from '@lemonadejs/wheel/contract.json';

verify.json carries the conformance proof produced by verify(Wheel).

Looking for the v5 plugin? See the archived v5 documentation.