Mobile

DateWheels

Month, day and year wheels that keep each other valid, with limits, and a value in ISO.

#mobile#interactive#date#picker#wheel#forms

Demo

Preview
Monday, January 31, 2028

Value: 2028-01-31

Booking window, 10 March 2026 to 5 February 2027, day first:

Friday, March 20, 2026

A date that does not exist, limits that are not dates and limits the wrong way round:

Sunday, November 1, 2026
Tuesday, January 15, 2030

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

Example

The code of the preview above.

demos/date_wheels.qwe
component DateWheelsDemoLive() {    state day = "2028-01-31"    state said = "2028-01-31"    <div.demo-stack>        <div id="date-wheels-demo-free">            <DateWheels label="Start date" value={day} today="2026-11-01" earliest="1990-01-01" latest="2035-12-31" name="start"                        on_change={(d) => { day = d; said = d }}/>        </div>        <p.demo-note id="date-wheels-demo-out">Value: {said}</p>        <div.demo-row>            <Button size="lg" variant="outline" id="date-wheels-demo-set" on_click={() => { day = "2024-02-29" }}>Go to 29 February 2024</Button>            <Button size="lg" variant="outline" id="date-wheels-demo-long" on_click={() => { day = "2031-12-31" }}>Go to the last of 2031</Button>        </div>    </div>}view DateWheelsDemo() =    <div.demo-stack>        <DateWheelsDemoLive/>        <div id="date-wheels-demo-limits">            <p.demo-note>Booking window, 10 March 2026 to 5 February 2027, day first:</p>            <DateWheels label="Check-in" today="2026-11-01" value="2026-03-20" earliest="2026-03-10" latest="2027-02-05" order="dmy" rows={3}/>        </div>        <div id="date-wheels-demo-bad">            <p.demo-note>A date that does not exist, limits that are not dates and limits the wrong way round:</p>            <DateWheels label="Odd" today="2026-11-01" value="2026-02-31" earliest="soon" latest="later" rows={3} name="odd"/>            <DateWheels label="Swapped" value="garbage" earliest="2030-06-01" latest="2030-01-15" rows={3} name="swapped"/>        </div>    </div>

Source

