WheelPicker
A drum wheel of options scrolled by finger, with the system's momentum, snapping on a row, and a real select underneath.
Demo
Size M, seat 14c. Flick a wheel, tap a row, or Tab to one and use the arrow keys.
Nothing to choose, a value that is not there, and a wheel that is off:
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/wheel_picker.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/wheel_picker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/wheel_picker.qwe
component WheelPickerDemoLive() { state size = "M" state seat = "14c" <div.demo-stack> <div class="demo-row wheel-picker-demo-row"> <WheelPicker label="Size" shown={true} value={size} field="wheel-picker-demo-size" options={["XXS", "XS", "S", "M", "L", "XL", "XXL", "3XL", "4XL"]} on_change={(v) => { size = v }}/> <WheelPicker label="Seat" shown={true} rows={3} value={seat} field="wheel-picker-demo-seat" options={[{ value: "14a", label: "14A window" }, { value: "14b", label: "14B taken", disabled: true }, { value: "14c", label: "14C aisle" }, { value: "14d", label: "14D aisle" }, { value: "14e", label: "14E taken", disabled: true }, { value: "14f", label: "14F window" }]} on_change={(v) => { seat = v }}/> </div> <p.demo-note id="wheel-picker-demo-out">Size {size}, seat {seat}. Flick a wheel, tap a row, or Tab to one and use the arrow keys.</p> <div.demo-row> <Button size="lg" variant="outline" id="wheel-picker-demo-set" on_click={() => { size = "XL" }}>Make it XL</Button> </div> </div>}let wheel_picker_demo_css = css`.wheel-picker-demo-row > * { flex: 1 1 140px; }`view WheelPickerDemo() = <div.demo-stack> <style>{wheel_picker_demo_css}</style> <WheelPickerDemoLive/> <form id="wheel-picker-demo-form" method="get" action="#" class="demo-narrow"> <WheelPicker label="Delivery" name="slot" value="Tuesday" rows={3} options={["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]}/> </form> <div class="demo-narrow" id="wheel-picker-demo-odd"> <p.demo-note>Nothing to choose, a value that is not there, and a wheel that is off:</p> <WheelPicker label="Nothing" rows={3}/> <WheelPicker label="Lost" rows={3} value="Z" options={[{ value: "A", disabled: true }, "B", "C"]}/> <WheelPicker label="Off" rows={3} value="C" disabled={true} options={["A", "B", "C"]}/> </div> </div>Source
ui/wheel_picker.qwe
// WheelPicker: a drum of options turned with a finger, as on a phone: it carries on with// the flick and settles on a row. It is the browser's own scrolling with scroll snap, so// the momentum is the system's own on either phone, and over it sits nothing: under it is a// real <select>, which a form sends, which a screen reader reads, and which the keyboard// moves (arrows, Page Up, Page Down, Home, End). A mouse drags it too, and a tap on a// row goes there.//// <WheelPicker label="Size" name="size" value="M" options={["XS", "S", "M", "L", "XL"]}/>// <WheelPicker label="Seat" options={[{ value: "1a", label: "1A window" }, { value: "1b", label: "1B", disabled: true }]}// value={seat} on_change={(v) => { seat = v }}/>//// options: text or numbers, or { value, label, disabled }. value: the one to start on, and// the one to move to when it changes (the first that can be chosen when it is not there).// label: its name for screen readers. shown: whether label is also written above it (false).// name: the field's name in a form (none by default). rows: how many rows show, an// odd number from 3 to 9 (5). disabled, field (the select's id), class as for every field.// on_change(value): inside one of your components, given the value as text once the wheel// has come to rest, not at each row it passes.// A row a person passes gives a small tick through quilloNative.haptic in the apps.//// There is no endless wheel: a wheel that loops needs rows made and dropped as it turns,// which fights the system's momentum.//// wheel_picker_options(options), wheel_picker_find(opts, value) and// wheel_picker_near(opts, i) are the logic, for other components.import ui/theme.qwe use ui_clslet wheel_picker_row = 44let wheel_picker_css = css`.ui-wheel-picker { display: grid; gap: 6px; min-width: 0; font: 18px/1.2 var(--ui-font); color: var(--ui-fg); }.ui-wheel-picker-label { font-size: 13px; font-weight: 500; text-align: center; }.ui-wheel-picker-drum { position: relative; height: calc(var(--rows) * 44px); min-width: 0; }/* The band the chosen row rests in, behind the rows. */.ui-wheel-picker-band { position: absolute; inset: calc((var(--rows) - 1) * 22px) 0 auto; height: 44px; border-radius: var(--ui-radius); background: var(--ui-subtle); border-block: 1px solid var(--ui-border); pointer-events: none;}.ui-wheel-picker-drum:has(select:focus-visible) .ui-wheel-picker-band { box-shadow: var(--ui-focus); }.ui-wheel-picker-list { position: relative; box-sizing: border-box; height: 100%; padding-block: calc((var(--rows) - 1) * 22px); overflow: hidden; overscroll-behavior: contain; scrollbar-width: none; cursor: grab; outline: none; perspective: 600px; -webkit-mask-image: linear-gradient(transparent, #000 34%, #000 66%, transparent); mask-image: linear-gradient(transparent, #000 34%, #000 66%, transparent); user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: pan-y;}.ui-wheel-picker-list::-webkit-scrollbar { display: none; }/* Until the browser takes over, the server's page shows the right row all the same. */.ui-wheel-picker-list:not(.ui-wheel-picker-ready) > .ui-wheel-picker-item:first-child { margin-top: calc(var(--at) * -44px); }.ui-wheel-picker-on { overflow-y: auto; scroll-snap-type: y mandatory; scroll-behavior: smooth; }.ui-wheel-picker-grab { cursor: grabbing; }.ui-wheel-picker-off .ui-wheel-picker-list { overflow: hidden; cursor: not-allowed; opacity: .55; }.ui-wheel-picker-item { box-sizing: border-box; height: 44px; padding-inline: 8px; display: flex; align-items: center; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; scroll-snap-align: center; scroll-snap-stop: normal; font-variant-numeric: tabular-nums;}.ui-wheel-picker-item[data-off] { color: var(--ui-muted); text-decoration: line-through; }/* The select lies over the wheel and takes no finger: a screen reader finds it where the wheel is, and it is big enough to reach. */.ui-wheel-picker-native { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; pointer-events: none; font-size: 16px; }@supports (animation-timeline: view()) { .ui-wheel-picker-on .ui-wheel-picker-item { animation: ui-wheel-picker-turn linear both; animation-timeline: view(); } @keyframes ui-wheel-picker-turn { from { transform: rotateX(62deg) scale(.9); } 50% { transform: none; } to { transform: rotateX(-62deg) scale(.9); } }}@media (prefers-reduced-motion: reduce) { .ui-wheel-picker-on { scroll-behavior: auto; } .ui-wheel-picker-on .ui-wheel-picker-item { animation: none; }}`// Each option as { value, label, off }, the value always text, whatever it was given as.fn wheel_picker_options(options) -> list { if type_of(options) != "list" { return [] } for o in options => type_of(o) == "map" ? { value: str(get(o, "value", "")), label: str(get(o, "label", get(o, "value", ""))), off: get(o, "disabled", false) == true } : { value: str(o), label: str(o), off: false }}// The row of a value, or else the first row that can be chosen.fn wheel_picker_find(opts: list, value) -> int { let wanted = str(value) let open = enumerate(opts) | filter((p) => !p[1].off) let hits = open | filter((p) => p[1].value == wanted) len(hits) > 0 ? hits[0][0] : len(open) > 0 ? open[0][0] : 0}// The row nearest to row i that can be chosen, going `way` (1 or -1) first on a tie.fn wheel_picker_near(opts: list, i: int, way: int = 1) -> int { let n = len(opts) if n == 0 { return 0 } let at = max([0, min([i, n - 1])]) let open = enumerate(opts) | filter((p) => !p[1].off) let ranked = sort_by(open, (p) => abs(p[0] - at) * 2 + ((p[0] - at) * way >= 0 ? 0 : 1)) len(ranked) == 0 ? at : ranked[0][0]}// How many rows show: odd, from 3 to 9.fn wheel_picker_shown(rows: int) -> int { let r = max([3, min([rows, 9])]) r % 2 == 0 ? r + 1 : r}// The row the wheel stops on after a mouse lets go, `speed` in px a millisecond: as far on// as a flick would carry it in about a quarter of a second.fn wheel_picker_landing(top: num, speed: num, count: int) -> int = max([0, min([round((top + speed * 250) / 44), count - 1])])// A tick on a phone, where there is one: `js` gives an error value elsewhere, which is// nothing to say, and a browser's vibrate is too coarse for rows going by.fn wheel_picker_tick() { js("quilloNative.haptic", "selection")}component WheelPicker(options: list = [], value = null, label: str = "Choose", shown: bool = false, name: str? = null, rows: int = 5, disabled: bool = false, field: str? = null, on_change = null, class: str? = null) { style global wheel_picker_css let opts = wheel_picker_options(options) let wanted = wheel_picker_find(opts, value) state at = wanted // the row in the middle now state chosen = len(opts) > 0 ? opts[wanted].value : "" // what the select and the caller have let asked = `${wanted}:${len(opts) > 0 ? opts[wanted].value : ""}` state seen = asked // what `value` and `options` last asked for state box = null state moving = false state last_top = -1 state grab = null // a mouse's drag: { y, top, dragging, speed, t } state grabbing = false // apart from `grab`, which changes on each move: only this is in the markup state dragged = false // the click that ends a mouse's drag is no tap state aim = null // a row the wheel is being sent to from outside // Rests on a row: says so once, moving off a row that cannot be chosen. let rest = () => { if len(opts) > 0 { let i = wheel_picker_near(opts, at) if i != at { at = i box.scrollTop = i * wheel_picker_row } else { let v = opts[i].value if v != chosen { chosen = v on_change && on_change(v) } } } } let go = (i: int) => { at = i moving = true last_top = -1 box.scrollTop = i * wheel_picker_row } // Rest is seen by the wheel not moving between two ticks, on a row. `scrollend` is // missing from older Safari, and no event says a finger has stopped but not lifted. let settle = () => { let top = box.scrollTop // Sent to a row: new rows are not on the page when `options` changes, and a scroll // set before they are stops at the old end, so it is set here until it holds. if aim != null { if box.clientHeight == 0 || abs(top - aim * wheel_picker_row) < 2 { aim = null } else { box.scrollTop = aim * wheel_picker_row } } if aim == null && top == last_top && abs(top - at * wheel_picker_row) < 2 { moving = false rest() } last_top = top } if moving { every(90, settle) } if asked != seen { seen = asked if wanted != at || (len(opts) > 0 && opts[wanted].value != chosen) { at = wanted chosen = len(opts) > 0 ? opts[wanted].value : "" aim = wanted last_top = -1 moving = true } } if box != null && len(opts) > 0 && at >= len(opts) { at = len(opts) - 1 moving = true } let n = wheel_picker_shown(rows) let row_of = (e) => (e.target.closest("[data-i]") ?? e.target).dataset.i <div class={ui_cls(["ui-wheel-picker", disabled && "ui-wheel-picker-off", class])} style={`--rows: ${n}; --at: ${wanted}`}> {shown && <span.ui-wheel-picker-label aria-hidden="true">{label}</span>} <div.ui-wheel-picker-drum> <div.ui-wheel-picker-band aria-hidden="true"></div> <div class={ui_cls(["ui-wheel-picker-list", box != null && "ui-wheel-picker-ready", box != null && !disabled && "ui-wheel-picker-on", grabbing && "ui-wheel-picker-grab"])} aria-hidden="true" tabindex="-1" @mount={(el) => { // The server's margin must go before the wheel can scroll that far. el.classList.add("ui-wheel-picker-ready") el.scrollTop = wanted * wheel_picker_row box = el // A wheel in a closed <dialog> has no height, so no scroll position, until it opens. let watch = js_new("ResizeObserver", () => { if !moving && abs(el.scrollTop - at * wheel_picker_row) > 2 { el.scrollTop = at * wheel_picker_row } }) watch.observe(el) () => { watch.disconnect() } }} @scroll={(e) => { let i = max([0, min([round(e.scrollTop / wheel_picker_row), len(opts) - 1])]) moving = true if aim == null && i != at { at = i wheel_picker_tick() } }} @click={(e) => { let i = row_of(e) let was_drag = dragged dragged = false if i != null && !disabled && !was_drag { let k = wheel_picker_near(opts, int(i)) if k != at { go(k) } } }} @pointerdown={(e) => { dragged = false aim = null if e.pointerType == "mouse" && e.button == 0 && !disabled { grab = { y: e.clientY, top: box.scrollTop, dragging: false, speed: 0, t: e.timeStamp, last: e.clientY } } }} @pointermove={(e) => { if grab != null { let dy = e.clientY - grab.y if !grab.dragging && abs(dy) > 5 { box.setPointerCapture(e.pointerId) box.style.scrollSnapType = "none" box.style.scrollBehavior = "auto" grab = { ...grab, dragging: true } grabbing = true } if grab.dragging { box.scrollTop = grab.top - dy let dt = max([1, e.timeStamp - grab.t]) grab = { ...grab, speed: (grab.last - e.clientY) / dt, t: e.timeStamp, last: e.clientY } } } }} @pointerup={(e) => { if grab != null { let was = grab grab = null grabbing = false if was.dragging { dragged = true box.style.scrollSnapType = "" box.style.scrollBehavior = "" let still = e.timeStamp - was.t > 80 go(wheel_picker_near(opts, wheel_picker_landing(box.scrollTop, still ? 0 : was.speed, len(opts)))) } } }} @pointercancel={() => { if grab != null { let was = grab grab = null grabbing = false box.style.scrollSnapType = "" box.style.scrollBehavior = "" if was.dragging { go(at) } } }}> {for [i, o] in enumerate(opts) => <div.ui-wheel-picker-item key={o.value} data-i={i} data-off={o.off ? "" : null}>{o.label}</div>} </div> <select.ui-wheel-picker-native id={field} name={name} aria-label={label} disabled={disabled} @change={(e) => { let i = e.target.selectedIndex if i >= 0 { go(i) } }} @keydown={(e) => { let to = match e.key { "ArrowUp" => at - 1 "ArrowDown" => at + 1 "PageUp" => at - 5 "PageDown" => at + 5 "Home" => 0 "End" => len(opts) - 1 _ => null } if to != null { e.preventDefault() let k = wheel_picker_near(opts, to, to < at ? -1 : 1) if k != at { go(k) } } }}> {for o in opts => <option key={o.value} value={o.value} selected={o.value == chosen} disabled={o.off}>{o.label}</option>} </select> </div> </div>}Related
DateWheelsMonth, day and year wheels that keep each other valid, with limits, and a value in ISO.2 views · 6 downloadsSheetSelectA select that opens as a bottom sheet, with search past a size, multi-select with Done, and a real field in a form.2 views · 9 downloadsTimeWheelsHour, minute and AM/PM wheels with steps, a 24-hour mode by locale, and limits across midnight.3 views · 10 downloadsFullscreenPickerA full-screen list to pick from, with search and a header with Done, that stays clear of the on-screen keyboard; on a tablet, a card.2 views · 6 downloadsBirthdayInputDay, month and year drop-downs that give one date in a hidden input, the days following the month and the years the age allowed.5 views · 7 downloadsCountryPickerA drop-down of every country with its flag and ISO code, favourites first, sending the two-letter code with a form.5 views · 7 downloads
Thread
Thread 0 posts
On WheelPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.