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.
Demo
0 of 2 seats chosen
Pick seats: on_change gets them and the total.
1 of 1 seat chosen
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.qwemain.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>}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 downloadsFareCalendarA month of the cheapest fare each day: prices coloured cheap to dear, the lowest day marked, months with fares to move between, and a day to pick.8 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 downloadsFlightResultA flight in search results: departure and arrival times, the time in the air, stops and layovers, cabin and bags, and the fare with Select.9 views · 9 downloadsFlightStatusA flight's status today: on time, delayed, boarding, in the air or cancelled, with the new times beside the old, gate changes and the baggage belt.9 views · 12 downloads
Thread
Thread 0 posts
On SeatMap: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.