Overlays

Flyout

A panel that opens beside its button at a chosen side, keeps open while you work in it, and closes on a click outside.

#popover#overlay#interactive

Demo

Preview
All orders, opened 0 times.

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

Example

The code of the preview above.

demos/flyout.qwe
component FlyoutDemoLive() {    state paid = false    state times = 0    <div.demo-row>        <Flyout id="flyout-demo-filters" trigger="Filters" title="Filter orders" side="right" on_open={() => { times += 1 }}>            <div.demo-stack>                <Switch label="Paid only" checked={paid} on_change={(v) => { paid = v }}/>                <Select name="status" label="Status" options={["Any", "Open", "Shipped"]}/>            </div>        </Flyout>        <span.demo-note>{paid ? "Paid only" : "All orders"}, opened {times} {times == 1 ? "time" : "times"}.</span>    </div>}view FlyoutDemo() =    <div.demo-stack>        <FlyoutDemoLive/>        <div.demo-row style="padding-top: 120px">            <Flyout id="flyout-demo-top" trigger="Above" side="top" align="center" variant="soft">                <p>Opened above its button, centred on it.</p>                <a href="#flyout">A link inside</a>            </Flyout>            <Flyout id="flyout-demo-bottom" trigger="Below, at the end" align="end" width={220}>                <p>Its right edge lines up with the button's.</p>            </Flyout>        </div>    </div>

Source

ui/flyout.qwe
// Flyout: a panel that opens beside its button, at the side you choose (under it,// above it, to its right or left), for a few settings, a filter, a mini form. It// stays open while you work in it, and closes on a click outside, on Escape (the// focus goes back to the button) or on the button again. Opening it moves the focus// to the first thing in it you can use.////   <Flyout id="filters" trigger="Filters" title="Filter orders" side="right">//       <Checkbox name="paid" label="Paid only"/>//   </Flyout>//   <Flyout id="more" trigger="Details" side="top" align="center" open={showing} on_close={() => { showing = false }}> ... </Flyout>//// id (required): the panel's id, unique on the page. trigger: the button's text,// styled as a Button (when button_css is on the page) of `variant` ("outline", the// default, "soft", "ghost" or "solid"). title: a heading for the panel; also its name// for screen readers (else the trigger's text is). children: the panel's content.// side: "bottom" (default), "top", "right" or "left". align: "start" (default),// "center" or "end", along that side. width: the panel's width in pixels (280).// open: open to start with, or from your component's state; on_open() and on_close():// inside one of your components, called when it opens and closes.import ui/theme.qwe use ui_clslet flyout_css = css`.ui-flyout { position: relative; display: inline-block; vertical-align: middle; }.ui-flyout-btn svg { width: 16px; height: 16px; margin-right: -4px; transition: transform .15s; }.ui-flyout-btn[aria-expanded="true"] svg { transform: rotate(180deg); }.ui-flyout-panel {  position: absolute; z-index: 30; box-sizing: border-box; width: min(var(--w, 280px), calc(100vw - 24px)); padding: 14px 16px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg);  box-shadow: var(--ui-shadow-lg); font: 14px/1.5 var(--ui-font); text-align: left; animation: ui-flyout-in .12s ease-out;}.ui-flyout-panel[hidden] { display: none; }@keyframes ui-flyout-in { from { opacity: 0; } }.ui-flyout-panel > :first-child { margin-top: 0; }.ui-flyout-panel > :last-child { margin-bottom: 0; }.ui-flyout-panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 600; line-height: 1.3; }.ui-flyout-bottom > .ui-flyout-panel { top: calc(100% + 8px); }.ui-flyout-top > .ui-flyout-panel { bottom: calc(100% + 8px); }.ui-flyout-right > .ui-flyout-panel { left: calc(100% + 8px); }.ui-flyout-left > .ui-flyout-panel { right: calc(100% + 8px); }.ui-flyout-bottom.ui-flyout-start > .ui-flyout-panel, .ui-flyout-top.ui-flyout-start > .ui-flyout-panel { left: 0; }.ui-flyout-bottom.ui-flyout-end > .ui-flyout-panel, .ui-flyout-top.ui-flyout-end > .ui-flyout-panel { right: 0; }.ui-flyout-bottom.ui-flyout-center > .ui-flyout-panel, .ui-flyout-top.ui-flyout-center > .ui-flyout-panel { left: 50%; translate: -50% 0; }.ui-flyout-right.ui-flyout-start > .ui-flyout-panel, .ui-flyout-left.ui-flyout-start > .ui-flyout-panel { top: 0; }.ui-flyout-right.ui-flyout-end > .ui-flyout-panel, .ui-flyout-left.ui-flyout-end > .ui-flyout-panel { bottom: 0; }.ui-flyout-right.ui-flyout-center > .ui-flyout-panel, .ui-flyout-left.ui-flyout-center > .ui-flyout-panel { top: 50%; translate: 0 -50%; }@media (prefers-reduced-motion: reduce) { .ui-flyout-panel { animation: none; } .ui-flyout-btn svg { transition: none; } }`// The arrow on the button, pointing to the side the panel opens on.fn flyout_arrow(side: str) =    match side {        "top" => "m6 15 6-6 6 6"        "right" => "m9 6 6 6-6 6"        "left" => "m15 6-6 6 6 6"        _ => "m6 9 6 6 6-6"    }component Flyout(id: str, trigger: str, title: str? = null, side: str = "bottom", align: str = "start", width: int = 280,                 variant: str = "outline", open: bool = false, on_open = null, on_close = null, class: str? = null, children) {    style global flyout_css    state shown = open    state host = null    let close = (back: bool) => {        if shown {            shown = false            if back { host.querySelector(".ui-flyout-btn").focus() }            on_close && on_close()        }    }    let show = () => {        shown = true        on_open && on_open()        // Shown at once (the markup follows `shown` a moment later), so the focus can go in.        let panel = host.querySelector(`#${id}`)        panel.hidden = false        let first = panel.querySelector(":is(a[href], button, input, select, textarea, [tabindex]):not([disabled]):not([tabindex='-1'])")        (first ?? panel).focus()    }    // Driven by your component's state: opening when `open` turns true, closing when it turns false.    state was = open    if open != was {        was = open        if open && !shown { show() }        if !open && shown { close(false) }    }    <div class={ui_cls(["ui-flyout", `ui-flyout-${side}`, `ui-flyout-${align}`, class])}         @mount={(el) => {             host = el             let away = (e) => { if shown && !el.contains(e.target) { close(false) } }             js("document.addEventListener", "pointerdown", away)             () => js("document.removeEventListener", "pointerdown", away)         }}         @keydown={(e) => { if e.key == "Escape" && shown { e.preventDefault(); close(true) } }}>        <button type="button" class={ui_cls(["ui-btn", `ui-btn-${variant}`, "ui-flyout-btn"])}                aria-expanded={shown ? "true" : "false"} aria-controls={id}                @click={() => { if shown { close(false) } else { show() } }}>            {trigger}            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                 stroke-linejoin="round" aria-hidden="true"><path d={flyout_arrow(side)}/></svg>        </button>        <div.ui-flyout-panel id={id} role="dialog" aria-label={title ?? trigger} tabindex="-1" hidden={!shown}             style={`--w: ${width}px`}>            {title != null && <h2>{title}</h2>}            {children}        </div>    </div>}
Thread
510Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.