Forms

YearPicker

A year picked from a grid of a decade, turned ten years at a time, from a button or in the page.

#forms#picker#calendar#keyboard#interactive

Demo

Preview
Model year
Year
Graduated

Graduated, 2020 – 2029

2019202020212022
2023202420252026
2027202820292030

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

Thread 0 posts

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

No posts yet. The first one opens the thread.