YearPicker
A year picked from a grid of a decade, turned ten years at a time, from a button or in the page.
Demo
Model year
2010 – 2019
| 2009 | 2010 | 2011 | 2012 |
| 2013 | 2014 | 2015 | 2016 |
| 2017 | 2018 | 2019 | 2020 |
Year
2020 – 2029
| 2019 | 2020 | 2021 | 2022 |
| 2023 | 2024 | 2025 | 2026 |
| 2027 | 2028 | 2029 | 2030 |
Choose a year.
Graduated
Graduated, 2020 – 2029
| 2019 | 2020 | 2021 | 2022 |
| 2023 | 2024 | 2025 | 2026 |
| 2027 | 2028 | 2029 | 2030 |
No year 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/year_picker.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/year_picker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/year_picker.qwe
component YearPickerDemoLive() { state year = null <div.demo-stack> <YearPicker name="yp-demo-grad" label="Graduated" inline={true} today={2026} latest={2030} on_change={(y) => { year = y }}/> <p.demo-note>{year == null ? "No year picked." : `Class of ${year}.`}</p> </div>}view YearPickerDemo() { <div.demo-stack> <div.demo-row> <YearPicker name="yp-demo-model" label="Model year" value={2019} earliest={1990} latest={2027} today={2026}/> <YearPicker name="yp-demo-sm" label="Year" size="sm" today={2026} error="Choose a year."/> </div> <YearPickerDemoLive/> </div>}Source
ui/year_picker.qwe
// YearPicker: a year (a graduation, a car's model year, a report's year), picked// from a grid of a decade, with the years either side of it greyed, under arrows// that turn the decade. It opens from a button, or sits in the page with// inline={true}. The arrow keys move by year and by row, PageUp / PageDown by ten// years, Home / End to the decade's first and last, Enter or Space picks, Escape// closes. The year goes in a hidden input named `name`, so a plain <form> sends it.//// <YearPicker name="year" label="Model year" value={2019} earliest={1990} latest={2027}/>// <YearPicker name="grad" label="Graduated" inline={true} on_change={(y) => { year = y }}/>//// value: the year to start with. earliest, latest: the first and last years that// can be chosen. today: the year marked as this one (this year by default).// inline: the grid always shown, without the button. placeholder: the button's text// while nothing is chosen ("Choose a year"). name, label, hint, error, required// (your route checks it), disabled, field (the button's id, "year-picker-" + name// by default) and size ("sm" or "md") as for every field.// on_change(year): inside one of your components, given the year as a number.import ui/theme.qwe use ui_clslet year_picker_css = css`.ui-year-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-year-picker-label { font-size: 13px; font-weight: 500; }.ui-year-picker-req { margin-left: 2px; color: var(--ui-danger); }.ui-year-picker-trigger { display: inline-flex; align-items: center; gap: 8px; min-width: 150px; 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); font-variant-numeric: tabular-nums; text-align: left; cursor: pointer; transition: border-color .15s, box-shadow .15s;}.ui-year-picker-trigger:hover:not(:disabled) { border-color: var(--ui-strong); }.ui-year-picker-trigger:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-year-picker-trigger:disabled { opacity: .55; cursor: not-allowed; }.ui-year-picker-trigger svg { flex: none; width: 16px; height: 16px; color: var(--ui-muted); }.ui-year-picker-trigger span { flex: 1; }.ui-year-picker-empty { color: var(--ui-muted); }.ui-year-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-year-picker-pop[hidden] { display: none; }.ui-year-picker-inline .ui-year-picker-pop { position: static; margin: 0; box-shadow: none; }.ui-year-picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }.ui-year-picker-head h2 { margin: 0; font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-year-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-year-picker-nav svg { width: 16px; height: 16px; }.ui-year-picker-nav:hover:not([aria-disabled="true"]) { background: var(--ui-subtle); color: var(--ui-fg); }.ui-year-picker-nav:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-year-picker-nav[aria-disabled="true"] { opacity: .35; cursor: not-allowed; }.ui-year-picker-grid { width: 100%; border-collapse: separate; border-spacing: 4px; table-layout: fixed; }.ui-year-picker-cell { height: 40px; border-radius: var(--ui-radius-sm); text-align: center; font-size: 13px; font-variant-numeric: tabular-nums; cursor: pointer; outline: none; transition: background .1s;}.ui-year-picker-cell:hover { background: var(--ui-subtle); }.ui-year-picker-cell:focus-visible, .ui-year-picker-cell:focus { box-shadow: var(--ui-focus); }.ui-year-picker-outside { color: var(--ui-muted); }.ui-year-picker-now { color: var(--ui-accent); font-weight: 700; box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-year-picker-cell[aria-selected="true"] { background: var(--ui-accent); color: var(--ui-accent-fg); font-weight: 600; }.ui-year-picker-cell[aria-disabled="true"] { color: var(--ui-muted); opacity: .45; cursor: not-allowed; background: none; }.ui-year-picker-sm .ui-year-picker-trigger { height: 30px; min-width: 120px; font-size: 13px; }.ui-year-picker-invalid .ui-year-picker-trigger { border-color: var(--ui-danger); }.ui-year-picker-hint, .ui-year-picker-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-year-picker-error { color: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-year-picker-trigger, .ui-year-picker-cell { transition: none; } }`component YearPicker(name: str? = null, label: str? = null, value: int? = null, earliest: int? = null, latest: int? = null, today: int? = null, inline: bool = false, placeholder: str = "Choose a year", 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 year_picker_css let now_y = today ?? date.parts(date.today()).year state chosen = value state at = value ?? today ?? date.parts(date.today()).year state open = false let id = field ?? `year-picker-${name ?? re.replace(lower(label ?? "year"), "[^a-z0-9]+", "-")}` let decade = floor(at / 10) * 10 let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`]) let allowed = (y: int) => (earliest == null || y >= earliest) && (latest == null || y <= latest) let kept = (y: int) => earliest != null && y < earliest ? earliest : latest != null && y > latest ? latest : y let root_of = (e) => e.target.closest(".ui-year-picker") let focus_cell = (root) => { js("requestAnimationFrame", (t = null) => { let cell = root.querySelector(`[data-y="${at}"]`) cell && cell.focus() }) } let go = (e, y: int) => { at = kept(y) focus_cell(root_of(e)) } let pick = (e, y: int) => { if allowed(y) { at = y let before = chosen chosen = y if !inline { open = false root_of(e).querySelector(".ui-year-picker-trigger").focus() } if y != before { on_change && on_change(y) } } } <div class={ui_cls(["ui-year-picker", inline && "ui-year-picker-inline", size == "sm" && "ui-year-picker-sm", error != null && "ui-year-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-year-picker-trigger").focus() } }}> {label != null && <span.ui-year-picker-label id={`${id}-label`}> {label}{required && <span.ui-year-picker-req aria-hidden="true">*</span>} </span>} {!inline && <button.ui-year-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_y) 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-year-picker-empty" : null}>{chosen == null ? placeholder : str(chosen)}</span> </button>} <div.ui-year-picker-pop id={`${id}-pop`} role={inline ? "group" : "dialog"} aria-labelledby={`${id}-decade`} hidden={!inline && !open}> <div.ui-year-picker-head> <button.ui-year-picker-nav type="button" aria-label="Previous ten years" disabled={disabled} aria-disabled={earliest != null && decade <= earliest ? "true" : null} @click={(e) => { if earliest == null || decade > earliest { go(e, at - 10) } }}> <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}-decade`} aria-live="polite">{label != null && inline ? <span.ui-sr>{label}, </span> : null}{decade} – {decade + 9}</h2> <button.ui-year-picker-nav type="button" aria-label="Next ten years" disabled={disabled} aria-disabled={latest != null && decade + 9 >= latest ? "true" : null} @click={(e) => { if latest == null || decade + 9 < latest { go(e, at + 10) } }}> <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-year-picker-grid role="grid" aria-labelledby={`${id}-decade`} @keydown={(e) => { let to = match e.key { "ArrowLeft" => at - 1 "ArrowRight" => at + 1 "ArrowUp" => at - 4 "ArrowDown" => at + 4 "Home" => decade "End" => decade + 9 "PageUp" => at - 10 "PageDown" => at + 10 _ => 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 y = decade - 1 + r * 4 + c <td role="gridcell" data-y={y} tabindex={disabled ? null : y == at ? "0" : "-1"} class={ui_cls(["ui-year-picker-cell", (y < decade || y > decade + 9) && "ui-year-picker-outside", y == now_y && "ui-year-picker-now"])} aria-selected={y == chosen ? "true" : "false"} aria-current={y == now_y ? "date" : null} aria-disabled={allowed(y) && !disabled ? null : "true"} @click={(e) => { if !disabled { pick(e, y) } }}>{y}</td> }} </tr>} </tbody> </table> </div> {name != null && <input type="hidden" name={name} value={chosen == null ? "" : str(chosen)}/>} {hint != null && <p.ui-year-picker-hint id={`${id}-hint`}>{hint}</p>} {error != null && <p.ui-year-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 downloadsWeekPickerAn ISO week picked from a month shown as numbered weeks, by click or the arrow keys, sent as 2026-W45.5 views · 8 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 YearPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.