Forms

QuantityInput

A compact count with − and + either side, kept to its limits and step, with the arrow keys, PageUp/PageDown, Home and End.

#forms#input#cart#commerce#interactive

Demo

Preview

Up to 10 per order.

2 tickets, $24 in all.

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/quantity_input.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/quantity_input.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/quantity_input.qwe
component QuantityInputDemoLive() {    state tickets = 2    <div.demo-stack>        <QuantityInput name="qi-demo-tickets" label="Tickets" value={tickets} least={1} most={8} unit="ticket"                       on_change={(n) => { tickets = n }}/>        <p.demo-note>{tickets} {tickets == 1 ? "ticket" : "tickets"}, ${tickets * 12} in all.</p>    </div>}view QuantityInputDemo() {    <div.demo-stack>        <div.demo-row>            <QuantityInput name="qi-demo-qty" value={1} least={0} most={10} hint="Up to 10 per order."/>            <QuantityInputDemoLive/>        </div>        <div.demo-row>            <QuantityInput name="qi-demo-pack" label="Packs of 6" size="sm" value={6} step={6} least={6} most={48}/>            <QuantityInput name="qi-demo-lg" label="Guests" size="lg" value={4}/>            <QuantityInput name="qi-demo-off" label="Sold out" value={0} disabled={true}/>        </div>    </div>}

Source

ui/quantity_input.qwe
// QuantityInput: a compact count with − and + on either side, for a cart line,// tickets or guests. The arrow keys step it, PageUp / PageDown by ten steps, Home// and End go to the least and the most; what is typed is kept to the limits and// the step when the box is left. The box is a real <input> named `name`, so a// plain <form> sends the number.////   <QuantityInput name="qty" value={1} least={1} most={10}/>//   <QuantityInput name="tickets" label="Tickets" value={2} unit="ticket" on_change={(n) => { tickets = n }}/>//// value: where it starts (1). least: the smallest (0). most: the largest (none).// step: what each press adds (1). unit: what is counted, for screen readers ("3// tickets"; units: its plural, unit + "s" by default). label: shown above it;// without one, screen readers hear "Quantity". size: "sm", "md" (default) or "lg".// name ("quantity"), hint, error, disabled, field (the box's id, "quantity-input-"// + name by default) as for every field. The buttons are disabled at the limits.// on_change(n): inside one of your components, given the number after each change.import ui/theme.qwe use ui_clslet quantity_input_css = css`.ui-quantity-input { display: grid; gap: 6px; min-width: 0; width: fit-content; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-quantity-input-label { font-size: 13px; font-weight: 500; }.ui-quantity-input-box {  display: inline-flex; align-items: center; height: 36px; padding: 2px; border: 1px solid var(--ui-border); border-radius: 999px;  background: var(--ui-surface); transition: border-color .15s, box-shadow .15s;}.ui-quantity-input-box:hover { border-color: var(--ui-strong); }.ui-quantity-input-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-quantity-input-btn {  display: grid; place-items: center; flex: none; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%;  background: var(--ui-subtle); color: var(--ui-fg); cursor: pointer; transition: background .15s, color .15s;}.ui-quantity-input-btn svg { width: 12px; height: 12px; }.ui-quantity-input-btn:hover:not(:disabled) { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-quantity-input-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-quantity-input-btn:disabled { opacity: .4; cursor: not-allowed; }.ui-quantity-input-box input {  width: 3.2em; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: transparent; text-align: center;  color: var(--ui-fg); font: 600 15px var(--ui-font); font-variant-numeric: tabular-nums;}.ui-quantity-input-sm .ui-quantity-input-box { height: 28px; }.ui-quantity-input-sm .ui-quantity-input-btn { width: 22px; height: 22px; }.ui-quantity-input-sm .ui-quantity-input-box input { width: 2.6em; font-size: 13px; }.ui-quantity-input-lg .ui-quantity-input-box { height: 46px; padding: 3px; }.ui-quantity-input-lg .ui-quantity-input-btn { width: 38px; height: 38px; }.ui-quantity-input-lg .ui-quantity-input-box input { width: 3.6em; font-size: 18px; }.ui-quantity-input-invalid .ui-quantity-input-box { border-color: var(--ui-danger); }.ui-quantity-input-disabled { opacity: .55; }.ui-quantity-input-hint, .ui-quantity-input-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-quantity-input-error { color: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-quantity-input-box, .ui-quantity-input-btn { transition: none; } }`// n kept between least and most (most may be null), and on a step from least.fn quantity_input_keep(n: int, least: int, most: int?, step: int) -> int {    let snapped = least + round((n - least) / step) * step    let top = most != null ? least + floor((most - least) / step) * step : null    snapped < least ? least : top != null && snapped > top ? top : snapped}component QuantityInput(name: str = "quantity", label: str? = null, value: int = 1, least: int = 0, most: int? = null,                        step: int = 1, unit: str? = null, units: str? = null, size: str = "md", hint: str? = null,                        error: str? = null, disabled: bool = false, field: str? = null, on_change = null, class: str? = null) {    style global quantity_input_css    state n = quantity_input_keep(value, least, most, step)    state text = str(quantity_input_keep(value, least, most, step))    let id = field ?? `quantity-input-${name}`    let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`])    let said = unit == null ? str(n) : `${n} ${n == 1 ? unit : units ?? unit + "s"}`    let set_to = (x: int) => {        let k = quantity_input_keep(x, least, most, step)        text = str(k)        if k != n {            n = k            on_change && on_change(k)        }    }    <div class={ui_cls(["ui-quantity-input", size != "md" && `ui-quantity-input-${size}`, error != null && "ui-quantity-input-invalid",                        disabled && "ui-quantity-input-disabled", class])}>        {label != null && <label.ui-quantity-input-label for={id}>{label}</label>}        <div.ui-quantity-input-box>            <button.ui-quantity-input-btn type="button" aria-label={`Decrease${label != null ? ` ${lower(label)}` : ""}`} aria-controls={id}                    disabled={disabled || n <= least} @click={() => set_to(n - step)}>                <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M2 6h8"/></svg>            </button>            <input type="text" id={id} name={name} value={text} inputmode="numeric" autocomplete="off" disabled={disabled}                   role="spinbutton" aria-valuenow={n} aria-valuemin={least} aria-valuemax={most} aria-valuetext={said}                   aria-label={label == null ? "Quantity" : null} aria-invalid={error != null ? "true" : null}                   aria-describedby={described != "" ? described : null}                   @input={(e) => {                       let digits = re.replace(e.value, "[^0-9]", "")                       text = digits                       e.target.value = digits                   }}                   @change={(e) => { set_to(int(text) ?? n) }}                   @keydown={(e) => {                       let to = match e.key {                           "ArrowUp" => n + step                           "ArrowDown" => n - step                           "PageUp" => n + step * 10                           "PageDown" => n - step * 10                           "Home" => least                           "End" => most                           _ => null                       }                       if to != null {                           e.preventDefault()                           set_to(to)                       }                   }}/>            <button.ui-quantity-input-btn type="button" aria-label={`Increase${label != null ? ` ${lower(label)}` : ""}`} aria-controls={id}                    disabled={disabled || (most != null && n + step > most)} @click={() => set_to(n + step)}>                <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M2 6h8M6 2v8"/></svg>            </button>        </div>        {hint != null && <p.ui-quantity-input-hint id={`${id}-hint`}>{hint}</p>}        {error != null && <p.ui-quantity-input-error id={`${id}-error`} role="alert">{error}</p>}    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.