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.
Demo
Contacts
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.qwemain.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>}Related
AppTabBarBottom tabs for a phone app: badges, safe-area padding, a second press scrolls to the top, each tab keeps its place, gone while the keyboard is up; a rail on a tablet.3 views · 8 downloadsChatScreenMessages grouped by sender and day, keeping your place while older ones load, at the bottom as new ones come, and clear of the keyboard.3 views · 7 downloadsCommentSheetComments in a sheet: threads, a reply bar above the keyboard, sort, and a count that stays right. A column at the side on a tablet.2 views · 7 downloadsContactListPeople grouped by letter with avatars from initials, fast search across names and numbers, a call and message action per row.3 views · 8 downloadsEmptyScreenEmpty states for first use, none-found and all-done, each with what to do next.3 views · 8 downloadsFilterChipsBarA sideways scroller of filter chips staying under the header, the active chip kept in view, a count of results, and clear.2 views · 6 downloads
Thread
Thread 0 posts
On SearchHeader: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.