SpeedDial
A floating action button that opens a column of smaller labelled actions, with the keyboard and a click elsewhere handled.
Demo
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.qwemain.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>}Related
DropdownMenuA button that opens a short list of links and actions; Escape or a click elsewhere closes it.6 views · 7 downloadsFabA round floating action button fixed to a corner of the screen, with an icon and an optional label.5 views · 7 downloadsSplitButtonThe usual action as a button, joined to a menu of the other ways to do it, with the keyboard.5 views · 4 downloadsDockA floating bar of icon buttons fixed to the bottom or a side of the page, with badges and the current one marked.6 views · 7 downloadsBookmarkButtonA save-for-later toggle whose bookmark fills when pressed, with or without its label.8 views · 8 downloadsButtonA button, or a link that looks like one: four variants, three tones, three sizes, loading.7 views · 7 downloads
Thread
Thread 0 posts
On SpeedDial: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.