FlightSearch
A 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.
Demo
Search: on_search gets the query.
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/flight_search.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/flight_search.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/flight_search.qwe
let flight_search_demo_airports = [ { code: "LHR", city: "London", name: "Heathrow" }, { code: "LGW", city: "London", name: "Gatwick" }, { code: "LIS", city: "Lisbon" }, { code: "OPO", city: "Porto" }, { code: "JFK", city: "New York", name: "John F. Kennedy" }, { code: "CDG", city: "Paris", name: "Charles de Gaulle" }, { code: "FRA", city: "Frankfurt" }, { code: "SIN", city: "Singapore", name: "Changi" },]component FlightSearchDemoLive() { state note = "Search: on_search gets the query." <div.demo-stack> <FlightSearch id="flight-search-demo" name="trip" from="LHR" to="LIS" depart="2026-11-05" back="2026-11-12" adults={2} kids={1} today="2026-10-30" airports={flight_search_demo_airports} on_search={(q) => { note = `on_search: ${q.trip} ${q.from} to ${q.to}, ${q.depart} to ${q.back ?? "-"}, ${q.adults}+${q.children}+${q.infants}, ${q.cabin}` }}/> <p.demo-note id="flight-search-demo-note">{note}</p> </div>}view FlightSearchDemo() = <div.demo-stack> <FlightSearchDemoLive/> <FlightSearch id="flight-search-demo-get" trip="oneway" cabin="business" action="#flights" airports={flight_search_demo_airports}/> </div>Source
ui/flight_search.qwe
// FlightSearch: the form that starts a flight booking: return or one way, where from// and where to (with a button that swaps them, and airports suggested as one types),// the dates out and back, the travellers (adults, children and infants, in a panel// that opens from a button) and the cabin, and Search. It checks what it can before// searching: both airports given and not the same, the way back not before the way// out, no more infants than adults, nine travellers at most. It is a real <form>: with// no on_search, Search sends trip, from, to, depart, return, adults, children, infants// and cabin to `action` as a GET.//// <FlightSearch action="/flights" from="LHR" to="LIS" depart="2026-11-05" back="2026-11-12" today="2026-10-30"// airports={[{ code: "LHR", city: "London", name: "Heathrow" }, { code: "LIS", city: "Lisbon" }]}/>// <FlightSearch trip="oneway" adults={2} kids={1} cabin="business" on_search={(q) => { results = find_flights(q) }}/>//// trip: "return" (default) or "oneway". from, to: what the boxes start with (a code,// "LHR", or a city). depart, back: "YYYY-MM-DD". adults (1), kids (0: children of 2 to// 11; `children` is a view's body), infants (0): the travellers to start with. cabin:// "economy" (default), "premium", "business" or "first". airports: a list of { code,// city, name? } offered as one types (a <datalist>). today: the first day that can be// picked ("YYYY-MM-DD"; none by default). action: where the form goes (this page).// name: put before the fields' names ("out" makes out_from, out_to, ...) and the ids.// label: what screen readers call the form ("Search flights"). on_search(q): inside// one of your components, called instead of sending the form, given { trip, from, to,// depart, back, adults, children, infants, cabin }. id, class.import ui/theme.qwe use ui_clslet flight_search_css = css`.ui-fsearch { display: grid; gap: 12px; min-width: 0; margin: 0; padding: 16px; 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); container-type: inline-size; }.ui-fsearch-top { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }.ui-fsearch-trip { display: inline-flex; gap: 2px; margin: 0; padding: 3px; border: 0; border-radius: 999px; background: var(--ui-subtle); }.ui-fsearch-trip legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }.ui-fsearch-trip label { position: relative; padding: 5px 14px; border-radius: 999px; color: var(--ui-muted); font-size: 13px; font-weight: 600; cursor: pointer; }.ui-fsearch-trip input { position: absolute; opacity: 0; pointer-events: none; }.ui-fsearch-trip label:has(input:checked) { background: var(--ui-surface); color: var(--ui-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .08); }.ui-fsearch-trip label:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-fsearch-who { position: relative; }.ui-fsearch-who > summary { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: 999px; font-size: 13px; font-weight: 500; cursor: pointer; list-style: none; }.ui-fsearch-who > summary::-webkit-details-marker { display: none; }.ui-fsearch-who > summary::after { content: ""; width: 6px; height: 6px; margin: -3px 0 0 2px; border: solid var(--ui-muted); border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); }.ui-fsearch-who > summary:hover { border-color: var(--ui-strong); }.ui-fsearch-who > summary:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fsearch-pop { position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; display: grid; width: 280px; max-width: calc(100vw - 32px); padding: 6px 16px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); }.ui-fsearch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--ui-border); }.ui-fsearch-row span { display: grid; }.ui-fsearch-row b { font-weight: 500; }.ui-fsearch-row small { color: var(--ui-muted); font-size: 12px; }.ui-fsearch-count { display: flex; align-items: center; gap: 10px; }.ui-fsearch-count output { min-width: 18px; text-align: center; font-variant-numeric: tabular-nums; }.ui-fsearch-step { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--ui-border); border-radius: 50%; background: var(--ui-surface); color: var(--ui-fg); font: 17px/1 var(--ui-font); cursor: pointer; }.ui-fsearch-step:hover:not(:disabled) { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-fsearch-step:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fsearch-step:disabled { opacity: .35; cursor: not-allowed; }.ui-fsearch-popfoot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 10px; color: var(--ui-muted); font-size: 12px; }.ui-fsearch-done { padding: 5px 12px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-fg); color: var(--ui-bg); font: 500 13px var(--ui-font); cursor: pointer; }.ui-fsearch-done:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fsearch-cabin { height: 32px; padding: 0 28px 0 12px; border: 1px solid var(--ui-border); border-radius: 999px; color: var(--ui-fg); font: 500 13px var(--ui-font); cursor: pointer; appearance: none; background: var(--ui-surface) no-repeat right 11px center / 9px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%23888' stroke-width='1.6'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E"); }.ui-fsearch-cabin:hover { border-color: var(--ui-strong); }.ui-fsearch-cabin:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fsearch-main { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }@container (min-width: 720px) { .ui-fsearch-main { grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; } }.ui-fsearch-places { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }.ui-fsearch-field { display: grid; gap: 4px; min-width: 0; }.ui-fsearch-field label { color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-fsearch-field input { width: 100%; height: 44px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 15px var(--ui-font); box-sizing: border-box; }.ui-fsearch-field input:hover { border-color: var(--ui-strong); }.ui-fsearch-field input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fsearch-field input[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-fsearch-places .ui-fsearch-field:last-of-type input { padding-left: 22px; }.ui-fsearch-swap { position: absolute; z-index: 1; left: 50%; bottom: 7px; display: grid; place-items: center; width: 30px; height: 30px; margin-left: -15px; padding: 0; border: 1px solid var(--ui-border); border-radius: 50%; background: var(--ui-surface); color: var(--ui-fg); cursor: pointer; transition: transform .2s; }.ui-fsearch-swap svg { width: 16px; height: 16px; }.ui-fsearch-swap:hover { border-color: var(--ui-accent); color: var(--ui-accent); transform: rotate(180deg); }.ui-fsearch-swap:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fsearch-dates { display: contents; }.ui-fsearch-go { height: 44px; padding: 0 22px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 15px var(--ui-font); cursor: pointer; }.ui-fsearch-go:hover { filter: brightness(1.08); }.ui-fsearch-go:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fsearch-errors { margin: 0; padding: 8px 12px 8px 28px; border-radius: var(--ui-radius-sm); background: var(--ui-danger-soft); color: var(--ui-fg); font-size: 13px; }.ui-fsearch-oneway .ui-fsearch-back { visibility: hidden; }@container (max-width: 719px) { .ui-fsearch-oneway .ui-fsearch-back { display: none; } }@media (prefers-reduced-motion: reduce) { .ui-fsearch-swap { transition: none; } .ui-fsearch-swap:hover { transform: none; } }`let flight_search_cabins = [ { value: "economy", label: "Economy" }, { value: "premium", label: "Premium economy" }, { value: "business", label: "Business" }, { value: "first", label: "First" },]// "2 adults, 1 child, 1 infant".fn flight_search_people(adults: int, children: int, infants: int) -> str { let say = (n: int, one: str, many: str) => n == 0 ? null : `${n} ${n == 1 ? one : many}` join([say(adults, "adult", "adults"), say(children, "child", "children"), say(infants, "infant", "infants")] | filter((x) => x != null), ", ")}// What is wrong with a search: a list of { field, text } (none when it can go), field// being the box to mark ("from", "to", "depart", "back" or "people").fn flight_search_problems(trip: str, from: str, to: str, depart: str, back: str, adults: int, infants: int) -> list { let a = upper(trim(from)) let b = upper(trim(to)) [ a == "" && { field: "from", text: "Say where you fly from." }, b == "" && { field: "to", text: "Say where you fly to." }, a != "" && a == b && { field: "to", text: "Where you fly to must not be where you fly from." }, depart == "" && { field: "depart", text: "Choose the day you fly out." }, trip == "return" && back == "" && { field: "back", text: "Choose the day you fly back, or search one way." }, trip == "return" && depart != "" && back != "" && back < depart && { field: "back", text: "The way back cannot be before the way out." }, infants > adults && { field: "people", text: "Each infant flies on an adult's lap: no more infants than adults." }, ] | filter((p) => p != false)}component FlightSearch(trip: str = "return", from: str = "", to: str = "", depart: str = "", back: str = "", adults: int = 1, kids: int = 0, infants: int = 0, cabin: str = "economy", airports: list = [], today: str? = null, action: str? = null, name: str? = null, label: str = "Search flights", on_search = null, id: str? = null, class: str? = null) { style global flight_search_css let field = (f: str) => name != null ? `${name}_${f}` : f let base = `flight-search-${name ?? "form"}` state kind = trip == "oneway" ? "oneway" : "return" state a = from state b = to state out = depart state home = back state people = { adults: adults < 1 ? 1 : adults, children: kids, infants } state seat = cabin state tried = false let total = people.adults + people.children let problems = flight_search_problems(kind, a, b, out, home, people.adults, people.infants) let shown = tried ? problems : [] let bad = (f: str) => tried && any(problems, (p) => p.field == f) ? "true" : null let step = (k: str, by: int) => { let n = get(people, k) + by let ok = match k { "adults" => n >= 1 && (by < 0 || total < 9) "children" => n >= 0 && (by < 0 || total < 9) _ => n >= 0 && n <= people.adults } if ok { let next = set(people, k, n) people = k == "adults" && next.infants > n ? set(next, "infants", n) : next } } let counter = (k: str, title: str, hint: str, least: int, most_now: bool) => { let n = get(people, k) <div.ui-fsearch-row data-kind={k}> <span><b id={`${base}-${k}`}>{title}</b>{" "}<small>{hint}</small></span> <span.ui-fsearch-count role="group" aria-labelledby={`${base}-${k}`}> <button.ui-fsearch-step type="button" aria-label={`Fewer ${lower(title)}`} disabled={n <= least} @click={() => step(k, -1)}>−</button> <output aria-live="polite">{n}</output> <button.ui-fsearch-step type="button" aria-label={`More ${lower(title)}`} disabled={most_now} @click={() => step(k, 1)}>+</button> </span> </div> } <form class={ui_cls(["ui-fsearch", kind == "oneway" && "ui-fsearch-oneway", class])} id={id} method="get" action={action} role="search" aria-label={label} novalidate> <div.ui-fsearch-top> <fieldset.ui-fsearch-trip> <legend>Trip</legend> <label><input type="radio" name={field("trip")} value="return" checked={kind == "return"} @change={() => { kind = "return" }}/>Return</label> <label><input type="radio" name={field("trip")} value="oneway" checked={kind == "oneway"} @change={() => { kind = "oneway" }}/>One way</label> </fieldset> <details.ui-fsearch-who @keydown={(e) => { if e.key == "Escape" { let d = e.target.closest("details") if d.open { e.preventDefault() d.open = false d.querySelector("summary").focus() } } }}> <summary aria-label={`Travellers: ${flight_search_people(people.adults, people.children, people.infants)}`}> <span.ui-fsearch-people>{flight_search_people(people.adults, people.children, people.infants)}</span> </summary> <div.ui-fsearch-pop role="group" aria-label="Travellers"> {counter("adults", "Adults", "12 or older", 1, total >= 9)} {counter("children", "Children", "2 to 11", 0, total >= 9)} {counter("infants", "Infants", "Under 2, on a lap", 0, people.infants >= people.adults)} <div.ui-fsearch-popfoot> <span>Up to 9 seats</span> <button.ui-fsearch-done type="button" @click={(e) => { let d = e.target.closest("details") d.open = false d.querySelector("summary").focus() }}>Done</button> </div> </div> </details> <select.ui-fsearch-cabin name={field("cabin")} aria-label="Cabin" @change={(e) => { seat = e.value }}> {for c in flight_search_cabins => <option value={c.value} selected={c.value == seat}>{c.label}</option>} </select> <input type="hidden" name={field("adults")} value={str(people.adults)}/> <input type="hidden" name={field("children")} value={str(people.children)}/> <input type="hidden" name={field("infants")} value={str(people.infants)}/> </div> <div.ui-fsearch-main> <div.ui-fsearch-places> <div.ui-fsearch-field> <label for={`${base}-from`}>From</label> <input id={`${base}-from`} name={field("from")} value={a} list={len(airports) > 0 ? `${base}-airports` : null} placeholder="City or airport" autocomplete="off" aria-invalid={bad("from")} aria-describedby={len(shown) > 0 ? `${base}-errors` : null} @input={(e) => { a = e.value }}/> </div> <button.ui-fsearch-swap type="button" aria-label="Swap from and to" @click={() => { let x = a a = b b = x }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7"/></svg> </button> <div.ui-fsearch-field> <label for={`${base}-to`}>To</label> <input id={`${base}-to`} name={field("to")} value={b} list={len(airports) > 0 ? `${base}-airports` : null} placeholder="City or airport" autocomplete="off" aria-invalid={bad("to")} aria-describedby={len(shown) > 0 ? `${base}-errors` : null} @input={(e) => { b = e.value }}/> </div> </div> <div.ui-fsearch-field> <label for={`${base}-depart`}>Depart</label> <input id={`${base}-depart`} type="date" name={field("depart")} value={out} min={today} aria-invalid={bad("depart")} @input={(e) => { out = e.value }}/> </div> <div.ui-fsearch-field.ui-fsearch-back> <label for={`${base}-back`}>Return</label> <input id={`${base}-back`} type="date" name={field("return")} value={home} min={out != "" ? out : today} disabled={kind == "oneway"} aria-invalid={bad("back")} @input={(e) => { home = e.value }}/> </div> <button.ui-fsearch-go type="submit" @click={(e) => { tried = true if len(problems) > 0 { e.preventDefault() } else if on_search != null { e.preventDefault() on_search({ trip: kind, from: trim(a), to: trim(b), depart: out, back: kind == "return" ? home : null, adults: people.adults, children: people.children, infants: people.infants, cabin: seat }) } }}>Search</button> </div> {len(shown) > 0 && <ul.ui-fsearch-errors id={`${base}-errors`} role="alert">{for p in shown => <li data-field={p.field}>{p.text}</li>}</ul>} {len(airports) > 0 && <datalist id={`${base}-airports`}> {for x in airports => <option value={x.code}>{x.city}{x.name != null && ` ${x.name}`} ({x.code})</option>} </datalist>} </form>}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 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 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 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 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 FlightSearch: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.