Forms

DurationInput

Hours, minutes and seconds typed side by side, giving one value in seconds, with the arrow keys and carrying past 59.

#forms#input#time#interactive

Demo

Preview
Length
1 hour, 30 minutes

Hours, minutes and seconds.

Cooking time
25 minutes

1500 seconds.

Lap
No duration
Timeout
0 seconds

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

Example

The code of the preview above.

demos/duration_input.qwe
component DurationInputDemoLive() {    state secs = 1500    <div.demo-stack>        <DurationInput name="di-demo-cook" label="Cooking time" value={1500} seconds={false} on_change={(s) => { secs = s }}/>        <p.demo-note>{secs == null ? "No time set." : `${secs} seconds.`}</p>    </div>}view DurationInputDemo() {    <div.demo-stack>        <div.demo-row>            <DurationInput name="di-demo-length" label="Length" value={5400} hint="Hours, minutes and seconds."/>            <DurationInputDemoLive/>        </div>        <div.demo-row>            <DurationInput name="di-demo-lap" label="Lap" hours={false} required={true}/>            <DurationInput name="di-demo-bad" label="Timeout" value={0} error="Give it at least a second."/>        </div>    </div>}

Source

ui/duration_input.qwe
// DurationInput: a length of time typed as hours, minutes and seconds, side by// side, giving one number of seconds: 1 h 30 min is 5400. Each part takes// digits, moves on to the next once full, and steps with the arrow keys (Shift for// tens); minutes or seconds past 59 carry into the part before when the field is// left. The total goes in a hidden input named `name`, so a plain <form> sends it.////   <DurationInput name="length" label="Length" value={5400}/>//   <DurationInput name="cook" label="Cooking time" seconds={false} on_change={(s) => { secs = s }}/>//// value: the seconds to start with (none: empty). hours, seconds: whether to show// those parts (true); without hours the minutes take any number, and without// seconds the value is still in seconds (whole minutes). most_hours: the largest// number of hours (99). name ("duration"), label (the legend), hint, error,// required, disabled and field (the ids' start, "duration-input-" + name by// default) as for every field. Screen readers hear the total in words.// on_change(seconds): inside one of your components, given the total (null when// every part is empty).import ui/theme.qwe use ui_clslet duration_input_css = css`.ui-duration-input { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-duration-input-label { padding: 0; font-size: 13px; font-weight: 500; }.ui-duration-input-req { margin-left: 2px; color: var(--ui-danger); }.ui-duration-input-box {  display: inline-flex; align-items: center; gap: 4px; width: fit-content; height: 38px; padding: 0 10px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  transition: border-color .15s, box-shadow .15s;}.ui-duration-input-box:hover { border-color: var(--ui-strong); }.ui-duration-input-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-duration-input-part { display: inline-flex; align-items: baseline; gap: 2px; }.ui-duration-input-part input {  width: 2.4ch; min-width: 0; height: 28px; padding: 0 3px; border: 0; border-radius: 4px; outline: none; background: transparent;  color: var(--ui-fg); font: 500 15px var(--ui-mono); text-align: right; font-variant-numeric: tabular-nums; box-sizing: content-box;}.ui-duration-input-part input:focus { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-duration-input-part input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-duration-input-wide input { width: 3.4ch; }.ui-duration-input-unit { color: var(--ui-muted); font-size: 13px; margin-right: 6px; user-select: none; }.ui-duration-input-part:last-child .ui-duration-input-unit { margin-right: 0; }.ui-duration-input-invalid .ui-duration-input-box { border-color: var(--ui-danger); }.ui-duration-input:disabled .ui-duration-input-box { opacity: .55; }.ui-duration-input-hint, .ui-duration-input-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-duration-input-error { color: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-duration-input-box { transition: none; } }`// Seconds in words: "1 hour, 30 minutes"; "0 seconds" for none.fn duration_input_words(total: int) -> str {    let h = floor(total / 3600)    let m = floor((total % 3600) / 60)    let s = total % 60    let parts = [h > 0 ? `${h} ${h == 1 ? "hour" : "hours"}` : null, m > 0 ? `${m} ${m == 1 ? "minute" : "minutes"}` : null,                 s > 0 ? `${s} ${s == 1 ? "second" : "seconds"}` : null] | filter((p) => p != null)    len(parts) == 0 ? "0 seconds" : join(parts, ", ")}fn duration_input_pad(n: int?) -> str = n == null ? "" : n < 10 ? `0${n}` : str(n)component DurationInput(name: str = "duration", label: str? = null, value: int? = null, hours: bool = true, seconds: bool = true,                        most_hours: int = 99, hint: str? = null, error: str? = null, required: bool = false,                        disabled: bool = false, field: str? = null, on_change = null, class: str? = null) {    style global duration_input_css    // The parts as typed: null for an empty one.    state h = value == null || !hours ? null : floor(value / 3600)    state m = value == null ? null : hours ? floor((value % 3600) / 60) : floor(value / 60)    state s = value == null || !seconds ? null : value % 60    state shown = 0    let id = field ?? `duration-input-${name}`    let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`, `${id}-total`])    let empty = h == null && m == null && s == null    let total = empty ? null : (h ?? 0) * 3600 + (m ?? 0) * 60 + (s ?? 0)    let most_m = hours ? 59 : 9999    let parts = (hours ? ["h"] : []) + ["m"] + (seconds ? ["s"] : [])    let value_of = (p: str) => p == "h" ? h : p == "m" ? m : s    let set_part = (p: str, x: int?) => {        let before = total        if p == "h" { h = x }        if p == "m" { m = x }        if p == "s" { s = x }        let after = h == null && m == null && s == null ? null : (h ?? 0) * 3600 + (m ?? 0) * 60 + (s ?? 0)        if after != before { on_change && on_change(after) }    }    // Minutes or seconds past 59 carried into the part before, once the field is left.    let carry = () => {        if total != null {            let t = total            let hh = hours ? floor(t / 3600) : null            let rest = hours ? t % 3600 : t            let mm = seconds ? floor(rest / 60) : floor(rest / 60)            let ss = seconds ? rest % 60 : null            let top = most_hours * 3600 + 3599            if hours && hh > most_hours {                h = most_hours                m = 59                s = seconds ? 59 : null                on_change && on_change(top)            } else {                h = hh                m = mm                s = ss            }            shown += 1        }    }    let move = (e, i: int) => {        let el = e.target.closest(".ui-duration-input-box").querySelector(`[data-i="${i}"]`)        if el != null {            el.focus()            el.select()        }    }    <fieldset class={ui_cls(["ui-duration-input", error != null && "ui-duration-input-invalid", class])} id={id} disabled={disabled}              aria-describedby={described}              @focusout={(e) => {                  let root = e.target.closest(".ui-duration-input")                  if !(e.relatedTarget != null && root.contains(e.relatedTarget)) { carry() }              }}>        {label != null && <legend.ui-duration-input-label>            {label}{required && <span.ui-duration-input-req aria-hidden="true">*</span>}        </legend>}        <div.ui-duration-input-box>            {for [i, p] in enumerate(parts) => {                let top = p == "h" ? most_hours : p == "m" ? most_m : 59                let v = value_of(p)                let word = p == "h" ? "Hours" : p == "m" ? "Minutes" : "Seconds"                <span class={ui_cls(["ui-duration-input-part", top > 99 && "ui-duration-input-wide"])} key={p}>                    <input type="text" id={`${id}-${p}`} data-i={i} inputmode="numeric" autocomplete="off" placeholder="00"                           value={shown >= 0 ? duration_input_pad(v) : ""} maxlength={len(str(top))} role="spinbutton" aria-label={word}                           aria-valuemin="0" aria-valuemax={top} aria-valuenow={v} required={required && i == 0}                           aria-invalid={error != null ? "true" : null}                           @focus={(e) => { e.target.select() }}                           @input={(e) => {                               let digits = re.replace(e.value, "[^0-9]", "")                               e.target.value = digits                               let x = digits == "" ? null : int(digits)                               set_part(p, x != null && p == "h" && x > top ? top : x)                               if len(digits) >= len(str(top)) && i < len(parts) - 1 { move(e, i + 1) }                           }}                           @keydown={(e) => {                               let by = e.shiftKey ? 10 : 1                               if e.key == "ArrowUp" || e.key == "ArrowDown" {                                   e.preventDefault()                                   let next = (v ?? 0) + (e.key == "ArrowUp" ? by : 0 - by)                                   set_part(p, next < 0 ? 0 : next > top ? top : next)                                   shown += 1                               }                               if e.key == "ArrowRight" && e.target.selectionStart == len(e.target.value) && i < len(parts) - 1 {                                   e.preventDefault()                                   move(e, i + 1)                               }                               if (e.key == "ArrowLeft" || (e.key == "Backspace" && e.target.value == "")) && e.target.selectionStart == 0 && i > 0 {                                   e.preventDefault()                                   move(e, i - 1)                               }                           }}/>                    <span.ui-duration-input-unit aria-hidden="true">{p == "h" ? "h" : p == "m" ? "min" : "s"}</span>                </span>            }}        </div>        <span.ui-sr id={`${id}-total`} aria-live="polite">{total == null ? "No duration" : duration_input_words(total)}</span>        <input type="hidden" name={name} value={total == null ? "" : str(total)}/>        {hint != null && <p.ui-duration-input-hint id={`${id}-hint`}>{hint}</p>}        {error != null && <p.ui-duration-input-error id={`${id}-error`} role="alert">{error}</p>}    </fieldset>}
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.