Travel

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.

#travel#flights#forms#search#interactive

Demo

Preview

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

Thread 0 posts

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

No posts yet. The first one opens the thread.