DurationInput
Hours, minutes and seconds typed side by side, giving one value in seconds, with the arrow keys and carrying past 59.
Demo
1500 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.qwemain.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>}Related
DateTimeInputA date and a time side by side on the browser's own fields, giving one ISO value, in a time zone when given one.5 views · 8 downloadsTimeInputA time in parts (hours, minutes, AM/PM) changed with the arrow keys or by typing.5 views · 6 downloadsAddressFormStreet, city, region, postcode and country in the usual layout, with the autocomplete names browsers fill from.5 views · 7 downloadsAutocompleteA free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.5 views · 7 downloadsCardInputA payment card as it is typed: the number grouped, the brand, MM/YY and the code, with typos said.6 views · 6 downloadsCharacterCounterA text box or textarea with a live 120 / 280 count that warns near the limit, as a hard stop or a soft one that blocks sending.5 views · 6 downloads
Thread
Thread 0 posts
On DurationInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.