Forms

DatePicker

A date typed as YYYY-MM-DD or picked in a calendar grid, with the keyboard, limits and days that cannot be chosen.

#forms#picker#calendar#interactive

Demo

Preview

Type it, or press ArrowDown for the calendar.

No date yet.

December or January; closed on Sundays and over Christmas.

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

Example

The code of the preview above.

demos/date_picker.qwe
component DatePickerDemoLive() {    state d = null    <div.demo-stack>        <DatePicker name="dp-demo-birthday" label="Birthday" week_start={0} on_change={(v) => { d = v }}/>        <p.demo-note>{d == null ? "No date yet." : `The form sends ${d}.`}</p>    </div>}view DatePickerDemo() =    <div.demo-stack>        <div.demo-row>            <DatePicker name="dp-demo-due" label="Due date" value="2026-11-02"                        hint="Type it, or press ArrowDown for the calendar."/>            <DatePickerDemoLive/>        </div>        <DatePicker name="dp-demo-checkin" label="Check-in" value="2026-12-22" min="2026-12-01" max="2027-01-31"                    disabled_dates={["2026-12-25", "2026-12-26"]} disabled_weekdays={[0]} required={true}                    hint="December or January; closed on Sundays and over Christmas."/>        <DatePicker name="dp-demo-late" label="Shipped on" value="2026-10-01" size="sm" error="That is before the order date."/>    </div>

Source

