MonthCalendar
A month of days to edit, each with a state and a price or a name: pick some (shift-click for a run), then act on them.
Demo
December 2026
No days picked.
Shown: 2026-12.
Click a day, shift-click another for every day between; the arrow keys move, PageUp and PageDown turn.
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_calendar.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/month_calendar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/month_calendar.qwe
// A host's December: prices under each day (Friday and Saturday dearer), two// nights booked, Christmas closed, and actions on the days picked.fn month_calendar_demo_prices(closed: list, price: map) -> map { let from = month_calendar_days(2026, 12, 1) range(31) | reduce({}, (m, i) => { let iso = month_calendar_iso(from + i) set(m, iso, contains(closed, iso) ? "closed" : get(price, iso) ?? (month_calendar_weekday(from + i) >= 5 ? "€140" : "€95")) })}component MonthCalendarDemoLive() { state picked = [] state closed = ["2026-12-24", "2026-12-25"] state price = {} state month = "2026-12" let days = [{ day: "2026-12-18", state: "booked", note: "Ana" }, { day: "2026-12-19", state: "booked", note: "Ana" }, { day: "2026-12-11", state: "held", note: "Lee" }] + (closed | map((d) => { day: d, state: "closed" })) <div.demo-stack> <MonthCalendar month={month} today="2026-12-03" days={days} labels={month_calendar_demo_prices(closed, price)} selected={picked} label="Nights at the river flat" on_select={(d) => { picked = d }} on_month={(m) => { month = m }}/> <div.demo-row> <button type="button" class="month-calendar-demo-close" disabled={len(picked) == 0} @click={() => { closed = unique(closed + picked); picked = [] }}>Close</button> <button type="button" class="month-calendar-demo-open" disabled={len(picked) == 0} @click={() => { closed = closed | filter((d) => !contains(picked, d)); picked = [] }}>Open</button> <button type="button" class="month-calendar-demo-price" disabled={len(picked) == 0} @click={() => { price = picked | reduce(price, (m, d) => set(m, d, "€200")); picked = [] }}>€200 a night</button> <p.demo-note>Shown: {month}.</p> </div> </div>}view MonthCalendarDemo() = <div.demo-stack> <MonthCalendarDemoLive/> <p.demo-note>Click a day, shift-click another for every day between; the arrow keys move, PageUp and PageDown turn.</p> </div>Source
ui/month_calendar.qwe
// MonthCalendar: a month of days, each with a state and a short text, for a// calendar someone edits: a host's nights (free, closed, booked) with their price,// a team's rota, a room's bookings. Click a day to pick it (again to let it go),// shift-click to pick every day from the last one clicked; then act on the days// picked. The arrow keys move between days, Enter or Space picks, PageUp and// PageDown turn the month.//// <MonthCalendar month="2026-12" today="2026-11-20" selected={picked}// days={[{ day: "2026-12-24", state: "closed" }, { day: "2026-12-26", state: "booked", note: "Ana" }]}// labels={prices} on_select={(days) => { picked = days }}/>//// Asked for by app/stays (quillolang/quillo, FEEDBACK5.md; branch stays-components).//// month: "YYYY-MM" shown first. days: a list of { day, state?, note? }: state is// any word, given as a class (ui-month-calendar-<state>; free, closed, booked and// held are styled), note a second line ("Ana", "2 left"). labels: a map of day to// a short text (a price). today: the day marked, and before which days cannot be// picked (the browser's own date by default); past: true lets them be picked.// selected: the days picked ("YYYY-MM-DD" each). week_start: 1 (Monday) or 0.// name: a plain <form> gets the days picked in it, comma-separated.// label: what screen readers call the calendar ("Calendar").// on_select(days): inside one of your components, given the days picked in order;// with it, the days shown picked are `selected`, as you keep them (set it to [] to// clear them); without it, the calendar keeps them itself.// on_month(month): told the "YYYY-MM" shown when the month is turned.import ui/theme.qwe use ui_clslet month_calendar_css = css`.ui-month-calendar { display: grid; gap: 8px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-month-calendar-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }.ui-month-calendar-head h2 { margin: 0; font-size: 16px; font-weight: 600; }.ui-month-calendar-nav { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); cursor: pointer;}.ui-month-calendar-nav svg { width: 18px; height: 18px; }.ui-month-calendar-nav:hover { border-color: var(--ui-strong); }.ui-month-calendar-nav:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-month-calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }.ui-month-calendar-wd { padding: 2px 0; font-size: 12px; font-weight: 500; color: var(--ui-muted); text-align: center; }.ui-month-calendar-day { display: grid; align-content: space-between; gap: 2px; min-height: 64px; min-width: 0; padding: 6px; text-align: left; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 12px/1.25 var(--ui-font); cursor: pointer;}.ui-month-calendar-day b { font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-month-calendar-day span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-month-calendar-label { font-weight: 500; }.ui-month-calendar-note { color: var(--ui-muted); }.ui-month-calendar-day:hover:not(:disabled) { border-color: var(--ui-strong); }.ui-month-calendar-day:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-month-calendar-day:disabled { opacity: .5; cursor: not-allowed; }.ui-month-calendar-today b { color: var(--ui-accent); }.ui-month-calendar-closed { background: repeating-linear-gradient(45deg, var(--ui-surface), var(--ui-surface) 6px, var(--ui-subtle) 6px, var(--ui-subtle) 8px);}.ui-month-calendar-closed .ui-month-calendar-label { text-decoration: line-through; color: var(--ui-muted); }.ui-month-calendar-booked { background: var(--ui-success-soft); border-color: transparent; }.ui-month-calendar-held { background: var(--ui-warning-soft); border-color: transparent; }.ui-month-calendar-day[aria-pressed="true"] { border-color: var(--ui-accent); box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-month-calendar-foot { margin: 0; font-size: 13px; color: var(--ui-muted); }@media (max-width: 520px) { .ui-month-calendar-day { min-height: 52px; padding: 4px; font-size: 11px; } .ui-month-calendar-note { display: none; }}`let month_calendar_months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]let month_calendar_weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]// DatePicker's date helpers, so that the file works on its own: a date is a day// number, the days since 1970-01-01.fn month_calendar_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}fn month_calendar_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 month_calendar_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]fn month_calendar_weekday(z: int) -> int = ((z % 7) + 11) % 7fn month_calendar_pad(n: int) -> str = n < 10 ? `0${n}` : str(n)fn month_calendar_iso(z: int) -> str { let c = month_calendar_civil(z) `${c.y}-${month_calendar_pad(c.m)}-${month_calendar_pad(c.d)}`}fn month_calendar_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 <= month_calendar_month_len(y, m) ? month_calendar_days(y, m, d) : null}// "2026-12" moved by k months: "2027-01" for k = 1.fn month_calendar_turn(month: str, k: int) -> str { let first = month_calendar_parse(`${month}-01`) ?? 0 let c = month_calendar_civil(first) let i = c.y * 12 + c.m - 1 + k `${floor(i / 12)}-${month_calendar_pad(i - floor(i / 12) * 12 + 1)}`}component MonthCalendar(month: str, days: list = [], labels: map = {}, selected: list = [], today: str? = null, past: bool = false, week_start: int = 1, name: str? = null, label: str = "Calendar", on_select = null, on_month = null, class: str? = null) { style global month_calendar_css state own_month = month state own_picked = selected state last = null state now_d = month_calendar_parse(today) ?? floor(now() / 86400000) let shown = on_month != null ? month : own_month let picked = on_select != null ? selected : own_picked let first = month_calendar_parse(`${shown}-01`) ?? month_calendar_parse(`${month}-01`) ?? now_d let c = month_calendar_civil(first) let n = month_calendar_month_len(c.y, c.m) let lead = (month_calendar_weekday(first) - week_start + 7) % 7 let order = range(7) | map((i) => (i + week_start) % 7) let by_day = days | reduce({}, (m, d) => set(m, d.day, d)) let open = (z: int) => past || z >= now_d let title = `${month_calendar_months[c.m - 1]} ${c.y}` let choose = (next: list) => { let sorted = next | unique | sort own_picked = sorted on_select && on_select(sorted) } let turn = (k: int) => { let to = month_calendar_turn(shown, k) own_month = to on_month && on_month(to) } let focus_day = (root, z: int) => { js("requestAnimationFrame", (t) => { let cell = root.querySelector(`[data-day="${month_calendar_iso(z)}"]`) cell && cell.focus() }) } <div class={ui_cls(["ui-month-calendar", class])} role="group" aria-label={label}> <div.ui-month-calendar-head> <button.ui-month-calendar-nav type="button" aria-label="Previous month" @click={() => turn(-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 aria-live="polite">{title}</h2> <button.ui-month-calendar-nav type="button" aria-label="Next month" @click={() => turn(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> <div.ui-month-calendar-grid @keydown={(e) => { let at = month_calendar_parse(e.target.dataset.day ?? "") let to = at == null ? null : match e.key { "ArrowLeft" => at - 1 "ArrowRight" => at + 1 "ArrowUp" => at - 7 "ArrowDown" => at + 7 _ => null } if to != null { e.preventDefault() // Found before turning: a new month takes the old day, and with it // e.target, off the page. let root = e.target.closest(".ui-month-calendar") if to < first { turn(-1) } if to >= first + n { turn(1) } focus_day(root, to) } if e.key == "PageUp" || e.key == "PageDown" { e.preventDefault() turn(e.key == "PageUp" ? -1 : 1) } }}> {for d in order => <span.ui-month-calendar-wd key={`wd${d}`} aria-hidden="true">{slice(month_calendar_weekdays[d], 0, 3)}</span>} {for i in range(lead) => <span key={`pad${i}`} aria-hidden="true"></span>} {for z in range(first, first + n) => { let iso = month_calendar_iso(z) let info = get(by_day, iso) ?? {} let state_word = info.state ?? "free" let text = get(labels, iso) let on = contains(picked, iso) let said = [`${month_calendar_weekdays[month_calendar_weekday(z)]} ${month_calendar_civil(z).d} ${month_calendar_months[c.m - 1]}`, state_word, text, info.note] | filter((x) => x != null) | join(", ") <button key={iso} type="button" data-day={iso} disabled={!open(z)} aria-pressed={on ? "true" : "false"} class={ui_cls(["ui-month-calendar-day", `ui-month-calendar-${state_word}`, z == now_d && "ui-month-calendar-today"])} aria-label={said} @click={(e) => { if e.shiftKey && last != null { let a = last < z ? last : z let b = last < z ? z : last choose(picked + (range(a, b + 1) | filter((x) => open(x)) | map((x) => month_calendar_iso(x)))) } else { choose(on ? picked | filter((p) => p != iso) : picked + [iso]) } last = z }}> <b>{month_calendar_civil(z).d}</b> <span.ui-month-calendar-label>{text ?? ""}</span> <span.ui-month-calendar-note>{info.note ?? (state_word == "free" ? "" : state_word)}</span> </button> }} </div> <p.ui-month-calendar-foot aria-live="polite">{len(picked) == 0 ? "No days picked." : len(picked) == 1 ? "1 day picked." : `${len(picked)} days picked.`}</p> {name != null ? <input type="hidden" name={name} value={join(picked, ",")}/> : null} </div>}Related
DateTimeInputA date and a time side by side on the browser's own fields, giving one ISO value, in a time zone when given one.5 views · 8 downloadsActivityFeedA stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.5 views · 7 downloadsTimelineWhat happened, in order, down a line of coloured dots, as an <ol> with <time> elements.6 views · 7 downloadsDeliveryEstimate"Arrives Tue 4 – Thu 6 Nov", worked out from how many days delivery takes, working days if asked.6 views · 8 downloadsAgendaListEvents grouped by day with the date at the left, each with its time, title and place; today and tomorrow named.6 views · 6 downloadsDayViewOne day as a column of hours with its events drawn at their times, side by side when they overlap, and all-day events above.7 views · 7 downloads
Thread
Thread 0 posts
On MonthCalendar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.