StepperRow
Minus and plus in a row, a hold to repeat and accelerate, typed values, limits, min and max stated to screen readers.
Demo
Age 13 and up
Up to 2.5 ml a day
Adults 2, dose 0.5, 0 changes.
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/stepper_row.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/stepper_row.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/stepper_row.qwe
component StepperRowDemoLive() { state adults = 2 state dose = 0.5 state calls = 0 <div.demo-stack> <StepperRow label="Adults" hint="Age 13 and up" value={adults} least={1} most={9} name="adults" field="stepper-row-demo-adults" on_change={(n) => { adults = n; calls += 1 }}/> <StepperRow label="Dose" hint="Up to 2.5 ml a day" unit="ml" value={dose} least={0} most={2.5} step={0.25} field="stepper-row-demo-dose" on_change={(n) => { dose = n }}/> <StepperRow label="Steps today" value={100} least={0} step={10} field="stepper-row-demo-long" typed={false}/> <p.demo-note id="stepper-row-demo-out">Adults {adults}, dose {dose}, {calls} changes.</p> </div>}view StepperRowDemo() = <StepperRowDemoLive/>Source
ui/stepper_row.qwe
// StepperRow: a row of a form with a minus and a plus, for a number of something: guests,// tickets, a dose. The buttons are big enough for a thumb; hold one and it repeats, faster// the longer it is held; the number can be typed too. It stops at its limits and says// so, and a screen reader hears it as a spin button with its smallest and largest.//// <StepperRow label="Adults" name="adults" value={2} least={1} most={9}/>// <StepperRow label="Dose" hint="Up to 2.5 ml" unit="ml" value={0.5} least={0} most={2.5} step={0.25} on_change={(n) => { dose = n }}/>//// value: the number, and it moves when this changes. least, most: the limits (none). step: what// a press adds (1); decimal places come from it, so 0.25 reads 0.50 and 1.75. label: what it// counts. hint: a line under the label. unit: after the number, for reading. name: makes// the field a form's. typed: false keeps people to the buttons. disabled, field (the// id), class as for every field. on_change(number): inside one of your components, given// the number after a tap, when a hold ends and when typing is done, not on every repeat.//// Keys, in the field: arrows step, Page Up and Page Down ten steps, Home and End go to the// limits. Text that is no number goes back to what it was; a number past a limit stops// at the limit and says so; a comma counts as the point.// On a wide container (440 px and up) the hint stands beside the label.//// stepper_row_places, stepper_row_add, stepper_row_text, stepper_row_read, stepper_row_range// and stepper_row_rate are the logic.import ui/theme.qwe use ui_clslet stepper_row_css = css`.ui-stepper-row { container-type: inline-size; min-width: 0; font: 16px/1.35 var(--ui-font); color: var(--ui-fg); }.ui-stepper-row-body { display: flex; align-items: center; gap: 12px; min-height: 60px; min-width: 0; }.ui-stepper-row-words { flex: 1; display: grid; min-width: 0; }.ui-stepper-row-label { font-weight: 500; }.ui-stepper-row-hint { font-size: 13px; color: var(--ui-muted); }.ui-stepper-row-box { flex: none; display: flex; align-items: center; gap: 4px; }.ui-stepper-row-btn { display: grid; place-items: center; width: 48px; height: 48px; margin: 0; padding: 0; border: 1px solid var(--ui-border); border-radius: 50%; background: var(--ui-surface); color: var(--ui-accent); cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;}.ui-stepper-row-btn svg { width: 22px; height: 22px; }.ui-stepper-row-btn:active { background: var(--ui-subtle); }.ui-stepper-row-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-stepper-row-btn[aria-disabled="true"] { color: var(--ui-muted); opacity: .5; cursor: not-allowed; }.ui-stepper-row-value { box-sizing: border-box; width: 72px; height: 48px; margin: 0; padding: 0 2px; border: 1px solid transparent; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: 600 18px var(--ui-font); text-align: center; font-variant-numeric: tabular-nums;}.ui-stepper-row-value:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-stepper-row-value[readonly] { cursor: default; }/* The unit sits inside the number's own width, so rows with and without one line up. */.ui-stepper-row-num { position: relative; display: block; }.ui-stepper-row-unit { position: absolute; inset: auto 0 1px; font-size: 11px; line-height: 1; text-align: center; color: var(--ui-muted); pointer-events: none; }.ui-stepper-row-united .ui-stepper-row-value { padding-bottom: 9px; }@media (hover: hover) { .ui-stepper-row-btn:hover:not([aria-disabled="true"]) { border-color: var(--ui-accent); } }@container (min-width: 440px) { .ui-stepper-row-words { display: flex; align-items: baseline; gap: 14px; } .ui-stepper-row-value { width: 96px; }}`// How many decimals a step has.fn stepper_row_places(step) -> int { let parts = split(str(step), ".") len(parts) > 1 && parts[1] != "0" ? min([len(parts[1]), 6]) : 0}// The number kept inside its limits.fn stepper_row_keep(n: num, least, most) -> num { let a = least != null && n < least ? least : n most != null && a > most ? most : a}// `n` plus `times` steps, inside the limits. Rounded, as 0.1 + 0.2 is not 0.3 in floats.fn stepper_row_add(n: num, step: num, times: int, least, most) -> num = stepper_row_keep(round(n + step * times, stepper_row_places(step)), least, most)// The number with its step's decimals: 1.5 by 0.25 is "1.50".fn stepper_row_text(n: num, places: int) -> str { if places == 0 { return str(round(n)) } let parts = split(str(round(n, places)), ".") let frac = len(parts) > 1 ? parts[1] : "" `${parts[0]}.${frac}${join(range(max([0, places - len(frac)])) | map((_i) => "0"), "")}`}// A typed number, or null. A comma counts as the point, and no more digits than a// number holds exactly.fn stepper_row_read(text: str) { let t = re.replace(trim(text), ",", ".") if !re.test(t, "^-?[0-9]{1,12}([.][0-9]{1,6})?$") { return null } float(t)}// What a screen reader is told of the limits.fn stepper_row_range(least, most, places: int) -> str { let a = least != null ? stepper_row_text(least, places) : null let b = most != null ? stepper_row_text(most, places) : null a != null && b != null ? `From ${a} to ${b}.` : a != null ? `At least ${a}.` : b != null ? `At most ${b}.` : ""}// The wait before the next repeat, in ms, after `done` repeats: slow to fast.fn stepper_row_rate(done: int) -> int = done < 3 ? 220 : done < 8 ? 120 : done < 20 ? 60 : 30// How many steps one repeat is: past 40 repeats a long range goes by fives, then by tens.fn stepper_row_stride(done: int) -> int = done < 40 ? 1 : done < 80 ? 5 : 10component StepperRow(value: num = 0, least: num? = null, most: num? = null, step: num = 1, label: str = "Number", hint: str? = null, unit: str? = null, name: str? = null, typed: bool = true, disabled: bool = false, field: str? = null, on_change = null, class: str? = null) { style global stepper_row_css let places = stepper_row_places(step) let start = stepper_row_keep(value, least, most) state now = start state seen = start state draft = null // what is being typed, while it is state holding = 0 // 1 or -1 while a button is held state rate = 420 state done = 0 state said = "" if start != seen { seen = start now = start } let id = field ?? `stepper-row-${re.replace(lower(label), "[^a-z0-9]+", "-")}` let shown = draft ?? stepper_row_text(now, places) let at_least = least != null && now <= least let at_most = most != null && now >= most let tell_limit = (n: num) => { said = least != null && n <= least ? `${stepper_row_text(least, places)} is the least.` : most != null && n >= most ? `${stepper_row_text(most, places)} is the most.` : "" } let change = (n: num, silent: bool = false) => { let moved = n != now now = n tell_limit(n) if moved && !silent { on_change && on_change(n) } } let press = (way: int, times: int = 1) => { draft = null change(stepper_row_add(now, step, way * times, least, most)) } let tick = () => { let next = stepper_row_add(now, step, holding * stepper_row_stride(done), least, most) done += 1 rate = stepper_row_rate(done) if next == now { holding = 0 } else { change(next, true) } } if holding != 0 { every(rate, tick) } let let_go = () => { holding = 0 } // The system took the finger: no click will come, so say where it got to here. let lost = () => { holding = 0 if done > 0 { done = 0 on_change && on_change(now) } } let hold = (e, way: int) => { if e.button == 0 && !disabled { e.target.closest("button").setPointerCapture(e.pointerId) draft = null done = 0 rate = 420 holding = way } } // A tap is one step, from click, so a keyboard and a screen reader are the same as a // finger; a click that ends a hold has done its work, and only says the end of it. let clicked = (e, way: int) => { if done == 0 || e.detail == 0 { press(way) } else { on_change && on_change(now) } done = 0 } let commit = (text: str) => { draft = null let n = stepper_row_read(text) if n != null { change(stepper_row_keep(round(n, places), least, most)) } } <div class={ui_cls(["ui-stepper-row", class])}> <div.ui-stepper-row-body> <div.ui-stepper-row-words> <label.ui-stepper-row-label for={id}>{label}</label> {hint != null && <span.ui-stepper-row-hint>{hint}</span>} </div> <div class={ui_cls(["ui-stepper-row-box", unit != null && "ui-stepper-row-united"])}> <button.ui-stepper-row-btn type="button" aria-label={`Less ${label}`} aria-controls={id} disabled={disabled} aria-disabled={at_least ? "true" : null} @pointerdown={(e) => { hold(e, -1) }} @pointerup={let_go} @pointercancel={lost} @contextmenu={(e) => { e.preventDefault() }} @click={(e) => { clicked(e, -1) }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg> </button> <span.ui-stepper-row-num> <input.ui-stepper-row-value id={id} name={name} type="text" inputmode={places > 0 || (least != null && least < 0) ? "decimal" : "numeric"} role="spinbutton" autocomplete="off" enterkeyhint="done" readonly={!typed} disabled={disabled} value={shown} aria-valuenow={now} aria-valuemin={least} aria-valuemax={most} aria-valuetext={unit != null ? `${stepper_row_text(now, places)} ${unit}` : null} aria-describedby={`${id}-range`} @input={(e) => { draft = e.value }} @focus={(e) => { e.target.select() }} @blur={(e) => { if draft != null { commit(e.target.value) } }} @keydown={(e) => { if e.key == "Enter" { e.preventDefault() e.target.blur() } let by = match e.key { "ArrowUp" => 1 "ArrowDown" => -1 "PageUp" => 10 "PageDown" => -10 _ => 0 } if by != 0 { e.preventDefault() press(by > 0 ? 1 : -1, abs(by)) } if e.key == "Home" && least != null { e.preventDefault() draft = null change(least) } if e.key == "End" && most != null { e.preventDefault() draft = null change(most) } }}/> {unit != null && <span.ui-stepper-row-unit aria-hidden="true">{unit}</span>} </span> <button.ui-stepper-row-btn type="button" aria-label={`More ${label}`} aria-controls={id} disabled={disabled} aria-disabled={at_most ? "true" : null} @pointerdown={(e) => { hold(e, 1) }} @pointerup={let_go} @pointercancel={lost} @contextmenu={(e) => { e.preventDefault() }} @click={(e) => { clicked(e, 1) }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14M12 5v14"/></svg> </button> </div> </div> <span.ui-sr id={`${id}-range`}>{stepper_row_range(least, most, places)}</span> <span.ui-sr aria-live="polite">{said}</span> </div>}Related
NumberPadAn on-screen number pad with a decimal and delete, a hold to delete fast and a haptic, for where the system keyboard is wrong.5 views · 7 downloadsComposerBarA message box that grows to a limit, swaps send and voice, keeps attachments in a tray, rides above the keyboard and is safe to paste into.3 views · 9 downloadsDateWheelsMonth, day and year wheels that keep each other valid, with limits, and a value in ISO.2 views · 6 downloadsDraftKeeperKeeps what was typed on this device through a reload and a crash, offers it back, and clears on send.2 views · 7 downloadsGalleryPickerPicks several photos through the system's picker, with thumbnails, an order, caps by count and size, HEIC refused with a reason, and previews freed after.2 views · 5 downloadsKeyboardAvoiderKeeps a focused field in view above the on-screen keyboard by visualViewport, on both systems, handing back on close.2 views · 6 downloads
Thread
Thread 0 posts
On StepperRow: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.