Overlays

SearchOverlay

A full-screen search with results as one types, grouped and marked, from a list or a server, opened with /.

#search#overlay#dialog#keyboard#interactive

Demo

Preview

Type to search.

Nothing opened yet.

Type to search.

Press / anywhere on the page to open the first.

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

Example

The code of the preview above.

demos/search_overlay.qwe
let search_overlay_demo_docs = [    { title: "Getting started", text: "Install Quillo and run your first program.", href: "#search_overlay", group: "Guides" },    { title: "Components", text: "Views and components, state and events.", group: "Guides" },    { title: "Live data", text: "State that follows the server as it changes.", group: "Guides" },    { title: "Button", text: "A button, or a link that looks like one.", group: "Components" },    { title: "Modal", text: "A dialog over the page.", group: "Components" },    { title: "Search overlay", text: "This one: a full-screen search.", group: "Components" },    { title: "Ada Lovelace", text: "Engineering, London.", group: "People" },    { title: "Grace Hopper", text: "Compilers, New York.", group: "People" },]// A search on the server, slowed a little so that "Searching…" shows.server fn search_overlay_demo_find(text: str) -> list with time {    sleep(200)    search_overlay_filter(search_overlay_demo_docs, text)}component SearchOverlayDemoLive() {    state picked = null    <div.demo-row>        <SearchOverlay id="search-demo" trigger="Search the docs" on_search={(t) => search_overlay_demo_find(t)}                       on_select={(r) => { picked = r.title }}/>        <span.demo-note>{picked == null ? "Nothing opened yet." : `Opened ${picked}.`}</span>    </div>}view SearchOverlayDemo() =    <div.demo-stack>        <SearchOverlayDemoLive/>        <div.demo-row>            <SearchOverlay id="search-demo-local" trigger="Search people" shortcut={false} placeholder="Name or city"                           items={search_overlay_demo_docs | filter((d) => d.group == "People")}/>        </div>        <p.demo-note>Press / anywhere on the page to open the first.</p>    </div>

Source

