Mobile

TimeWheels

Hour, minute and AM/PM wheels with steps, a 24-hour mode by locale, and limits across midnight.

#mobile#interactive#time#picker#wheel#forms

Demo

Preview
7:30 AM

Value: 07:30

A night shift, 22:00 to 02:00 in steps of 15 minutes, on en-GB's clock:

23:15

The same limits on a 12-hour clock:

10:30 PM

A time that does not exist, a step of 0, limits nothing fits, and a locale that is not one:

00:00
10:00
1:05 PM

Get it

It needs theme.qwe, as every component does, and wheel_picker.qwe; 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/wheel_picker.qwe$ curl -O https://quillolang.org/components/ui/time_wheels.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/time_wheels.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/time_wheels.qwe
component TimeWheelsDemoLive() {    state alarm = "07:30"    <div.demo-stack>        <div id="time-wheels-demo-alarm">            <TimeWheels label="Alarm" name="alarm" value={alarm} step={5} locale="en-US" on_change={(t) => { alarm = t }}/>        </div>        <p.demo-note id="time-wheels-demo-out">Value: {alarm}</p>        <Button size="lg" variant="outline" id="time-wheels-demo-set" on_click={() => { alarm = "18:12" }}>Set 18:12</Button>    </div>}view TimeWheelsDemo() =    <div.demo-stack>        <TimeWheelsDemoLive/>        <div id="time-wheels-demo-night">            <p.demo-note>A night shift, 22:00 to 02:00 in steps of 15 minutes, on en-GB's clock:</p>            <TimeWheels label="Shift start" name="shift" value="23:10" step={15} earliest="22:00" latest="02:00" locale="en-GB" rows={3}/>        </div>        <div id="time-wheels-demo-late">            <p.demo-note>The same limits on a 12-hour clock:</p>            <TimeWheels label="Pick-up" name="pickup" value="22:30" step={30} earliest="22:00" latest="01:00" hour24={false} rows={3}/>        </div>        <div id="time-wheels-demo-bad">            <p.demo-note>A time that does not exist, a step of 0, limits nothing fits, and a locale that is not one:</p>            <TimeWheels label="Odd" name="odd" value="25:99" step={0} earliest="never" latest="soon" locale="not a locale!" rows={3}/>            <TimeWheels label="Tight" name="tight" value="10:00" step={30} earliest="10:05" latest="10:10" hour24={true} rows={3}/>            <TimeWheels label="By the browser" name="plain" value="13:05" rows={3}/>        </div>    </div>

Source

