Data

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.

#calendar#time#no-js

Demo

Preview

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.qwe
main.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>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.