Mobile

SearchHeader

A header whose search field opens in place with Cancel, recent searches and your results; the keyboard stays up through them; a dropdown panel on a tablet.

#mobile#interactive#tablet#search#header#keyboard

Demo

Preview

Contacts

Recent

Query: ; sent: ; picked: nobody; recents changed 0

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

Example

The code of the preview above.

demos/search_header.qwe
let search_header_demo_people = ["Ana Reyes", "Ben Okafor", "Carla Lim", "Dante Cruz", "Elena Marsh", "Farid Haddad", "Grace Tan", "Hugo Silva", "Ines Duarte", "Jun Park", "Kira Novak", "Luis Ortega"]let search_header_demo_css = css`.ui-searchhead-demo-list { margin: 0; padding: 0; list-style: none; }.ui-searchhead-demo-list li { border-bottom: 1px solid var(--ui-border); }.ui-searchhead-demo-list a { box-sizing: border-box; display: flex; align-items: center; min-height: 48px; padding: 0 16px; color: var(--ui-accent); font: 15px var(--ui-font); text-decoration: none; }.ui-searchhead-demo-list button { box-sizing: border-box; width: 100%; min-height: 48px; padding: 0 16px; border: 0; background: transparent; color: var(--ui-fg); font: 15px var(--ui-font); text-align: start; cursor: pointer; }`view SearchHeaderDemoList(people: list, on_pick) =    <ul.ui-searchhead-demo-list>        {for p in people => <li key={p}><button type="button" @click={() => { on_pick(p) }}>{p}</button></li>}        <li><a href="#search-header-import">Import contacts</a></li>        <li><a href="#search-header-groups">Browse groups</a></li>        <li><a href="#search-header-help">Help with search</a></li>    </ul>component SearchHeaderDemoLive() {    style global search_header_demo_css    state q = ""    state picked = "nobody"    state sent = ""    state run = 0    state wait_ms = 0    state changed = 0    let hits = search_header_demo_people | filter((p) => contains(lower(p), lower(trim(q))))    <div.demo-stack>        <div.demo-tablet>            <SearchHeader key={run} id="search-header-demo" delay={wait_ms} title="Contacts" placeholder="Search contacts" recent={["Grace", "Ben", "grace", "Luis", 7, "  "]} storage="ui-search-header-demo" on_recent={(_list) => { changed += 1 }}                          count={len(hits)} empty_text="Nobody by that name" on_search={(t) => { q = t }} on_submit={(t) => { sent = t }}                          results={<SearchHeaderDemoList people={hits} on_pick={(p) => { picked = p }}/>}>                <SearchHeaderDemoList people={search_header_demo_people} on_pick={(p) => { picked = p }}/>            </SearchHeader>        </div>        <p class="demo-note" style="overflow-wrap: anywhere">Query: {q}; sent: {sent}; picked: {picked}; recents changed {changed}</p>        <div.demo-row>            <Button size="lg" variant="outline" on_click={() => { run += 1 }}>Build it again</Button>            <Button size="lg" variant="outline" on_click={() => { wait_ms = 300 }}>Wait for a pause in typing</Button>        </div>    </div>}view SearchHeaderDemo() = <SearchHeaderDemoLive/>

Source