ui/time_wheels.qwe
// TimeWheels: hour, minute and AM/PM wheels, or hour and minute on a 24-hour clock, as the// locale has it. Minutes go in steps, and limits may run across midnight: 22:00 to 02:00 is// four hours, and the wheels show only what falls in them, in that order. Each wheel is a// WheelPicker, with a real <select> under it. The form gets one field, "HH:MM" on a 24-hour clock.////   <TimeWheels name="alarm" label="Alarm" value="07:30" step={5}/>//   <TimeWheels label="Night shift" value="23:00" earliest="22:00" latest="02:00" step={15} locale="en-GB"//               on_change={(t) => { start = t }}/>//// value: "HH:MM", and it moves the wheels when it changes (not a time: the earliest, or// midnight). step: minutes between rows (1); a value off the steps goes to the nearest.// earliest, latest: the limits, "HH:MM"; a later earliest than latest means they cross midnight,// and a time past midnight says "next day". hour24: true or false to choose the clock;// left out, the locale chooses. locale: "en-GB", say; left out, the browser's own language// once it has started (the server's page assumes 12 hours until then, as it cannot// know; give a locale or hour24 to have no change). periods: the two words for AM and PM.// rows: as in WheelPicker (5). label, name, disabled, class as for every field.// on_change(time): inside one of your components, given "HH:MM" once a wheel has come to rest.//// On a wide container (440 px and up) the time stands in a panel beside the wheels.//// time_wheels_parse, time_wheels_times, time_wheels_hours, time_wheels_minutes,// time_wheels_nearest, time_wheels_label and time_wheels_24 are the logic.import ui/theme.qwe use ui_clsimport ui/wheel_picker.qwe use WheelPickerlet time_wheels_css = css`.ui-time-wheels { container-type: inline-size; min-width: 0; font: 15px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-time-wheels-body { display: grid; gap: 10px; border: 0; margin: 0; padding: 0; min-width: 0; }.ui-time-wheels-set { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; min-width: 0; }.ui-time-wheels-out { display: block; text-align: center; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-time-wheels-next { margin-left: 6px; font-weight: 500; color: var(--ui-muted); }@container (min-width: 440px) {  .ui-time-wheels-body { grid-template-columns: minmax(150px, 220px) minmax(0, 320px); justify-content: center; align-items: center; gap: 16px; }  .ui-time-wheels-out { padding: 18px 12px; border-radius: var(--ui-radius); background: var(--ui-subtle); font-size: 26px; font-weight: 700; }  .ui-time-wheels-next { display: block; margin: 0; font-size: 14px; }}`fn time_wheels_pad(n: int) -> str = n < 10 ? `0${n}` : str(n)// "HH:MM" as minutes after midnight, or null.fn time_wheels_parse(text) {    if type_of(text) != "str" || !re.test(text, "^[0-9]{1,2}:[0-9]{2}$") { return null }    let p = split(text, ":") | map((s) => int(s))    p[0] <= 23 && p[1] <= 59 ? p[0] * 60 + p[1] : null}fn time_wheels_text(t: int) -> str = `${time_wheels_pad(floor(t / 60))}:${time_wheels_pad(t % 60)}`// Whether the time t is inside the limits, which cross midnight when lo is after hi.fn time_wheels_inside(t: int, lo, hi) -> bool {    if lo == null || hi == null { return (lo == null || t >= lo) && (hi == null || t <= hi) }    lo <= hi ? t >= lo && t <= hi : t >= lo || t <= hi}// Every time on the steps inside the limits, in order from the earliest, so a span// across midnight reads 22:00, 23:00, 00:00. Limits nothing fits are dropped.fn time_wheels_times(step: int, lo, hi) -> list {    let by = max([1, min([step, 60])])    let marks = range(0, 60) | filter((m) => m % by == 0)    let all = flatten(range(24) | map((h) => marks | map((m) => h * 60 + m)))    let inside = all | filter((t) => time_wheels_inside(t, lo, hi))    if len(inside) == 0 { return all }    lo == null ? inside : sort_by(inside, (t) => (t - lo + 1440) % 1440)}// The time in `times` nearest to t, going round the clock, the earlier on a tie.fn time_wheels_nearest(times: list, t: int) -> int {    let gap = (x) => { let d = abs(x - t); min([d, 1440 - d]) }    sort_by(times, (x) => gap(x) * 2000 + x)[0]}// The time as 0 for the morning and 1 for the afternoon.fn time_wheels_half(t: int) -> int = t >= 720 ? 1 : 0// The hours to show, as 0 to 23, in the order `times` reaches them; on a 12-hour clock// only those of the chosen half of the day.fn time_wheels_hours(times: list, half) -> list =    unique(times | filter((t) => half == null || time_wheels_half(t) == half) | map((t) => floor(t / 60)))// The minutes of an hour to show, in order.fn time_wheels_minutes(times: list, hour: int) -> list =    sort(times | filter((t) => floor(t / 60) == hour) | map((t) => t % 60))// How the hour is written on either clock.fn time_wheels_hour_label(h: int, h24: bool) -> str =    h24 ? time_wheels_pad(h) : str(h % 12 == 0 ? 12 : h % 12)// The whole time as a person reads it.fn time_wheels_label(t: int, h24: bool, periods: list) -> str {    let h = floor(t / 60)    h24 ? time_wheels_text(t) : `${time_wheels_hour_label(h, false)}:${time_wheels_pad(t % 60)} ${periods[time_wheels_half(t)]}`}// Whether a locale keeps a 24-hour clock: true but for the places that keep 12 hours. For the// server's page, which has no browser to ask; the browser's own answer replaces it.fn time_wheels_24(locale: str) -> bool {    let tag = lower(locale)    let lang = split(tag, "-")[0]    !contains(["en-us", "en-ca", "en-au", "en-nz", "en-ph", "en-in", "es-mx", "es-us", "es-co", "fr-ca"], tag)        && !contains(["ar", "bn", "fil", "hi", "ko", "ms", "tl", "ur"], lang)}component TimeWheels(value: str? = null, step: int = 1, earliest: str? = null, latest: str? = null, hour24: bool? = null, locale: str? = null,                     periods: list = ["AM", "PM"], rows: int = 5, label: str = "Time", name: str? = null, disabled: bool = false,                     on_change = null, class: str? = null) {    style global time_wheels_css    let lo = time_wheels_parse(earliest)    let hi = time_wheels_parse(latest)    let times = time_wheels_times(step, lo, hi)    let start = time_wheels_nearest(times, time_wheels_parse(value) ?? lo ?? 0)    state h24 = hour24 ?? time_wheels_24(locale ?? "en-US")    state seen = start    state cur = start    if start != seen {        seen = start        cur = start    }    let words = len(periods) == 2 ? periods : ["AM", "PM"]    let half = h24 ? null : time_wheels_half(cur)    let hour = floor(cur / 60)    let minute = cur % 60    let set_to = (t: int) => {        if t != cur {            cur = t            on_change && on_change(time_wheels_text(t))        }    }    // Another hour keeps the minute if it can; another half of the day keeps the hour.    let pick_hour = (h: int) => { set_to(time_wheels_nearest(times | filter((t) => floor(t / 60) == h), h * 60 + minute)) }    let pick_minute = (m: int) => { set_to(hour * 60 + m) }    let pick_half = (p: int) => {        let same = times | filter((t) => time_wheels_half(t) == p)        if len(same) > 0 { set_to(time_wheels_nearest(same, (hour % 12 + (p == 1 ? 12 : 0)) * 60 + minute)) }    }    let halves = unique(times | map((t) => time_wheels_half(t)))    let next_day = lo != null && hi != null && lo > hi && cur <= hi    <div class={ui_cls(["ui-time-wheels", class])}         @mount={(el) => {             // Intl throws on a tag it cannot read, and nothing here could catch it.             let tag = locale ?? js("navigator.language")             if hour24 == null && type_of(tag) == "str" && re.test(tag, "^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,4}$") {                 let cycle = js_new("Intl.DateTimeFormat", tag, { hour: "numeric" }).resolvedOptions().hourCycle                 h24 = cycle == "h23" || cycle == "h24"             }         }}>        <fieldset.ui-time-wheels-body aria-label={label}>            <output.ui-time-wheels-out aria-live="polite">                {time_wheels_label(cur, h24, words)}{next_day ? " " : ""}{next_day && <span.ui-time-wheels-next>next day</span>}            </output>            <div.ui-time-wheels-set>                <div><WheelPicker label="Hour" rows={rows} disabled={disabled} value={hour}                                  options={time_wheels_hours(times, half) | map((h) => { value: str(h), label: time_wheels_hour_label(h, h24) })}                                  on_change={(v) => { pick_hour(int(v)) }}/></div>                <div><WheelPicker label="Minute" rows={rows} disabled={disabled} value={minute}                                  options={time_wheels_minutes(times, hour) | map((m) => { value: str(m), label: time_wheels_pad(m) })}                                  on_change={(v) => { pick_minute(int(v)) }}/></div>                {!h24 && <div><WheelPicker label="AM or PM" rows={rows} disabled={disabled} value={half}                                           options={halves | map((p) => { value: str(p), label: str(words[p]) })}                                           on_change={(v) => { pick_half(int(v)) }}/></div>}            </div>        </fieldset>        {name != null && <input type="hidden" name={name} value={time_wheels_text(cur)}/>}    </div>}
Thread
310Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.