Mobile

EmptyScreen

Empty states for first use, none-found and all-done, each with what to do next.

#mobile#no-js#tablet#empty#onboarding#search

Demo

Preview

0 actions, 0 others.

No invoices match “red bicycle lamp”

  • Take off a filter to see more.
  • Try fewer words.

No reports yet

When you have reports, they will show up here.

1 of 2 done

  1. Connect a source (done)
  2. Pick a template

Get it

It needs theme.qwe, as every component does, and button.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/button.qwe$ curl -O https://quillolang.org/components/ui/empty_screen.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/empty_screen.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/empty_screen.qwe
let empty_screen_demo_css = css`.empty-screen-demo-keys { display: flex; flex-wrap: wrap; gap: 8px; }.empty-screen-demo-q { box-sizing: border-box; width: 100%; min-height: 46px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px var(--ui-font); }`component EmptyScreenDemoLive() {    style global empty_screen_demo_css    state kind = "none"    state q = "red bicycle lamp"    state filters = [{ id: "open", label: "Open only" }, { id: "week", label: "This week" }]    state steps = [{ label: "Add a customer", done: true }, { label: "Set your prices" }, { label: "Send your first invoice" }]    state acted = 0    state second = 0    <div.demo-stack>        <div.empty-screen-demo-keys>            {for k in ["first", "none", "done"] => <Button key={k} size="lg" id={`empty-screen-demo-k${k}`} variant={kind == k ? "solid" : "outline"} on_click={() => { kind = k }}>{k}</Button>}        </div>        <label class="ui-sr" for="empty-screen-demo-q">Search</label>        <input.empty-screen-demo-q id="empty-screen-demo-q" value={q} @input={(e) => { q = e.value }}/>        <p.demo-note id="empty-screen-demo-out">{acted} actions, {second} others.</p>        <div.demo-phone>            <EmptyScreen id="empty-screen-demo" kind={kind} noun="invoices" query={q} filters={filters} steps={steps} count={12}                         next="The next ones come on Monday."                         action={kind == "first" ? "Create an invoice" : (kind == "done" ? "See what is finished" : null)}                         secondary={kind == "first" ? "Import from a file" : null}                         on_action={() => { acted += 1; if kind == "none" { q = ""; filters = [] } }}                         on_secondary={() => { second += 1 }}                         on_clear={(id) => { filters = filters | filter((f) => f.id != id) }}                         on_step={(i) => { steps = enumerate(steps) | map((p) => p[0] == i ? { label: p[1].label, done: true } : p[1]) }}/>        </div>    </div>}view EmptyScreenDemo() =    <div.demo-stack>        <EmptyScreenDemoLive/>        <div.demo-tablet style="height: 360px">            <EmptyScreen id="empty-screen-demo-plain" kind="first" noun="reports" action="Write a report" href="/reports/new"                         steps={[{ label: "Connect a source", done: true }, { label: "Pick a template", href: "/templates" }]}/>        </div>    </div>

Source

