Forms

WeekPicker

An ISO week picked from a month shown as numbered weeks, by click or the arrow keys, sent as 2026-W45.

#forms#picker#calendar#keyboard#interactive

Demo

Preview
Week

ISO weeks start on Monday.

Week
Sprint starts

Sprint starts, October 2026

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

Example

The code of the preview above.

demos/week_picker.qwe
component WeekPickerDemoLive() {    state week = null    <div.demo-stack>        <WeekPicker name="wp-demo-sprint" label="Sprint starts" inline={true} today="2026-10-28"                    on_change={(w) => { week = w }}/>        <p.demo-note>{week == null ? "No week picked." : `${week.week}: ${week.start} to ${week.end}.`}</p>    </div>}view WeekPickerDemo() {    <div.demo-stack>        <div.demo-row>            <WeekPicker name="wp-demo-week" label="Week" value="2026-W45" today="2026-10-28" hint="ISO weeks start on Monday."/>            <WeekPicker name="wp-demo-sm" label="Week" size="sm" today="2026-10-28" error="Choose a week."/>        </div>        <WeekPickerDemoLive/>    </div>}

Source

ui/week_picker.qwe
// WeekPicker: an ISO week (Monday to Sunday, numbered as ISO 8601 does: week 1 is// the one with the year's first Thursday), picked from a month shown as weeks with// their numbers. Click a week, or move with the arrow keys (up and down by week,// PageUp / PageDown by month, Home / End to the month's first and last week) and// press Enter or Space. It opens from a button, or sits in the page with// inline={true}. The week goes in a hidden input named `name` as "2026-W45", the// form <input type="week"> sends, so a plain <form> sends it.////   <WeekPicker name="week" label="Week" value="2026-W45"/>//   <WeekPicker name="sprint" label="Sprint starts" inline={true} on_change={(w) => { week = w }}/>//// value: "YYYY-Www" to start with. today: the day whose week is marked as this one// ("YYYY-MM-DD"; today by default). inline: always shown, without the button.// placeholder: the button's text while nothing is chosen ("Choose a week").// name, label, hint, error, required (your route checks it), disabled, field (the// button's id, "week-picker-" + name by default) and size ("sm" or "md") as for// every field.// on_change(week): inside one of your components, given { week: "2026-W45",// start: "2026-11-02", end: "2026-11-08" }: the code and the Monday and Sunday.import ui/theme.qwe use ui_clslet week_picker_css = css`.ui-week-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-week-picker-label { font-size: 13px; font-weight: 500; }.ui-week-picker-req { margin-left: 2px; color: var(--ui-danger); }.ui-week-picker-trigger {  display: inline-flex; align-items: center; gap: 8px; min-width: 240px; 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-week-picker-trigger:hover:not(:disabled) { border-color: var(--ui-strong); }.ui-week-picker-trigger:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-week-picker-trigger:disabled { opacity: .55; cursor: not-allowed; }.ui-week-picker-trigger svg { flex: none; width: 16px; height: 16px; color: var(--ui-muted); }.ui-week-picker-trigger span { flex: 1; }.ui-week-picker-trigger small { color: var(--ui-muted); font-size: 12px; }.ui-week-picker-empty { color: var(--ui-muted); }.ui-week-picker-pop {  position: absolute; z-index: 30; top: 100%; left: 0; width: 320px; 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-week-picker-pop[hidden] { display: none; }.ui-week-picker-inline .ui-week-picker-pop { position: static; margin: 0; box-shadow: none; }.ui-week-picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }.ui-week-picker-head h2 { margin: 0; font-size: 15px; font-weight: 600; }.ui-week-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-week-picker-nav svg { width: 16px; height: 16px; }.ui-week-picker-nav:hover { background: var(--ui-subtle); color: var(--ui-fg); }.ui-week-picker-nav:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-week-picker-cols, .ui-week-picker-week { display: grid; grid-template-columns: 34px repeat(7, 1fr); align-items: center; }.ui-week-picker-cols { padding: 0 2px 4px; color: var(--ui-muted); font-size: 12px; font-weight: 500; text-align: center; }.ui-week-picker-list { display: grid; gap: 2px; outline: none; }.ui-week-picker-week {  height: 34px; padding: 0 2px; border-radius: var(--ui-radius-sm); font-size: 13px; text-align: center; font-variant-numeric: tabular-nums;  cursor: pointer; outline: none; transition: background .1s;}.ui-week-picker-week:hover { background: var(--ui-subtle); }.ui-week-picker-week:focus-visible, .ui-week-picker-week:focus { box-shadow: var(--ui-focus); }.ui-week-picker-num { color: var(--ui-muted); font-size: 11px; font-weight: 600; }.ui-week-picker-out { color: var(--ui-muted); opacity: .6; }.ui-week-picker-today { color: var(--ui-accent); font-weight: 700; }.ui-week-picker-week[aria-selected="true"] { background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-week-picker-week[aria-selected="true"] .ui-week-picker-num, .ui-week-picker-week[aria-selected="true"] .ui-week-picker-out,.ui-week-picker-week[aria-selected="true"] .ui-week-picker-today { color: inherit; opacity: 1; }.ui-week-picker-sm .ui-week-picker-trigger { height: 30px; min-width: 200px; font-size: 13px; }.ui-week-picker-invalid .ui-week-picker-trigger { border-color: var(--ui-danger); }.ui-week-picker-hint, .ui-week-picker-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-week-picker-error { color: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-week-picker-trigger, .ui-week-picker-week { transition: none; } }`let week_picker_months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October",                          "November", "December"]// The Monday of the week holding day d.fn week_picker_monday(d: str) -> str = date.add_days(d, 1 - date.weekday(d))// The ISO year and week number of the week starting `monday`.fn week_picker_number(monday: str) {    let thursday = date.add_days(monday, 3)    let y = date.parts(thursday).year    { year: y, week: floor(date.days_between(date.of(y, 1, 1), thursday) / 7) + 1 }}fn week_picker_code(monday: str) -> str {    let w = week_picker_number(monday)    `${w.year}-W${w.week < 10 ? "0" : ""}${w.week}`}// "2026-W45" as its Monday, or null when it is not a week.fn week_picker_parse(text: str?) {    let t = trim(text ?? "")    if !re.test(t, "^\\d{4}-W\\d{2}$") { return null }    let y = int(slice(t, 0, 4))    let w = int(slice(t, 6, 8))    if w < 1 || w > 53 { return null }    let monday = date.add_days(week_picker_monday(date.of(y, 1, 4)), (w - 1) * 7)    week_picker_number(monday).year == y ? monday : null}// "2 – 8 November 2026", "28 December 2026 – 3 January 2027".fn week_picker_span(monday: str) -> str {    let a = date.parts(monday)    let b = date.parts(date.add_days(monday, 6))    let first = a.year != b.year ? `${a.day} ${week_picker_months[a.month - 1]} ${a.year}`        : a.month != b.month ? `${a.day} ${week_picker_months[a.month - 1]}` : str(a.day)    `${first} – ${b.day} ${week_picker_months[b.month - 1]} ${b.year}`}// The Mondays of the weeks shown for the month starting `first`.fn week_picker_rows(first: str) -> list {    let start = week_picker_monday(first)    let n = floor(date.days_between(start, date.end_of_month(first)) / 7) + 1    range(n) | map((i) => date.add_days(start, i * 7))}component WeekPicker(name: str? = null, label: str? = null, value: str? = null, today: str? = null, inline: bool = false,                     placeholder: str = "Choose a week", 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 week_picker_css    let today_d = today ?? date.today()    let this_week = week_picker_monday(today_d)    state chosen = week_picker_parse(value)    state at = week_picker_parse(value) ?? week_picker_monday(today ?? date.today())    state open = false    // The month shown: at first the one holding the active week's Thursday; it changes only when    // the active week leaves its rows, so a week shared by two months stays where it was.    state first = date.start_of_month(date.add_days(week_picker_parse(value) ?? week_picker_monday(today ?? date.today()), 3))    let id = field ?? `week-picker-${name ?? re.replace(lower(label ?? "week"), "[^a-z0-9]+", "-")}`    let shown = date.parts(first)    let rows = week_picker_rows(first)    let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`])    let root_of = (e) => e.target.closest(".ui-week-picker")    let focus_row = (root) => {        js("requestAnimationFrame", (t = null) => {            let row = root.querySelector(`[data-monday="${at}"]`)            row && row.focus()        })    }    let go = (e, monday: str) => {        at = monday        if !contains(week_picker_rows(first), monday) { first = date.start_of_month(date.add_days(monday, 3)) }        focus_row(root_of(e))    }    // The month `k` months from the one shown, on its first week whose Thursday is in it.    let turn = (e, k: int) => {        let next = date.add_months(first, k)        first = next        go(e, week_picker_monday(date.add_days(next, 3)))    }    let pick = (e, monday: str) => {        at = monday        let before = chosen        chosen = monday        if !inline {            open = false            root_of(e).querySelector(".ui-week-picker-trigger").focus()        }        if monday != before { on_change && on_change({ week: week_picker_code(monday), start: monday, end: date.add_days(monday, 6) }) }    }    <div class={ui_cls(["ui-week-picker", inline && "ui-week-picker-inline", size == "sm" && "ui-week-picker-sm",                        error != null && "ui-week-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-week-picker-trigger").focus()             }         }}>        {label != null && <span.ui-week-picker-label id={`${id}-label`}>            {label}{required && <span.ui-week-picker-req aria-hidden="true">*</span>}        </span>}        {!inline && <button.ui-week-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 = chosen ?? this_week                        first = date.start_of_month(date.add_days(chosen ?? this_week, 3))                        open = true                        focus_row(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.5h14M6 12h8M7 2.8v3.4M13 2.8v3.4"/></svg>            {chosen == null                ? <span.ui-week-picker-empty>{placeholder}</span>                : <span>Week {week_picker_number(chosen).week}, {week_picker_number(chosen).year} <small>{week_picker_span(chosen)}</small></span>}        </button>}        <div.ui-week-picker-pop id={`${id}-pop`} role={inline ? "group" : "dialog"} aria-labelledby={`${id}-month`}             hidden={!inline && !open}>            <div.ui-week-picker-head>                <button.ui-week-picker-nav type="button" aria-label="Previous month" disabled={disabled} @click={(e) => turn(e, -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">{label != null && inline ? <span.ui-sr>{label}, </span> : null}{week_picker_months[shown.month - 1]} {shown.year}</h2>                <button.ui-week-picker-nav type="button" aria-label="Next month" disabled={disabled} @click={(e) => turn(e, 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-week-picker-cols aria-hidden="true">                <span>Wk</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span><span>Su</span>            </div>            <div.ui-week-picker-list role="listbox" aria-labelledby={`${id}-month`}                 @keydown={(e) => {                     let to = match e.key {                         "ArrowUp" => date.add_days(at, -7)                         "ArrowDown" => date.add_days(at, 7)                         "ArrowLeft" => date.add_days(at, -7)                         "ArrowRight" => date.add_days(at, 7)                         "Home" => rows[0]                         "End" => rows[len(rows) - 1]                         _ => null                     }                     if to != null {                         e.preventDefault()                         go(e, to)                     }                     if e.key == "PageUp" || e.key == "PageDown" {                         e.preventDefault()                         turn(e, e.key == "PageUp" ? -1 : 1)                     }                     if e.key == "Enter" || e.key == " " {                         e.preventDefault()                         pick(e, at)                     }                 }}>                {for monday in rows => {                    let n = week_picker_number(monday)                    <div.ui-week-picker-week key={monday} role="option" data-monday={monday} tabindex={disabled ? null : monday == at ? "0" : "-1"}                         aria-selected={monday == chosen ? "true" : "false"} aria-current={monday == this_week ? "date" : null}                         aria-disabled={disabled ? "true" : null} aria-label={`Week ${n.week}, ${week_picker_span(monday)}`}                         @click={(e) => { if !disabled { pick(e, monday) } }}>                        <span.ui-week-picker-num aria-hidden="true">{n.week}</span>                        {for i in range(7) => {                            let d = date.add_days(monday, i)                            <span aria-hidden="true" class={ui_cls([date.parts(d).month != shown.month && "ui-week-picker-out",                                                                    d == today_d && "ui-week-picker-today"])}>{date.parts(d).day}</span>                        }}                    </div>                }}            </div>        </div>        {name != null && <input type="hidden" name={name} value={chosen == null ? "" : week_picker_code(chosen)}/>}        {hint != null && <p.ui-week-picker-hint id={`${id}-hint`}>{hint}</p>}        {error != null && <p.ui-week-picker-error id={`${id}-error`} role="alert">{error}</p>}    </div>}
Thread
58Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.