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.
Demo
Filter orders
Opened above its button, centred on it.
A link insideIts right edge lines up with the button's.
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.qwemain.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>}Related
HoverCardA card of details on hover or focus, after a short wait, that stays under the pointer; Escape hides it.5 views · 6 downloadsNotificationCenterA bell with the unread count, opening a panel of notifications to filter and mark read, one by one or all at once.5 views · 6 downloadsPopoverA small panel of anything beside a button, on the browser's popover: Escape and clicks outside close it.5 views · 5 downloadsTooltipA short note shown while something is hovered or focused, in CSS alone, on any of its four sides.5 views · 9 downloadsAlertDialogA modal that only informs, with one OK button, on the browser's own <dialog> and no JavaScript.6 views · 7 downloadsChatWidgetA chat button in the corner that opens a small chat panel, with replies from your server and a count for unseen ones.4 views · 7 downloads
Thread
Thread 0 posts
On Flyout: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.