ui/search_overlay.qwe
// SearchOverlay: a search that takes the whole screen, with results as one types,// grouped (Pages, People, Orders...) and the words typed marked in them. The arrow// keys move through the results, Enter opens one, Escape closes. It opens from its// button or by pressing / anywhere on the page (outside a text box). It is the// browser's own <dialog>, so focus stays in it; the box is an ARIA combobox and the// number of results is announced.////   <SearchOverlay id="site-search" items={pages}/>//   <SearchOverlay id="find" on_search={(t) => find_everything(t)} on_select={(r) => { open_result(r) }}/>//// id (required): unique on the page; the results' ids start with it.// items: what to search, a list of { title, text?, href?, group? }, matched on the// title and the text as one types. on_search(text): instead of items, called as one// types (once there are min_chars characters, 1) and gives back the results, of the// same shape: a `server fn` that queries, say. An answer that comes after a newer// question is dropped, and "Searching…" shows while one is on its way.// trigger: the button's text ("Search"); null for none. shortcut: / opens it (true).// placeholder ("Search…"), label (its name for screen readers, "Search"), empty ("No// results"), limit (the most results shown, 20).// on_select(result): inside one of your components, called with the result opened// (then its href, if it has one, is followed).import ui/theme.qwe use ui_clslet search_overlay_css = css`.ui-search-overlay-host { display: contents; }.ui-search-overlay-trigger {  display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 8px 0 11px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-muted); font: 14px/1 var(--ui-font); cursor: pointer;}.ui-search-overlay-trigger:hover { border-color: var(--ui-strong); }.ui-search-overlay-trigger:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-search-overlay-trigger svg { width: 16px; height: 16px; }.ui-search-overlay kbd, .ui-search-overlay-trigger kbd {  display: inline-block; min-width: 20px; padding: 2px 5px; border: 1px solid var(--ui-border); border-bottom-width: 2px;  border-radius: 4px; background: var(--ui-subtle); color: var(--ui-muted); font: 500 11px/1.3 var(--ui-font); text-align: center;}.ui-search-overlay {  box-sizing: border-box; width: 100vw; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;  background: color-mix(in srgb, var(--ui-bg) 92%, transparent); color: var(--ui-fg); font: 15px/1.5 var(--ui-font);}.ui-search-overlay[open] { display: flex; justify-content: center; animation: ui-search-overlay-in .16s ease-out; }.ui-search-overlay::backdrop { background: var(--ui-overlay); backdrop-filter: blur(6px); }@keyframes ui-search-overlay-in { from { opacity: 0; } }.ui-search-overlay-col { display: flex; flex-direction: column; width: min(680px, 100%); height: 100%; padding: max(24px, 8vh) 20px 0; box-sizing: border-box; }.ui-search-overlay-bar { display: flex; align-items: center; gap: 12px; flex: none; padding: 0 4px 12px; border-bottom: 2px solid var(--ui-accent); }.ui-search-overlay-bar > svg { flex: none; width: 22px; height: 22px; color: var(--ui-muted); }.ui-search-overlay-input {  flex: 1; min-width: 0; height: 48px; padding: 0; border: 0; background: transparent; color: var(--ui-fg);  font: 500 22px/1.2 var(--ui-font); outline: none;}.ui-search-overlay-input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-search-overlay-input::-webkit-search-cancel-button { display: none; }.ui-search-overlay-x {  display: inline-flex; align-items: center; gap: 6px; flex: none; height: 32px; padding: 0 8px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-muted); font: 13px/1 var(--ui-font); cursor: pointer;}.ui-search-overlay-x:hover { background: var(--ui-subtle); color: var(--ui-fg); }.ui-search-overlay-x:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-search-overlay-list { flex: 1; min-height: 0; margin: 0; padding: 12px 0 32px; overflow: auto; list-style: none; overscroll-behavior: contain; }.ui-search-overlay-list ul { margin: 0; padding: 0; list-style: none; }.ui-search-overlay-group { padding: 14px 12px 6px; color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }.ui-search-overlay-opt { display: grid; gap: 2px; padding: 10px 12px; border-radius: var(--ui-radius-sm); cursor: pointer; scroll-margin: 8px; }.ui-search-overlay-opt[aria-selected="true"] { background: var(--ui-accent-soft); }.ui-search-overlay-title { font-weight: 500; }.ui-search-overlay-text { overflow: hidden; color: var(--ui-muted); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }.ui-search-overlay-opt mark { background: none; color: var(--ui-accent); font-weight: 650; }.ui-search-overlay-note { margin: 0; padding: 32px 12px; color: var(--ui-muted); text-align: center; }@media (prefers-reduced-motion: reduce) { .ui-search-overlay[open] { animation: none; } }`// The text in pieces, [{ text, hit }], hit where the words of `query` are (any case).fn search_overlay_marks(text: str, query: str) -> list {    let q = lower(trim(query))    if q == "" { return [{ text, hit: false }] }    let low = lower(text)    let parts = []    let i = 0    while i < len(text) {        let rest = slice(low, i)        let at = index_of(rest, q)        if at == null {            parts = parts | push({ text: slice(text, i), hit: false })            i = len(text)        } else {            if at > 0 { parts = parts | push({ text: slice(text, i, i + at), hit: false }) }            parts = parts | push({ text: slice(text, i + at, i + at + len(q)), hit: true })            i = i + at + len(q)        }    }    parts}// The items whose title or text has every word of the query.fn search_overlay_filter(items: list, query: str) -> list {    let words = split(lower(trim(query)), " ") | filter((w) => w != "")    items | filter((it) => {        let hay = lower(`${it.title} ${get(it, "text") ?? ""}`)        all(words, (w) => contains(hay, w))    })}component SearchOverlay(id: str, items: list = [], on_search = null, min_chars: int = 1, trigger: str? = "Search",                        shortcut: bool = true, placeholder: str = "Search…", label: str = "Search", empty: str = "No results",                        limit: int = 20, on_select = null, class: str? = null) {    style global search_overlay_css    state dlg = null    state query = ""    state found = []    state busy = false    state asked = 0    state active = 0    let ready = len(trim(query)) >= min_chars    let results = take(!ready ? [] : on_search != null ? found : search_overlay_filter(items, query), limit)    let groups = unique(results | map((r) => get(r, "group") ?? ""))    let ordered = flatten(for g in groups => results | filter((r) => (get(r, "group") ?? "") == g))    let n = len(ordered)    let at = active < n ? active : 0    let show = () => {        query = ""        found = []        active = 0        if !dlg.open { dlg.showModal() }        dlg.querySelector("input").focus()    }    let choose = (r) => {        dlg.close()        on_select && on_select(r)        if get(r, "href") != null { navigate(r.href) }    }    <div class={ui_cls(["ui-search-overlay-host", class])}>        {trigger != null && <button.ui-search-overlay-trigger type="button" aria-haspopup="dialog"                                    aria-keyshortcuts={shortcut ? "/" : null} @click={show}>            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"                 aria-hidden="true"><circle cx="7" cy="7" r="4.75"/><path d="M10.5 10.5L14 14"/></svg>            <span>{trigger}</span>            {shortcut && <kbd aria-hidden="true">/</kbd>}        </button>}        <dialog.ui-search-overlay aria-label={label}                @mount={(el) => {                    dlg = el                    let onkey = (e) => {                        let t = e.target                        let typing = t != null && t.closest != null && t.closest("input, textarea, select, [contenteditable]") != null                        if e.key == "/" && !typing && !el.open && !e.defaultPrevented && !e.ctrlKey && !e.metaKey && !e.altKey {                            e.preventDefault()                            show()                        }                    }                    if shortcut { js("document.addEventListener", "keydown", onkey) }                    () => { if shortcut { js("document.removeEventListener", "keydown", onkey) } }                }}>            <div.ui-search-overlay-col>                <div.ui-search-overlay-bar>                    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"                         aria-hidden="true"><circle cx="7" cy="7" r="4.75"/><path d="M10.5 10.5L14 14"/></svg>                    <input.ui-search-overlay-input type="search" id={`${id}-input`} role="combobox" value={query}                           placeholder={placeholder} aria-label={label} aria-expanded={n > 0 ? "true" : "false"}                           aria-controls={`${id}-list`} aria-autocomplete="list" aria-busy={busy ? "true" : null}                           aria-activedescendant={n > 0 ? `${id}-opt-${at}` : null} autocomplete="off" spellcheck="false"                           @input={(e) => {                               query = e.value                               active = 0                               if on_search != null && len(trim(e.value)) >= min_chars {                                   asked += 1                                   let mine = asked                                   busy = true                                   // The answer, kept if no newer question was asked meanwhile.                                   let land = (v = null) => {                                       if mine == asked {                                           found = type_of(v) == "list" ? v : []                                           busy = false                                       }                                   }                                   let r = on_search(e.value)                                   if type_of(r) == "promise" { r.then(land, (_ = null) => { land([]) }) } else { land(r) }                               }                           }}                           @keydown={(e) => {                               if (e.key == "ArrowDown" || e.key == "ArrowUp") && n > 0 {                                   e.preventDefault()                                   active = e.key == "ArrowDown" ? (at + 1) % n : (at + n - 1) % n                                   let el = dlg.querySelector(`#${id}-opt-${active}`)                                   el && el.scrollIntoView({ block: "nearest" })                               }                               if e.key == "Enter" {                                   e.preventDefault()                                   if n > 0 { choose(ordered[at]) }                               }                           }}/>                    <button.ui-search-overlay-x type="button" aria-label="Close search" @click={() => dlg.close()}><kbd aria-hidden="true">Esc</kbd></button>                </div>                <ul.ui-search-overlay-list id={`${id}-list`} role="listbox" aria-label={label} hidden={n == 0}>                    {for g in groups => <li key={g} role="presentation">                        {g != "" && <div.ui-search-overlay-group aria-hidden="true">{g}</div>}                        <ul role="group" aria-label={g != "" ? g : null}>                            {for r in ordered | filter((r) => (get(r, "group") ?? "") == g) {                                let i = index_of(ordered, r) ?? 0                                <li.ui-search-overlay-opt key={`${g}/${r.title}/${i}`} id={`${id}-opt-${i}`} role="option"                                    aria-selected={i == at ? "true" : "false"}                                    @mousemove={() => { if active != i { active = i } }}                                    @mousedown={(e) => { e.preventDefault() }}                                    @click={() => { choose(r) }}>                                    <span.ui-search-overlay-title>{for p in search_overlay_marks(r.title, query) =>                                        p.hit ? <mark>{p.text}</mark> : p.text}</span>                                    {get(r, "text") != null && <span.ui-search-overlay-text>{r.text}</span>}                                </li>                            }}                        </ul>                    </li>}                </ul>                {n == 0 && <p.ui-search-overlay-note>{!ready ? "Type to search." : busy ? "Searching…" : `${empty} for “${trim(query)}”.`}</p>}                <p.ui-sr role="status" aria-live="polite">{!ready || busy ? "" : n == 1 ? "1 result" : `${n} results`}</p>            </div>        </dialog>    </div>}
Thread
75Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.