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.
Demo
Period
2026
| Jan | Feb | Mar | Apr |
| May | Jun | Jul | Aug |
| Sep | Oct | Nov | Dec |
The arrow keys move; PageUp/PageDown turn the year.
Expiry
2026
| Jan | Feb | Mar | Apr |
| May | Jun | Jul | Aug |
| Sep | Oct | Nov | Dec |
That card has expired.
Start month
Start month, 2026
| Jan | Feb | Mar | Apr |
| May | Jun | Jul | Aug |
| Sep | Oct | Nov | Dec |
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.qwemain.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>}Related
WeekPickerAn ISO week picked from a month shown as numbered weeks, by click or the arrow keys, sent as 2026-W45.5 views · 8 downloadsYearPickerA year picked from a grid of a decade, turned ten years at a time, from a button or in the page.4 views · 7 downloadsBirthdayInputDay, month and year drop-downs that give one date in a hidden input, the days following the month and the years the age allowed.5 views · 7 downloadsDatePickerA date typed as YYYY-MM-DD or picked in a calendar grid, with the keyboard, limits and days that cannot be chosen.7 views · 6 downloadsDateRangePickerA first and last day picked on two months (with a preview as you go) or from presets such as Last 7 days; or a stay in nights, around booked days, with a price under each.4 views · 6 downloadsRecurrencePickerHow something repeats, daily, weekly on chosen days, monthly or yearly, and when it ends, said in words and sent as a rule and an RRULE.5 views · 7 downloads
Thread
Thread 0 posts
On MonthPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.