ui/date_picker.qwe
// DatePicker: a date typed as YYYY-MM-DD, or picked in a calendar that opens// beside it. The calendar is the ARIA date-picker dialog: a month as a grid, where// the arrow keys move by day and week, PageUp / PageDown by month (with Shift, by// year), Home / End to the week's start and end, Enter or Space picks, and Escape// closes. Today is marked; dates before `min`, after `max` or in `disabled_dates`// cannot be picked. The text box is a real input named `name`, so a plain <form>// sends "2026-03-14" (it also checks the form without a script, by `pattern`).////   <DatePicker name="due" label="Due date" value="2026-11-02"/>//   <DatePicker name="checkin" label="Check-in" min="2026-11-01" max="2027-01-31"//               disabled_dates={["2026-12-25", "2026-12-26"]} disabled_weekdays={[0]} required={true}/>//   <DatePicker label="Birthday" week_start={0} on_change={(d) => { birthday = d }}/>//// value: "YYYY-MM-DD" to start with. min, max: the first and last dates that can be// chosen. disabled_dates: dates that cannot ("YYYY-MM-DD" each). disabled_weekdays:// days of the week that cannot (0 Sunday ... 6 Saturday). week_start: the calendar's// first column (1, Monday; 0 for Sunday). today: the date to mark as today (the// browser's own date by default). placeholder ("YYYY-MM-DD").// name, label, hint, error, required, disabled, field (the text box's id,// "date-picker-" + name by default) and size ("sm" or "md") as for every field.// A date typed that is not one, or cannot be chosen, is said so under the box, and// the form is not sent until it is put right.// on_change(date): inside one of your components, given "YYYY-MM-DD" (null when// it is emptied).import ui/theme.qwe use ui_clslet date_picker_css = css`.ui-date-picker { position: relative; display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-date-picker-label { font-size: 13px; font-weight: 500; color: var(--ui-fg); }.ui-date-picker-req { margin-left: 2px; color: var(--ui-danger); }.ui-date-picker-box {  display: flex; align-items: center; width: 200px; max-width: 100%; height: 38px; padding: 0 4px 0 12px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  transition: border-color .15s, box-shadow .15s;}.ui-date-picker-box:hover { border-color: var(--ui-strong); }.ui-date-picker-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-date-picker-invalid .ui-date-picker-box { border-color: var(--ui-danger); }.ui-date-picker-box input {  flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: transparent;  color: var(--ui-fg); font: 14px var(--ui-font); font-variant-numeric: tabular-nums;}.ui-date-picker-box input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-date-picker-btn {  display: inline-grid; place-items: center; flex: none; width: 30px; height: 30px; padding: 0; border: 0;  border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-date-picker-btn svg { width: 18px; height: 18px; }.ui-date-picker-btn:hover:not(:disabled):not([aria-disabled="true"]) { background: var(--ui-subtle); color: var(--ui-fg); }.ui-date-picker-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-date-picker-btn:disabled, .ui-date-picker-btn[aria-disabled="true"] { opacity: .4; cursor: not-allowed; }.ui-date-picker-pop {  position: absolute; z-index: 30; top: 100%; left: 0; width: 300px; max-width: calc(100vw - 32px); margin-top: 4px; padding: 12px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg);}.ui-date-picker-pop[hidden] { display: none; }.ui-date-picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }.ui-date-picker-head h2 { margin: 0; font-size: 14px; font-weight: 600; }.ui-date-picker-grid { width: 100%; border-collapse: collapse; table-layout: fixed; }.ui-date-picker-grid th { padding: 4px 0; font-size: 12px; font-weight: 500; color: var(--ui-muted); text-align: center; }.ui-date-picker-grid td { padding: 1px; text-align: center; }.ui-date-picker-day {  height: 36px; border-radius: var(--ui-radius-sm); font-size: 13px; font-variant-numeric: tabular-nums; cursor: pointer;  outline: none; transition: background .1s;}.ui-date-picker-day:hover { background: var(--ui-subtle); }.ui-date-picker-day:focus-visible, .ui-date-picker-day:focus { box-shadow: var(--ui-focus); }.ui-date-picker-today { font-weight: 700; color: var(--ui-accent); box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-date-picker-day[aria-selected="true"] { background: var(--ui-accent); color: var(--ui-accent-fg); font-weight: 600; }.ui-date-picker-day[aria-disabled="true"] { color: var(--ui-muted); opacity: .45; text-decoration: line-through; cursor: not-allowed; background: none; }.ui-date-picker-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 8px; }.ui-date-picker-link {  padding: 4px 8px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent);  font: 500 13px var(--ui-font); cursor: pointer;}.ui-date-picker-link:hover:not(:disabled) { background: var(--ui-accent-soft); }.ui-date-picker-link:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-date-picker-link:disabled { color: var(--ui-muted); cursor: not-allowed; }.ui-date-picker-keys { font-size: 12px; color: var(--ui-muted); }.ui-date-picker-hint, .ui-date-picker-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-date-picker-error { color: var(--ui-danger); }.ui-date-picker-sm .ui-date-picker-box { height: 30px; width: 170px; }.ui-date-picker-sm .ui-date-picker-box input { font-size: 13px; }.ui-date-picker-sm .ui-date-picker-btn { width: 24px; height: 24px; }.ui-date-picker-disabled .ui-date-picker-box { opacity: .55; }@media (max-width: 420px) {  .ui-date-picker-pop { right: 0; width: auto; max-width: 300px; }  .ui-date-picker-day { height: 32px; }  .ui-date-picker-keys { display: none; }}@media (prefers-reduced-motion: reduce) { .ui-date-picker-box, .ui-date-picker-day { transition: none; } }`let date_picker_months = ["January", "February", "March", "April", "May", "June", "July", "August", "September",                          "October", "November", "December"]let date_picker_weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]// A date as a day number: days since 1970-01-01 (H. Hinnant's days_from_civil).fn date_picker_days(y: int, m: int, d: int) -> int {    let yy = m <= 2 ? y - 1 : y    let era = floor(yy / 400)    let yoe = yy - era * 400    let doy = floor((153 * ((m + 9) % 12) + 2) / 5) + d - 1    era * 146097 + yoe * 365 + floor(yoe / 4) - floor(yoe / 100) + doy - 719468}// A day number as { y, m, d }.fn date_picker_civil(z: int) {    let zz = z + 719468    let era = floor(zz / 146097)    let doe = zz - era * 146097    let yoe = floor((doe - floor(doe / 1460) + floor(doe / 36524) - floor(doe / 146096)) / 365)    let doy = doe - (365 * yoe + floor(yoe / 4) - floor(yoe / 100))    let mp = floor((5 * doy + 2) / 153)    let m = mp < 10 ? mp + 3 : mp - 9    { y: yoe + era * 400 + (m <= 2 ? 1 : 0), m: m, d: doy - floor((153 * mp + 2) / 5) + 1 }}fn date_picker_month_len(y: int, m: int) -> int =    m == 2 ? (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) ? 29 : 28) : [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1]// 0 for Sunday ... 6 for Saturday.fn date_picker_weekday(z: int) -> int = ((z % 7) + 11) % 7fn date_picker_pad(n: int) -> str = n < 10 ? `0${n}` : str(n)fn date_picker_iso(z: int) -> str {    let c = date_picker_civil(z)    `${c.y}-${date_picker_pad(c.m)}-${date_picker_pad(c.d)}`}// "YYYY-MM-DD" as a day number, or null when it is not a date.fn date_picker_parse(text: str?) {    let t = trim(text ?? "")    if !re.test(t, "^\\d{4}-\\d{2}-\\d{2}$") { return null }    let y = int(slice(t, 0, 4))    let m = int(slice(t, 5, 7))    let d = int(slice(t, 8, 10))    m >= 1 && m <= 12 && d >= 1 && d <= date_picker_month_len(y, m) ? date_picker_days(y, m, d) : null}// The same day `k` months on (or back), kept inside the month: Jan 31 + 1 is Feb 28.fn date_picker_add_months(z: int, k: int) -> int {    let c = date_picker_civil(z)    let i = c.m - 1 + k    let y = c.y + floor(i / 12)    let m = i - floor(i / 12) * 12 + 1    let n = date_picker_month_len(y, m)    date_picker_days(y, m, c.d > n ? n : c.d)}// The month holding day z, as weeks of seven day numbers (null outside the month).fn date_picker_weeks(z: int, week_start: int) -> list {    let c = date_picker_civil(z)    let first = date_picker_days(c.y, c.m, 1)    let n = date_picker_month_len(c.y, c.m)    let lead = (date_picker_weekday(first) - week_start + 7) % 7    range(ceil((lead + n) / 7)) | map((w) => range(7) | map((i) => {        let at = w * 7 + i - lead        at >= 0 && at < n ? first + at : null    }))}fn date_picker_long(z: int) -> str {    let c = date_picker_civil(z)    `${date_picker_weekdays[date_picker_weekday(z)]} ${c.d} ${date_picker_months[c.m - 1]} ${c.y}`}component DatePicker(name: str? = null, label: str? = null, value: str? = null, min: str? = null, max: str? = null,                     disabled_dates: list = [], disabled_weekdays: list = [], week_start: int = 1,                     today: str? = null, placeholder: str = "YYYY-MM-DD", 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 date_picker_css    let lo = date_picker_parse(min)    let hi = date_picker_parse(max)    let blocked = disabled_dates | map((t) => date_picker_parse(t))    state chosen = date_picker_parse(value)    state text = value ?? ""    state now_d = date_picker_parse(today) ?? floor(now() / 86400000)    state open = false    state at = date_picker_parse(value) ?? date_picker_parse(today) ?? floor(now() / 86400000)    state problem = ""    let id = field ?? `date-picker-${name ?? re.replace(lower(label ?? "date"), "[^a-z0-9]+", "-")}`    let shown = date_picker_civil(at)    let weeks = date_picker_weeks(at, week_start)    let first = date_picker_days(shown.y, shown.m, 1)    let last_day = first + date_picker_month_len(shown.y, shown.m) - 1    let order = range(7) | map((i) => (i + week_start) % 7)    let shown_error = problem != "" ? problem : error    let described = ui_cls([hint != null && `${id}-hint`, shown_error != null && `${id}-error`])    let allowed = (z: int) => (lo == null || z >= lo) && (hi == null || z <= hi) && !contains(blocked, z)        && !contains(disabled_weekdays, date_picker_weekday(z))    let kept = (z: int) => lo != null && z < lo ? lo : hi != null && z > hi ? hi : z    let root_of = (e) => e.target.closest(".ui-date-picker")    // Once the page shows the month, puts the focus on day `at` in it.    let focus_day = (root) => {        js("requestAnimationFrame", (t = null) => {            let cell = root.querySelector(`[data-day="${at}"]`)            cell && cell.focus()        })    }    let why = (z) => z == null ? "Type a date as YYYY-MM-DD, such as 2026-03-14."        : lo != null && z < lo ? `Choose ${date_picker_iso(lo)} or later.`        : hi != null && z > hi ? `Choose ${date_picker_iso(hi)} or earlier.`        : "That date cannot be chosen."    let set_chosen = (z) => {        let before = chosen        chosen = z        problem = ""        if z != before { on_change && on_change(z == null ? null : date_picker_iso(z)) }    }    let pick = (e, z: int) => {        if allowed(z) {            let root = root_of(e)            at = z            text = date_picker_iso(z)            set_chosen(z)            open = false            let box = root.querySelector(".ui-date-picker-box input")            box.setCustomValidity("")            box.focus()        }    }    let show = (e) => {        if today == null {            let d = js_new("Date")            now_d = date_picker_days(d.getFullYear(), d.getMonth() + 1, d.getDate())        }        at = kept(chosen ?? now_d)        open = true        focus_day(root_of(e))    }    let go = (e, z: int) => {        at = kept(z)        focus_day(root_of(e))    }    <div class={ui_cls(["ui-date-picker", size == "sm" && "ui-date-picker-sm", shown_error != null && "ui-date-picker-invalid",                        disabled && "ui-date-picker-disabled", class])}         @focusout={(e) => {             let root = root_of(e)             if open && root != null && !(e.relatedTarget != null && root.contains(e.relatedTarget)) { open = false }         }}         @keydown={(e) => {             if e.key == "Escape" && open {                 e.preventDefault()                 open = false                 root_of(e).querySelector(".ui-date-picker-btn").focus()             }         }}>        {label != null && <label.ui-date-picker-label for={id}>            {label}{required && <span.ui-date-picker-req aria-hidden="true">*</span>}        </label>}        <div.ui-date-picker-box>            <input type="text" id={id} name={name} value={text} placeholder={placeholder} required={required}                   disabled={disabled} pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}" autocomplete="off" spellcheck="false"                   aria-invalid={shown_error != null ? "true" : null} aria-describedby={described != "" ? described : null}                   @input={(e) => {                       text = e.value                       let z = date_picker_parse(e.value)                       if trim(e.value) == "" {                           e.target.setCustomValidity("")                           set_chosen(null)                       } else if z != null && allowed(z) {                           e.target.setCustomValidity("")                           at = z                           set_chosen(z)                       }                   }}                   @change={(e) => {                       let z = date_picker_parse(e.value)                       if trim(e.value) == "" || (z != null && allowed(z)) {                           e.target.setCustomValidity("")                           problem = ""                       } else {                           set_chosen(null)                           problem = why(z)                           e.target.setCustomValidity(problem)                       }                   }}                   @keydown={(e) => {                       if e.key == "ArrowDown" && !open {                           e.preventDefault()                           show(e)                       }                   }}/>            <button.ui-date-picker-btn type="button" disabled={disabled} aria-haspopup="dialog"                    aria-expanded={open ? "true" : "false"} aria-controls={`${id}-dialog`}                    aria-label={chosen != null ? `Choose date, ${date_picker_long(chosen)} is chosen` : "Choose date"}                    @click={(e) => { if open { open = false } else { show(e) } }}>                <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"                     stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="14" height="12.5" rx="2"/>                    <path d="M3 8.5h14M7 2.8v3.4M13 2.8v3.4"/></svg>            </button>        </div>        <div.ui-date-picker-pop id={`${id}-dialog`} role="dialog" aria-modal="false" aria-labelledby={`${id}-month`}             hidden={!open}>            <div.ui-date-picker-head>                <button.ui-date-picker-btn type="button" aria-label="Previous month"                        aria-disabled={lo != null && first <= lo ? "true" : null}                        @click={(e) => { if lo == null || first > lo { go(e, date_picker_add_months(at, -1)) } }}>                    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"                         stroke-linejoin="round" aria-hidden="true"><path d="M12 5l-5 5 5 5"/></svg>                </button>                <h2 id={`${id}-month`} aria-live="polite">{date_picker_months[shown.m - 1]} {shown.y}</h2>                <button.ui-date-picker-btn type="button" aria-label="Next month"                        aria-disabled={hi != null && last_day >= hi ? "true" : null}                        @click={(e) => { if hi == null || last_day < hi { go(e, date_picker_add_months(at, 1)) } }}>                    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"                         stroke-linejoin="round" aria-hidden="true"><path d="M8 5l5 5-5 5"/></svg>                </button>            </div>            <table.ui-date-picker-grid role="grid" aria-labelledby={`${id}-month`}                   @keydown={(e) => {                       let back = (date_picker_weekday(at) - week_start + 7) % 7                       let to = match e.key {                           "ArrowLeft" => at - 1                           "ArrowRight" => at + 1                           "ArrowUp" => at - 7                           "ArrowDown" => at + 7                           "Home" => at - back                           "End" => at + 6 - back                           "PageUp" => date_picker_add_months(at, e.shiftKey ? -12 : -1)                           "PageDown" => date_picker_add_months(at, e.shiftKey ? 12 : 1)                           _ => null                       }                       if to != null {                           e.preventDefault()                           go(e, to)                       }                       if e.key == "Enter" || e.key == " " {                           e.preventDefault()                           pick(e, at)                       }                   }}>                <thead><tr>{for d in order => <th scope="col" abbr={date_picker_weekdays[d]}>{slice(date_picker_weekdays[d], 0, 2)}</th>}</tr></thead>                <tbody>                    {for week in weeks => <tr>                        {for z in week => z == null                            ? <td role="gridcell"></td>                            : <td role="gridcell" data-day={z} tabindex={z == at ? "0" : "-1"}                                  class={ui_cls(["ui-date-picker-day", z == now_d && "ui-date-picker-today"])}                                  aria-label={date_picker_long(z)} aria-selected={z == chosen ? "true" : null}                                  aria-current={z == now_d ? "date" : null} aria-disabled={allowed(z) ? null : "true"}                                  @click={(e) => {                                      at = z                                      pick(e, z)                                  }}>{date_picker_civil(z).d}</td>}                    </tr>}                </tbody>            </table>            <div.ui-date-picker-foot>                <button.ui-date-picker-link type="button" disabled={!allowed(now_d)} @click={(e) => pick(e, now_d)}>Today</button>                <span.ui-date-picker-keys aria-hidden="true">Arrows move · Enter picks</span>            </div>        </div>        {hint != null && <p.ui-date-picker-hint id={`${id}-hint`}>{hint}</p>}        {shown_error != null && <p.ui-date-picker-error id={`${id}-error`} role="alert">{shown_error}</p>}    </div>}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.