ui/empty_screen.qwe
// EmptyScreen: what fills a screen with nothing to show, for the three reasons a screen// is empty, each with what to do next. First use: nothing here yet, with the steps to// begin, the next one marked. None found: a search or filters matched nothing, saying// what was looked for, with the filters to take off and advice fitting the search.// All done: the list is finished, which is good news, with what comes next. It needs no// script, so it works on a plain page with links, and takes callbacks inside a component.////   <EmptyScreen kind="first" noun="invoices" action="Create an invoice" href="/invoices/new"//                steps={[{ label: "Add a customer", done: true }, { label: "Set your prices", href: "/prices" }]}/>//   <EmptyScreen kind="none" noun="orders" query={q} filters={[{ id: "open", label: "Open only" }]}//                on_clear={(id) => { drop(id) }} on_action={() => { q = "" }}/>//   <EmptyScreen kind="done" noun="tasks" count={12} next="The next ones come on Monday." action="See what is finished"/>//// kind: "first" (default), "none" or "done". noun: what the screen lists, in the plural// ("items"). title, text: in place of the words chosen. query: what was searched for.// filters: for "none", a list of { id, label } now on, each a button that takes it off.// steps: for "first", a list of { label, done?, href?, id? }, with progress and the next// step marked. count: for "done", how many were finished. next: for "done", when more// will come. action, href: the main button, a link with href. By default "none" offers// "Clear the search" with a query or filters. secondary, secondary_href: a quieter// button. fill: it is the screen: fixed to the window; else it fills its parent. id,// class: for the outer element. children: anything more, under the buttons.// on_action(), on_secondary(), on_clear(id), on_step(index): inside one of your components.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonlet empty_screen_css = css`.ui-empty-screen {  container-type: inline-size; box-sizing: border-box; display: flex; width: 100%; height: 100%; min-height: 100%; overflow-y: auto; overscroll-behavior: contain;  background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font);  padding: max(16px, var(--ui-safe-top, env(safe-area-inset-top, 0px))) max(20px, var(--ui-safe-right, env(safe-area-inset-right, 0px))) max(16px, var(--ui-safe-bottom, env(safe-area-inset-bottom, 0px))) max(20px, var(--ui-safe-left, env(safe-area-inset-left, 0px)));}.ui-empty-screen-fill { position: fixed; inset: 0; z-index: 30; height: auto; }.ui-empty-screen-page { flex: 1; display: grid; align-content: center; justify-items: center; gap: 12px; min-width: 0; text-align: center; }.ui-empty-screen-art { display: grid; place-items: center; width: 104px; height: 104px; border-radius: 50%; background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-empty-screen-done .ui-empty-screen-art { background: var(--ui-success-soft); color: var(--ui-success); }.ui-empty-screen-art svg { width: 52px; height: 52px; }.ui-empty-screen-tick { stroke-dasharray: 26; stroke-dashoffset: 26; animation: ui-empty-screen-draw .5s .15s ease-out forwards; }@keyframes ui-empty-screen-draw { to { stroke-dashoffset: 0; } }.ui-empty-screen-title { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 22px; font-weight: 650; line-height: 1.25; }.ui-empty-screen-text { max-width: 42ch; margin: 0; color: var(--ui-muted); }.ui-empty-screen-tips { max-width: 42ch; margin: 0; padding: 0; color: var(--ui-muted); list-style: none; }.ui-empty-screen-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }.ui-empty-screen-chip {  display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: 999px;  background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1.2 var(--ui-font); cursor: pointer;}.ui-empty-screen-chip:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-empty-screen-chip svg { width: 14px; height: 14px; color: var(--ui-muted); }.ui-empty-screen-count { margin: 0; color: var(--ui-muted); font-size: 14px; font-variant-numeric: tabular-nums; }.ui-empty-screen-steps { display: grid; gap: 6px; width: 100%; max-width: 420px; margin: 0; padding: 0; list-style: none; text-align: left; }.ui-empty-screen-step {  display: flex; align-items: center; gap: 10px; box-sizing: border-box; width: 100%; min-height: 48px; padding: 8px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: inherit; text-align: left; text-decoration: none;}button.ui-empty-screen-step, a.ui-empty-screen-step { cursor: pointer; }.ui-empty-screen-step:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-empty-screen-step-next { border-color: var(--ui-accent); background: var(--ui-accent-soft); font-weight: 600; }.ui-empty-screen-step-done { color: var(--ui-muted); text-decoration: line-through; }.ui-empty-screen-mark { display: grid; place-items: center; flex: none; width: 24px; height: 24px; border: 1.5px solid var(--ui-strong); border-radius: 50%; font-size: 12px; font-variant-numeric: tabular-nums; }.ui-empty-screen-step-done .ui-empty-screen-mark { border-color: var(--ui-success); background: var(--ui-success); color: var(--ui-bg); }.ui-empty-screen-step-next .ui-empty-screen-mark { border-color: var(--ui-accent); }.ui-empty-screen-actions { display: grid; gap: 10px; width: 100%; max-width: 420px; margin-top: 6px; }.ui-empty-screen-actions .ui-btn { width: 100%; }@container (min-width: 640px) {  .ui-empty-screen-page { grid-template-columns: 180px minmax(0, 480px); justify-content: center; justify-items: start; column-gap: 32px; align-content: center; text-align: left; }  .ui-empty-screen-art { grid-row: 1 / span 8; width: 180px; height: 180px; }  .ui-empty-screen-art svg { width: 88px; height: 88px; }  .ui-empty-screen-page > :not(.ui-empty-screen-art) { grid-column: 2; }  .ui-empty-screen-title { font-size: 26px; }  .ui-empty-screen-chips { justify-content: flex-start; }  .ui-empty-screen-actions { display: flex; flex-wrap: wrap; max-width: none; }  .ui-empty-screen-actions .ui-btn { width: auto; min-width: 160px; }}@media (prefers-reduced-motion: reduce) { .ui-empty-screen-tick { animation: none; stroke-dashoffset: 0; } }`fn empty_screen_kind(kind) -> str = contains(["first", "none", "done"], kind) ? kind : "first"// Long enough to read, short enough for a phone's heading; a cut comes at a character, not in a word's bytes.fn empty_screen_clip(query) -> str {    let q = type_of(query) == "str" ? trim(query) : ""    len(q) > 40 ? trim(slice(q, 0, 40)) + "…" : q}fn empty_screen_title(kind: str, noun: str, query, filters: list, count) -> str {    let q = empty_screen_clip(query)    match kind {        "none" => q != "" ? `No ${noun} match “${q}”` : (len(filters) > 0 ? `No ${noun} match these filters` : `No ${noun} found`)        "done" => `All ${noun} done`        _ => `No ${noun} yet`    }}fn empty_screen_text(kind: str, noun: str, count, next) -> str =    match kind {        "none" => ""        "done" => next != null && next != "" ? next : "There is nothing left to do here."        _ => `When you have ${noun}, they will show up here.`    }// Advice that fits what was searched for, at most two lines, the likeliest first.fn empty_screen_tips(query, filters: list) -> list {    let q = empty_screen_clip(query)    let words = split(q, " ") | filter((w) => w != "")    let tips = [        len(filters) > 0 && "Take off a filter to see more.",        len(words) > 2 && "Try fewer words.",        q != "" && len(q) < 3 && "Try a longer word.",        q != "" && re.test(q, "[0-9]") && "Check the number.",        q != "" && "Check the spelling.",    ] | filter((t) => t != false)    take(tips, 2)}// Steps done, and the index of the next one to do, or null with all of them done.fn empty_screen_progress(steps: list) {    let open = enumerate(steps) | filter((p) => get(p[1], "done") != true)    { done: len(steps) - len(open), total: len(steps), next: len(open) > 0 ? open[0][0] : null }}fn empty_screen_art(kind: str) =    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">        {match kind {            "none" => <path d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14ZM16.5 16.5 21 21M8.5 9l5 4M13.5 9l-5 4"/>            "done" => <path class="ui-empty-screen-tick" d="M5 12.5l4.5 4.5L19 7.5"/>            _ => <path d="M3.5 13.5l2.6-7.1a2 2 0 0 1 1.9-1.4h8a2 2 0 0 1 1.9 1.4l2.6 7.1v4a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2zM3.5 13.5H8l1.5 2.5h5l1.5-2.5h4.5"/>        }}    </svg>fn empty_screen_step_body(i: int, step, is_done: bool) = [    <span.ui-empty-screen-mark aria-hidden="true">{is_done ? "✓" : i + 1}</span>,    <span>{step.label}{is_done && <span.ui-sr> (done)</span>}</span>,]// A step: a link with an href, a button with on_step, else plain text. A view, not a// function, so that a press does not remake the button under the finger.view EmptyScreenStep(i: int, step, next, on_step = null) {    let is_done = get(step, "done") == true    let cls = ui_cls(["ui-empty-screen-step", is_done && "ui-empty-screen-step-done", next == i && "ui-empty-screen-step-next"])    let here = next == i ? "step" : null    let link = get(step, "href")    link != null        ? <a class={cls} href={link} aria-current={here}>{empty_screen_step_body(i, step, is_done)}</a>        : (on_step != null            ? <button class={cls} type="button" aria-current={here} @click={() => { on_step(i) }}>{empty_screen_step_body(i, step, is_done)}</button>            : <div class={cls} aria-current={here}>{empty_screen_step_body(i, step, is_done)}</div>)}view EmptyScreen(kind: str = "first", noun: str = "items", title: str? = null, text: str? = null, query: str? = null, filters: list = [],                 steps: list = [], count = null, next: str? = null, action: str? = null, href: str? = null, secondary: str? = null,                 secondary_href: str? = null, fill: bool = false, id: str? = null, class: str? = null, on_action = null, on_secondary = null,                 on_clear = null, on_step = null, children) {    style global empty_screen_css    let shown = empty_screen_kind(kind)    let search = shown == "none" ? empty_screen_clip(query) : ""    let on_filters = shown == "none" ? filters : []    let tips = shown == "none" ? empty_screen_tips(query, on_filters) : []    let list_steps = shown == "first" ? steps : []    let progress = empty_screen_progress(list_steps)    let words = text ?? empty_screen_text(shown, noun, count, next)    let main_action = action ?? (shown == "none" && (search != "" || len(on_filters) > 0) ? "Clear the search" : null)    <section class={ui_cls(["ui-empty-screen", `ui-empty-screen-${shown}`, fill && "ui-empty-screen-fill", class])} id={id} data-kind={shown}>        <div.ui-empty-screen-page>            <div.ui-empty-screen-art>{empty_screen_art(shown)}</div>            <h1.ui-empty-screen-title>{title ?? empty_screen_title(shown, noun, query, on_filters, count)}</h1>            {words != "" && <p.ui-empty-screen-text>{words}</p>}            {shown == "done" && type_of(count) == "int" && count > 0 && <p.ui-empty-screen-count>{count} finished</p>}            {len(tips) > 0 && <ul.ui-empty-screen-tips>{for t in tips => <li key={t}>{t}</li>}</ul>}            {len(on_filters) > 0 && <div.ui-empty-screen-chips role="group" aria-label="Filters on">                {for f in on_filters => <button.ui-empty-screen-chip key={f.id} type="button" aria-label={`Take off the filter ${f.label}`}                                                @click={() => { on_clear && on_clear(f.id) }}>                    {f.label}                    <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>                </button>}            </div>}            {progress.total > 0 && <p.ui-empty-screen-count>{progress.done} of {progress.total} done</p>}            {progress.total > 0 && <ol.ui-empty-screen-steps>                {for p in enumerate(list_steps) => <li key={p[0]}><EmptyScreenStep i={p[0]} step={p[1]} next={progress.next} on_step={on_step}/></li>}            </ol>}            {(main_action != null || secondary != null) && <div.ui-empty-screen-actions>                {main_action != null && <Button size="lg" href={href} on_click={on_action}>{main_action}</Button>}                {secondary != null && <Button size="lg" variant="outline" href={secondary_href} on_click={on_secondary}>{secondary}</Button>}            </div>}            {children}        </div>    </section>}
Thread
38Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.