Travel

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.

#travel#list#checklist#forms#interactive

Demo

Preview

Lisbon, 4 nights

4 of 12 packed
Documents1/4
Clothes0/4
Toiletries1/2
TechAll packed (2)

Tick something: on_change gets the list.

Day hike

0 of 3 packed
Bag0/3
Extras0/0

Nothing here yet.

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

Thread 0 posts

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

No posts yet. The first one opens the thread.