Travel

SeatMap

A 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.

#travel#flights#forms#picker#interactive

Demo

Preview
0 of 2 seats chosen—

Pick seats: on_change gets them and the total.

1 of 1 seat chosenFree

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

Example

The code of the preview above.

demos/seat_map.qwe
component SeatMapDemoLive() {    state note = "Pick seats: on_change gets them and the total."    <div.demo-stack>        <SeatMap id="seat-map-demo" name="seat" rows={16} skip_rows={[13]} layout="ABC DEF" passengers={["Ada Lovelace", "Charles Babbage"]}                 taken={["1A", "1B", "1E", "2C", "2D", "3A", "4F", "5B", "5C", "6E", "8A", "8D", "9C", "11B", "12F", "14A", "15D", "16C"]}                 exit_rows={[11, 12]} extra_rows={[1]} price={12.00m} extra_price={35.00m}                 on_change={(seats, total) => { note = `on_change: ${join(seats | map((s) => s ?? "none"), ", ")}; ${total} in all.` }}/>        <p.demo-note id="seat-map-demo-note">{note}</p>    </div>}view SeatMapDemo() =    <div class="demo-row" style="align-items: start; gap: 32px">        <SeatMapDemoLive/>        <SeatMap id="seat-map-demo-small" name="hop" rows={6} layout="AC DF" taken={["2A", "4D"]} price={0} front_rows={2} front_price={9}                 currency="EUR" locale="de-DE" value={["3F"]} label="Seats, ATR 72"/>    </div>

Source

