Mobile

WeekStrip

Days in a swiped strip with dots for what is on them, today, and a jump to any day.

#mobile#interactive#gesture#calendar#swipe#tablet

Demo

Preview

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

Example

The code of the preview above.

demos/week_strip.qwe
let week_strip_demo_marks = [    { date: "2026-11-02", title: "Standup", tone: "info" },    { date: "2026-11-04", title: "Dentist", tone: "danger" }, { date: "2026-11-04", title: "Lunch with Ana", tone: "success" },    { date: "2026-11-06", title: "Rent", tone: "warning" }, { date: "2026-11-06", title: "Call the bank" }, { date: "2026-11-06", title: "Pick up keys" }, { date: "2026-11-06", title: "Pay the school", tone: "danger" }, { date: "2026-11-06", title: "Book review" },    { date: "2026-11-12", title: "Flight to Lisbon", tone: "info" },]component WeekStripDemoLive() {    state log = ""    state weeks = ""    <div.demo-stack>        <WeekStrip today="2026-11-04" marks={week_strip_demo_marks} label="This week"                   on_pick={(d) => { log = d }} on_week={(w) => { weeks = w }}/>        <p.demo-note id="week-strip-log">{log}</p>        <p.demo-note id="week-strip-week">{weeks}</p>        // Sunday first, and bad data, for the test to look at: neither should stop the page.        <div id="week-strip-sunday"><WeekStrip today="2026-12-30" week_start={7} marks={[null, 5, { date: "not a day" }, { date: "2026-02-30" }, { date: "2026-12-30", tone: "loud" }]}/></div>        <div id="week-strip-bad" hidden="true"><WeekStrip today="nonsense" week_start={99} marks="none"/></div>    </div>}view WeekStripDemo() = <div.demo-stack><WeekStripDemoLive/></div>

Source

ui/week_strip.qwe
// WeekStrip: a week of days across a phone, swiped a week at a time, with a dot for each// thing on a day, today marked, and a jump to any day through the system's own date picker.// The buttons at its ends and the arrow keys do what a swipe does: left and right a day, up// and down a week, Home and End the week's ends, Page Up and Page Down a week. On a tablet// each day has room, so it shows the first things by name. Left open past midnight, today// moves on when the page comes back.////   component Planner() {//       state day = "2026-11-04"//       <WeekStrip today="2026-11-04" picked={day} marks={events} on_pick={(d) => { day = d }}/>//   }//// today: "YYYY-MM-DD"; left out, the server's day when it renders. picked: the day chosen to// start with, followed when it changes (today). marks: each { date, title?, tone? }, tone being// "accent", "success", "warning", "danger" or "info"; those without a good date are left out.// week_start: 1 for Monday to 7 for Sunday (1). label: its name for screen readers. dots: how many// a day shows before a plus (3). class.// on_pick(date): inside one of your components, a day was chosen, by a tap, a key, a jump, or a// swipe, which keeps the weekday. on_week(first): the first day of the week showing, when it changes.import ui/theme.qwe use ui_clslet week_strip_css = css`.ui-week-strip { container-type: inline-size; color: var(--ui-fg); font: 15px/1.3 var(--ui-font); }.ui-week-strip-head { display: flex; align-items: center; gap: 2px; min-height: 46px; }.ui-week-strip-jump { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 4px; min-height: 44px; border-radius: var(--ui-radius-sm); font-weight: 600; }.ui-week-strip-jump span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-week-strip-jump svg { flex: none; width: 16px; height: 16px; color: var(--ui-muted); }/* The field lies over the title, unseen: a tap on it is a tap on the system's own picker. */.ui-week-strip-date { position: absolute; inset: 0; width: 100%; height: 100%; min-width: 0; margin: 0; padding: 0; border: 0; opacity: 0; font-size: 16px; cursor: pointer; }.ui-week-strip-date::-webkit-calendar-picker-indicator { position: absolute; inset: 0; width: auto; height: auto; margin: 0; padding: 0; cursor: pointer; }.ui-week-strip-jump:focus-within { box-shadow: var(--ui-focus); }.ui-week-strip-btn { display: grid; flex: none; place-items: center; min-width: 44px; min-height: 44px; padding: 0 8px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; }.ui-week-strip-btn:disabled { color: var(--ui-strong); cursor: default; }.ui-week-strip-btn svg { width: 20px; height: 20px; }.ui-week-strip-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-week-strip-view { overflow: hidden; touch-action: pan-y; -webkit-touch-callout: none; }.ui-week-strip-track { display: flex; width: 300%; transform: translateX(-33.3333%); will-change: transform; }.ui-week-strip-moving { user-select: none; -webkit-user-select: none; }.ui-week-strip-page { display: grid; flex: 1 1 0; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; min-width: 0; }.ui-week-strip-day { display: grid; justify-items: center; align-content: start; gap: 1px; min-width: 0; min-height: 68px; padding: 6px 0 4px; border: 0; border-radius: var(--ui-radius); background: transparent; color: var(--ui-fg); font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }.ui-week-strip-name { color: var(--ui-muted); font-size: 12px; }.ui-week-strip-num { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; font-size: 16px; font-weight: 500; }.ui-week-strip-day[aria-current="date"] .ui-week-strip-num { color: var(--ui-accent); font-weight: 700; }.ui-week-strip-day[aria-pressed="true"] .ui-week-strip-num { background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-week-strip-day:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-week-strip-dots { display: flex; align-items: center; justify-content: center; gap: 3px; min-height: 10px; }.ui-week-strip-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ui-accent); }.ui-week-strip-dot[data-tone="success"] { background: var(--ui-success); }.ui-week-strip-dot[data-tone="warning"] { background: var(--ui-warning); }.ui-week-strip-dot[data-tone="danger"] { background: var(--ui-danger); }.ui-week-strip-dot[data-tone="info"] { background: var(--ui-info); }.ui-week-strip-plus { color: var(--ui-muted); font-size: 10px; line-height: 10px; }.ui-week-strip-titles { display: none; }.ui-week-strip-say { min-height: 0; margin: 2px 0 0; color: var(--ui-danger); font-size: 13px; text-align: center; }.ui-week-strip-say:empty { display: none; }/* A tablet's days have room, so they say what is on them. */@container (min-width: 600px) {  .ui-week-strip-day { min-height: 128px; padding: 8px 6px; justify-items: stretch; text-align: left; background: var(--ui-subtle); }  .ui-week-strip-day[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--ui-accent); }  .ui-week-strip-name { font-size: 13px; }  .ui-week-strip-num { font-size: 18px; }  .ui-week-strip-dots { justify-content: flex-start; }  .ui-week-strip-titles { display: grid; gap: 2px; margin-top: 4px; font-size: 12px; }  .ui-week-strip-titles span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}@media (hover: hover) {  .ui-week-strip-btn:hover:not(:disabled) { background: var(--ui-subtle); }  .ui-week-strip-day:hover { background: var(--ui-subtle); }}`// The first day of the week holding d.fn week_strip_first(d: str, week_start: int) -> str {    let back = (date.weekday(d) - week_start + 7) % 7    date.add_days(d, -back)}fn week_strip_start_day(n: int) -> int = n >= 1 && n <= 7 ? n : 1fn week_strip_days(first: str) -> list = range(0, 7) | map((i) => date.add_days(first, i))// What heads the strip: one month, or the two a week falls across.fn week_strip_title(first: str) -> str {    let last = date.add_days(first, 6)    if slice(first, 0, 7) == slice(last, 0, 7) { return date.format(first, "MMMM YYYY") }    if slice(first, 0, 4) == slice(last, 0, 4) { return `${date.format(first, "MMM")} to ${date.format(last, "MMM YYYY")}` }    `${date.format(first, "MMM YYYY")} to ${date.format(last, "MMM YYYY")}`}// What can be shown of the marks, by day.fn week_strip_clean(marks) {    if type_of(marks) != "list" { return {} }    let good = marks | filter((m) => type_of(m) == "map" && type_of(get(m, "date")) == "str" && date.valid(m.date, "date"))    group_by(good, (m) => m.date)}fn week_strip_tone(t) = contains(["success", "warning", "danger", "info"], t) ? t : "accent"// A day as a screen reader hears it.fn week_strip_says(d: str, today: str, count: int) -> str {    let name = date.format(d, "dddd D MMMM")    let when = d == today ? ", today" : ""    let many = count == 0 ? "" : count == 1 ? ", 1 item" : `, ${count} items`    `${name}${when}${many}`}// How many weeks a release goes: -1 to the past, 1 to the future, 0 back. dx is the drag// (left negative), v the speed, width the strip's.fn week_strip_goes(dx: num, v: num, width: num) -> int {    if abs(v) >= 0.3 && abs(dx) >= 24 { return v * dx > 0 ? (dx < 0 ? 1 : -1) : 0 }    if abs(dx) >= width * 0.4 { return dx < 0 ? 1 : -1 }    0}fn week_strip_axis(dx: num, dy: num, slop: num) -> str {    if abs(dx) < slop && abs(dy) < slop { return "" }    abs(dx) > abs(dy) ? "x" : "y"}fn week_strip_sample(s, pos: num, at: num) {    let dt = at - s.at    dt < 32 ? s : { pos, at, v: (pos - s.pos) / dt }}fn week_strip_speed(s, pos: num, at: num, moved: num) -> num {    if at - moved > 80 { return 0 }    let dt = at - s.at    let since = dt >= 8 ? (pos - s.pos) / dt : 0    abs(since) > abs(s.v) ? since : s.v}// The new day for a key, or null.fn week_strip_key(k: str, sel: str, first: str) = match k {    "ArrowLeft" => date.add_days(sel, -1)    "ArrowRight" => date.add_days(sel, 1)    "ArrowUp" => date.add_days(sel, -7)    "ArrowDown" => date.add_days(sel, 7)    "PageUp" => date.add_days(sel, -7)    "PageDown" => date.add_days(sel, 7)    "Home" => first    "End" => date.add_days(first, 6)    _ => null}view WeekStripPage(days: list, by_day, today: str, picked: str, dots: int, active: bool) =    <div.ui-week-strip-page role="group" inert={active ? null : "true"} aria-hidden={active ? null : "true"}>        {for d in days => <button.ui-week-strip-day type="button" key={d} data-day={d} tabindex={d == picked ? "0" : "-1"}                aria-label={week_strip_says(d, today, len(get(by_day, d, [])))} aria-pressed={d == picked ? "true" : "false"}                aria-current={d == today ? "date" : null}>            <span.ui-week-strip-name aria-hidden="true">{date.format(d, "ddd")}</span>            <span.ui-week-strip-num aria-hidden="true">{int(slice(d, 8, 10))}</span>            <span.ui-week-strip-dots aria-hidden="true">                {for m in take(get(by_day, d, []), dots) => <span.ui-week-strip-dot data-tone={week_strip_tone(get(m, "tone"))}></span>}                {len(get(by_day, d, [])) > dots && <span.ui-week-strip-plus>+</span>}            </span>            <span.ui-week-strip-titles aria-hidden="true">{for m in take(get(by_day, d, []) | filter((x) => get(x, "title") != null), 2) => <span>{m.title}</span>}</span>        </button>}    </div>component WeekStrip(today: str = "", picked: str = "", marks = [], week_start: int = 1, label: str = "Week", dots: int = 3, on_pick = null, on_week = null, class: str? = null) {    style global week_strip_css    state day = date.valid(today, "date") ? today : date.today()    state sel = date.valid(picked, "date") ? picked : day    state first = week_strip_first(sel, week_strip_start_day(week_start))    state said = ""    state flying = false    state swiped = 0    state g = null    state view_el = null    state track = null    state root = null    let starts = week_strip_start_day(week_start)    let by_day = week_strip_clean(marks)    // Following the prop, when a parent changes the day.    if date.valid(picked, "date") && picked != sel { sel = picked; first = week_strip_first(picked, starts) }    let last_week = date.add_days(first, -7)    let next_week = date.add_days(first, 7)    let focus_day = (d: str) => {        js("requestAnimationFrame", (_t = null) => {            let b = root.querySelector(`[data-day="${d}"]:not([tabindex="-1"])`)            b && b.focus()        })    }    let pick = (d: str) => {        said = ""        let week = week_strip_first(d, starts)        let moved = week != first        sel = d        first = week        on_pick && on_pick(d)        if moved { on_week && on_week(week) }    }    // The strip slides on and the days change under it at the end, so a swipe's last moment is the same as its first.    let fly = (way: int) => {        let w = view_el.offsetWidth        let calm = js("matchMedia", "(prefers-reduced-motion: reduce)").matches        flying = true        let from = track.style.transform != "" ? track.style.transform : "translateX(-33.3333%)"        let run = track.animate([{ transform: from }, { transform: `translateX(calc(-33.3333% + ${-way * w}px))` }], { duration: calm ? 0 : 220, easing: "cubic-bezier(.2, .8, .2, 1)", fill: "forwards" })        run.addEventListener("finish", (_e = null) => {            flying = false            pick(date.add_days(sel, way * 7))            track.style.transform = ""            js("requestAnimationFrame", (_t = null) => { run.cancel() })        })    }    let back_home = () => {        track.classList.remove("ui-week-strip-moving")        track.style.transition = "transform .22s cubic-bezier(.2, .8, .2, 1)"        track.style.transform = ""    }    <div class={ui_cls(["ui-week-strip", class])} role="group" aria-label={label}         @mount={(me) => {             root = me             view_el = me.querySelector(".ui-week-strip-view")             track = me.querySelector(".ui-week-strip-track")             // A day counted from the clock's, so a `today` from a prop moves on with it.             let base = day             let local = (_x = null) => {                 let t = js_new("Date")                 date.of(t.getFullYear(), t.getMonth() + 1, t.getDate())             }             let started = local()             let d = js("document")             let back = (_e = null) => {                 if js("document.visibilityState") == "visible" {                     day = date.add_days(base, date.days_between(started, local()))                 }             }             d.addEventListener("visibilitychange", back)             () => { d.removeEventListener("visibilitychange", back) }         }}>        <div.ui-week-strip-head>            <button.ui-week-strip-btn type="button" aria-label="Week before" @click={() => { if !flying { fly(-1) } }}>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>            </button>            <label.ui-week-strip-jump>                <span>{week_strip_title(first)}</span>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>                <input.ui-week-strip-date type="date" aria-label="Jump to a day" value={sel}                       @change={(e) => {                           let v = e.value                           if v == "" {                               e.value = sel                           } else if !date.valid(v, "date") || v < "1900-01-01" || v > "2999-12-31" {                               said = "Pick a day between 1900 and 2999."                               e.value = sel                           } else {                               pick(v)                           }                       }}/>            </label>            <button.ui-week-strip-btn type="button" disabled={sel == day ? true : null} @click={() => { pick(day); focus_day(day) }}>Today</button>            <button.ui-week-strip-btn type="button" aria-label="Week after" @click={() => { if !flying { fly(1) } }}>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>            </button>        </div>        <div.ui-week-strip-view             @click={(e) => {                 let b = e.target.closest("[data-day]")                 // With a mouse a click follows even a drag.                 if b != null && !flying && e.timeStamp - swiped > 350 {                     pick(b.dataset.day)                 }             }}             @keydown={(e) => {                 let d = week_strip_key(e.key, sel, first)                 if d != null {                     e.preventDefault()                     pick(d)                     focus_day(d)                 }             }}             @pointerdown={(e) => {                 g = e.isPrimary && e.button == 0 && !flying ? { pid: e.pointerId, x: e.clientX, y: e.clientY, on: false, at: 0, shift: 0, moved: e.timeStamp, s: { pos: e.clientX, at: e.timeStamp, v: 0 } } : null             }}             @pointermove={(e) => {                 if g != null && e.pointerId == g.pid {                     let dx = e.clientX - g.x                     if !g.on {                         let way = week_strip_axis(dx, e.clientY - g.y, 8)                         if way == "y" { g = null }                         if way == "x" {                             view_el.setPointerCapture(e.pointerId)                             track.style.transition = "none"                             track.classList.add("ui-week-strip-moving")                             g = { ...g, on: true, shift: dx > 0 ? 8 : -8 }                         }                     }                     if g != null && g.on {                         let x = dx - g.shift                         track.style.transform = `translateX(calc(-33.3333% + ${x}px))`                         g = { ...g, at: x, moved: e.timeStamp, s: week_strip_sample(g.s, e.clientX, e.timeStamp) }                     }                 }             }}             @pointerup={(e) => {                 if g != null && e.pointerId == g.pid {                     let was = g                     g = null                     if was.on {                         swiped = e.timeStamp                         let v = week_strip_speed(was.s, e.clientX, e.timeStamp, was.moved)                         let way = week_strip_goes(was.at, v, view_el.offsetWidth)                         track.classList.remove("ui-week-strip-moving")                         if way != 0 {                             track.style.transition = ""                             fly(way)                         } else {                             back_home()                         }                     }                 }             }}             @pointercancel={(e) => {                 if g != null && e.pointerId == g.pid {                     let was = g                     g = null                     if was.on { back_home() }                 }             }}>            <div.ui-week-strip-track>                <WeekStripPage days={week_strip_days(last_week)} by_day={by_day} today={day} picked={sel} dots={dots} active={false}/>                <WeekStripPage days={week_strip_days(first)} by_day={by_day} today={day} picked={sel} dots={dots} active={true}/>                <WeekStripPage days={week_strip_days(next_week)} by_day={by_day} today={day} picked={sel} dots={dots} active={false}/>            </div>        </div>        <p.ui-week-strip-say role="status">{said}</p>    </div>}
Thread
28Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.