Demo
The form sends 18:30.
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/time_input.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/time_input.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/time_input.qwe
component TimeInputDemoLive() { state picked = "18:30" <div.demo-stack> <TimeInput name="ti-demo-pickup" label="Pick-up time" value="18:30" hour12={true} step={15} min="08:00" max="20:00" hint="Between 8 AM and 8 PM, every 15 minutes." on_change={(t) => { picked = t ?? "" }}/> <p.demo-note>{picked != "" ? `The form sends ${picked}.` : "Not complete yet."}</p> </div>}view TimeInputDemo() = <div.demo-stack> <div.demo-row> <TimeInput name="ti-demo-start" label="Starts at" value="09:30" required={true}/> <TimeInput name="ti-demo-end" label="Ends at"/> </div> <TimeInputDemoLive/> <TimeInput name="ti-demo-alarm" label="Alarm" value="07:00" size="sm" error="That is before the first train."/> </div>Source
ui/time_input.qwe
// TimeInput: a time of day typed in parts, hours and minutes (and AM/PM on a// 12-hour clock), each a small box of its own. ArrowUp / ArrowDown change the part// that has the focus, digits type it and move on when it is complete, ArrowLeft /// ArrowRight go between the parts, Backspace empties one. A plain <form> gets the// time as `name`, 24-hour "HH:MM" (empty until both parts are there).//// <TimeInput name="start" label="Starts at" value="09:30"/>// <TimeInput name="pickup" label="Pick-up" hour12={true} step={15} min="08:00" max="18:00"/>//// value: "HH:MM" to start with. hour12: a 12-hour clock with AM / PM (the form// still gets 24-hour time). step: minutes the arrows move by (1; 15 gives :00,// :15, :30, :45). min, max: "HH:MM", the earliest and latest it takes; a time// outside them is brought back to the nearest.// name, label, hint, error, required, disabled, field (the ids' start,// "time-input-" + name by default) and size ("sm" or "md") as for every field.// on_change(time): inside one of your components, given "HH:MM" (null when it is// not complete).import ui/theme.qwe use ui_clslet time_input_css = css`.ui-time-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-time-input-label { padding: 0; font-size: 13px; font-weight: 500; color: var(--ui-fg); }.ui-time-input-req { margin-left: 2px; color: var(--ui-danger); }.ui-time-input-box { display: inline-flex; align-items: center; gap: 2px; width: fit-content; height: 38px; padding: 0 10px 0 6px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); transition: border-color .15s, box-shadow .15s;}.ui-time-input-box:hover { border-color: var(--ui-strong); }.ui-time-input-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-time-input-invalid .ui-time-input-box { border-color: var(--ui-danger); }.ui-time-input-seg { width: 2.4ch; height: 28px; margin: 0; padding: 0 2px; border: 0; border-radius: 4px; background: transparent; color: var(--ui-fg); font: 15px/1 var(--ui-mono); text-align: center; caret-color: transparent;}.ui-time-input-seg::placeholder { color: var(--ui-muted); opacity: 1; }.ui-time-input-seg:focus { outline: none; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-time-input-seg:focus::placeholder { color: var(--ui-accent-fg); }.ui-time-input-seg:focus::selection { background: transparent; }.ui-time-input-ampm { width: 3.2ch; margin-left: 6px; }.ui-time-input-colon { color: var(--ui-muted); font: 15px/1 var(--ui-mono); }.ui-time-input-icon { width: 16px; height: 16px; margin: 0 4px 0 2px; color: var(--ui-muted); flex: none; }.ui-time-input-sm .ui-time-input-box { height: 30px; }.ui-time-input-sm .ui-time-input-seg { height: 22px; font-size: 13px; }.ui-time-input-hint, .ui-time-input-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-time-input-error { color: var(--ui-danger); }.ui-time-input:disabled .ui-time-input-box { opacity: .55; cursor: not-allowed; }@media (prefers-reduced-motion: reduce) { .ui-time-input-box { transition: none; } }`fn time_input_pad(n: int) -> str = n < 10 ? `0${n}` : str(n)// "HH:MM" as minutes after midnight, or null.fn time_input_parse(text: str?) { if text == null || !re.test(text, "^\\d{1,2}:\\d{2}$") { return null } let [h, m] = split(text, ":") | map((p) => int(p)) h <= 23 && m <= 59 ? h * 60 + m : null}// Typed digits as one part's number: { n, done }. The last two digits if they make a// number up to `hi`, else the last one; done when no further digit could follow.fn time_input_take(digits: str, lo: int, hi: int) { let k = len(digits) let two = k >= 2 ? int(slice(digits, k - 2, k)) : null if two != null && two >= lo && two <= hi { return { n: two, done: true } } let one = int(slice(digits, k - 1, k)) one * 10 > hi ? { n: one < lo ? lo : one, done: true } : { n: one < lo ? null : one, done: false }}component TimeInput(name: str? = null, label: str? = null, value: str? = null, hour12: bool = false, step: int = 1, min: str? = null, max: str? = null, hint: str? = null, error: str? = null, required: bool = false, disabled: bool = false, field: str? = null, size: str = "md", on_change = null, class: str? = null) { style global time_input_css let start = time_input_parse(value) state h = start != null ? floor(start / 60) : null state m = start != null ? start % 60 : null state pm = start != null && start >= 720 let id = field ?? `time-input-${name ?? re.replace(lower(label ?? "time"), "[^a-z0-9]+", "-")}` let lo = time_input_parse(min) ?? 0 let hi = time_input_parse(max) ?? 1439 let shown_h = h == null ? null : hour12 ? (h % 12 == 0 ? 12 : h % 12) : h let out = h != null && m != null ? `${time_input_pad(h)}:${time_input_pad(m)}` : null let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`]) let set_time = (nh, nm) => { let total = nh != null && nm != null ? nh * 60 + nm : null let kept = total == null ? null : total < lo ? lo : total > hi ? hi : total let fh = kept != null ? floor(kept / 60) : nh let fm = kept != null ? kept % 60 : nm let before = out h = fh m = fm if fh != null { pm = fh >= 12 } let after = fh != null && fm != null ? `${time_input_pad(fh)}:${time_input_pad(fm)}` : null if after != before { on_change && on_change(after) } } let move = (e, by: int) => { let to = e.target.closest(".ui-time-input-box").querySelector(`[data-i="${int(e.target.dataset.i) + by}"]`) if to != null { to.focus() to.select() } } // An hour as typed on the clock shown (1-12 on a 12-hour one) in 24 hours. let hour_of = (n: int) => hour12 ? n % 12 + (pm ? 12 : 0) : n let keys = (e, part: str) => { let k = e.key if k == "ArrowUp" || k == "ArrowDown" || k == "PageUp" || k == "PageDown" { e.preventDefault() let up = k == "ArrowUp" || k == "PageUp" if part == "h" { let by = (k == "PageUp" || k == "PageDown" ? 3 : 1) * (up ? 1 : -1) let nh = h == null ? floor(lo / 60) : hour12 ? (h >= 12 ? 12 : 0) + ((h % 12) + by + 120) % 12 : (h + by + 240) % 24 set_time(nh, m) } else if part == "m" { let by = (k == "PageUp" || k == "PageDown" ? 10 : 1) * step let snapped = m == null ? 0 : up ? floor(m / step) * step + by : ceil(m / step) * step - by set_time(h, ((snapped % 60) + 60) % 60) } else if h == null { pm = !pm } else { set_time((h + 12) % 24, m) } } if k == "Home" || k == "End" { e.preventDefault() let first = k == "Home" if part == "h" { set_time(hour12 ? (first ? (pm ? 12 : 0) : (pm ? 23 : 11)) : (first ? 0 : 23), m) } if part == "m" { set_time(h, first ? 0 : 60 - step) } } if k == "ArrowLeft" || k == "ArrowRight" { e.preventDefault() move(e, k == "ArrowLeft" ? -1 : 1) } if k == "Backspace" || k == "Delete" { e.preventDefault() if part == "h" && h != null { set_time(null, m) } else if part == "m" && m != null { set_time(h, null) } else if k == "Backspace" { move(e, -1) } } if part == "p" && (lower(k) == "a" || lower(k) == "p") { e.preventDefault() if h != null { set_time(h % 12 + (lower(k) == "p" ? 12 : 0), m) } else { pm = lower(k) == "p" } } } let typed = (e, part: str) => { let digits = re.replace(e.value, "[^0-9]", "") if digits == "" { e.target.value = part == "h" ? (shown_h == null ? "" : time_input_pad(shown_h)) : (m == null ? "" : time_input_pad(m)) } else { let got = part == "h" ? time_input_take(digits, hour12 ? 1 : 0, hour12 ? 12 : 23) : time_input_take(digits, 0, 59) if got.n != null { if part == "h" { set_time(hour_of(got.n), m) } else { set_time(h, got.n) } e.target.value = time_input_pad(got.n) } else { e.target.value = digits } if got.done { move(e, 1) } } } <fieldset class={ui_cls(["ui-time-input", size == "sm" && "ui-time-input-sm", error != null && "ui-time-input-invalid", class])} id={id} disabled={disabled} aria-describedby={described != "" ? described : null}> {label != null && <legend.ui-time-input-label> {label}{required && <span.ui-time-input-req aria-hidden="true">*</span>} </legend>} <div.ui-time-input-box> <svg.ui-time-input-icon viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.2"/><path d="M8 4.6V8l2.3 1.5"/></svg> <input.ui-time-input-seg type="text" id={`${id}-h`} data-i="0" role="spinbutton" inputmode="numeric" autocomplete="off" aria-label="Hours" placeholder="--" value={shown_h == null ? "" : time_input_pad(shown_h)} aria-valuemin={hour12 ? 1 : 0} aria-valuemax={hour12 ? 12 : 23} aria-valuenow={shown_h} aria-valuetext={shown_h == null ? "empty" : null} required={required} aria-invalid={error != null ? "true" : null} @focus={(e) => { e.target.select() }} @keydown={(e) => keys(e, "h")} @input={(e) => typed(e, "h")}/> <span.ui-time-input-colon aria-hidden="true">:</span> <input.ui-time-input-seg type="text" id={`${id}-m`} data-i="1" role="spinbutton" inputmode="numeric" autocomplete="off" aria-label="Minutes" placeholder="--" value={m == null ? "" : time_input_pad(m)} aria-valuemin="0" aria-valuemax="59" aria-valuenow={m} aria-valuetext={m == null ? "empty" : null} required={required} aria-invalid={error != null ? "true" : null} @focus={(e) => { e.target.select() }} @keydown={(e) => keys(e, "m")} @input={(e) => typed(e, "m")}/> {hour12 && <input.ui-time-input-seg.ui-time-input-ampm type="text" id={`${id}-p`} data-i="2" role="spinbutton" autocomplete="off" aria-label="AM or PM" value={pm ? "PM" : "AM"} aria-valuetext={pm ? "PM" : "AM"} aria-valuemin="0" aria-valuemax="1" aria-valuenow={pm ? 1 : 0} @focus={(e) => { e.target.select() }} @keydown={(e) => keys(e, "p")} @input={(e) => { let t = lower(e.value) let want = contains(t, "p") ? true : contains(t, "a") ? false : pm if h != null && want != pm { set_time(h % 12 + (want ? 12 : 0), m) } else { pm = want } e.target.value = want ? "PM" : "AM" }}/>} </div> {name != null && <input type="hidden" name={name} value={out ?? ""}/>} {hint != null && <p.ui-time-input-hint id={`${id}-hint`}>{hint}</p>} {error != null && <p.ui-time-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 downloadsDurationInputHours, minutes and seconds typed side by side, giving one value in seconds, with the arrow keys and carrying past 59.5 views · 7 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 TimeInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.