Mobile

StepperRow

Minus and plus in a row, a hold to repeat and accelerate, typed values, limits, min and max stated to screen readers.

#mobile#interactive#stepper#number#input#forms

Demo

Preview
Age 13 and up
From 1 to 9.
Up to 2.5 ml a day
From 0.00 to 2.50.
At least 0.

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.qwe
main.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>}
Thread
37Useful? Like it, or add to its thread below.

Thread 0 posts

On StepperRow: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.