QuickAddBar
Type a line and it finds the date, list and priority in the words, marked as you go.
Demo
nothing
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/quick_add_bar.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/quick_add_bar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/quick_add_bar.qwe
component QuickAddBarDemoLive() { state added = [] state live = "nothing" <div.demo-stack> <QuickAddBar today="2026-11-04" now="10:20" lists={["Inbox", "Work", "Home", "Side project"]} on_add={(t) => { added = added + [t] }} on_change={(t) => { live = `${t.title}|${t.date}|${t.time}|${t.list}|${t.priority}` }}/> <ul.demo-note id="quick-add-added">{for t in added => <li>{t.title} / {t.date} / {t.time} / {t.list} / {t.priority} / {t.repeat}</li>}</ul> <p.demo-note id="quick-add-live">{live}</p> </div>}component QuickAddBarDemoDocked() { <div.demo-phone id="quick-add-screen" style="height: 240px"> <QuickAddBar today="2026-11-04" lists={["Work"]} docked={true} new_lists={false} day_first={false}/> </div>}view QuickAddBarDemo() = <div.demo-stack><QuickAddBarDemoLive/><QuickAddBarDemoDocked/></div>Source
ui/quick_add_bar.qwe
// QuickAddBar: one line that becomes a task. As it is typed it finds the day, the time, the// list and the priority in the words and marks them in the line itself, so "Call Ana tomorrow// 5pm #work !!" is a task called "Call Ana", and each thing found stands as a chip under it.// Tapping a chip puts those words back in the title, for the day "may 3" really was a// bread recipe. It stays above the on-screen keyboard, and keeps the keyboard up for the// next one. Nothing is sent anywhere: the reading is here.//// component Inbox() {// <QuickAddBar today="2026-11-01" now="10:20" lists={["Inbox", "Work", "Home"]} docked={true}// on_add={(task) => { save(task) }}/>// }//// It reads: today, tonight, tomorrow, monday (the next one, a week off if today is one), next// friday (in the week after this), this friday, next week, next month, this weekend, end of// month, in 3 days, in 2 weeks, in 2 hours, 3 Nov, Nov 3rd, the 14th, 2026-11-14, 14/11; at 5,// 5pm, 17:30, noon, midnight; every day, every monday, daily, weekly, monthly, weekdays;// #work, in Work, for Work, work list; !, !!, !!!, p1 to p3, urgent, asap, high priority,// low priority. Short day names (mon, sat) count only after on, by, due, next, this or every,// as "sat" and "sun" are words. "at 5" with no am or pm is 5 in the afternoon, up to 7.//// today: "YYYY-MM-DD", the day the words count from; left out, the server's when it renders.// now: "HH:MM", for "in 2 hours", and so that a time already gone means tomorrow.// lists: the names of the lists. new_lists: a #name matching none is a new list (true).// day_first: 14/11 is the 14th (true), or 11/14 is (false). placeholder. label: the field's name// for screen readers. docked: stands at the bottom of the screen, above the keyboard and the// home bar (false: it stays where it is put). autofocus: takes the focus on arrival, which// brings the keyboard up (false). class.// on_add(task): inside one of your components, called with { title, date, time, list, list_new,// priority, repeat, text }, any left null when not found: date "YYYY-MM-DD", time "HH:MM",// priority "high", "medium" or "low", repeat "day", "week", "month", "weekdays", or a day's// name in lower case, text the line as typed. on_change(task): the same, at every change.import ui/theme.qwe use ui_clslet quick_add_bar_css = css`.ui-quick-add { container-type: inline-size; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); background: var(--ui-surface); }.ui-quick-add-docked { position: fixed; left: 0; right: 0; bottom: var(--ui-quick-add-kb, 0px); z-index: 20; padding: 8px max(10px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); border-top: 1px solid var(--ui-border); box-shadow: 0 -4px 16px rgba(15, 14, 24, .08); }.ui-quick-add-docked[data-up="1"] { padding-bottom: 8px; }.ui-quick-add-inner { display: grid; gap: 6px; }.ui-quick-add-row { display: flex; gap: 8px; min-width: 0; }.ui-quick-add-field { position: relative; flex: 1; min-width: 0; height: 46px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-bg); overflow: hidden; }.ui-quick-add-field:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }/* The line is drawn twice, in the same font: the field's own text is clear, its caret shows, and the marks show through it. */.ui-quick-add-mirror, .ui-quick-add-input { box-sizing: border-box; position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0 12px; border: 0; font: 16px/44px var(--ui-font); letter-spacing: 0; white-space: pre; }.ui-quick-add-mirror { overflow: hidden; color: var(--ui-fg); pointer-events: none; }.ui-quick-add-input { background: transparent; color: transparent; -webkit-text-fill-color: transparent; caret-color: var(--ui-fg); outline: none; }.ui-quick-add-input::placeholder { color: var(--ui-muted); -webkit-text-fill-color: var(--ui-muted); opacity: 1; }.ui-quick-add-mark { border-radius: 3px; }.ui-quick-add-mark[data-kind="date"], .ui-quick-add-mark[data-kind="time"], .ui-quick-add-mark[data-kind="repeat"] { background: var(--ui-info-soft); color: var(--ui-info); }.ui-quick-add-mark[data-kind="list"] { background: var(--ui-success-soft); color: var(--ui-success); }.ui-quick-add-mark[data-kind="priority"] { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-quick-add-add { flex: none; min-width: 64px; min-height: 46px; padding: 0 14px; border: 0; border-radius: var(--ui-radius); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 15px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-quick-add-add:disabled { background: var(--ui-subtle); color: var(--ui-muted); cursor: default; }.ui-quick-add-add:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-quick-add-chips { display: flex; gap: 6px; min-height: 44px; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }.ui-quick-add-chips:empty { display: none; }.ui-quick-add-chips::-webkit-scrollbar { display: none; }.ui-quick-add-chip { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); white-space: nowrap; cursor: pointer; }.ui-quick-add-chip[data-kind="date"] { background: var(--ui-info-soft); color: var(--ui-info); }.ui-quick-add-chip[data-kind="list"] { background: var(--ui-success-soft); color: var(--ui-success); }.ui-quick-add-chip[data-kind="priority"] { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-quick-add-chip svg { width: 12px; height: 12px; }.ui-quick-add-chip:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-quick-add-say { min-height: 18px; margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-quick-add-say:empty { display: none; }/* On a tablet what was found stands beside the line, so the bar stays one row tall. */@container (min-width: 600px) { .ui-quick-add-inner { grid-template-columns: minmax(0, 1fr) minmax(180px, 40%); align-items: center; } .ui-quick-add-chips { justify-content: flex-end; } .ui-quick-add-chips:empty { display: flex; } .ui-quick-add-say { grid-column: 1 / -1; }}`let quick_add_bar_days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]let quick_add_bar_short_days = [["mon"], ["tue", "tues"], ["wed", "weds"], ["thu", "thur", "thurs"], ["fri"], ["sat"], ["sun"]]let quick_add_bar_months = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]let quick_add_bar_counts = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve"]fn quick_add_bar_pad(n: int) -> str = n < 10 ? `0${n}` : str(n)// The words of a line with where each starts and ends, in characters. A word's closing commas// and stops are not part of it; it is a whole `!!` that counts as priority.fn quick_add_bar_words(text: str) -> list { let out = [] let at = 0 for piece in split(text, " ") { if piece != "" { let bare = re.replace(piece, "[,.;:?!]+$", "") let kept = bare != "" ? bare : piece out = out + [{ raw: piece, w: lower(kept), s: at, e: at + len(kept) }] } at += len(piece) + 1 } out}fn quick_add_bar_word(ws: list, i: int) -> str = i >= 0 && i < len(ws) ? ws[i].w : ""// 1 for Monday to 7 for Sunday, or null. Short names only where asked for.fn quick_add_bar_weekday(w: str, short: bool) { let long = index_of(quick_add_bar_days, w) if long != null { return long + 1 } if !short { return null } let hits = enumerate(quick_add_bar_short_days) | filter((p) => contains(p[1], w)) len(hits) > 0 ? hits[0][0] + 1 : null}// 1 to 12, or null: a month's name, whole or from three letters.fn quick_add_bar_month(w: str) { if len(w) < 3 { return null } let hits = enumerate(quick_add_bar_months) | filter((p) => p[1] == w || (len(w) <= 4 && starts_with(p[1], w)) || (w == "sept" && p[1] == "september")) len(hits) > 0 ? hits[0][0] + 1 : null}// The day after today that falls on a weekday; `same` lets it be today.fn quick_add_bar_upcoming(today: str, wd: int, same: bool) -> str { let ahead = (wd - date.weekday(today) + 7) % 7 date.add_days(today, ahead == 0 && !same ? 7 : ahead)}// That weekday in the week after this one, weeks starting on Monday.fn quick_add_bar_next_week(today: str, wd: int) -> str = date.add_days(today, 7 - date.weekday(today) + wd)fn quick_add_bar_iso(y: int, m: int, d: int) { if y < 1 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31 { return null } let s = `${y}-${quick_add_bar_pad(m)}-${quick_add_bar_pad(d)}` date.valid(s, "date") ? s : null}// A month and a day with no year: the next time it comes.fn quick_add_bar_dated(m: int, d: int, year, today: str) { let y = int(slice(today, 0, 4)) if year != null { return quick_add_bar_iso(year, m, d) } let first = quick_add_bar_iso(y, m, d) if first != null && first >= today { return first } let y2 = y + 1 let y3 = y + 4 let after = quick_add_bar_iso(y2, m, d) if after != null { return after } quick_add_bar_iso(y3 - y3 % 4, m, d)}// "14", "14th": the number of a day, or null.fn quick_add_bar_ordinal(w: str) { if !re.test(w, "^[0-9]{1,2}(st|nd|rd|th)?$") { return null } let n = int(re.replace(w, "[a-z]+$", "")) n >= 1 && n <= 31 ? n : null}// "the 14th": the next 14th there is.fn quick_add_bar_nth(n: int, today: str) { let y = int(slice(today, 0, 4)) let m = int(slice(today, 5, 7)) let here = quick_add_bar_iso(y, m, n) if here != null && here >= today { return here } let next = date.start_of_month(date.add_months(today, 1)) let there = quick_add_bar_iso(int(slice(next, 0, 4)), int(slice(next, 5, 7)), n) if there != null { return there } let later = date.start_of_month(date.add_months(today, 2)) quick_add_bar_iso(int(slice(later, 0, 4)), int(slice(later, 5, 7)), n)}fn quick_add_bar_count(w: str) { if w == "a" || w == "an" { return 1 } if re.test(w, "^[0-9]{1,3}$") { return int(w) } index_of(quick_add_bar_counts, w)}fn quick_add_bar_year(w: str) = re.test(w, "^[0-9]{4}$") ? int(w) : null// What a time reads as, or null: "5pm", "5:30pm", "17:30", and "5" when `spoken`, after "at".// Without am or pm, 1 to 7 is the afternoon, unless written 07:30.fn quick_add_bar_time(w: str, spoken: bool) { if w == "noon" { return "12:00" } if w == "midnight" { return "00:00" } let half = ends_with(w, "am") ? "am" : ends_with(w, "pm") ? "pm" : "" let digits = half != "" ? slice(w, 0, len(w) - 2) : w let bits = split(digits, ":") if len(bits) > 2 || !re.test(bits[0], "^[0-9]{1,2}$") { return null } if len(bits) == 2 && !re.test(bits[1], "^[0-9]{2}$") { return null } if len(bits) == 1 && half == "" && !spoken { return null } let h = int(bits[0]) let m = len(bits) == 2 ? int(bits[1]) : 0 if m > 59 { return null } if half != "" { if h < 1 || h > 12 { return null } let h24 = half == "pm" ? (h == 12 ? 12 : h + 12) : (h == 12 ? 0 : h) return `${quick_add_bar_pad(h24)}:${quick_add_bar_pad(m)}` } if h > 23 { return null } let padded = len(bits[0]) == 2 && h < 10 `${quick_add_bar_pad(h >= 1 && h <= 7 && !padded ? h + 12 : h)}:${quick_add_bar_pad(m)}`}fn quick_add_bar_time_at(ws: list, i: int) { let a = quick_add_bar_word(ws, i) let b = quick_add_bar_word(ws, i + 1) let c = quick_add_bar_word(ws, i + 2) if a == "at" || a == "@" { if (c == "am" || c == "pm") && re.test(b, "^[0-9:]{1,5}$") { let t = quick_add_bar_time(b + c, true) if t != null { return { n: 3, time: t } } } let t2 = quick_add_bar_time(b, true) return t2 != null ? { n: 2, time: t2 } : null } if (b == "am" || b == "pm") && re.test(a, "^[0-9:]{1,5}$") { let t3 = quick_add_bar_time(a + b, true) if t3 != null { return { n: 2, time: t3 } } } let t4 = quick_add_bar_time(a, false) t4 != null ? { n: 1, time: t4 } : null}fn quick_add_bar_repeat_at(ws: list, i: int, today: str) { let a = quick_add_bar_word(ws, i) let b = quick_add_bar_word(ws, i + 1) if a == "daily" { return { n: 1, date: today, repeat: "day" } } if a == "weekly" { return { n: 1, date: today, repeat: "week" } } if a == "monthly" { return { n: 1, date: today, repeat: "month" } } if a == "weekdays" { return { n: 1, date: quick_add_bar_weekdays_from(today), repeat: "weekdays" } } if a != "every" { return null } if b == "day" || b == "week" || b == "month" { return { n: 2, date: today, repeat: b } } if b == "weekday" { return { n: 2, date: quick_add_bar_weekdays_from(today), repeat: "weekdays" } } let wd = quick_add_bar_weekday(b, true) if wd == null { return null } { n: 2, date: quick_add_bar_upcoming(today, wd, true), repeat: quick_add_bar_days[wd - 1] }}// The first working day from today.fn quick_add_bar_weekdays_from(today: str) -> str { let wd = date.weekday(today) wd > 5 ? date.add_days(today, 8 - wd) : today}// The day words starting at word i, without a leading on, by or due. `said`: one of those came// first, so a short day name means a day.fn quick_add_bar_date_core(ws: list, i: int, today: str, day_first: bool, said: bool) { let a = quick_add_bar_word(ws, i) let b = quick_add_bar_word(ws, i + 1) let c = quick_add_bar_word(ws, i + 2) let e = quick_add_bar_word(ws, i + 3) if a == "today" || a == "tod" { return { n: 1, date: today } } if a == "tonight" { return { n: 1, date: today, time: "20:00" } } if a == "tomorrow" || a == "tmrw" || a == "tmr" { return { n: 1, date: date.add_days(today, 1) } } if a == "day" && b == "after" && c == "tomorrow" { return { n: 3, date: date.add_days(today, 2) } } if a == "next" { let wd = quick_add_bar_weekday(b, true) if wd != null { return { n: 2, date: quick_add_bar_next_week(today, wd) } } if b == "week" { return { n: 2, date: quick_add_bar_next_week(today, 1) } } if b == "month" { return { n: 2, date: date.start_of_month(date.add_months(today, 1)) } } if b == "year" { return { n: 2, date: `${int(slice(today, 0, 4)) + 1}-01-01` } } return null } if a == "this" { if b == "weekend" { return { n: 2, date: quick_add_bar_upcoming(today, 6, true) } } let wd2 = quick_add_bar_weekday(b, true) return wd2 != null ? { n: 2, date: quick_add_bar_upcoming(today, wd2, true) } : null } if a == "weekend" { return { n: 1, date: quick_add_bar_upcoming(today, 6, true) } } if a == "eom" { return { n: 1, date: date.end_of_month(today) } } if a == "eow" { return { n: 1, date: quick_add_bar_upcoming(today, 7, true) } } if a == "end" && b == "of" { let n = c == "the" ? 4 : 3 let unit = c == "the" ? e : c if unit == "month" { return { n, date: date.end_of_month(today) } } if unit == "week" { return { n, date: quick_add_bar_upcoming(today, 7, true) } } return null } let wd3 = quick_add_bar_weekday(a, said) if wd3 != null { return { n: 1, date: quick_add_bar_upcoming(today, wd3, false) } } // Nov 3, Nov 3rd, Nov 3 2027. let m = quick_add_bar_month(a) if m != null { let d = quick_add_bar_ordinal(b) if d != null { let y = quick_add_bar_year(c) let found = quick_add_bar_dated(m, d, y, today) return found != null ? { n: y != null ? 3 : 2, date: found } : null } } // 3 Nov, 3rd of November, the 3rd. let skip = a == "the" ? 1 : 0 let d2 = quick_add_bar_ordinal(quick_add_bar_word(ws, i + skip)) if d2 != null { let of = quick_add_bar_word(ws, i + skip + 1) == "of" ? 1 : 0 let m2 = quick_add_bar_month(quick_add_bar_word(ws, i + skip + 1 + of)) if m2 != null { let y2 = quick_add_bar_year(quick_add_bar_word(ws, i + skip + 2 + of)) let found2 = quick_add_bar_dated(m2, d2, y2, today) return found2 != null ? { n: skip + 2 + of + (y2 != null ? 1 : 0), date: found2 } : null } if skip == 1 || (said && re.test(quick_add_bar_word(ws, i), "[a-z]$")) { let nth = quick_add_bar_nth(d2, today) return nth != null ? { n: 1 + skip, date: nth } : null } } if re.test(a, "^[0-9]{4}-[0-9]{2}-[0-9]{2}$") { return date.valid(a, "date") ? { n: 1, date: a } : null } if re.test(a, "^[0-9]{1,2}/[0-9]{1,2}$") { let bits = split(a, "/") let one = int(bits[0]) let two = int(bits[1]) let found3 = quick_add_bar_dated(day_first ? two : one, day_first ? one : two, null, today) return found3 != null ? { n: 1, date: found3 } : null } null}// "in 3 days" and the like, or null. Hours need the clock, and carry the day over.fn quick_add_bar_in_at(ws: list, i: int, today: str, now) { if quick_add_bar_word(ws, i) != "in" { return null } let n = quick_add_bar_count(quick_add_bar_word(ws, i + 1)) if n == null { return null } let unit = re.replace(quick_add_bar_word(ws, i + 2), "s$", "") if unit == "day" { return { n: 3, date: date.add_days(today, n) } } if unit == "week" { return { n: 3, date: date.add_days(today, n * 7) } } if unit == "month" { return { n: 3, date: date.add_months(today, n) } } if unit == "year" { return { n: 3, date: date.add_years(today, n) } } if (unit == "hour" || unit == "hr" || unit == "minute" || unit == "min") && now != null { let mins = int(slice(now, 0, 2)) * 60 + int(slice(now, 3, 5)) + (unit == "hour" || unit == "hr" ? n * 60 : n) let t = `${quick_add_bar_pad(int((mins % 1440) / 60))}:${quick_add_bar_pad(mins % 60)}` return { n: 3, date: date.add_days(today, int(mins / 1440)), time: t } } null}fn quick_add_bar_date_at(ws: list, i: int, today: str, now, day_first: bool) { let a = quick_add_bar_word(ws, i) let every = quick_add_bar_repeat_at(ws, i, today) if every != null { return every } let span = quick_add_bar_in_at(ws, i, today, now) if span != null { return span } if a == "on" || a == "by" || a == "due" { let inner = quick_add_bar_date_core(ws, i + 1, today, day_first, true) return inner != null ? { ...inner, n: inner.n + 1 } : null } quick_add_bar_date_core(ws, i, today, day_first, false)}fn quick_add_bar_priority_at(ws: list, i: int) { let raw = ws[i].raw let a = quick_add_bar_word(ws, i) let b = quick_add_bar_word(ws, i + 1) if raw == "!!!" || a == "p1" || a == "urgent" || a == "asap" { return { n: 1, priority: "high" } } if raw == "!!" || a == "p2" { return { n: 1, priority: "medium" } } if raw == "!" || a == "p3" { return { n: 1, priority: "low" } } if b == "priority" && contains(["high", "medium", "low"], a) { return { n: 2, priority: a } } if a == "priority" && contains(["high", "medium", "low"], b) { return { n: 2, priority: b } } null}// The list a #word means: one of `lists` by its name or its start, or a new one.fn quick_add_bar_hash(name: str, lists: list, new_lists: bool) { let want = lower(name) let same = lists | filter((l) => lower(l) == want) if len(same) > 0 { return { list: same[0], list_new: false } } let start = lists | filter((l) => starts_with(lower(l), want)) if len(start) > 0 { return { list: start[0], list_new: false } } new_lists ? { list: name, list_new: true } : null}// How many words a known list's name takes from word i, or 0.fn quick_add_bar_named(ws: list, i: int, name: str) -> int { let parts = split(lower(name), " ") let got = range(0, len(parts)) | filter((k) => quick_add_bar_word(ws, i + k) == parts[k]) len(got) == len(parts) ? len(parts) : 0}fn quick_add_bar_list_at(ws: list, i: int, lists: list, new_lists: bool) { let a = quick_add_bar_word(ws, i) if starts_with(a, "#") && len(a) > 1 { let hit = quick_add_bar_hash(slice(ws[i].raw, 1, 1 + ws[i].e - ws[i].s - 1), lists, new_lists) return hit != null ? { ...hit, n: 1 } : null } let lead = contains(["in", "for", "to"], a) ? 1 : 0 let hits = lists | map((l) => ({ l, k: quick_add_bar_named(ws, i + lead, l) })) | filter((h) => h.k > 0) if len(hits) == 0 { return null } let h = hits[0] let closes = quick_add_bar_word(ws, i + lead + h.k) == "list" if lead == 1 { return { n: 1 + h.k + (closes ? 1 : 0), list: h.l, list_new: false } } closes ? { n: h.k + 1, list: h.l, list_new: false } : null}// What the line says. `dropped`: the found words, in lower case, that were tapped away and// stand as plain words. The first of each kind counts; a later one stays in the title.fn quick_add_bar_read(text: str, today: str, now, lists: list, new_lists: bool, day_first: bool, dropped: list) { let ws = quick_add_bar_words(text) let n = len(ws) let tokens = [] let used = [] let found = { date: null, time: null, list: null, list_new: false, priority: null, repeat: null } let i = 0 while i < n { let try_kind = "" let hit = null if found.priority == null { hit = quick_add_bar_priority_at(ws, i) try_kind = "priority" } if hit == null && found.list == null { hit = quick_add_bar_list_at(ws, i, lists, new_lists) try_kind = "list" } if hit == null && found.time == null { hit = quick_add_bar_time_at(ws, i) try_kind = "time" } if hit == null && found.date == null { hit = quick_add_bar_date_at(ws, i, today, now, day_first) try_kind = "date" } let span = hit != null ? slice(text, ws[i].s, ws[i + hit.n - 1].e) : "" if hit != null && !contains(dropped, lower(span)) { let kind = try_kind == "date" && get(hit, "repeat") != null ? "repeat" : try_kind tokens = tokens + [{ s: ws[i].s, e: ws[i + hit.n - 1].e, kind, text: span }] used = used + range(i, i + hit.n) found = { ...found, ...hit } i += hit.n } else { i += 1 } } let title = enumerate(ws) | filter((p) => !contains(used, p[0])) | map((p) => p[1].raw) | join(" ") { ...found, title, tokens }}// The line cut at what was found, each piece { t, kind }, for the marks.fn quick_add_bar_pieces(text: str, tokens: list) -> list { let chars = split(text, "") let at = 0 let out = [] for tok in sort_by(tokens, (x) => x.s) { if tok.s > at { out = out + [{ t: slice(chars, at, tok.s) | join(""), kind: "" }] } out = out + [{ t: slice(chars, tok.s, tok.e) | join(""), kind: tok.kind }] at = tok.e } if at < len(chars) { out = out + [{ t: slice(chars, at, len(chars)) | join(""), kind: "" }] } out}// A time already gone today, with no day given, means tomorrow.fn quick_add_bar_settle_day(read, today: str, now) { if read.time == null || read.date != null { return read } let gone = now != null && read.time <= now { ...read, date: gone ? date.add_days(today, 1) : today }}// How a found day and time read on a chip.fn quick_add_bar_when(d, t, today: str) -> str { if d == null { return t != null ? t : "" } let n = date.days_between(today, d) let day = n == 0 ? "Today" : n == 1 ? "Tomorrow" : n > 1 && n < 7 ? date.format(d, "dddd") : slice(d, 0, 4) == slice(today, 0, 4) ? date.format(d, "ddd D MMM") : date.format(d, "D MMM YYYY") t != null ? `${day} ${t}` : day}fn quick_add_bar_repeat_words(r) -> str = match r { "day" => "Every day" "week" => "Every week" "month" => "Every month" "weekdays" => "Weekdays" _ => `Every ${upper(slice(r, 0, 1))}${slice(r, 1, len(r))}`}// The chips: what was found, each with the words it came from, to give them back.fn quick_add_bar_chips(read, today: str) -> list { let of = (kinds: list) => read.tokens | filter((t) => contains(kinds, t.kind)) | map((t) => lower(t.text)) let when = quick_add_bar_when(read.date, read.time, today) let chips = [ when != "" ? { kind: "date", label: when, words: of(["date", "time", "repeat"]) } : null, read.repeat != null ? { kind: "date", label: quick_add_bar_repeat_words(read.repeat), words: of(["repeat"]) } : null, read.list != null ? { kind: "list", label: read.list_new ? `New list: ${read.list}` : read.list, words: of(["list"]) } : null, read.priority != null ? { kind: "priority", label: `${upper(slice(read.priority, 0, 1))}${slice(read.priority, 1, len(read.priority))} priority`, words: of(["priority"]) } : null, ] chips | filter((c) => c != null)}// The #word the caret stands at the end of, without its #, or null.fn quick_add_bar_partial(text: str, caret: int) { let before = slice(text, 0, caret) let last = before == "" || ends_with(before, " ") ? "" : split(before, " ")[len(split(before, " ")) - 1] starts_with(last, "#") ? slice(last, 1, len(last)) : null}fn quick_add_bar_lists(lists) -> list = type_of(lists) == "list" ? lists | filter((l) => type_of(l) == "str" && trim(l) != "") : []component QuickAddBar(today: str = "", now: str = "", lists = [], new_lists: bool = true, day_first: bool = true, label: str = "Add a task", placeholder: str = "Call Ana tomorrow 5pm #work !!", docked: bool = false, autofocus: bool = false, on_add = null, on_change = null, class: str? = null) { style global quick_add_bar_css state day = date.valid(today, "date") ? today : date.today() state text = "" state caret = 0 state dropped = [] state said = "" state typing = false state input = null state mirror = null let clock = re.test(now, "^[0-2][0-9]:[0-5][0-9]$") ? now : null let names = quick_add_bar_lists(lists) let read = quick_add_bar_settle_day(quick_add_bar_read(text, day, clock, names, new_lists, day_first, dropped), day, clock) let pieces = quick_add_bar_pieces(text, read.tokens) let chips = quick_add_bar_chips(read, day) let part = quick_add_bar_partial(text, caret) let offers = part != null ? names | filter((l) => starts_with(lower(l), lower(part)) && l != read.list) | take(8) : [] let blank = trim(read.title) == "" let task = (r) => ({ title: r.title, date: r.date, time: r.time, list: r.list, list_new: r.list_new, priority: r.priority, repeat: r.repeat, text }) let line_up = () => { mirror.scrollLeft = input.scrollLeft } let changed = (v: str) => { said = "" if on_change != null { let r = quick_add_bar_settle_day(quick_add_bar_read(v, day, clock, names, new_lists, day_first, dropped), day, clock) on_change({ ...task(r), text: v }) } } let add = () => { if blank { said = text == "" ? "" : "Add a few words for the task itself." } else { let t = task(read) let name = read.title let when = quick_add_bar_when(read.date, read.time, day) text = "" caret = 0 dropped = [] input.value = "" mirror.scrollLeft = 0 said = when != "" ? `Added ${name}, ${when}.` : `Added ${name}.` on_add && on_add(t) } } <form class={ui_cls(["ui-quick-add", docked && "ui-quick-add-docked", class])} autocomplete="off" @mount={(me) => { input = me.querySelector(".ui-quick-add-input") mirror = me.querySelector(".ui-quick-add-mirror") if autofocus { input.focus() } let win = js("window") let v = win.visualViewport if docked && v != null { // The two systems differ: Android usually shrinks the page itself, so this reads 0; // iOS leaves the layout alone, and the keyboard is what the visible part lost. let fit = (_e = null) => { let gap = max([0, win.innerHeight - v.height - v.offsetTop]) me.style.setProperty("--ui-quick-add-kb", `${gap}px`) me.dataset.up = gap > 80 ? "1" : "" } fit() v.addEventListener("resize", fit) v.addEventListener("scroll", fit) return () => { v.removeEventListener("resize", fit) v.removeEventListener("scroll", fit) } } }} @submit={(e) => { e.preventDefault() add() }}> <div.ui-quick-add-inner> <div.ui-quick-add-row> <div.ui-quick-add-field> <div.ui-quick-add-mirror aria-hidden="true">{for p in pieces => <span class={p.kind != "" ? "ui-quick-add-mark" : null} data-kind={p.kind != "" ? p.kind : null}>{p.t}</span>}</div> <input.ui-quick-add-input type="text" value={text} placeholder={placeholder} aria-label={label} maxlength="300" enterkeyhint="done" autocapitalize="sentences" spellcheck="false" autocomplete="off" @input={(e) => { let v = e.value let units = e.target.selectionStart text = v caret = len(v) if v == "" { dropped = [] } changed(v) line_up() // The field counts in UTF-16 units and a line in characters. let before = js_new("String", v).slice(0, units) caret = len(before) js("requestAnimationFrame", (_t = null) => { line_up() }) }} @scroll={() => { line_up() }}/> </div> <button.ui-quick-add-add type="submit" disabled={text != "" && blank ? true : null} @mousedown={(e) => { e.preventDefault() }}>Add</button> </div> <div.ui-quick-add-chips> {for o in offers => <button.ui-quick-add-chip type="button" key={`o-${o}`} data-kind="list" aria-label={`Use the list ${o}`} @mousedown={(e) => { e.preventDefault() }} @click={() => { let cut = slice(text, 0, caret - len(part) - 1) let rest = slice(text, caret, len(text)) let v = `${cut}#${o} ${trim(rest)}` let spot = len(cut) + len(o) + 2 text = v caret = spot input.value = v input.focus() let units = js_new("String", slice(v, 0, spot)).length input.setSelectionRange(units, units) changed(v) }}>{o}</button>} {for c in chips => <button.ui-quick-add-chip type="button" key={`c-${c.kind}-${c.label}`} data-kind={c.kind} aria-label={`${c.label}: keep these as plain words`} @mousedown={(e) => { e.preventDefault() }} @click={() => { dropped = dropped + c.words changed(text) }}> {c.label} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg> </button>} </div> <p.ui-quick-add-say role="status">{said}</p> </div> </form>}Related
ReminderPickerToday, tomorrow, next week or custom, repeating, with a plain sentence of what was set.2 views · 7 downloadsAppTabBarBottom tabs for a phone app: badges, safe-area padding, a second press scrolls to the top, each tab keeps its place, gone while the keyboard is up; a rail on a tablet.3 views · 8 downloadsChatScreenMessages grouped by sender and day, keeping your place while older ones load, at the bottom as new ones come, and clear of the keyboard.3 views · 7 downloadsCommentSheetComments in a sheet: threads, a reply bar above the keyboard, sort, and a count that stays right. A column at the side on a tablet.2 views · 7 downloadsKeyboardHudHold a modifier on an attached keyboard to see the screen's shortcuts, found from data, conflicts reported, focus left alone.3 views · 7 downloadsNoteEditorA note with a toolbar on the keyboard, autosave, checklists and Markdown shortcuts.2 views · 7 downloads
Thread
Thread 0 posts
On QuickAddBar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.