ui/seat_map.qwe
// SeatMap: a plane's cabin seen from above, to pick a seat for each passenger: rows of// seats with the aisles between, the window, aisle and middle seats told apart, seats// already taken, rows with extra legroom and the exit rows marked, and a price for// each kind of seat. The passengers are tabs above it: a seat clicked goes to the one// chosen, who then hands over to the next without a seat. The arrow keys move around// the cabin (Home and End along a row) and Enter or Space takes a seat. A plain <form>// gets each passenger's seat as `name`_0, `name`_1, ...////   <SeatMap rows={24} layout="ABC DEF" taken={["1A", "1B", "3C", "7D"]} exit_rows={[12, 13]} extra_rows={[1, 2]}//            price={12.00m} extra_price={35.00m} passengers={["Ada Lovelace", "Charles Babbage"]}/>//   <SeatMap rows={6} first_row={1} layout="AC DF" price={0} passengers={["Grace Hopper"]}//            on_change={(seats, total) => { chosen = seats }}/>//// layout: the seat letters across, a space for each aisle ("ABC DEF"; "AC DF" for a// small cabin; "ABC DEFG HJK" for a wide one). rows: how many; first_row: the first// row's number (1); skip_rows: numbers not used ([13]). taken: seats no one can have// ("12A"). exit_rows, extra_rows: rows by the exits and other rows with extra legroom.// price: a standard seat (0 is "Free"); extra_price: a seat with extra legroom (an// exit row too; price by default); front_rows: how many rows at the front cost// front_price. Prices are decimals best (numbers are read as decimals). passengers:// their names ("Passenger 1"); value: the seats they start with (a list, null for// none). currency: ISO code ("USD"); locale: how amounts are written ("en-US"). name:// the fields' names ("seat"). label: what screen readers call the cabin ("Seat map").// on_change(seats, total): inside one of your components, the seats in passenger order// (null where none) and their total, after each change. id, class.import ui/theme.qwe use ui_clslet seat_map_css = css`.ui-seatmap { display: grid; gap: 14px; min-width: 0; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); font-variant-numeric: tabular-nums; }.ui-seatmap-who { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-seatmap-tab { display: grid; gap: 1px; min-width: 120px; padding: 7px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 13px/1.3 var(--ui-font); text-align: left; cursor: pointer; }.ui-seatmap-tab b { font-weight: 600; }.ui-seatmap-tab span { color: var(--ui-muted); font-size: 12px; }.ui-seatmap-tab:hover { border-color: var(--ui-strong); }.ui-seatmap-tab:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-seatmap-tab[aria-pressed="true"] { border-color: var(--ui-accent); box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-seatmap-tab[aria-pressed="true"]:focus-visible { box-shadow: inset 0 0 0 1px var(--ui-accent), var(--ui-focus); }.ui-seatmap-plane { justify-self: start; max-width: 100%; overflow-x: auto; padding: 28px 36px 22px; border: 1px solid var(--ui-border);  border-radius: 90px 90px var(--ui-radius) var(--ui-radius); background: var(--ui-surface); }.ui-seatmap-cabin { display: grid; gap: 6px; }.ui-seatmap-row { position: relative; display: flex; align-items: center; gap: 6px; }.ui-seatmap-head { color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-seatmap-head span { width: 34px; text-align: center; }.ui-seatmap-aisle { width: 26px; color: var(--ui-muted); font-size: 11px; font-weight: 600; text-align: center; }.ui-seatmap-exit::before, .ui-seatmap-exit::after { content: "EXIT"; position: absolute; top: 50%; color: var(--ui-danger); font-size: 9px; font-weight: 700;  letter-spacing: .04em; transform: translateY(-50%); }.ui-seatmap-exit::before { right: calc(100% + 6px); }.ui-seatmap-exit::after { left: calc(100% + 6px); }.ui-seatmap-extra { margin-top: 6px; }.ui-seatmap-seat { position: relative; display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border: 1px solid var(--ui-strong);  border-radius: 8px 8px 5px 5px; background: var(--ui-surface); color: var(--ui-fg); font: 600 11px var(--ui-font); cursor: pointer;  transition: background .12s, border-color .12s; }.ui-seatmap-seat::after { content: ""; position: absolute; left: 4px; right: 4px; bottom: 3px; height: 3px; border-radius: 2px; background: var(--ui-border); }.ui-seatmap-seat:hover:not([aria-disabled="true"]) { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-seatmap-seat:focus-visible { outline: none; box-shadow: var(--ui-focus); z-index: 1; }.ui-seatmap-xl { border-color: var(--ui-info); background: var(--ui-info-soft); }.ui-seatmap-fr { border-color: var(--ui-warning); background: var(--ui-warning-soft); }.ui-seatmap-seat[aria-disabled="true"] { border-color: var(--ui-border); background: var(--ui-subtle); color: var(--ui-muted); cursor: not-allowed; }.ui-seatmap-seat[aria-disabled="true"]::before { content: ""; width: 12px; height: 12px;  background: linear-gradient(45deg, transparent 45%, var(--ui-strong) 45% 55%, transparent 55%), linear-gradient(-45deg, transparent 45%, var(--ui-strong) 45% 55%, transparent 55%); }.ui-seatmap-seat[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-seatmap-seat[aria-pressed="true"]::after { background: color-mix(in srgb, var(--ui-accent-fg) 40%, transparent); }.ui-seatmap-now { box-shadow: 0 0 0 2px var(--ui-surface), 0 0 0 4px var(--ui-accent); }.ui-seatmap-key { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 12px; }.ui-seatmap-key li { display: inline-flex; align-items: center; gap: 6px; }.ui-seatmap-key i { width: 14px; height: 14px; border: 1px solid var(--ui-strong); border-radius: 4px 4px 3px 3px; background: var(--ui-surface); }.ui-seatmap-key .ui-seatmap-k-xl { border-color: var(--ui-info); background: var(--ui-info-soft); }.ui-seatmap-key .ui-seatmap-k-fr { border-color: var(--ui-warning); background: var(--ui-warning-soft); }.ui-seatmap-key .ui-seatmap-k-taken { border-color: var(--ui-border); background: var(--ui-subtle); }.ui-seatmap-key .ui-seatmap-k-mine { border-color: var(--ui-accent); background: var(--ui-accent); }.ui-seatmap-sum { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; padding: 12px 14px;  border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-seatmap-sum output { font-size: 18px; font-weight: 700; }@media (prefers-reduced-motion: reduce) { .ui-seatmap-seat { transition: none; } }`// An amount in its currency; "Free" for nothing.fn seat_map_money(x: dec, currency: str, locale: str) -> str {    if x == 0 { return "Free" }    let m = money.of(x, currency, "half_up")    type_of(m) == "error" ? `${format_number(round(x, 2, "half_up"), locale, 2)} ${currency}` : money.format(m, locale)}// Where a seat is in its block: "window", "aisle" or "middle".fn seat_map_side(blocks: list, letter: str) -> str {    let i = index_of(blocks, blocks | find((b) => contains(b, letter)))    let b = blocks[i]    let at = index_of(b, letter)    let n = len(b)    if (i == 0 && at == 0) || (i == len(blocks) - 1 && at == n - 1) { return "window" }    if (at == 0 && i > 0) || (at == n - 1 && i < len(blocks) - 1) { return "aisle" }    "middle"}// A passenger's initials, for their seat: "AL" for Ada Lovelace, "1" for a number.fn seat_map_initials(name: str, i: int) -> str {    let words = split(trim(name), " ") | filter((w) => w != "")    len(words) == 0 || re.test(name, "^Passenger \\d+$") ? str(i + 1) : upper(join(words | take(2) | map((w) => slice(w, 0, 1)), ""))}component SeatMap(layout: str = "ABC DEF", rows: int = 20, first_row: int = 1, skip_rows: list = [], taken: list = [], exit_rows: list = [],                  extra_rows: list = [], price = 0, extra_price = null, front_rows: int = 0, front_price = null,                  passengers: list = ["Passenger 1"], value: list? = null, currency: str = "USD", locale: str = "en-US", name: str = "seat",                  label: str = "Seat map", on_change = null, id: str? = null, class: str? = null) {    style global seat_map_css    let blocks = split(trim(layout), " ") | filter((b) => b != "") | map((b) => split(b, ""))    let letters = flatten(blocks)    let numbers = range(first_row, first_row + rows + len(skip_rows)) | filter((r) => !contains(skip_rows, r)) | take(rows)    let base = `seat-map-${name}`    let fronts = front_price != null ? take(numbers, front_rows) : []    let cost_of = (seat: str?) => {        if seat == null { return 0m }        let r = int(re.replace(seat, "[A-Z]+$", "")) ?? 0        if contains(fronts, r) { return dec(front_price) }        contains(exit_rows, r) || contains(extra_rows, r) ? dec(extra_price ?? price) : dec(price)    }    let start = passengers | enumerate | map((p) => {        let v = value != null ? value[p[0]] : null        v != null && !contains(taken, v) ? v : null    })    state seats = start    state now = max([0, index_of(start, null) ?? 0])    state focus = start[0] ?? (flatten(numbers | map((r) => letters | map((l) => `${r}${l}`))) | find((s) => !contains(taken, s)))    let total = sum(seats | map((s) => cost_of(s))) + 0m    let owner = (seat: str) => index_of(seats, seat)    let changed = (next: list) => {        seats = next        on_change && on_change(next, sum(next | map((s) => cost_of(s))) + 0m)    }    let take_seat = (seat: str) => {        let who = owner(seat)        if contains(taken, seat) {            null        } else if who == now {            changed(set(seats, now, null))        } else if who != null {            now = who        } else {            let next = set(seats, now, seat)            changed(next)            let free = next | enumerate | find((p) => p[1] == null && p[0] != now)            if free != null { now = free[0] }        }    }    let move = (e, dr: int, dc: int, to_end: str?) => {        let r = int(e.target.dataset.r) ?? 0        let c = int(e.target.dataset.c) ?? 0        let nr = r + dr        let nc = to_end == "home" ? 0 : (to_end == "end" ? len(letters) - 1 : c + dc)        if nr >= 0 && nr < len(numbers) && nc >= 0 && nc < len(letters) {            e.preventDefault()            let next = e.target.closest(".ui-seatmap-cabin").querySelector(`[data-r="${nr}"][data-c="${nc}"]`)            if next != null {                focus = next.dataset.seat                next.focus()            }        }    }    let kinds = [        { key: "std", text: `Standard ${seat_map_money(dec(price), currency, locale)}` },        len(fronts) > 0 && { key: "fr", text: `Front ${len(fronts) == 1 ? `row ${fronts[0]}` : `rows ${fronts[0]} to ${fronts[-1]}`} ${seat_map_money(dec(front_price), currency, locale)}` },        (len(exit_rows) > 0 || len(extra_rows) > 0) && { key: "xl", text: `Extra legroom ${seat_map_money(dec(extra_price ?? price), currency, locale)}` },        len(taken) > 0 && { key: "taken", text: "Taken" },        { key: "mine", text: "Chosen" },    ] | filter((k) => k != false)    <div class={ui_cls(["ui-seatmap", class])} id={id}>        {len(passengers) > 1 && <ul.ui-seatmap-who aria-label="Passengers">            {for p in enumerate(passengers) => {                let i = p[0]                let s = seats[i]                <li key={i}>                    <button.ui-seatmap-tab type="button" aria-pressed={i == now ? "true" : "false"} data-passenger={i} @click={() => { now = i }}>                        <b>{p[1]}</b>{" "}<span>{s != null ? `Seat ${s} · ${seat_map_money(cost_of(s), currency, locale)}` : "No seat yet"}</span>                    </button>                </li>            }}        </ul>}        <div.ui-seatmap-plane>            <div.ui-seatmap-cabin role="group" aria-label={len(passengers) > 1 ? `${label}: choosing for ${passengers[now]}` : label}>                <div.ui-seatmap-row.ui-seatmap-head aria-hidden="true">                    {for b in enumerate(blocks) => [                        b[0] > 0 && <span.ui-seatmap-aisle></span>,                        for l in b[1] => <span>{l}</span>,                    ]}                </div>                {for rp in enumerate(numbers) => {                    let ri = rp[0]                    let r = rp[1]                    let exit = contains(exit_rows, r)                    let roomy = exit || contains(extra_rows, r)                    <div class={ui_cls(["ui-seatmap-row", exit && "ui-seatmap-exit", roomy && "ui-seatmap-extra"])} key={r} data-row={r}>                        {for b in enumerate(blocks) => [                            b[0] > 0 && <span.ui-seatmap-aisle aria-hidden="true">{b[0] == 1 ? r : ""}</span>,                            for l in b[1] => {                                let seat = `${r}${l}`                                let ci = index_of(letters, l)                                let gone = contains(taken, seat)                                let who = owner(seat)                                let cost = cost_of(seat)                                let said = join([`Seat ${seat}`, seat_map_side(blocks, l), exit ? "exit row" : (roomy ? "extra legroom" : (contains(fronts, r) ? "front row" : null)),                                                 gone ? "taken" : (who != null ? `chosen for ${passengers[who]}` : seat_map_money(cost, currency, locale))]                                    | filter((x) => x != null), ", ")                                <button class={ui_cls(["ui-seatmap-seat", contains(fronts, r) ? "ui-seatmap-fr" : (roomy && "ui-seatmap-xl"), who != null && who == now && "ui-seatmap-now"])}                                        type="button" data-seat={seat} data-r={ri} data-c={ci} aria-label={said}                                        aria-pressed={who != null ? "true" : "false"} aria-disabled={gone ? "true" : null}                                        tabindex={seat == focus ? "0" : "-1"} title={gone ? null : `${seat} · ${seat_map_money(cost, currency, locale)}`}                                        @focusin={() => { focus = seat }}                                        @click={() => {                                            focus = seat                                            take_seat(seat)                                        }}                                        @keydown={(e) => {                                            match e.key {                                                "ArrowRight" => move(e, 0, 1, null)                                                "ArrowLeft" => move(e, 0, -1, null)                                                "ArrowDown" => move(e, 1, 0, null)                                                "ArrowUp" => move(e, -1, 0, null)                                                "Home" => move(e, 0, 0, "home")                                                "End" => move(e, 0, 0, "end")                                                _ => null                                            }                                        }}>{who != null ? seat_map_initials(passengers[who], who) : ""}</button>                            },                        ]}                    </div>                }}            </div>        </div>        <ul.ui-seatmap-key aria-hidden="true">{for k in kinds => <li><i class={`ui-seatmap-k-${k.key}`}></i>{k.text}</li>}</ul>        <div.ui-seatmap-sum>            <span id={`${base}-total-label`}>{len(seats | filter((s) => s != null))} of {len(passengers)} {len(passengers) == 1 ? "seat" : "seats"} chosen</span>            <output aria-labelledby={`${base}-total-label`} aria-live="polite" data-amount={str(total)}>{all(seats, (x) => x == null) ? "—" : seat_map_money(total, currency, locale)}</output>        </div>        {for p in enumerate(seats) => <input type="hidden" key={p[0]} name={`${name}_${p[0]}`} value={p[1] ?? ""}/>}    </div>}
Thread
87Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.