WeekPicker
An ISO week picked from a month shown as numbered weeks, by click or the arrow keys, sent as 2026-W45.
Demo
Week
November 2026
ISO weeks start on Monday.
Week
October 2026
Choose a 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.qwemain.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>}Related
MonthPickerA month and year picked from a grid of twelve months under the year, from a button or in the page, sent as YYYY-MM.4 views · 7 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 WeekPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.