SearchList
A list that filters as you type, marks what matched, counts the results and is walked with the arrow keys.
Demo
7 results
- RoutesA route answers requests by method and path, with typed path parameters and query patterns that convert text to numbers.
- ComponentsA component is a view with state that also runs in the browser, resumed from what the server rendered.
- Server functionsCall the server from a component as if it were a local function; arguments are checked as records.
- DatabaseSQLite by default, Postgres and MySQL when asked: db.query, db.one, db.exec and transactions with retries.
- UploadsA multipart form sends files; each one is in request.files under its field's name, saved with save_upload.
- Forms without JavaScriptEvery field is a real input with a name, so a plain form posts it to a route and the server answers.
- Live datalive(load) makes component state follow the server: it loads again whenever something it read changes.
4 of 8 results
Nobody picked.
Get it
It needs theme.qwe, as every component does, and match_highlight.qwe; 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/match_highlight.qwe$ curl -O https://quillolang.org/components/ui/search_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/search_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/search_list.qwe
let search_list_demo_docs = [ { title: "Routes", text: "A route answers requests by method and path, with typed path parameters and query patterns that convert text to numbers.", href: "#breadcrumbs", tags: ["Server"] }, { title: "Components", text: "A component is a view with state that also runs in the browser, resumed from what the server rendered.", href: "#combobox", tags: ["Browser"] }, { title: "Server functions", text: "Call the server from a component as if it were a local function; arguments are checked as records.", href: "#copy_button", tags: ["Server", "Browser"] }, { title: "Database", text: "SQLite by default, Postgres and MySQL when asked: db.query, db.one, db.exec and transactions with retries.", href: "#data_table", tags: ["Server", "Data"] }, { title: "Uploads", text: "A multipart form sends files; each one is in request.files under its field's name, saved with save_upload.", href: "#file_drop", tags: ["Server", "Forms"] }, { title: "Records", text: "type names a shape that is checked when a function is called and made from what a client sends.", href: "#description_list", tags: ["Data"] }, { title: "Forms without JavaScript", text: "Every field is a real input with a name, so a plain form posts it to a route and the server answers.", href: "#text_field", tags: ["Forms"] }, { title: "Live data", text: "live(load) makes component state follow the server: it loads again whenever something it read changes.", href: "#sparkline", tags: ["Browser", "Data"] },]let search_list_demo_people = [ { title: "Ada Lovelace", text: "Engineering · London", tags: ["Engineering"] }, { title: "Grace Hopper", text: "Engineering · Arlington", tags: ["Engineering"] }, { title: "Hedy Lamarr", text: "Design · Vienna", tags: ["Design"] }, { title: "Katherine Johnson", text: "Research · Hampton", tags: ["Research"] }, { title: "Margaret Hamilton", text: "Engineering · Boston", tags: ["Engineering"] }, { title: "Radia Perlman", text: "Research · Seattle", tags: ["Research"] }, { title: "Annie Easley", text: "Research · Cleveland", tags: ["Research"] }, { title: "Lynn Conway", text: "Design · Ann Arbor", tags: ["Design"] },]component SearchListDemoLive() { state picked = null <div.demo-stack> <SearchList id="sl-people" label="Find a colleague" show_label={true} placeholder="Name, team or city" items={search_list_demo_people} limit={4} on_select={(p) => { picked = p.title }}/> <p.demo-note>{picked == null ? "Nobody picked." : `Picked ${picked}.`}</p> </div>}view SearchListDemo() = <div.demo-stack> <SearchList id="sl-docs" label="Search the guide" items={search_list_demo_docs} facets={true} query="server" empty="Nothing in the guide"/> <SearchListDemoLive/> </div>Source
ui/search_list.qwe
// SearchList: a search box over a list that keeps what matches as it is typed, with// the words found marked, how many there are said to screen readers, and the// results reached with the arrow keys: Down from the box goes into them, Up and Down// move, Escape goes back to the box, Enter in the box opens the first. Needs// ui/match_highlight.qwe.//// <SearchList id="docs" label="Search the docs" facets={true} items={[// { title: "Routes", text: "Answer requests by method and path.", href: "/docs/routes", tags: ["Server"] },// { title: "Components", text: "State that runs in the browser.", href: "/docs/components", tags: ["Browser"] },// ]}/>// <SearchList id="people" items={team} on_select={(p) => { open_profile(p.title) }}/>//// id (required): unique on the page; its parts' ids start with it.// items: a list of { title, text?, href?, tags? }. Every word typed must be in its// title, text or tags, in any case. An item with an href is a link; one without is a// button that, inside one of your components, calls on_select(item).// label: the box's name ("Search"); shown above it when show_label is true.// placeholder ("Search…"), name ("q", so a <form method="get"> can send it), query (the// text to start with). facets: chips of the items' tags, with how many match each,// that keep one tag (facet_label names them: "Tags"). limit: the most shown at once.// empty: what it says when nothing matches ("No results").import ui/match_highlight.qwe use MatchHighlight, match_highlight_wordsimport ui/theme.qwe use ui_clslet search_list_css = css`.ui-sl { display: grid; gap: 10px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-sl-label { font-size: 13px; font-weight: 500; }.ui-sl-box { position: relative; display: flex; align-items: center; }.ui-sl-box svg { position: absolute; left: 11px; width: 16px; height: 16px; color: var(--ui-muted); pointer-events: none; }.ui-sl-input { box-sizing: border-box; width: 100%; height: 40px; margin: 0; padding: 0 12px 0 35px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 15px/1 var(--ui-font); appearance: none; transition: border-color .15s, box-shadow .15s;}.ui-sl-input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-sl-input:hover { border-color: var(--ui-strong); }.ui-sl-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-sl-facets { display: flex; flex-wrap: wrap; gap: 6px; }.ui-sl-chip { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer;}.ui-sl-chip:hover:not(:disabled) { border-color: var(--ui-strong); }.ui-sl-chip:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-sl-chip[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-sl-chip:disabled { opacity: .5; cursor: not-allowed; }.ui-sl-chip span { color: var(--ui-muted); font-variant-numeric: tabular-nums; }.ui-sl-chip[aria-pressed="true"] span { color: inherit; }.ui-sl-count { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-sl-list { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }.ui-sl-hit { display: grid; gap: 2px; width: 100%; padding: 10px 12px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: inherit; text-align: left; text-decoration: none; cursor: pointer;}.ui-sl-hit:hover { background: var(--ui-subtle); }.ui-sl-hit:focus-visible { outline: none; background: var(--ui-subtle); box-shadow: var(--ui-focus); }.ui-sl-title { font-weight: 600; color: var(--ui-fg); }a.ui-sl-hit .ui-sl-title { color: var(--ui-accent); }.ui-sl-text { color: var(--ui-muted); font-size: 13px; overflow-wrap: anywhere; }.ui-sl-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }.ui-sl-tag { padding: 1px 7px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; }.ui-sl-hit:hover .ui-sl-tag, .ui-sl-hit:focus-visible .ui-sl-tag { background: var(--ui-bg); }.ui-sl-empty { padding: 26px 16px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-sm); color: var(--ui-muted); text-align: center; }.ui-sl-empty b { color: var(--ui-fg); }@media (prefers-reduced-motion: reduce) { .ui-sl-input { transition: none; } }`// Whether an item has every word of the query (words in lower case).fn search_list_matches(item: map, words: list) -> bool { let hay = lower(join([item.title, get(item, "text") ?? "", join(get(item, "tags") ?? [], " ")], " ")) all(words, (w) => contains(hay, w))}// A piece of a long text around the first word found, at most `width` characters.fn search_list_snippet(text: str, words: list, width: int = 160) -> str { if len(text) <= width { return text } let low = lower(text) let at = min(words | map((w) => index_of(low, w)) | filter((i) => i != null)) ?? 0 let start = at > 50 ? at - 40 : 0 let end = start + width `${start > 0 ? "…" : ""}${trim(slice(text, start, end))}${end < len(text) ? "…" : ""}`}component SearchList(id: str, items: list = [], label: str = "Search", show_label: bool = false, placeholder: str = "Search…", name: str = "q", query: str = "", facets: bool = false, facet_label: str = "Tags", limit: int? = null, empty: str = "No results", on_select = null, class: str? = null) { style global search_list_css state text = query state tag = null let words = match_highlight_words(text) let found = items | filter((it) => search_list_matches(it, words)) let tags = unique(flatten(items | map((it) => get(it, "tags") ?? []))) let tagged = tag == null ? found : found | filter((it) => contains(get(it, "tags") ?? [], tag)) let total = len(tagged) let shown = limit == null ? tagged : take(tagged, limit) let go = (e, i: int) => { let el = e.target.closest(".ui-sl").querySelector(`[data-i="${i}"]`) el && el.focus() } let pick = (it) => { on_select && on_select(it) } <div class={ui_cls(["ui-sl", class])}> {show_label && <label.ui-sl-label for={`${id}-q`}>{label}</label>} <div.ui-sl-box role="search"> <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-sl-input type="search" id={`${id}-q`} name={name} value={text} placeholder={placeholder} aria-label={show_label ? null : label} aria-controls={`${id}-list`} aria-describedby={`${id}-count`} autocomplete="off" spellcheck="false" @input={(e) => { text = e.value }} @keydown={(e) => { if e.key == "ArrowDown" && len(shown) > 0 { e.preventDefault() go(e, 0) } if e.key == "Escape" && text != "" { e.preventDefault() text = "" } if e.key == "Enter" && len(shown) > 0 { e.preventDefault() let first_hit = e.target.closest(".ui-sl").querySelector(`[data-i="0"]`) first_hit && first_hit.click() } }}/> </div> {facets && len(tags) > 0 && <div.ui-sl-facets role="group" aria-label={facet_label}> <button.ui-sl-chip type="button" aria-pressed={tag == null ? "true" : "false"} @click={() => { tag = null }}>All <span>{len(found)}</span></button> {for t in tags => <button.ui-sl-chip key={t} type="button" aria-pressed={tag == t ? "true" : "false"} disabled={tag != t && count(found, (it) => contains(get(it, "tags") ?? [], t)) == 0} @click={() => { tag = tag == t ? null : t }}> {t} <span>{count(found, (it) => contains(get(it, "tags") ?? [], t))}</span> </button>} </div>} <p.ui-sl-count id={`${id}-count`} role="status" aria-live="polite"> {total == 0 ? "No results" : total == 1 ? "1 result" : len(shown) < total ? `${len(shown)} of ${total} results` : `${total} results`} </p> <ul.ui-sl-list id={`${id}-list`} aria-label={`${label}: results`} hidden={total == 0} @keydown={(e) => { let i = int(e.target.dataset.i ?? "") ?? null if i != null { let n = len(shown) if e.key == "ArrowDown" { e.preventDefault(); go(e, (i + 1) % n) } if e.key == "ArrowUp" { e.preventDefault() if i == 0 { e.target.closest(".ui-sl").querySelector("input").focus() } else { go(e, i - 1) } } if e.key == "Home" { e.preventDefault(); go(e, 0) } if e.key == "End" { e.preventDefault(); go(e, n - 1) } if e.key == "Escape" { e.preventDefault(); e.target.closest(".ui-sl").querySelector("input").focus() } } }}> {for [i, it] in enumerate(shown) => <li key={`${it.title}/${get(it, "href") ?? i}`}> {get(it, "href") != null ? <a.ui-sl-hit href={it.href} data-i={i} @click={() => { pick(it) }}> <span.ui-sl-title><MatchHighlight text={it.title} query={text}/></span> {get(it, "text") != null && <span.ui-sl-text><MatchHighlight text={search_list_snippet(it.text, words)} query={text}/></span>} {len(get(it, "tags") ?? []) > 0 && <span.ui-sl-tags>{for t in it.tags => <span.ui-sl-tag><MatchHighlight text={t} query={text}/></span>}</span>} </a> : <button.ui-sl-hit type="button" data-i={i} @click={() => { pick(it) }}> <span.ui-sl-title><MatchHighlight text={it.title} query={text}/></span> {get(it, "text") != null && <span.ui-sl-text><MatchHighlight text={search_list_snippet(it.text, words)} query={text}/></span>} {len(get(it, "tags") ?? []) > 0 && <span.ui-sl-tags>{for t in it.tags => <span.ui-sl-tag><MatchHighlight text={t} query={text}/></span>}</span>} </button>} </li>} </ul> {total == 0 && <div.ui-sl-empty>{empty}{trim(text) != "" && <span> for <b>“{trim(text)}”</b></span>}{tag != null && <span> in {tag}</span>}.</div>} </div>}Related
DataGridSort, search, filter by a column's values, pick rows and page through them, in one table.5 views · 9 downloadsLogViewerLog lines with each level in its colour, buttons to show or hide levels, a search that marks matches, wrapping and following the end.8 views · 7 downloadsQueryBuilderBuilds nested AND/OR filters with operators by field type, and gives a JSON filter and parametrised SQL, with a live count of the sample rows that match.3 views · 5 downloadsReviewListReviews sorted by a select (newest, highest, most helpful), filtered by stars with counts, shown a page at a time.6 views · 7 downloadsFaqQuestions that open to their answers on <details>, with a search box that keeps only the ones matching.6 views · 7 downloadsFilterBarA search box with filter menus and chips, counts, removable pills and Clear all, sent as a GET form.5 views · 8 downloads
Thread
Thread 0 posts
On SearchList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.