Demo
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.qwemain.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>}Related
AlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloadsApprovalQueueOne request at a time, swiped to approve or reject, with a required reason, undo and a count of what is left.3 views · 6 downloadsCalendarSheetA month on a sheet that folds to a week by drag, with the day's agenda under it.3 views · 4 downloadsConversationListChats with unread counts, a muted state, pinned at the top, typing indicator, swipe to archive, and relative times from a prop.3 views · 6 downloadsSwipeDeckA stack of cards flung left or right with a turn of the wrist, undo, and buttons that do the same.3 views · 9 downloadsSwipeDismissListSwipe rows away, the rest closing up, with undo in batches that come back in order.3 views · 5 downloads
Thread
Thread 0 posts
On WeekStrip: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.