Actions

SpeedDial

A floating action button that opens a column of smaller labelled actions, with the keyboard and a click elsewhere handled.

#button#menu#mobile#interactive

Demo

Preview

Did: nothing yet

Get it

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

Example

The code of the preview above.

demos/speed_dial.qwe
component SpeedDialDemoLive() {    state did = "nothing yet"    <div.demo-stack>        <div style="position: relative; height: 260px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius)">            <SpeedDial id="sd-demo-create" label="Create" inline={true} items={[                { label: "New document", icon: "edit", href: "#speed_dial" },                { label: "Send a message", icon: "message" },                { label: "Upload", icon: "up", disabled: true },                { label: "Call", icon: "phone" },            ]} on_select={(it) => { did = it.label }}/>            <SpeedDial id="sd-demo-top" label="Quick actions" icon="search" tone="neutral" corner="top-left" inline={true} items={[                { label: "Find people", icon: "search" },                { label: "Email", icon: "mail" },            ]} on_select={(it) => { did = it.label }}/>        </div>        <p.demo-note>Did: {did}</p>    </div>}view SpeedDialDemo() = <SpeedDialDemoLive/>

Source

ui/speed_dial.qwe
// SpeedDial: a floating action button that opens a short column of smaller actions// above it (new file, new folder, upload), each with its label beside it. Escape, a// click elsewhere, or the button again closes it; the arrow keys move through the// actions once it is open. It is styled as a Fab (fab_css, for the main button).////   <SpeedDial id="create" label="Create" items={[//       { label: "New document", icon: "edit", href: "/docs/new" },//       { label: "Upload", icon: "up" },//   ]}/>//   <SpeedDial id="ops" label="Actions" items={actions} on_select={(it) => { run(it.label) }}/>//// id (required): unique on the page; the actions' ids start with it.// label: the main button's name ("Actions"). icon: its icon, as Fab's ("plus"; it// turns into an x while open). items: a list of { label, icon?, href?, disabled? }:// icon as Fab's; an item with an href is a link, one without is a button that closes// the dial and, inside one of your components, calls on_select(item).// corner: "bottom-right" (default), "bottom-left", "top-right" or "top-left" (then// the actions open downwards). inline: in the corner of its nearest positioned// ancestor rather than the screen. tone: "accent" (default), "neutral" or "danger".import ui/fab.qwe use fab_pathimport ui/theme.qwe use ui_clslet speed_dial_css = css`.ui-speed-dial { position: fixed; z-index: 40; display: flex; flex-direction: column-reverse; align-items: flex-end; gap: 12px; }.ui-speed-dial-bottom-right { right: max(20px, env(safe-area-inset-right)); bottom: max(20px, env(safe-area-inset-bottom)); }.ui-speed-dial-bottom-left { left: max(20px, env(safe-area-inset-left)); bottom: max(20px, env(safe-area-inset-bottom)); align-items: flex-start; }.ui-speed-dial-top-right { right: max(20px, env(safe-area-inset-right)); top: max(20px, env(safe-area-inset-top)); flex-direction: column; }.ui-speed-dial-top-left { left: max(20px, env(safe-area-inset-left)); top: max(20px, env(safe-area-inset-top)); flex-direction: column; align-items: flex-start; }.ui-speed-dial-inline { position: absolute; z-index: 2; }.ui-speed-dial-inline.ui-speed-dial-bottom-right { right: 16px; bottom: 16px; }.ui-speed-dial-inline.ui-speed-dial-bottom-left { left: 16px; bottom: 16px; }.ui-speed-dial-inline.ui-speed-dial-top-right { right: 16px; top: 16px; }.ui-speed-dial-inline.ui-speed-dial-top-left { left: 16px; top: 16px; }.ui-speed-dial > .ui-fab { position: static; }.ui-speed-dial-main svg { transition: transform .2s; }.ui-speed-dial-open .ui-speed-dial-main svg { transform: rotate(135deg); }.ui-speed-dial-list { display: none; flex-direction: inherit; align-items: inherit; gap: 10px; margin: 0; padding: 0; list-style: none; }.ui-speed-dial-open .ui-speed-dial-list { display: flex; }.ui-speed-dial-list li { display: flex; align-items: center; gap: 10px; animation: ui-speed-dial-in .15s ease-out backwards; }.ui-speed-dial-bottom-left li, .ui-speed-dial-top-left li { flex-direction: row-reverse; }@keyframes ui-speed-dial-in { from { opacity: 0; transform: translateY(6px); } }.ui-speed-dial-name {  padding: 5px 10px; border-radius: var(--ui-radius-sm); background: var(--ui-fg); color: var(--ui-bg);  font: 500 13px/1.3 var(--ui-font); white-space: nowrap; box-shadow: var(--ui-shadow);}.ui-speed-dial-item {  display: inline-grid; place-items: center; width: 42px; height: 42px; padding: 0; border: 1px solid var(--ui-border);  border-radius: 50%; background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow); cursor: pointer;  text-decoration: none; transition: background .15s, border-color .15s;}.ui-speed-dial-item:hover { background: var(--ui-subtle); border-color: var(--ui-strong); }.ui-speed-dial-item:focus-visible { outline: none; box-shadow: var(--ui-shadow), var(--ui-focus); }.ui-speed-dial-item[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }.ui-speed-dial-item svg { width: 20px; height: 20px; }@media (prefers-reduced-motion: reduce) { .ui-speed-dial-main svg { transition: none; } .ui-speed-dial-list li { animation: none; } }`component SpeedDial(id: str, label: str = "Actions", icon: str = "plus", items: list = [], corner: str = "bottom-right",                    inline: bool = false, tone: str = "accent", on_select = null, class: str? = null) {    style global speed_dial_css    state open = false    state host = null    let actions = () => js("Array.from", host.querySelectorAll(".ui-speed-dial-item:not([aria-disabled=true])"))    let move = (by: int) => {        let found = actions()        let n = len(found)        if n == 0 { return null }        let at = reduce(enumerate(found), null, (got, p) => p[1].isSameNode(js("document.activeElement")) ? p[0] : got)        let to = at == null ? (by > 0 ? 0 : n - 1) : (at + by + n) % n        found[to].focus()    }    let close = (back: bool) => {        open = false        if back { host.querySelector(".ui-speed-dial-main").focus() }    }    <div class={ui_cls(["ui-speed-dial", `ui-speed-dial-${corner}`, inline && "ui-speed-dial-inline", open && "ui-speed-dial-open", class])}         @mount={(el) => {             host = el             let away = (e) => { if open && !el.contains(e.target) { open = false } }             js("document.addEventListener", "pointerdown", away)             () => js("document.removeEventListener", "pointerdown", away)         }}         @keydown={(e) => {             if e.key == "Escape" && open { e.preventDefault(); close(true) }             if (e.key == "ArrowUp" || e.key == "ArrowDown") && open {                 e.preventDefault()                 move(e.key == (starts_with(corner, "top") ? "ArrowDown" : "ArrowUp") ? 1 : -1)             }         }}         @focusout={(e) => { if open && (e.relatedTarget == null || !host.contains(e.relatedTarget)) { open = false } }}>        <button class={ui_cls(["ui-fab", "ui-speed-dial-main", tone != "accent" && `ui-fab-${tone}`])} type="button" id={id}                aria-label={label} title={label} aria-haspopup="true" aria-expanded={open ? "true" : "false"}                aria-controls={`${id}-list`} @click={() => { open = !open }}>            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                 stroke-linejoin="round" aria-hidden="true"><path d={fab_path(icon)}/></svg>        </button>        <ul.ui-speed-dial-list id={`${id}-list`} aria-label={label}>            {for [i, it] in enumerate(items) => <li key={it.label} style={`animation-delay: ${i * 30}ms`}>                <span.ui-speed-dial-name id={`${id}-name-${i}`} aria-hidden="true">{it.label}</span>                {get(it, "href") != null && get(it, "disabled") != true                    ? <a.ui-speed-dial-item href={it.href} aria-label={it.label} title={it.label} @click={() => { open = false }}>                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                             stroke-linejoin="round" aria-hidden="true"><path d={fab_path(get(it, "icon") ?? "plus")}/></svg>                      </a>                    : <button.ui-speed-dial-item type="button" aria-label={it.label} title={it.label}                              aria-disabled={get(it, "disabled") == true ? "true" : null}                              @click={() => {                                  if get(it, "disabled") != true {                                      close(true)                                      on_select && on_select(it)                                  }                              }}>                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                             stroke-linejoin="round" aria-hidden="true"><path d={fab_path(get(it, "icon") ?? "plus")}/></svg>                      </button>}            </li>}        </ul>    </div>}
Thread
58Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.