Forms

MonthPicker

A month and year picked from a grid of twelve months under the year, from a button or in the page, sent as YYYY-MM.

#forms#picker#calendar#keyboard#interactive

Demo

Preview
Period

The arrow keys move; PageUp/PageDown turn the year.

Expiry
Start month

Start month, 2026

JanFebMarApr
MayJunJulAug
SepOctNovDec

No month picked.

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

Example

The code of the preview above.

demos/month_picker.qwe
component MonthPickerDemoLive() {    state month = null    <div.demo-stack>        <MonthPicker name="mp-demo-start" label="Start month" inline={true} earliest="2026-04" latest="2027-06" today="2026-10"                     on_change={(m) => { month = m }}/>        <p.demo-note>{month == null ? "No month picked." : `Starting ${month}.`}</p>    </div>}view MonthPickerDemo() {    <div.demo-stack>        <div.demo-row>            <MonthPicker name="mp-demo-period" label="Period" value="2026-03" today="2026-10" hint="The arrow keys move; PageUp/PageDown turn the year."/>            <MonthPicker name="mp-demo-exp" label="Expiry" size="sm" today="2026-10" error="That card has expired."/>        </div>        <MonthPickerDemoLive/>    </div>}

Source

ui/month_picker.qwe
// MonthPicker: a month of a year (a card's expiry, a report's period, a start// month), picked from a grid of the twelve months under the year, which the arrows// beside it turn. It opens from a button, or sits in the page with inline={true}.// The grid is the ARIA grid pattern: the arrow keys move by month and by row,// PageUp / PageDown by year, Home / End to January and December, Enter or Space// picks, Escape closes. The month goes in a hidden input named `name` as// "YYYY-MM", so a plain <form> sends it.////   <MonthPicker name="period" label="Period" value="2026-03"/>//   <MonthPicker name="start" label="Start month" earliest="2026-01" latest="2027-06" inline={true}//                on_change={(m) => { start = m }}/>//// value: "YYYY-MM" to start with. earliest, latest: the first and last months that// can be chosen ("YYYY-MM"). today: the month marked as this one ("YYYY-MM"; the// date today by default). inline: the grid always shown, without the button.// placeholder: the button's text while nothing is chosen ("Choose a month").// name, label, hint, error, required (your route checks it), disabled, field (the// button's id, "month-picker-" + name by default) and size ("sm" or "md") as for// every field.// on_change(month): inside one of your components, given "YYYY-MM".import ui/theme.qwe use ui_clslet month_picker_css = css`.ui-month-picker { position: relative; display: grid; gap: 6px; min-width: 0; width: fit-content; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-month-picker-label { font-size: 13px; font-weight: 500; }.ui-month-picker-req { margin-left: 2px; color: var(--ui-danger); }.ui-month-picker-trigger {  display: inline-flex; align-items: center; gap: 8px; min-width: 190px; height: 38px; padding: 0 12px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg);  font: 14px var(--ui-font); text-align: left; cursor: pointer; transition: border-color .15s, box-shadow .15s;}.ui-month-picker-trigger:hover:not(:disabled) { border-color: var(--ui-strong); }.ui-month-picker-trigger:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-month-picker-trigger:disabled { opacity: .55; cursor: not-allowed; }.ui-month-picker-trigger svg { flex: none; width: 16px; height: 16px; color: var(--ui-muted); }.ui-month-picker-trigger span { flex: 1; }.ui-month-picker-empty { color: var(--ui-muted); }.ui-month-picker-pop {  position: absolute; z-index: 30; top: 100%; left: 0; width: 280px; 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-month-picker-pop[hidden] { display: none; }.ui-month-picker-inline .ui-month-picker-pop { position: static; margin: 0; box-shadow: none; }.ui-month-picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }.ui-month-picker-head h2 { margin: 0; font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-month-picker-nav {  display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-month-picker-nav svg { width: 16px; height: 16px; }.ui-month-picker-nav:hover:not([aria-disabled="true"]) { background: var(--ui-subtle); color: var(--ui-fg); }.ui-month-picker-nav:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-month-picker-nav[aria-disabled="true"] { opacity: .35; cursor: not-allowed; }.ui-month-picker-grid { width: 100%; border-collapse: separate; border-spacing: 4px; table-layout: fixed; }.ui-month-picker-cell {  height: 40px; border-radius: var(--ui-radius-sm); text-align: center; font-size: 13px; cursor: pointer; outline: none; transition: background .1s;}.ui-month-picker-cell:hover { background: var(--ui-subtle); }.ui-month-picker-cell:focus-visible, .ui-month-picker-cell:focus { box-shadow: var(--ui-focus); }.ui-month-picker-now { color: var(--ui-accent); font-weight: 700; box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-month-picker-cell[aria-selected="true"] { background: var(--ui-accent); color: var(--ui-accent-fg); font-weight: 600; }.ui-month-picker-cell[aria-disabled="true"] { color: var(--ui-muted); opacity: .45; cursor: not-allowed; background: none; }.ui-month-picker-sm .ui-month-picker-trigger { height: 30px; min-width: 160px; font-size: 13px; }.ui-month-picker-invalid .ui-month-picker-trigger { border-color: var(--ui-danger); }.ui-month-picker-hint, .ui-month-picker-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-month-picker-error { color: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-month-picker-trigger, .ui-month-picker-cell { transition: none; } }`let month_picker_names = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October",                          "November", "December"]// "YYYY-MM" as a month number (year * 12 + month - 1), or null.fn month_picker_parse(text: str?) {    let t = trim(text ?? "")    if !re.test(t, "^\\d{4}-\\d{2}$") { return null }    let m = int(slice(t, 5, 7))    m >= 1 && m <= 12 ? int(slice(t, 0, 4)) * 12 + m - 1 : null}fn month_picker_iso(k: int) -> str {    let m = k % 12 + 1    `${floor(k / 12)}-${m < 10 ? "0" : ""}${m}`}fn month_picker_long(k: int) -> str = `${month_picker_names[k % 12]} ${floor(k / 12)}`component MonthPicker(name: str? = null, label: str? = null, value: str? = null, earliest: str? = null, latest: str? = null,                      today: str? = null, inline: bool = false, placeholder: str = "Choose a month", 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 month_picker_css    let lo = month_picker_parse(earliest)    let hi = month_picker_parse(latest)    let now_k = month_picker_parse(today ?? slice(date.today(), 0, 7))    state chosen = month_picker_parse(value)    state at = month_picker_parse(value) ?? month_picker_parse(today ?? slice(date.today(), 0, 7))    state open = false    let id = field ?? `month-picker-${name ?? re.replace(lower(label ?? "month"), "[^a-z0-9]+", "-")}`    let year = floor(at / 12)    let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`])    let allowed = (k: int) => (lo == null || k >= lo) && (hi == null || k <= hi)    let kept = (k: int) => lo != null && k < lo ? lo : hi != null && k > hi ? hi : k    let root_of = (e) => e.target.closest(".ui-month-picker")    let focus_cell = (root) => {        js("requestAnimationFrame", (t = null) => {            let cell = root.querySelector(`[data-k="${at}"]`)            cell && cell.focus()        })    }    let go = (e, k: int) => {        at = kept(k)        focus_cell(root_of(e))    }    let pick = (e, k: int) => {        if allowed(k) {            at = k            let before = chosen            chosen = k            if !inline {                open = false                root_of(e).querySelector(".ui-month-picker-trigger").focus()            }            if k != before { on_change && on_change(month_picker_iso(k)) }        }    }    <div class={ui_cls(["ui-month-picker", inline && "ui-month-picker-inline", size == "sm" && "ui-month-picker-sm",                        error != null && "ui-month-picker-invalid", class])}         @focusout={(e) => {             let root = root_of(e)             if open && !inline && root != null && !(e.relatedTarget != null && root.contains(e.relatedTarget)) { open = false }         }}         @keydown={(e) => {             if e.key == "Escape" && open && !inline {                 e.preventDefault()                 open = false                 root_of(e).querySelector(".ui-month-picker-trigger").focus()             }         }}>        {label != null && <span.ui-month-picker-label id={`${id}-label`}>            {label}{required && <span.ui-month-picker-req aria-hidden="true">*</span>}        </span>}        {!inline && <button.ui-month-picker-trigger type="button" id={id} disabled={disabled} aria-haspopup="dialog"                aria-expanded={open ? "true" : "false"} aria-controls={`${id}-pop`}                aria-labelledby={label != null ? `${id}-label ${id}` : null}                aria-invalid={error != null ? "true" : null} aria-describedby={described != "" ? described : null}                @click={(e) => {                    if open { open = false } else {                        at = kept(chosen ?? now_k)                        open = true                        focus_cell(root_of(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>            <span class={chosen == null ? "ui-month-picker-empty" : null}>{chosen == null ? placeholder : month_picker_long(chosen)}</span>        </button>}        <div.ui-month-picker-pop id={`${id}-pop`} role={inline ? "group" : "dialog"} aria-labelledby={`${id}-year`}             hidden={!inline && !open}>            <div.ui-month-picker-head>                <button.ui-month-picker-nav type="button" aria-label="Previous year" disabled={disabled}                        aria-disabled={lo != null && year * 12 <= lo ? "true" : null}                        @click={(e) => { if lo == null || year * 12 > lo { go(e, at - 12) } }}>                    <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}-year`} aria-live="polite">{label != null && inline ? <span.ui-sr>{label}, </span> : null}{year}</h2>                <button.ui-month-picker-nav type="button" aria-label="Next year" disabled={disabled}                        aria-disabled={hi != null && year * 12 + 11 >= hi ? "true" : null}                        @click={(e) => { if hi == null || year * 12 + 11 < hi { go(e, at + 12) } }}>                    <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-month-picker-grid role="grid" aria-labelledby={`${id}-year`}                   @keydown={(e) => {                       let to = match e.key {                           "ArrowLeft" => at - 1                           "ArrowRight" => at + 1                           "ArrowUp" => at - 4                           "ArrowDown" => at + 4                           "Home" => year * 12                           "End" => year * 12 + 11                           "PageUp" => at - 12                           "PageDown" => at + 12                           _ => null                       }                       if to != null {                           e.preventDefault()                           go(e, to)                       }                       if e.key == "Enter" || e.key == " " {                           e.preventDefault()                           pick(e, at)                       }                   }}>                <tbody>                    {for r in range(3) => <tr>                        {for c in range(4) => {                            let k = year * 12 + r * 4 + c                            <td role="gridcell" data-k={k} tabindex={disabled ? null : k == at ? "0" : "-1"}                                class={ui_cls(["ui-month-picker-cell", k == now_k && "ui-month-picker-now"])}                                aria-label={month_picker_long(k)} aria-selected={k == chosen ? "true" : "false"}                                aria-current={k == now_k ? "date" : null} aria-disabled={allowed(k) && !disabled ? null : "true"}                                @click={(e) => { if !disabled { pick(e, k) } }}>{slice(month_picker_names[k % 12], 0, 3)}</td>                        }}                    </tr>}                </tbody>            </table>        </div>        {name != null && <input type="hidden" name={name} value={chosen == null ? "" : month_picker_iso(chosen)}/>}        {hint != null && <p.ui-month-picker-hint id={`${id}-hint`}>{hint}</p>}        {error != null && <p.ui-month-picker-error id={`${id}-error`} role="alert">{error}</p>}    </div>}
Thread
47Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.