PackingList
What to pack, in groups: a checkbox for each thing with how many, a bar of how much is packed, things to add or take off, packed ones hidden and all unpacked again.
Demo
Lisbon, 4 nights
4 of 12 packedTick something: on_change gets the list.
Day hike
0 of 3 packedGet 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/packing_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/packing_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/packing_list.qwe
component PackingListDemoLive() { state note = "Tick something: on_change gets the list." <div.demo-stack> <PackingList id="packing-list-demo" name="packed" title="Lisbon, 4 nights" groups={[ { name: "Documents", items: ["Passport", "Boarding passes", { label: "Euros", packed: true }, "Travel insurance card"] }, { name: "Clothes", items: [{ label: "T-shirts", qty: 4 }, { label: "Socks", qty: 5 }, "Light jacket", "Walking shoes"] }, { name: "Toiletries", items: ["Toothbrush", { label: "Sunscreen", packed: true }] }, { name: "Tech", items: [{ label: "Phone charger", packed: true }, { label: "Plug adapter (type F)", packed: true }] }]} on_change={(items) => { let n = len(items | filter((i) => i.packed)) note = `on_change: ${n} of ${len(items)} packed.` }}/> <p.demo-note id="packing-list-demo-note">{note}</p> </div>}view PackingListDemo() = <div class="demo-row" style="align-items: start"> <PackingListDemoLive/> <PackingList id="packing-list-demo-day" name="day" title="Day hike" add={false} groups={[{ name: "Bag", items: ["Water", "Snacks", "Rain jacket"] }, { name: "Extras", items: [] }]}/> </div>Source
ui/packing_list.qwe
// PackingList: what to pack for a trip, in groups (clothes, toiletries, documents,// tech), each thing a checkbox to tick as it goes in the bag, with how many when more// than one. A bar shows how much is packed; each group counts its own and says when it// is done. Things can be added to a group (the same thing again adds one to its count)// and taken off the list, packed ones hidden, and everything unpacked again. A plain// <form> gets the things packed as `name`, one value each.//// <PackingList title="Lisbon, 4 nights" groups={[// { name: "Documents", items: ["Passport", "Boarding passes", { label: "Euros", packed: true }] },// { name: "Clothes", items: [{ label: "T-shirts", qty: 4 }, { label: "Socks", qty: 5 }, "Light jacket"] }]}/>//// groups: a list of { name, items }, each item a string or { label, qty? (1), packed?// (false) }. title: the heading ("Packing list"). name: what a form gets the packed// things as ("packed"). add: false leaves out adding things. on_change(items): inside// one of your components, given every thing as { group, label, qty, packed } after// each change. id, class.import ui/theme.qwe use ui_clslet packing_list_css = css`.ui-packlist { display: grid; gap: 14px; min-width: 0; max-width: 560px; padding: 18px 20px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-packlist-head { display: grid; gap: 8px; }.ui-packlist-titlerow { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; }.ui-packlist .ui-packlist-title { margin: 0; font-size: 17px; font-weight: 650; }.ui-packlist-count { color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-packlist-bar { height: 8px; overflow: hidden; border-radius: 999px; background: var(--ui-subtle); box-shadow: inset 0 0 0 1px var(--ui-border); }.ui-packlist-bar span { display: block; height: 100%; border-radius: 999px; background: var(--ui-accent); transition: width .25s; }.ui-packlist-done .ui-packlist-bar span { background: var(--ui-success); }.ui-packlist-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; font-size: 13px; }.ui-packlist-hide { display: inline-flex; align-items: center; gap: 6px; color: var(--ui-muted); cursor: pointer; }.ui-packlist-hide input { width: 15px; height: 15px; margin: 0; accent-color: var(--ui-accent); }.ui-packlist-reset { padding: 0; border: 0; background: none; color: var(--ui-accent); font: 500 13px var(--ui-font); cursor: pointer; }.ui-packlist-reset:hover:not(:disabled) { text-decoration: underline; }.ui-packlist-reset:focus-visible { outline: none; border-radius: 2px; box-shadow: var(--ui-focus); }.ui-packlist-reset:disabled { color: var(--ui-muted); opacity: .6; cursor: not-allowed; }.ui-packlist-group { min-width: 0; margin: 0; padding: 0; border: 0; }.ui-packlist-group legend { display: flex; align-items: center; gap: 8px; width: 100%; padding: 0 0 6px; border-bottom: 1px solid var(--ui-border); color: var(--ui-muted); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }.ui-packlist-tally { margin-left: auto; font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; font-weight: 600; }.ui-packlist-full .ui-packlist-tally { color: var(--ui-success); }.ui-packlist-items { display: grid; margin: 0; padding: 4px 0 0; list-style: none; }.ui-packlist-item { display: flex; align-items: center; gap: 10px; min-height: 36px; padding: 2px 4px; border-radius: var(--ui-radius-sm); }.ui-packlist-item:hover { background: var(--ui-subtle); }.ui-packlist-item label { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 0; cursor: pointer; }.ui-packlist-item input { flex: none; width: 18px; height: 18px; margin: 0; accent-color: var(--ui-success); cursor: pointer; }.ui-packlist-item input:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-packlist-label { min-width: 0; overflow-wrap: anywhere; }.ui-packlist-qty { flex: none; padding: 0 6px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-packlist-packed .ui-packlist-label { color: var(--ui-muted); text-decoration: line-through; }.ui-packlist-remove { flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 50%; background: none; color: var(--ui-muted); cursor: pointer; opacity: 0; }.ui-packlist-remove svg { width: 14px; height: 14px; }.ui-packlist-item:hover .ui-packlist-remove, .ui-packlist-remove:focus-visible { opacity: 1; }.ui-packlist-remove:hover { color: var(--ui-danger); background: var(--ui-danger-soft); }.ui-packlist-remove:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: none) { .ui-packlist-remove { opacity: 1; } }.ui-packlist-empty { margin: 0; padding: 6px 4px; color: var(--ui-muted); font-size: 13px; }.ui-packlist-add { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; padding-top: 12px; border-top: 1px solid var(--ui-border); }.ui-packlist-add input, .ui-packlist-add select { min-width: 0; height: 36px; padding: 0 10px; 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); }.ui-packlist-add input:focus, .ui-packlist-add select:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-packlist-add button { height: 36px; padding: 0 14px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 14px var(--ui-font); cursor: pointer; }.ui-packlist-add button:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (max-width: 460px) { .ui-packlist-add { grid-template-columns: 1fr 1fr; } .ui-packlist-add input { grid-column: 1 / -1; } }@media (prefers-reduced-motion: reduce) { .ui-packlist-bar span { transition: none; } }`// The things of the groups as one list of { id, group, label, qty, packed }.fn packing_list_items(groups: list) -> list { let out = [] for g in groups { for it in (g.items ?? []) { let m = type_of(it) == "map" ? it : { label: str(it) } out = out | push({ id: len(out) + 1, group: g.name, label: m.label, qty: m.qty ?? 1, packed: m.packed == true }) } } out}component PackingList(groups: list, title: str = "Packing list", name: str = "packed", add: bool = true, on_change = null, id: str? = null, class: str? = null) { style global packing_list_css state items = packing_list_items(groups) state next_id = len(packing_list_items(groups)) + 1 state hide = false state draft = "" state into = groups[0].name ?? "" state said = "" let base = `packing-list-${name}` let total = len(items) let packed = len(items | filter((i) => i.packed)) let pct = total > 0 ? floor(packed * 100 / total) : 0 let changed = (next: list) => { items = next on_change && on_change(next | map((i) => { group: i.group, label: i.label, qty: i.qty, packed: i.packed })) } let toggle = (item_id: int, on: bool) => { changed(items | map((i) => i.id == item_id ? merge(i, { packed: on }) : i)) } let drop = (it) => { changed(items | filter((i) => i.id != it.id)) said = `Took ${it.label} off the list` } let put = () => { let label = trim(draft) if label != "" { let same = items | find((i) => i.group == into && lower(i.label) == lower(label)) if same != null { changed(items | map((i) => i.id == same.id ? merge(i, { qty: i.qty + 1, packed: false }) : i)) said = `${same.label} in ${into}: now ${same.qty + 1}` } else { changed(items | push({ id: next_id, group: into, label, qty: 1, packed: false })) next_id += 1 said = `Added ${label} to ${into}` } draft = "" } } <section class={ui_cls(["ui-packlist", total > 0 && packed == total && "ui-packlist-done", class])} id={id} aria-labelledby={`${base}-title`}> <div.ui-packlist-head> <div.ui-packlist-titlerow> <h3.ui-packlist-title id={`${base}-title`}>{title}</h3> <span.ui-packlist-count id={`${base}-count`}>{packed} of {total} packed</span> </div> <div.ui-packlist-bar role="progressbar" aria-labelledby={`${base}-count`} aria-valuemin="0" aria-valuemax={total} aria-valuenow={packed}> <span style={`width: ${pct}%`}></span> </div> <div.ui-packlist-tools> <label.ui-packlist-hide><input type="checkbox" checked={hide} @change={(e) => { hide = e.checked }}/>Hide packed</label> <button.ui-packlist-reset type="button" disabled={packed == 0} @click={() => { changed(items | map((i) => merge(i, { packed: false }))) said = "Everything unpacked" }}>Unpack all</button> </div> </div> {for g in groups => { let mine = items | filter((i) => i.group == g.name) let done = len(mine | filter((i) => i.packed)) let shown = hide ? mine | filter((i) => !i.packed) : mine let full = len(mine) > 0 && done == len(mine) <fieldset class={ui_cls(["ui-packlist-group", full && "ui-packlist-full"])} key={g.name} data-group={g.name}> <legend>{g.name}<span.ui-packlist-tally>{full ? ["All packed", <span.ui-sr> ({done})</span>] : `${done}/${len(mine)}`}</span></legend> {len(shown) == 0 ? <p.ui-packlist-empty>{len(mine) == 0 ? "Nothing here yet." : "All packed."}</p> : <ul.ui-packlist-items> {for it in shown => <li class={ui_cls(["ui-packlist-item", it.packed && "ui-packlist-packed"])} key={it.id} data-item={it.label}> <label> <input type="checkbox" name={name} value={it.label} checked={it.packed} @change={(e) => toggle(it.id, e.checked)}/> <span.ui-packlist-label>{it.label}</span> {it.qty > 1 && <span.ui-packlist-qty><span.ui-sr>, </span>×{it.qty}</span>} </label> <button.ui-packlist-remove type="button" aria-label={`Take ${it.label} off the list`} @click={() => drop(it)}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg> </button> </li>} </ul>} </fieldset> }} {add && <div.ui-packlist-add role="group" aria-label="Add to the list"> <input type="text" value={draft} placeholder="Add something to pack" aria-label="Thing to pack" autocomplete="off" @input={(e) => { draft = e.value }} @keydown={(e) => { if e.key == "Enter" { e.preventDefault(); put() } }}/> <select aria-label="Group" @change={(e) => { into = e.value }}> {for g in groups => <option value={g.name} selected={g.name == into}>{g.name}</option>} </select> <button type="button" @click={() => put()}>Add</button> </div>} <span.ui-sr aria-live="polite">{said}</span> </section>}Related
BaggageSelectorChecked bags for each passenger with counters, what the first, second and third bag cost, the cabin allowance, the total and the saving against the airport.9 views · 8 downloadsEntryRequirementsVisa and entry rules for a passport: visa-free, travel authorisation, eVisa or visa required, the stay allowed, passport validity, documents and health rules.9 views · 8 downloadsFareOptionsFare families side by side, Basic to Flex: the price a person and over the cheapest, what each includes, costs extra or leaves out, and the total for everyone.8 views · 11 downloadsFlightSearchA flight search form: return or one way, from and to with a swap button and airport suggestions, dates, travellers and cabin, checked before it searches.8 views · 7 downloadsRoomPickerA hotel's rooms with their rates (non-refundable, free cancellation, breakfast) and a counter for each, rooms left, the stay's total, who it sleeps and Reserve.9 views · 9 downloadsSeatMapA plane's cabin to pick a seat for each passenger: aisles, window and aisle seats, taken seats, exit and extra legroom rows, a price per seat and the total.8 views · 7 downloads
Thread
Thread 0 posts
On PackingList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.