ui/date_wheels.qwe
// DateWheels: month, day and year wheels that keep each other valid: there is no 31// February to reach, and a limit takes away the months and days past it. Each wheel is a// WheelPicker, so each has a real <select> under it for screen readers and keys. The form// gets one field, the date in ISO.////   <DateWheels name="birthday" label="Birthday" value="1990-06-15" latest={today}/>//   <DateWheels label="Check-in" today={today} earliest={today} latest="2027-12-31" order="dmy"//               on_change={(d) => { checkin = d }}/>//// value: the date, "YYYY-MM-DD", and it moves the wheels when it changes. today: where// they start when value is empty or not a date, as it cannot be read while the server// renders; else they start on the earliest date. earliest, latest: the limits, "YYYY-MM-DD"// (1900-01-01 and 2100-12-31; the wrong way round, they are swapped; not a date, left out).// order: "mdy" (default), "dmy" or "ymd". months: twelve names, for another language;// weekdays: seven, Sunday first. rows: as in WheelPicker (5). label, name, disabled, class// as for every field. on_change(date): inside one of your components, given the ISO date// once a wheel has come to rest.//// A day that does not fit the new month goes to the month's last day and does not come// back: 31 January, then February, then March is 28 or 29 March, as on a phone.// On a wide container (440 px and up, past any phone held upright: a tablet or a pane) the date stands in a panel beside// the wheels.//// date_wheels_parse, date_wheels_fit, date_wheels_month_list, date_wheels_day_list and// date_wheels_weekday are the logic.import ui/theme.qwe use ui_clsimport ui/wheel_picker.qwe use WheelPickerlet date_wheels_month_names = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]let date_wheels_day_names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]let date_wheels_css = css`.ui-date-wheels { container-type: inline-size; min-width: 0; font: 15px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-date-wheels-body { display: grid; gap: 10px; border: 0; margin: 0; padding: 0; min-width: 0; }.ui-date-wheels-set { display: grid; grid-template-columns: 1.7fr .8fr 1.1fr; gap: 4px; min-width: 0; }.ui-date-wheels-ymd .ui-date-wheels-set { grid-template-columns: 1.1fr 1.7fr .8fr; }.ui-date-wheels-dmy .ui-date-wheels-set { grid-template-columns: .8fr 1.7fr 1.1fr; }.ui-date-wheels-out { display: block; text-align: center; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-date-wheels-big { display: none; }@container (min-width: 440px) {  .ui-date-wheels-body { grid-template-columns: minmax(150px, 220px) minmax(0, 420px); justify-content: center; align-items: center; gap: 16px; }  .ui-date-wheels-out { padding: 18px 12px; border-radius: var(--ui-radius); background: var(--ui-subtle); }  .ui-date-wheels-big { display: block; font-size: 56px; line-height: 1.1; font-weight: 700; }  .ui-date-wheels-line { display: block; font-weight: 500; color: var(--ui-muted); }}`fn date_wheels_pad(n: int, width: int = 2) -> str {    let s = str(n)    len(s) >= width ? s : join(range(width - len(s)) | map((_i) => "0"), "") + s}fn date_wheels_days_in(y: int, m: int) -> int {    if m == 2 { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 ? 29 : 28 }    m == 4 || m == 6 || m == 9 || m == 11 ? 30 : 31}// "YYYY-MM-DD" as { y, m, d }, or null for anything that is not a real date.fn date_wheels_parse(text) {    if type_of(text) != "str" || !re.test(text, "^[0-9]{4}-[0-9]{2}-[0-9]{2}$") { return null }    let p = split(text, "-") | map((s) => int(s))    p[1] >= 1 && p[1] <= 12 && p[2] >= 1 && p[2] <= date_wheels_days_in(p[0], p[1]) ? { y: p[0], m: p[1], d: p[2] } : null}fn date_wheels_iso(p) -> str = `${date_wheels_pad(p.y, 4)}-${date_wheels_pad(p.m)}-${date_wheels_pad(p.d)}`fn date_wheels_rank(p) -> int = p.y * 10000 + p.m * 100 + p.d// The nearest date to y, m, d that is real and inside the limits, moving the day// only as far as it has to: a month too short takes its last day.fn date_wheels_fit(y: int, m: int, d: int, lo, hi) {    let days = date_wheels_days_in(y, m)    let p = { y: y, m: m, d: min([d, days]) }    date_wheels_rank(p) < date_wheels_rank(lo) ? lo : date_wheels_rank(p) > date_wheels_rank(hi) ? hi : p}// The months of year y inside the limits, as numbers.fn date_wheels_month_list(y: int, lo, hi) -> list =    range(y == lo.y ? lo.m : 1, (y == hi.y ? hi.m : 12) + 1)fn date_wheels_day_list(y: int, m: int, lo, hi) -> list =    range(y == lo.y && m == lo.m ? lo.d : 1, (y == hi.y && m == hi.m ? hi.d : date_wheels_days_in(y, m)) + 1)// The day of the week, 0 for Sunday (Sakamoto's method), so no date library is needed// while the wheels turn.fn date_wheels_weekday(y: int, m: int, d: int) -> int {    let shift = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]    let yy = m < 3 ? y - 1 : y    (yy + floor(yy / 4) - floor(yy / 100) + floor(yy / 400) + shift[m - 1] + d) % 7}// The limits as parts, the right way round.fn date_wheels_limits(earliest, latest) {    let lo = date_wheels_parse(earliest) ?? { y: 1900, m: 1, d: 1 }    let hi = date_wheels_parse(latest) ?? { y: 2100, m: 12, d: 31 }    date_wheels_rank(lo) > date_wheels_rank(hi) ? { lo: hi, hi: lo } : { lo: lo, hi: hi }}fn date_wheels_start(value, today, lo, hi) {    let p = date_wheels_parse(value) ?? date_wheels_parse(today) ?? lo    date_wheels_fit(p.y, p.m, p.d, lo, hi)}component DateWheels(value: str? = null, today: str? = null, earliest: str? = null, latest: str? = null, order: str = "mdy",                     months: list = date_wheels_month_names, weekdays: list = date_wheels_day_names, rows: int = 5, label: str = "Date",                     name: str? = null, disabled: bool = false, on_change = null, class: str? = null) {    style global date_wheels_css    let bounds = date_wheels_limits(earliest, latest)    let lo = bounds.lo    let hi = bounds.hi    let asked = date_wheels_iso(date_wheels_start(value, today, lo, hi))    state seen = asked    state cur = date_wheels_start(value, today, lo, hi)    if asked != seen {        seen = asked        cur = date_wheels_start(value, today, lo, hi)    }    let name_of = (m: int) => len(months) == 12 ? str(months[m - 1]) : date_wheels_month_names[m - 1]    let day_name = (i: int) => len(weekdays) == 7 ? str(weekdays[i]) : date_wheels_day_names[i]    let pick = (y: int, m: int, d: int) => {        let next = date_wheels_fit(y, m, d, lo, hi)        if next != cur {            cur = next            on_change && on_change(date_wheels_iso(next))        }    }    let month_opts = date_wheels_month_list(cur.y, lo, hi) | map((m) => { value: str(m), label: name_of(m) })    let parts = order == "dmy" ? ["d", "m", "y"] : order == "ymd" ? ["y", "m", "d"] : ["m", "d", "y"]    <div class={ui_cls(["ui-date-wheels", order == "dmy" && "ui-date-wheels-dmy", order == "ymd" && "ui-date-wheels-ymd", class])}>        <fieldset.ui-date-wheels-body aria-label={label}>            <output.ui-date-wheels-out aria-live="polite">                <span.ui-date-wheels-big aria-hidden="true">{cur.d}</span>                <span.ui-date-wheels-line>{day_name(date_wheels_weekday(cur.y, cur.m, cur.d))}, {name_of(cur.m)} {cur.d}, {cur.y}</span>            </output>            <div.ui-date-wheels-set>                {for part in parts => <div key={part}>                    {part == "m" ? <WheelPicker label="Month" rows={rows} disabled={disabled} value={cur.m} options={month_opts}                                                on_change={(v) => { pick(cur.y, int(v), cur.d) }}/>                     : part == "d" ? <WheelPicker label="Day" rows={rows} disabled={disabled} value={cur.d} options={date_wheels_day_list(cur.y, cur.m, lo, hi)}                                                  on_change={(v) => { pick(cur.y, cur.m, int(v)) }}/>                     : <WheelPicker label="Year" rows={rows} disabled={disabled} value={cur.y} options={range(lo.y, hi.y + 1)}                                    on_change={(v) => { pick(int(v), cur.m, cur.d) }}/>}                </div>}            </div>        </fieldset>        {name != null && <input type="hidden" name={name} value={date_wheels_iso(cur)}/>}    </div>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.