SeatPickerSheet
Pick seats in a sheet by zoom and tap, with fares, who sits beside you, seats found together and accessible seats.
Demo
Confirmed: , total nothing yet. Changes 0, closed 0.
Get it
It needs theme.qwe, as every component does, and button.qwe; 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/button.qwe$ curl -O https://quillolang.org/components/ui/seat_picker_sheet.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/seat_picker_sheet.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/seat_picker_sheet.qwe
let seat_picker_sheet_demo_fares = [ { name: "Front", first: 1, last: 4, price: 42.50m }, { name: "Exit row", first: 12, last: 12, price: 55.00m }, { name: "Back", first: 18, last: 30, price: 9.99m },]component SeatPickerSheetDemoLive() { state seats = [] state sum_of = "nothing yet" state changes = 0 state closed = 0 <div.demo-stack> <div.demo-row> <SeatPickerSheet trigger="Choose seats" title="Lisbon to Dubai" count={2} rows={24} layout="ABC DEFG HJK" skip_rows={[13]} taken={["1A", "1B", "1C", "2D", "5A", "5B", "6B", "6C", "14C", "14D", "15B", "18H"]} exit_rows={[12]} access_seats={["3C", "3D", "4C"]} base={18.00m} fares={seat_picker_sheet_demo_fares} currency="EUR" locale="en-IE" name="seats" on_change={(_s, _t) => { changes += 1 }} on_confirm={(s, t) => { seats = s; sum_of = str(t) }} on_close={() => { closed += 1 }}/> </div> <p.demo-note>Confirmed: <span class="seat-picker-sheet-demo-seats">{join(seats, ", ")}</span>, total <span class="seat-picker-sheet-demo-total">{sum_of}</span>. Changes <span class="seat-picker-sheet-demo-changes">{changes}</span>, closed <span class="seat-picker-sheet-demo-closed">{closed}</span>.</p> </div>}view SeatPickerSheetDemo() = <SeatPickerSheetDemoLive/>Source
ui/seat_picker_sheet.qwe
// SeatPickerSheet: picks seats in a sheet that rises over the page, on a cabin to be pinched,// dragged and tapped. A wide cabin does not fit a phone at a size a finger can press, so// the first tap on a small seat zooms in on it and the next one takes it. It shows each seat's// fare, the total, what is beside the seat just taken, and whether a party sits together, and it// finds a run of seats together with one press. Someone who needs an accessible seat// says so, and those seats stand out while exit rows, which need a person able to help in// an emergency, are closed. On a tablet the sheet stands in the middle, the cabin beside the fares.//// import ui/seat_picker_sheet.qwe use SeatPickerSheet//// <SeatPickerSheet trigger="Choose seats" count={2} rows={30} layout="ABC DEF" skip_rows={[13]} taken={["1A", "12C"]}// exit_rows={[14, 15]} access_seats={["3A", "3B"]} base={18.00m}// fares={[{ name: "Front", first: 1, last: 5, price: 42.00m }, { name: "Exit", first: 14, last: 15, price: 55.00m }]}// on_confirm={(seats, total) => { chosen = seats }}/>//// trigger: the words of a button that opens it. Inside one of your components, open it from// your own state instead: open={...} and on_close(). title: its heading ("Choose your seats").// count: how many seats to take (1); a press past that gives up the oldest taken. layout: the seat// letters across, a space for each aisle ("ABC DEF"). rows: how many; first_row (1); skip_rows:// numbers not used. taken: seats no one can have. exit_rows. access_seats: seats with a// movable armrest or the room for a person with a disability. base: the fare of any seat in no// band (0 is "Included"). fares: bands of rows, { name, first, last, price }, the first that holds// a row counting; prices are decimals. currency ("USD"), locale ("en-US"). value: the seats it// starts with. name: a hidden field for a plain <form>, the seats with commas between.// on_change(seats, total): after each change. on_confirm(seats, total): the button at its foot.// Both inside one of your components; total is a decimal. on_close(): it has closed.//// The equal of the gestures: + and - zoom, arrow keys walk the seats, Enter or Space takes one.// Escape, Close and pulling the handle down close it; "Find seats together" does the search.//// seat_picker_sheet_cabin, seat_picker_sheet_toggle, seat_picker_sheet_neighbours,// seat_picker_sheet_together, seat_picker_sheet_suggest and seat_picker_sheet_total are the working.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonlet seat_picker_sheet_css = css`html:has(.ui-seatps-dlg[open]) { overflow: hidden; }.ui-seatps-dlg { box-sizing: border-box; position: fixed; inset: auto 0 0 0; width: 100%; max-width: 100vw; height: calc(100dvh - max(12px, env(safe-area-inset-top))); max-height: none; margin: 0; padding: 0; overflow: hidden; border: 1px solid var(--ui-border); border-bottom: 0; border-radius: calc(var(--ui-radius) + 8px) calc(var(--ui-radius) + 8px) 0 0; background: var(--ui-surface); color: var(--ui-fg); font: 15px/1.4 var(--ui-font); overscroll-behavior: contain; transition: transform .28s cubic-bezier(.22, 1, .36, 1);}.ui-seatps-dlg[open] { display: flex; flex-direction: column; animation: ui-seatps-up .28s cubic-bezier(.22, 1, .36, 1); }@keyframes ui-seatps-up { from { transform: translateY(100%); } }.ui-seatps-dlg::backdrop { background: var(--ui-overlay); }.ui-seatps-grip { flex: none; display: grid; place-items: center; width: 100%; height: 44px; padding: 0; border: 0; background: transparent; cursor: grab; touch-action: none; -webkit-touch-callout: none; user-select: none; }.ui-seatps-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-seatps-grip:focus-visible { outline: none; }.ui-seatps-grip:focus-visible span { background: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-seatps-head { flex: none; display: flex; align-items: center; gap: 8px; margin-top: -8px; padding: 0 8px 6px 16px; touch-action: none; user-select: none; }.ui-seatps-head h2 { flex: 1; min-width: 0; margin: 0; font-size: 17px; font-weight: 600; }.ui-seatps-icon { box-sizing: border-box; display: grid; place-items: center; flex: none; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 600 20px/1 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent; }.ui-seatps-icon:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-seatps-icon svg { width: 18px; height: 18px; }.ui-seatps-close { border-color: transparent; background: transparent; color: var(--ui-muted); }.ui-seatps-in { flex: 1; min-height: 0; display: flex; flex-direction: column; container-type: inline-size; }.ui-seatps-main { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }/* Clip, not hidden: a hidden box scrolls when a seat in it takes focus, and the pan is ours. */.ui-seatps-view { position: relative; flex: 1; min-height: 0; overflow: clip; touch-action: none; -webkit-touch-callout: none; user-select: none; background: var(--ui-subtle); }.ui-seatps-plane { position: absolute; left: 0; top: 0; display: grid; gap: 4px; width: max-content; padding: 10px 8px 24px; transform-origin: 0 0; will-change: transform; }.ui-seatps-plane[data-glide] { transition: transform .3s cubic-bezier(.22, 1, .36, 1); }.ui-seatps-row { display: flex; gap: 4px; align-items: center; }.ui-seatps-num { width: 28px; color: var(--ui-muted); font-size: 12px; font-weight: 600; text-align: center; }.ui-seatps-gap { width: 24px; }.ui-seatps-exit { display: flex; justify-content: space-between; padding: 0 36px; color: var(--ui-danger); font-size: 11px; font-weight: 700; letter-spacing: .08em; }.ui-seatps-seat { box-sizing: border-box; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ui-border); border-radius: 10px 10px 6px 6px; background: var(--f, var(--ui-surface)); color: var(--ui-fg); font: 600 13px/1 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-seatps-seat[data-band="1"] { --f: color-mix(in srgb, var(--ui-accent) 16%, var(--ui-surface)); }.ui-seatps-seat[data-band="2"] { --f: color-mix(in srgb, var(--ui-warning) 22%, var(--ui-surface)); }.ui-seatps-seat[data-band="3"] { --f: color-mix(in srgb, var(--ui-info) 20%, var(--ui-surface)); }.ui-seatps-seat[aria-disabled="true"] { background: var(--ui-strong); color: var(--ui-muted); opacity: .5; cursor: default; }.ui-seatps-seat[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); opacity: 1; }.ui-seatps-seat[data-access] { box-shadow: inset 0 0 0 2px var(--ui-success); }.ui-seatps-seat:focus-visible { outline: 3px solid var(--ui-accent); outline-offset: 1px; }.ui-seatps-foot { flex: none; display: grid; gap: 8px; padding: 8px 12px max(10px, env(safe-area-inset-bottom)); border-top: 1px solid var(--ui-border); }.ui-seatps-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-seatps-legend li { display: flex; align-items: center; gap: 5px; }.ui-seatps-swatch { width: 12px; height: 12px; border: 1px solid var(--ui-border); border-radius: 3px; background: var(--f, var(--ui-surface)); }.ui-seatps-swatch[data-band="1"] { --f: color-mix(in srgb, var(--ui-accent) 16%, var(--ui-surface)); }.ui-seatps-swatch[data-band="2"] { --f: color-mix(in srgb, var(--ui-warning) 22%, var(--ui-surface)); }.ui-seatps-swatch[data-band="3"] { --f: color-mix(in srgb, var(--ui-info) 20%, var(--ui-surface)); }.ui-seatps-say { min-height: 40px; margin: 0; font-size: 14px; }.ui-seatps-note { color: var(--ui-muted); display: block; }.ui-seatps-tools { display: flex; flex-wrap: wrap; gap: 8px; }.ui-seatps-chip { box-sizing: border-box; min-height: 44px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-seatps-chip[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-seatps-chip:focus-visible { outline: none; box-shadow: var(--ui-focus); }/* With the room, a card in the middle with the cabin beside what it costs. */@media (min-width: 700px) { .ui-seatps-dlg { inset: 0; width: min(92vw, 860px); height: min(88dvh, 760px); margin: auto; border-bottom: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 8px); } .ui-seatps-dlg[open] { animation: none; } .ui-seatps-grip { display: none; } .ui-seatps-head { margin-top: 0; padding-top: 10px; touch-action: auto; }}.ui-seatps-in-row { flex: 1; min-height: 0; display: flex; flex-direction: column; }@container (min-width: 620px) { .ui-seatps-in-row { flex-direction: row; } .ui-seatps-foot { flex: 0 0 300px; align-content: start; border-top: 0; border-left: 1px solid var(--ui-border); }}@media (hover: hover) { .ui-seatps-seat:not([aria-disabled="true"]):hover { border-color: var(--ui-accent); } }@media (prefers-reduced-motion: reduce) { .ui-seatps-dlg, .ui-seatps-plane[data-glide] { transition: none; } .ui-seatps-dlg[open] { animation: none; } }`// A cabin's seat in the plane's own pixels; the plane is scaled, so this is its size at zoom 1.let seat_picker_sheet_size = 44fn seat_picker_sheet_blocks(layout) = (type_of(layout) == "str" ? split(trim(layout), " ") : []) | filter((b) => b != "") | map((b) => split(b, ""))// Where a seat is in its block: "window", "aisle" or "middle".fn seat_picker_sheet_side(i: int, count: int, at: int, n: int) { if (i == 0 && at == 0) || (i == count - 1 && at == n - 1) { return "window" } if (at == 0 && i > 0) || (at == n - 1 && i < count - 1) { return "aisle" } "middle"}// A fare from data as a decimal. What cannot be read counts as nothing, and does not stop the page.fn seat_picker_sheet_dec(x) { let t = type_of(x) if t == "dec" || t == "int" || (t == "float" && math.isfinite(x)) { return dec(x) } t == "str" && re.test(x, "^-?[0-9]{1,12}(\\.[0-9]{1,6})?$") ? dec(x) : dec(0)}// The band of fares that holds a row: { band (1 up, 0 for none), name, price }.fn seat_picker_sheet_fare(row: int, fares, base) { let list = type_of(fares) == "list" ? fares : [] let found = find(enumerate(list), (p) => type_of(p[1]) == "map" && row >= (p[1].first ?? 0) && row <= (p[1].last ?? 0)) found == null ? { band: 0, name: "Standard", price: seat_picker_sheet_dec(base) } : { band: found[0] + 1, name: str(found[1].name ?? "Seat"), price: seat_picker_sheet_dec(found[1].price) }}// Every row: { row, exit, cells }, each cell { aisle } or a seat { id, letter, side, block, at,// taken, exit, access, band, price, fare }. All that does not depend on the person, worked once.// A cabin is capped at 120 rows, as a page of ten thousand seats is no use to anyone.fn seat_picker_sheet_cabin(layout, rows, first_row, skip_rows, taken, exit_rows, access_seats, fares, base) { let blocks = seat_picker_sheet_blocks(layout) let count = len(blocks) let how_many = max([0, min([type_of(rows) == "int" ? rows : 0, 120])]) let skip = type_of(skip_rows) == "list" ? skip_rows : [] let gone = type_of(taken) == "list" ? taken : [] let exits = type_of(exit_rows) == "list" ? exit_rows : [] let access = type_of(access_seats) == "list" ? access_seats : [] let numbers = range(first_row, first_row + how_many + len(skip)) | filter((n) => !contains(skip, n)) take(numbers, how_many) | map((n) => { let fare = seat_picker_sheet_fare(n, fares, base) let is_exit = contains(exits, n) let cells = flatten(for pair in enumerate(blocks) => { let b = pair[0] let letters = pair[1] let seats = for s in enumerate(letters) => { let id = `${n}${s[1]}` { id: id, letter: s[1], side: seat_picker_sheet_side(b, count, s[0], len(letters)), block: b, at: s[0], taken: contains(gone, id), exit: is_exit, access: contains(access, id), band: fare.band, price: fare.price, fare: fare.name, row: n } } b == 0 ? seats : [{ aisle: true, id: `${n}-${b}` }] + seats }) { row: n, exit: is_exit, cells: cells } })}fn seat_picker_sheet_seats(cabin) = flatten(cabin | map((r) => r.cells | filter((c) => c.aisle != true)))fn seat_picker_sheet_find(cabin, id) = find(seat_picker_sheet_seats(cabin), (s) => s.id == id)// A seat a person can have, given what they asked for.fn seat_picker_sheet_open(s, need: bool) = s != null && !s.taken && !(need && s.exit)// The seats after a press: a seat held is given up; one more than `count` gives up the oldest.fn seat_picker_sheet_toggle(chosen, id: str, count: int) { if contains(chosen, id) { return chosen | filter((s) => s != id) } let more = chosen + [id] len(more) > max([count, 1]) ? slice(more, len(more) - max([count, 1]), len(more)) : more}// The sum of the seats' fares. Decimals, so it comes to the cent.fn seat_picker_sheet_total(cabin, chosen) { let seats = seat_picker_sheet_seats(cabin) reduce(chosen, dec(0), (sum_so_far, id) => { let s = find(seats, (x) => x.id == id) s == null ? sum_so_far : sum_so_far + s.price })}// The seats either side of one in its block, across no aisle: { left, right }, each "free",// "taken", "yours", or null at the wall or the aisle.fn seat_picker_sheet_neighbours(cabin, id, chosen) { let row = find(cabin, (r) => len(r.cells | filter((c) => c.id == id)) > 0) if row == null { return { left: null, right: null } } let cells = row.cells let i = index_of(cells | map((c) => c.id), id) let say = (c) => c == null || c.aisle == true ? null : (contains(chosen, c.id) ? "yours" : (c.taken ? "taken" : "free")) { left: i > 0 ? say(cells[i - 1]) : null, right: i + 1 < len(cells) ? say(cells[i + 1]) : null }}// How a party sits: "alone" (one seat or none), "together" (a run in a block), "aisle" (a run but// for the aisle), or "apart".fn seat_picker_sheet_together(cabin, chosen) { if len(chosen) < 2 { return "alone" } let seats = seat_picker_sheet_seats(cabin) let held = chosen | map((id) => find(seats, (s) => s.id == id)) | filter((s) => s != null) if len(held) < 2 || len(unique(held | map((s) => s.row))) > 1 { return "apart" } let row = find(cabin, (r) => r.row == held[0].row) let order = row.cells | filter((c) => c.aisle != true) | map((c) => c.id) let spots = held | map((s) => index_of(order, s.id)) | sort() if spots[len(spots) - 1] - spots[0] != len(spots) - 1 { return "apart" } len(unique(held | map((s) => s.block))) == 1 ? "together" : "aisle"}// The cheapest run of `count` free seats in one block, nearest the front: a list of ids, or [].fn seat_picker_sheet_suggest(cabin, count: int, need: bool) { let runs = flatten(cabin | map((r) => { let seats = r.cells | filter((c) => c.aisle != true) let blocks = unique(seats | map((s) => s.block)) flatten(blocks | map((b) => { let inside = seats | filter((s) => s.block == b) let tries = range(0, max([0, len(inside) - count + 1])) tries | map((i) => slice(inside, i, i + count)) | filter((run) => all(run, (s) => seat_picker_sheet_open(s, need))) })) })) if len(runs) == 0 { return [] } let cheapest = runs | sort_by((run) => float(run[0].price) * 1000 + run[0].row) cheapest[0] | map((s) => s.id)}// The seat a key goes to from `id`, past gaps in the rows; null at the edge.fn seat_picker_sheet_step(cabin, id, k: str) { let here = seat_picker_sheet_find(cabin, id) if here == null { return null } let row_i = index_of(cabin | map((r) => r.row), here.row) let order = cabin[row_i].cells | filter((c) => c.aisle != true) | map((c) => c.id) let at = index_of(order, id) if k == "ArrowLeft" { return at > 0 ? order[at - 1] : null } if k == "ArrowRight" { return at + 1 < len(order) ? order[at + 1] : null } let to = k == "ArrowUp" ? row_i - 1 : row_i + 1 if to < 0 || to >= len(cabin) { return null } `${cabin[to].row}${here.letter}`}fn seat_picker_sheet_money(x, currency: str, locale: str) { if x == 0 { return "Included" } 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)}// What a seat's button says to a screen reader.fn seat_picker_sheet_label(s, currency: str, locale: str, need: bool) { let state = s.taken ? ", taken" : (need && s.exit ? ", closed: exit row" : "") `Row ${s.row}, seat ${s.letter}, ${s.side}${s.exit ? ", exit row" : ""}${s.access ? ", accessible" : ""}, ${seat_picker_sheet_money(s.price, currency, locale)}${state}`}// What is said about the last seat taken.fn seat_picker_sheet_beside(cabin, id, chosen) { let n = seat_picker_sheet_neighbours(cabin, id, chosen) let word = (v) => v == "free" ? "an empty seat" : (v == "taken" ? "someone" : "your party") let sides = [n.left, n.right] | filter((v) => v != null) if len(sides) == 0 { return "No one beside you." } if len(sides) == 1 { return `Beside you: ${word(sides[0])}.` } sides[0] == sides[1] ? `Beside you: ${word(sides[0])} on each side.` : `Beside you: ${word(sides[0])} and ${word(sides[1])}.`}fn seat_picker_sheet_party(way: str) = match way { "together" => "Your party sits together." "aisle" => "Your party sits together, across the aisle." "apart" => "Your party is not together." _ => ""}// Pan and zoom held to what can be seen: a plane narrower than the view stands in its middle.fn seat_picker_sheet_clamp(x: num, y: num, z: num, plane_w: num, plane_h: num, view_w: num, view_h: num) { let w = plane_w * z let h = plane_h * z { x: w <= view_w ? (view_w - w) / 2 : max([view_w - w, min([0, x])]), y: h <= view_h ? 0 : max([view_h - h, min([0, y])]), z: z }}// The zoom that fits the width, never above life size.fn seat_picker_sheet_fit(plane_w: num, view_w: num) = plane_w <= 0 ? 1 : min([1, view_w / plane_w])// What a pinch from (z0, x0, y0) with fingers around (fx0, fy0) gives now that they are `ratio`// as far apart, around (fx, fy): the point under the fingers stays under them.fn seat_picker_sheet_pinch(z0: num, x0: num, y0: num, fx0: num, fy0: num, ratio: num, fx: num, fy: num, least: num, most: num) { let z = max([least, min([most, z0 * ratio])]) let k = z / z0 { z: z, x: fx - (fx0 - x0) * k, y: fy - (fy0 - y0) * k }}// A finger as it goes: its last few places, to tell how fast it was going.fn seat_picker_sheet_samples(s, at: num, x: num, y: num) = slice(s, -5, len(s)) + [[at, x, y]]// { x, y } in pixels a millisecond over the last tenth of a second; nothing if it stopped first.fn seat_picker_sheet_speed(s, at: num) { if len(s) < 2 { return { x: 0, y: 0 } } let last = s[len(s) - 1] if at - last[0] > 120 { return { x: 0, y: 0 } } let recent = s | filter((p) => last[0] - p[0] <= 100) let first = len(recent) > 1 ? recent[0] : s[len(s) - 2] let took = last[0] - first[0] took <= 0 ? { x: 0, y: 0 } : { x: (last[1] - first[1]) / took, y: (last[2] - first[2]) / took }}// Whether a pull on the handle closes it: far enough, or fast enough, and going down.fn seat_picker_sheet_dismissed(dy: num, speed: num, height: num) = speed >= 0.5 && dy > 12 || (dy >= height * 0.28 && speed > -0.2)component SeatPickerSheet(trigger: str? = null, open: bool = false, title: str = "Choose your seats", count: int = 1, layout: str = "ABC DEF", rows: int = 30, first_row: int = 1, skip_rows = [], taken = [], exit_rows = [], access_seats = [], base = 0, fares = [], currency: str = "USD", locale: str = "en-US", value = [], name: str? = null, on_change = null, on_confirm = null, on_close = null, class: str? = null) { style global seat_picker_sheet_css let cabin = seat_picker_sheet_cabin(layout, rows, first_row, skip_rows, taken, exit_rows, access_seats, fares, base) let bands = (type_of(fares) == "list" ? fares : []) | filter((f) => type_of(f) == "map") state dlg = null state view_el = null state plane = null state started = false // the cabin is made on the first opening: hundreds of buttons nobody may ask for state chosen = type_of(value) == "list" ? value : [] state last_seat = null state need = false state cursor = "" // the seat Tab lands on; arrows move it state say_more = "" // Pan and zoom. Kept out of the markup, so moving does not touch the page: they go straight // to the plane's transform, and only `shown_z` is read by anything, at rest. state x = 0 state y = 0 state z = 1 state shown_z = 1 state least = 1 // Fingers on the cabin. state downs = [] // [{ id, x, y }] state samples = [] state panned = false state pinch_from = null state from_x = 0 state from_y = 0 // The handle. state grip_y = null state grip_dy = 0 state grip_samples = [] let total = seat_picker_sheet_total(cabin, chosen) let paint = (glide: bool) => { if glide { plane.setAttribute("data-glide", "") } else { plane.removeAttribute("data-glide") } plane.style.transform = `translate(${round(x, 2)}px, ${round(y, 2)}px) scale(${round(z, 4)})` } let settle = (glide: bool) => { let c = seat_picker_sheet_clamp(x, y, z, plane.offsetWidth, plane.offsetHeight, view_el.clientWidth, view_el.clientHeight) x = c.x y = c.y shown_z = z paint(glide) } let fit = () => { least = seat_picker_sheet_fit(plane.offsetWidth, view_el.clientWidth) z = max([z, least]) settle(false) } let zoom_about = (to: num, fx: num, fy: num) => { let k = to / z x = fx - (fx - x) * k y = fy - (fy - y) * k z = to settle(true) } let zoom_by = (by: num) => { zoom_about(max([least, min([2.5, z * by])]), view_el.clientWidth / 2, view_el.clientHeight / 2) } // A seat brought into sight, in the plane's own pixels, from where it is. let reveal = (el) => { let left = el.offsetLeft * z + x let top = el.offsetTop * z + y let size = seat_picker_sheet_size * z if left < 8 { x = x - left + 8 } if top < 8 { y = y - top + 8 } if left + size > view_el.clientWidth - 8 { x = x - (left + size - view_el.clientWidth + 8) } if top + size > view_el.clientHeight - 8 { y = y - (top + size - view_el.clientHeight + 8) } settle(true) } let changed = () => { on_change && on_change(chosen, seat_picker_sheet_total(cabin, chosen)) } let take_seat = (id: str) => { let s = seat_picker_sheet_find(cabin, id) cursor = id if !seat_picker_sheet_open(s, need) { say_more = s != null && !s.taken ? "Exit rows need someone able to help in an emergency." : "That seat is taken." } else { chosen = seat_picker_sheet_toggle(chosen, id, count) last_seat = contains(chosen, id) ? id : (len(chosen) > 0 ? chosen[len(chosen) - 1] : null) say_more = s.exit && contains(chosen, id) ? "Exit row: you must be able to help in an emergency." : "" changed() } } let close_it = () => { dlg.close() } let opened = () => { started = true if !dlg.open { dlg.showModal() } } if open && dlg != null && !dlg.open { opened() } if !open && dlg != null && dlg.open && trigger == null { dlg.close() } let way = seat_picker_sheet_together(cabin, chosen) let beside = last_seat != null ? seat_picker_sheet_beside(cabin, last_seat, chosen) : "" <div class={ui_cls(["ui-seatps-host", class])} style="display: contents"> {trigger != null && <Button size="lg" class="ui-seatps-open" on_click={opened}>{trigger}</Button>} {name != null && <input type="hidden" name={name} value={join(chosen, ",")}/>} <dialog.ui-seatps-dlg aria-label={title} @mount={(el) => { dlg = el }} @close={() => { dlg.style.removeProperty("transform") on_close && on_close() }}> <button.ui-seatps-grip type="button" aria-label="Close" @click={() => { if grip_dy < 8 { close_it() } }} @pointerdown={(e) => { grip_y = e.clientY grip_dy = 0 grip_samples = [[e.timeStamp, 0, e.clientY]] e.target.closest("button").setPointerCapture(e.pointerId) dlg.style.transition = "none" }} @pointermove={(e) => { if grip_y != null { grip_dy = max([0, e.clientY - grip_y]) grip_samples = seat_picker_sheet_samples(grip_samples, e.timeStamp, 0, e.clientY) dlg.style.transform = `translateY(${grip_dy}px)` } }} @pointerup={(e) => { if grip_y != null { let speed = seat_picker_sheet_speed(grip_samples, e.timeStamp).y grip_y = null dlg.style.removeProperty("transition") if seat_picker_sheet_dismissed(grip_dy, speed, dlg.offsetHeight) { dlg.close() } else { dlg.style.removeProperty("transform") } } }} @pointercancel={(_e) => { grip_y = null grip_dy = 0 dlg.style.removeProperty("transition") dlg.style.removeProperty("transform") }}><span aria-hidden="true"></span></button> <div.ui-seatps-head> <h2>{title}</h2> {started && <button.ui-seatps-icon.ui-seatps-more type="button" aria-label="Zoom in" @click={() => { zoom_by(1.4) }}>+</button>} {started && <button.ui-seatps-icon.ui-seatps-less type="button" aria-label="Zoom out" @click={() => { zoom_by(0.7) }}>-</button>} <button.ui-seatps-icon.ui-seatps-close type="button" aria-label="Close" @click={close_it}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg> </button> </div> {started && <div.ui-seatps-in> <div.ui-seatps-in-row> <div.ui-seatps-main> <div.ui-seatps-view data-zoom={shown_z} @mount={(el) => { view_el = el let watching = js_new("ResizeObserver", (_x) => { if plane != null { fit() } }) watching.observe(el) () => { watching.disconnect() } }} @pointerdown={(e) => { let box = view_el.getBoundingClientRect() let px = e.clientX - box.left let py = e.clientY - box.top downs = (downs | filter((d) => d.id != e.pointerId)) + [{ id: e.pointerId, x: px, y: py }] samples = [[e.timeStamp, px, py]] from_x = x from_y = y panned = false if len(downs) == 2 { let a = downs[0] let b = downs[1] let far = max([1, math.hypot(a.x - b.x, a.y - b.y)]) pinch_from = { z: z, x: x, y: y, far: far, cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2 } panned = true } }} @pointermove={(e) => { let box = view_el.getBoundingClientRect() let px = e.clientX - box.left let py = e.clientY - box.top if len(downs) == 2 && pinch_from != null { downs = downs | map((d) => d.id == e.pointerId ? { id: d.id, x: px, y: py } : d) let a = downs[0] let b = downs[1] let p = seat_picker_sheet_pinch(pinch_from.z, pinch_from.x, pinch_from.y, pinch_from.cx, pinch_from.cy, math.hypot(a.x - b.x, a.y - b.y) / pinch_from.far, (a.x + b.x) / 2, (a.y + b.y) / 2, least, 2.5) x = p.x y = p.y z = p.z paint(false) } else if len(downs) == 1 && downs[0].id == e.pointerId { let dx = px - downs[0].x let dy = py - downs[0].y if !panned && max([abs(dx), abs(dy)]) >= 8 { panned = true view_el.setPointerCapture(e.pointerId) } if panned { x = from_x + dx y = from_y + dy samples = seat_picker_sheet_samples(samples, e.timeStamp, px, py) paint(false) } } }} @pointerup={(e) => { let was = len(downs) downs = downs | filter((d) => d.id != e.pointerId) if was == 2 { // One finger stays: it goes on panning from here. pinch_from = null if len(downs) == 1 { downs = [{ id: downs[0].id, x: downs[0].x, y: downs[0].y }] from_x = x from_y = y } } if len(downs) == 0 { if panned && was == 1 { let v = seat_picker_sheet_speed(samples, e.timeStamp) x = x + v.x * 180 y = y + v.y * 180 } settle(true) } }} @pointercancel={(e) => { downs = downs | filter((d) => d.id != e.pointerId) if len(downs) < 2 { pinch_from = null } if len(downs) == 0 { panned = false settle(true) } }} @click={(e) => { let seat = e.target.closest("[data-seat]") if seat != null && !panned { let id = seat.dataset.seat // Too small to be sure of: this tap was for looking. if seat_picker_sheet_size * z < 40 { let box = view_el.getBoundingClientRect() zoom_about(1, e.clientX - box.left, e.clientY - box.top) cursor = id } else { take_seat(id) } } panned = false }} @wheel={(e) => { y = y - e.deltaY x = x - e.deltaX settle(false) }}> <div.ui-seatps-plane data-small-target="" @mount={(el) => { plane = el let first = seat_picker_sheet_seats(cabin) if len(first) > 0 { cursor = first[0].id } z = 0 fit() }} @keydown={(e) => { let seat = e.target.closest("[data-seat]") if seat != null && contains(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], e.key) { e.preventDefault() let to = seat_picker_sheet_step(cabin, seat.dataset.seat, e.key) let next_el = to == null ? null : plane.querySelector(`[data-seat="${to}"]`) if next_el != null { cursor = to next_el.focus() reveal(next_el) } } }}> {for r in cabin => <div.ui-seatps-rowwrap key={r.row}> {r.exit && <div.ui-seatps-exit aria-hidden="true"><span>EXIT</span><span>EXIT</span></div>} <div.ui-seatps-row role="group" aria-label={`Row ${r.row}`}> <span.ui-seatps-num aria-hidden="true">{r.row}</span> {for c in r.cells => c.aisle == true ? <span.ui-seatps-gap key={c.id} aria-hidden="true"></span> : <button.ui-seatps-seat key={c.id} type="button" data-seat={c.id} data-band={c.band} data-access={c.access ? "" : null} tabindex={cursor == c.id ? "0" : "-1"} aria-pressed={contains(chosen, c.id) ? "true" : "false"} aria-disabled={c.taken || (need && c.exit) ? "true" : null} aria-label={seat_picker_sheet_label(c, currency, locale, need)}>{c.letter}</button>} </div> </div>} </div> </div> </div> <div.ui-seatps-foot> <ul.ui-seatps-legend> {base != 0 && <li><span.ui-seatps-swatch data-band="0"></span>Standard {seat_picker_sheet_money(seat_picker_sheet_dec(base), currency, locale)}</li>} {for pair in enumerate(bands) => <li key={pair[0]}><span.ui-seatps-swatch data-band={pair[0] + 1}></span>{pair[1].name ?? "Seat"} {seat_picker_sheet_money(seat_picker_sheet_dec(pair[1].price), currency, locale)}</li>} </ul> <p.ui-seatps-say role="status"> <span.ui-seatps-held>{len(chosen) == 0 ? `Pick ${count == 1 ? "a seat" : `${count} seats`}.` : `${join(chosen, ", ")}. ${seat_picker_sheet_money(total, currency, locale)}.`}</span> <span.ui-seatps-note>{join([beside, seat_picker_sheet_party(way), say_more] | filter((t) => t != ""), " ")}</span> </p> <div.ui-seatps-tools> <button.ui-seatps-chip.ui-seatps-find type="button" @click={() => { let run = seat_picker_sheet_suggest(cabin, max([count, 1]), need) if len(run) == 0 { say_more = `No ${count} seats together.` } else { chosen = run last_seat = run[0] say_more = "" changed() let el = plane.querySelector(`[data-seat="${run[0]}"]`) if el != null { cursor = run[0] reveal(el) } } }}>{count > 1 ? "Find seats together" : "Find a seat"}</button> <button.ui-seatps-chip.ui-seatps-need type="button" aria-pressed={need ? "true" : "false"} @click={() => { need = !need if need { let kept = chosen | filter((id) => seat_picker_sheet_open(seat_picker_sheet_find(cabin, id), true)) if len(kept) != len(chosen) { chosen = kept changed() } } say_more = need ? "Green seats have the room or a movable armrest. Exit rows are closed." : "" }}>Accessible seat</button> </div> <Button size="lg" full={true} class="ui-seatps-confirm" disabled={len(chosen) == 0} on_click={() => { on_confirm && on_confirm(chosen, total) dlg.close() }}>{len(chosen) == 0 ? "Confirm" : `Confirm ${len(chosen)} ${len(chosen) == 1 ? "seat" : "seats"}, ${seat_picker_sheet_money(total, currency, locale)}`}</Button> </div> </div> </div>} </dialog> </div>}Related
ActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloadsAlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloadsApprovalQueueOne request at a time, swiped to approve or reject, with a required reason, undo and a count of what is left.3 views · 5 downloadsBoardingPassCardA boarding pass with its code ready at full brightness, the gate and time large, a countdown, and offline working.3 views · 8 downloadsBookSpreadReader pages, one column upright and a two-page spread turned, by swipe or edge tap, the place kept by text and not page number.2 views · 7 downloads
Thread
Thread 0 posts
On SeatPickerSheet: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.