ui/search_header.qwe
// SearchHeader: a screen's header whose search field opens where it stands. Tapping// it brings Cancel beside it and recent searches under it; typing swaps those for// your results; and the keyboard stays up through all of it, as the field is one// element that is never made again, and a tap on a result or a recent search does not// take the focus away. Cancel or Escape closes it. On a phone the results take the// screen under the header; in a pane 600 px wide or more they drop down from the// field in a panel, with the screen still there behind.////   import ui/search_header.qwe use SearchHeader////   component Contacts(people: list) {//       state q = ""//       let hits = people | filter((p) => contains(lower(p.name), lower(q)))//       <SearchHeader title="Contacts" storage="contacts-recent" count={len(hits)}//                     results={<PeopleList people={hits}/>} on_search={(text) => { q = text }}>//           <PeopleList {people}/>//       </SearchHeader>//   }//// children: the screen under the header. results: HTML, shown while there is text; it// follows your state, so it goes inside one of your components. title: the heading.// actions: HTML at the end of the title's row, whose buttons are kept to 44 px each way.// placeholder and label: the field's hint ("Search") and its name for screen readers.// value: text it starts with. count: how many results, told to screen readers, and// empty_text is shown at 0. on_search(text): called with each change of the text, and// with "" on Cancel. delay: wait that many ms after the last key first (0). on_submit(text):// called on the keyboard's Search key, and on a recent search, which then counts as// used. on_cancel(): called when it closes. recent: searches to start with, newest first.// on_recent(list): called with the whole list each time it changes. storage: a key to// keep recent searches under in the browser (none, so they last the page); keys that// cannot be read or written just mean none are kept. limit: how many to keep (8). id:// the field's id, unique on the page. The box takes the size of its parent, which needs// a height.//// In the iOS and Android apps the browser's storage comes back empty at each launch, as// the app's address changes, so there `storage` lasts one launch. To keep them for good,// save what on_recent gives you in your database and pass it back as `recent`.//// The field is there from the start, not a button that makes it, since iOS raises the// keyboard only for a focus made by the tap itself. Keys: down from the field goes into// the list, up from its first item comes back, Escape closes. A tap beside a tablet's// panel closes it too.import ui/theme.qwe use ui_clslet search_header_css = css`.ui-searchhead { position: relative; display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; overflow: hidden; container-type: inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font); }.ui-searchhead-head { position: relative; z-index: 2; flex: none; padding: env(safe-area-inset-top) max(12px, env(safe-area-inset-right)) 8px max(12px, env(safe-area-inset-left)); border-bottom: 1px solid var(--ui-border); background: var(--ui-surface); }.ui-searchhead-top { display: flex; align-items: center; gap: 8px; min-height: 48px; overflow: hidden; transition: min-height .18s, max-height .18s, opacity .18s; max-height: 80px; }.ui-searchhead-title { flex: 1; min-width: 0; margin: 0; overflow: hidden; font-size: 24px; font-weight: 700; line-height: 1.2; outline: none; text-overflow: ellipsis; white-space: nowrap; }.ui-searchhead-top button { display: inline-grid; flex: none; place-items: center; min-width: 44px; min-height: 44px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ui-accent); font: inherit; cursor: pointer; }.ui-searchhead-top svg { width: 24px; height: 24px; }.ui-searchhead-on .ui-searchhead-top { min-height: 0; max-height: 0; opacity: 0; visibility: hidden; }.ui-searchhead-row { display: flex; align-items: center; gap: 4px; margin: 0; }.ui-searchhead-field { position: relative; display: flex; flex: 1; align-items: center; min-width: 0; }.ui-searchhead-glass { position: absolute; inset-inline-start: 10px; width: 18px; height: 18px; color: var(--ui-muted); pointer-events: none; }/* 16 px: iOS zooms the page in on a field whose text is smaller. */.ui-searchhead-input {  box-sizing: border-box; width: 100%; height: 44px; padding: 0 44px 0 36px; border: 1px solid transparent; border-radius: 12px; background: var(--ui-subtle); color: var(--ui-fg); font: 16px var(--ui-font);  outline: none; -webkit-appearance: none; appearance: none;}.ui-searchhead-input::-webkit-search-cancel-button { display: none; }.ui-searchhead-input:focus { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-searchhead-clear {  position: absolute; inset-inline-end: 0; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-searchhead-clear svg { width: 18px; height: 18px; }.ui-searchhead-clear:focus-visible, .ui-searchhead-cancel:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-searchhead-cancel { display: none; flex: none; min-width: 44px; height: 44px; padding: 0 8px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 16px var(--ui-font); cursor: pointer; }.ui-searchhead-on .ui-searchhead-cancel { display: block; }.ui-searchhead-body { position: relative; flex: 1; min-height: 0; }.ui-searchhead-page { height: 100%; overflow: auto; overscroll-behavior: contain; }.ui-searchhead-drop {  position: absolute; inset: 0; z-index: 3; box-sizing: border-box; display: none; padding-bottom: calc(var(--ui-searchhead-kb, 0px) + env(safe-area-inset-bottom)); overflow: auto; overscroll-behavior: contain; background: var(--ui-bg);}.ui-searchhead-on .ui-searchhead-drop { display: block; }.ui-searchhead-list { margin: 0; padding: 4px 0; list-style: none; }.ui-searchhead-listhead { display: flex; align-items: center; justify-content: space-between; min-height: 44px; padding: 0 4px 0 16px; color: var(--ui-muted); font: 600 13px var(--ui-font); }.ui-searchhead-recent { display: flex; align-items: stretch; }.ui-searchhead-pick, .ui-searchhead-drop-all, .ui-searchhead-forget { min-height: 44px; border: 0; background: transparent; color: var(--ui-fg); font: 15px var(--ui-font); cursor: pointer; }.ui-searchhead-pick { flex: 1; min-width: 0; padding: 0 8px 0 16px; overflow: hidden; text-align: start; text-overflow: ellipsis; white-space: nowrap; }.ui-searchhead-drop-all { padding: 0 12px; color: var(--ui-accent); font-size: 14px; }.ui-searchhead-forget { display: grid; flex: none; place-items: center; width: 44px; padding: 0; color: var(--ui-muted); }.ui-searchhead-forget svg { width: 16px; height: 16px; }.ui-searchhead-pick:focus-visible, .ui-searchhead-drop-all:focus-visible, .ui-searchhead-forget:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }@media (hover: hover) { .ui-searchhead-pick:hover, .ui-searchhead-forget:hover { background: var(--ui-subtle); } }.ui-searchhead-none { margin: 0; padding: 32px 16px; color: var(--ui-muted); text-align: center; overflow-wrap: anywhere; }@container (min-width: 600px) {  .ui-searchhead-head { display: flex; align-items: center; gap: 16px; padding-inline: 24px; padding-bottom: 0; min-height: 64px; }  .ui-searchhead-top { flex: 1; min-width: 0; }  .ui-searchhead-on .ui-searchhead-top { min-height: 48px; max-height: 80px; opacity: 1; visibility: visible; }  .ui-searchhead-row { flex: 0 1 420px; }  .ui-searchhead-drop {    inset: 8px 24px auto auto; width: 420px; max-height: min(70%, 520px, calc(100% - var(--ui-searchhead-kb, 0px) - 16px)); padding-bottom: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg);  }}@media (prefers-reduced-motion: reduce) { .ui-searchhead-top { transition: none; } }`// Recent searches with one more at the front: blanks do not count, a repeat in another// case moves up, the list stops at `limit`, and a very long text is cut.fn search_header_remember(list_of: list, text: str, limit: int) -> list {    let clean = slice(trim(text), 0, 200)    if clean == "" { return take(list_of, limit < 0 ? 0 : limit) }    let rest = list_of | filter((s) => lower(s) != lower(clean))    take([clean] + rest, limit < 0 ? 0 : limit)}// A list of recent searches from anywhere: only texts count, each once, as a row is// keyed by its text, and a repeat in another case is a repeat.fn search_header_clean(got, limit: int) -> list {    if type_of(got) != "list" { return [] }    let texts = take(got, 200) | filter((s) => type_of(s) == "str" && trim(s) != "") | map((s) => slice(s, 0, 200))    let lows = texts | map((s) => lower(s))    take(enumerate(texts) | filter((p) => index_of(lows, lower(p[1])) == p[0]) | map((p) => p[1]), limit < 0 ? 0 : limit)}// What came out of storage, whatever it was.fn search_header_read(text, limit: int) -> list {    if type_of(text) != "str" { return [] }    search_header_clean(json.parse(text) ?? [], limit)}// How much of the page the keyboard covers, for the list's last rows to scroll clear of// it. Zero when zoomed, as that shrinks what is visible too.fn search_header_cover(height: num, seen: num, top: num, scale: num) -> num {    if scale > 1.01 { return 0 }    let gone = height - seen - top    gone > 0 ? gone : 0}// What screen readers are told.fn search_header_status(text: str, count) -> str {    if text == "" || count == null { return "" }    count == 1 ? "1 result" : `${count} results`}fn search_header_cross() =    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>component SearchHeader(title: str = "", results = null, actions = null, placeholder: str = "Search", label: str = "Search", value: str = "", count: int? = null,                       empty_text: str = "No results", on_search = null, delay: int = 0, on_submit = null, on_cancel = null, recent = [], on_recent = null,                       storage: str? = null, limit: int = 8, id: str = "ui-searchhead", class: str? = null, children) {    style global search_header_css    state q = value    state on = value != ""    state recents = search_header_clean(recent, limit)    state waiting_text = null    // text waiting out `delay`    state win = null    state box = null    state field = null    state under = null    state heading = null    // Each key starts the clock again, as this reads the text waiting.    if waiting_text != null && delay > 0 {        every(delay, () => {            let text = waiting_text            waiting_text = null            on_search && on_search(text)        })    }    let tell = (text: str) => {        if delay > 0 && text != "" {            waiting_text = text        } else {            waiting_text = null            on_search && on_search(text)        }    }    // A failed read or write of storage is no reason to stop.    let keep = (list_of: list) => {        recents = list_of        on_recent && on_recent(list_of)        if storage != null {            js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.setItem", storage, json.stringify(list_of))) }))        }    }    let remember = (text: str) => { keep(search_header_remember(recents, text, limit)) }    let close = () => {        on = false        q = ""        tell("")        field.blur()        // After the update: on a phone the heading is hidden while the field is open.        win.requestAnimationFrame(() => { heading.focus({ preventScroll: true }) })        on_cancel && on_cancel()    }    let run = (text: str) => {        q = text        tell(text)        on_submit && on_submit(trim(text))        field.focus({ preventScroll: true })        remember(text)    }    <div class={ui_cls(["ui-searchhead", on && "ui-searchhead-on", class])}         @mount={(el) => {             win = js("window")             box = el             let vv = win.visualViewport             let cover = () => {                 let gone = search_header_cover(win.innerHeight, vv.height, vv.offsetTop, vv.scale)                 el.style.setProperty("--ui-searchhead-kb", `${gone}px`)             }             if vv != null {                 cover()                 vv.addEventListener("resize", cover)                 vv.addEventListener("scroll", cover)             }             if storage != null {                 let saved = js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.getItem", storage)) }))                 let list_of = search_header_read(saved, limit)                 if len(list_of) > 0 { recents = list_of }             }             () => {                 if vv != null {                     vv.removeEventListener("resize", cover)                     vv.removeEventListener("scroll", cover)                 }             }         }}         @keydown={(e) => {             if on && e.key == "Escape" {                 e.preventDefault()                 close()             }             if on && (e.key == "ArrowDown" || e.key == "ArrowUp") {                 let items = under.querySelectorAll("a[href], button, input, [tabindex='0']")                 let n = items.length                 let here = e.target                 let from_field = here.isSameNode(field)                 if n > 0 && (from_field || under.contains(here)) {                     let at = -1                     let i = 0                     while i < n {                         if here.isSameNode(items.item(i)) { at = i }                         i += 1                     }                     let to = e.key == "ArrowDown" ? (at + 1 < n ? at + 1 : at) : at - 1                     if !from_field || e.key == "ArrowDown" {                         e.preventDefault()                         if to < 0 { field.focus() } else { items.item(to).focus() }                     }                 }             }         }}>        <header.ui-searchhead-head>            <div.ui-searchhead-top>                <h1.ui-searchhead-title tabindex="-1" @mount={(el) => { heading = el }}>{title}</h1>                {actions}            </div>            <form.ui-searchhead-row role="search" @submit={() => {                on_submit && on_submit(trim(q))                remember(q)            }}>                <div.ui-searchhead-field>                    <svg.ui-searchhead-glass viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3"/></svg>                    <input.ui-searchhead-input id={id} type="search" name="q" value={q} placeholder={placeholder} aria-label={label} enterkeyhint="search" autocomplete="off"                           autocapitalize="off" autocorrect="off" spellcheck="false"                           @mount={(el) => { field = el }}                           @focus={() => { on = true }}                           @input={(e) => {                               let text = e.value                               q = text                               tell(text)                           }}/>                    {q != "" && <button.ui-searchhead-clear type="button" aria-label="Clear the search"                            @mousedown={(e) => { e.preventDefault() }}                            @click={() => {                                q = ""                                tell("")                                field.focus({ preventScroll: true })                            }}>{search_header_cross()}</button>}                </div>                <button.ui-searchhead-cancel type="button" @click={close}>Cancel</button>            </form>        </header>        <div.ui-searchhead-body @click={(e) => {            // Beside a tablet's panel: the screen behind is inert, so the tap lands here.            if on && e.target.closest(".ui-searchhead-drop") == null { close() }        }}>            <div.ui-searchhead-page inert={on ? "" : null}>{children}</div>            <div.ui-searchhead-drop @mount={(el) => { under = el }}                 @mousedown={(e) => {                     // The tap keeps the field its focus, so the keyboard stays. A field in the results may still take it.                     if e.target.closest("input, textarea, select") == null { e.preventDefault() }                 }}>                {q == "" && len(recents) > 0 && <div>                    <div.ui-searchhead-listhead>                        <span>Recent</span>                        <button.ui-searchhead-drop-all type="button" @click={() => { keep([]) }}>Clear</button>                    </div>                    <ul.ui-searchhead-list>                        {for s in recents => <li.ui-searchhead-recent key={s}>                            <button.ui-searchhead-pick type="button" @click={() => { run(s) }}>{s}</button>                            <button.ui-searchhead-forget type="button" aria-label={`Remove ${s}`}                                    @click={() => { keep(recents | filter((r) => r != s)) }}>{search_header_cross()}</button>                        </li>}                    </ul>                </div>}                {q != "" && <div.ui-searchhead-results>{results}</div>}                {q != "" && count == 0 && <p.ui-searchhead-none>{empty_text}</p>}            </div>            <div.ui-sr aria-live="polite" aria-atomic="true">{search_header_status(q, count)}</div>        </div>    </div>}
Thread
35Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.