AdaptiveDialog
A sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.
Demo
Route: Morning run. Closed: 0.
Get it
It needs theme.qwe, as every component does, and button.qwe and bottom_sheet.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/bottom_sheet.qwe$ curl -O https://quillolang.org/components/ui/adaptive_dialog.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/adaptive_dialog.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/adaptive_dialog.qwe
let adaptive_dialog_demo_css = css`.adaptive-dialog-demo-field { box-sizing: border-box; display: block; width: 100%; height: 46px; margin: 6px 0 12px; 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 AdaptiveDialogDemoLive() { style global adaptive_dialog_demo_css state asking = false state saved = "Morning run" state draft = "Morning run" state closes = 0 state dialog_width = 520 <div.demo-stack> <div.demo-row> <Button size="lg" variant="outline" on_click={() => { asking = true; draft = saved }}>Rename the route</Button> <AdaptiveDialog title="About routes" trigger="Read more" width={dialog_width}> <p>A route keeps its distance, its climbs and every run you made on it.</p> <p>On a phone this is a sheet you can drag away. Turn a tablet or make the window wider and it becomes a dialog, keeping its place.</p> </AdaptiveDialog> <Button size="lg" variant="ghost" on_click={() => { dialog_width = dialog_width == 520 ? 460 : 520 }}>Change the width</Button> </div> <AdaptiveDialog title="Rename the route" open={asking} width={dialog_width} footer={<div.demo-row> <Button size="lg" variant="ghost" on_click={() => { asking = false }}>Cancel</Button> <Button size="lg" on_click={() => { saved = draft; asking = false }}>Save</Button> </div>} on_close={() => { asking = false; closes += 1 }}> <label.demo-note for="adaptive-dialog-name">Name</label> <input.adaptive-dialog-demo-field id="adaptive-dialog-name" bind:value={draft}/> <p.demo-note>Everyone you share this route with sees the new name.</p> </AdaptiveDialog> <p.demo-note>Route: <span class="adaptive-dialog-demo-saved">{saved}</span>. Closed: <span class="adaptive-dialog-demo-closes">{closes}</span>.</p> </div>}view AdaptiveDialogDemo() = <AdaptiveDialogDemoLive/>Source
ui/adaptive_dialog.qwe
// AdaptiveDialog: one piece of content, two shapes. Where the window is narrow it is a sheet// from the bottom that a finger can drag away; where there is room it is a dialog in the// middle of the screen. It changes while open, on rotation or when Split View changes the// window, with what is in it, what was typed, its scroll and focus all kept, as it is// the same element. It is the browser's own <dialog>: focus stays in it and comes back.//// import ui/adaptive_dialog.qwe use AdaptiveDialog//// <AdaptiveDialog title="Rename" trigger="Rename">// <TextInput label="Name" name="name"/>// </AdaptiveDialog>// <AdaptiveDialog title="Delete this?" open={asking} footer={<Button on_click={go}>Delete</Button>}// on_close={() => { asking = false }}> ... </AdaptiveDialog>//// title: its heading, and its name for screen readers. trigger: the text of a button that// opens it. Inside one of your components, open it from your own state instead: open={...}// and on_close(). breakpoint: under how many pixels of width it is a sheet (600). width: how// wide the dialog may be, in pixels (520). dismissable: a tap outside, or dragging the// sheet off, closes it (true). footer: buttons kept in view at its foot: HTML, so it needs// AdaptiveDialog inside one of your components. on_close(): called when it has closed.// children: what it holds.//// The width asked about is the window's and not the nearest container's: a <dialog> stands// over the whole window, wherever in the page it was written, and in Split View the window// is the app's own part of the screen. The equal of the drag is the Close button, Escape and// the phone's back gesture.import ui/button.qwe use Buttonimport ui/bottom_sheet.qwe use bottom_sheet_track, bottom_sheet_track_move, bottom_sheet_speed, bottom_sheet_rubber, bottom_sheet_dismissed, bottom_sheet_back, bottom_sheet_keyboard_watchlet adaptive_dialog_css = css`html:has(.ui-adaptive-dialog[open]) { overflow: hidden; }.ui-adaptive-dialog-host { display: contents; }.ui-adaptive-dialog { position: fixed; inset: 0 0 var(--ui-adaptive-dialog-kb, 0px) 0; box-sizing: border-box; flex-direction: column; width: min(var(--ui-adaptive-dialog-w, 520px), calc(100vw - 32px)); max-width: none; height: fit-content; max-height: calc(100dvh - var(--ui-adaptive-dialog-kb, 0px) - 32px); margin: auto; padding: 0; overflow: hidden; border: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 4px); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 15px/1.5 var(--ui-font); overscroll-behavior: contain; opacity: 0; transform: scale(.96); transition: transform .24s cubic-bezier(.22, 1, .36, 1), opacity .18s, overlay .24s allow-discrete, display .24s allow-discrete;}.ui-adaptive-dialog[open] { display: flex; opacity: 1; transform: none; }@starting-style { .ui-adaptive-dialog[open] { opacity: 0; transform: scale(.96); } }.ui-adaptive-dialog::backdrop { background: var(--ui-overlay); }.ui-adaptive-dialog-sheet { inset: auto 0 var(--ui-adaptive-dialog-kb, 0px) 0; width: 100%; max-height: calc(100dvh - var(--ui-adaptive-dialog-kb, 0px) - max(12px, env(safe-area-inset-top))); margin: 0; border-width: 1px 0 0; border-radius: calc(var(--ui-radius) + 8px) calc(var(--ui-radius) + 8px) 0 0; box-shadow: var(--ui-shadow-lg), 0 120px 0 0 var(--ui-surface); opacity: 1; transform: translateY(100%);}.ui-adaptive-dialog-sheet[open] { transform: translateY(0); }@starting-style { .ui-adaptive-dialog-sheet[open] { opacity: 1; transform: translateY(100%); } }.ui-adaptive-dialog-grip { display: none; flex: none; align-items: center; justify-content: center; height: 24px; touch-action: none; }.ui-adaptive-dialog-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-adaptive-dialog-sheet .ui-adaptive-dialog-grip { display: flex; }.ui-adaptive-dialog-head { display: flex; align-items: center; gap: 8px; flex: none; padding: 8px 8px 8px 20px; border-bottom: 1px solid var(--ui-border); }.ui-adaptive-dialog-sheet .ui-adaptive-dialog-head { padding-top: 0; touch-action: none; -webkit-touch-callout: none; user-select: none; }.ui-adaptive-dialog-head h2 { flex: 1; min-width: 0; margin: 0; font-size: 17px; font-weight: 600; line-height: 1.3; }.ui-adaptive-dialog-close { display: grid; place-items: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer;}@media (hover: hover) { .ui-adaptive-dialog-close:hover { background: var(--ui-subtle); color: var(--ui-fg); } }.ui-adaptive-dialog-close:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-adaptive-dialog-close svg { width: 18px; height: 18px; }.ui-adaptive-dialog-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 16px 20px 20px; }.ui-adaptive-dialog-body > :first-child { margin-top: 0; }.ui-adaptive-dialog-body > :last-child { margin-bottom: 0; }.ui-adaptive-dialog-foot { display: flex; justify-content: flex-end; gap: 8px; flex: none; padding: 12px 20px; border-top: 1px solid var(--ui-border); }.ui-adaptive-dialog-sheet .ui-adaptive-dialog-foot, .ui-adaptive-dialog-sheet .ui-adaptive-dialog-body:last-child { padding-bottom: max(16px, env(safe-area-inset-bottom)); }@media (prefers-reduced-motion: reduce) { .ui-adaptive-dialog { transition: none; } }`// A sheet under the breakpoint; a breakpoint that makes no sense is taken as 600.fn adaptive_dialog_is_sheet(width: num, breakpoint) { let at = (type_of(breakpoint) == "int" || type_of(breakpoint) == "float") && breakpoint > 0 ? breakpoint : 600 width < at}component AdaptiveDialog(title: str, trigger: str? = null, open: bool = false, breakpoint: num = 600, width: int = 520, dismissable: bool = true, footer = null, on_close = null, children) { style global adaptive_dialog_css state dlg = null state body = null state win = null state sheet = false state track = null state zone = null // "handle" or "body", then "drag" state moved = false state undo_back = null state undo_kb = null let shut = () => { dlg.close() } let spring = () => { dlg.style.removeProperty("transform") dlg.style.removeProperty("transition") } let choose = (_e) => { let is_sheet = adaptive_dialog_is_sheet(win.innerWidth, breakpoint) // Turned in the middle of a drag: the finger is no longer moving this shape. if is_sheet != sheet && zone == "drag" { spring() } if is_sheet != sheet { track = null zone = null } sheet = is_sheet } let forget = () => { win.removeEventListener("resize", choose) undo_kb && undo_kb() undo_kb = null undo_back && undo_back() undo_back = null } let show = () => { choose(null) dlg.showModal() win.addEventListener("resize", choose) undo_kb = bottom_sheet_keyboard_watch(win, (px) => { dlg.style.setProperty("--ui-adaptive-dialog-kb", `${px}px`) }) undo_back = bottom_sheet_back(win, shut) } if open && dlg != null && !dlg.open { show() } if !open && dlg != null && dlg.open && trigger == null { shut() } let moving = (e) => { if track != null { track = bottom_sheet_track_move(track, e.clientX, e.clientY, e.timeStamp) if track.lock == "x" && zone != "drag" { track = null zone = null } if track != null && track.lock == "y" { if zone == "body" && (track.y < track.y0 || body.scrollTop > 0) { track = null zone = null } else { if zone != "drag" { zone = "drag" moved = true dlg.setPointerCapture(e.pointerId) dlg.style.transition = "none" } let dy = track.y - track.y0 let y = dy >= 0 ? (dismissable ? dy : bottom_sheet_rubber(dy, 60)) : 0 - bottom_sheet_rubber(0 - dy, 60) dlg.style.transform = `translateY(${round(y, 2)}px)` } } } } let ended = (e) => { if track != null && zone == "drag" { let leave = dismissable && bottom_sheet_dismissed(track.y - track.y0, dlg.offsetHeight, bottom_sheet_speed(track, "y", e.timeStamp)) spring() if leave { shut() } } track = null zone = null } let cancelled = () => { if zone == "drag" { spring() } track = null zone = null } // The width is on the host, which nothing here touches by hand: the dialog's own style // holds where it stands, and an attribute that follows a prop is rewritten whole. <div.ui-adaptive-dialog-host style={`--ui-adaptive-dialog-w: ${width}px`}> {trigger != null && <Button variant="outline" size="lg" on_click={show}>{trigger}</Button>} <dialog class={sheet ? "ui-adaptive-dialog ui-adaptive-dialog-sheet" : "ui-adaptive-dialog"} aria-label={title} @mount={(el) => { // win first: giving dlg a value sets off the `if open` above. win = js("window") dlg = el if open && !dlg.open { show() } // Taken off the page while open, it must not leave anything on the window. () => { forget() } }} @close={() => { forget() track = null zone = null spring() dlg.style.removeProperty("--ui-adaptive-dialog-kb") on_close && on_close() }} @click={(e) => { let box = dlg.getBoundingClientRect() let outside = e.clientX < box.left || e.clientX > box.right || e.clientY < box.top || e.clientY > box.bottom if dismissable && e.target.isSameNode(dlg) && !moved && outside { shut() } moved = false }} @pointerdown={(e) => { let t = e.target let on_handle = t.closest(".ui-adaptive-dialog-grip, .ui-adaptive-dialog-head") != null && t.closest(".ui-adaptive-dialog-close") == null let on_body = t.closest(".ui-adaptive-dialog-body") != null moved = false // Only a sheet moves, and a mouse in its body selects text. if sheet && e.button == 0 && (on_handle || (on_body && e.pointerType == "touch")) { track = bottom_sheet_track(e.clientX, e.clientY, e.timeStamp) zone = on_handle ? "handle" : "body" } }} @pointermove={moving} @pointerup={ended} @pointercancel={cancelled} @touchmove={(e) => { if zone == "drag" && e.cancelable { e.preventDefault() } }}> <div.ui-adaptive-dialog-grip aria-hidden="true"><span></span></div> <div.ui-adaptive-dialog-head> <h2>{title}</h2> <button.ui-adaptive-dialog-close type="button" aria-label="Close" @click={shut}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg> </button> </div> <div.ui-adaptive-dialog-body @mount={(el) => { body = el }}>{children}</div> {footer != null && <div.ui-adaptive-dialog-foot>{footer}</div>} </dialog> </div>}Related
ActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAnchoredPopoverA popover with an arrow tied to its button on a tablet and a sheet on a phone, re-anchored on rotation and when a keyboard opens.3 views · 5 downloadsCartSheetA cart in a sheet with quantities, swipe to remove with undo, live totals in decimals, a free-shipping note and a checkout button.2 views · 9 downloadsCheckoutSheetA one-screen checkout in a sheet with address, delivery, payment and a live total, every part validated, and a slide to pay.3 views · 6 downloadsCommentSheetComments in a sheet: threads, a reply bar above the keyboard, sort, and a count that stays right. A column at the side on a tablet.2 views · 8 downloadsConflictSheetThis device's version against the server's, chosen field by field, explained in plain words.2 views · 6 downloads
Thread
Thread 0 posts
On AdaptiveDialog: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.