ActionSheet
A 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.
Demo
Chosen: nothing yet. 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/action_sheet.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/action_sheet.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/action_sheet.qwe
component ActionSheetDemoLive() { state did = "nothing yet" state closes = 0 <div.demo-stack> <div.demo-row> <ActionSheet title="Holiday photo" message="Taken on 12 August, 2.4 MB" trigger="Photo options" actions={[{ label: "Share", value: "share" }, "Duplicate", { label: "Move to album", value: "move" }, { label: "Print", value: "print", disabled: true }, { label: "Delete photo", value: "delete", destructive: true }]} on_pick={(v) => { did = v }} on_close={() => { closes += 1 }}/> <ActionSheet title="Choose a size" trigger="Long list" actions={["Extra small", "Small", "Medium", "Large", "Extra large", "Size 6", "Size 8", "Size 10", "Size 12", "Size 14", "Size 16", "Size 18", "Size 20", "Size 22", "Size 24", "Size 26"]} on_pick={(v) => { did = v }}/> </div> <p.demo-note>Chosen: <span class="action-sheet-demo-did">{did}</span>. Closed: <span class="action-sheet-demo-closes">{closes}</span>.</p> </div>}view ActionSheetDemo() = <ActionSheetDemoLive/>Source
ui/action_sheet.qwe
// ActionSheet: a short list of things to do, rising from the bottom of the phone, with a// destructive one in red and Cancel apart from the rest. Swiped down it goes; so it does on// the phone's back gesture, on Escape and on a tap above it. On a tablet there is room, so// it is a small card beside what opened it, as iPadOS does, and it turns from one to the// other on rotation. It is the browser's own <dialog>, so focus stays in it and comes back.//// import ui/action_sheet.qwe use ActionSheet//// <ActionSheet title="Photo" trigger="Options"// actions={[{ label: "Share", value: "share" }, "Duplicate",// { label: "Delete", value: "delete", destructive: true }]}// on_pick={(what) => { chosen = what }}/>// <ActionSheet actions={rows} open={asking} anchor="row-menu" on_pick={run} on_close={() => { asking = false }}/>//// actions: a list of maps, or plain text for one that needs nothing else. Each has label,// value (its label when left out), destructive and disabled. A row without a label is// left out. title, message: what it says above them; title is also its name for screen// readers ("Actions"). cancel: the words on the last button ("Cancel"). trigger: the text// of a button that opens it. Inside one of your components, open it from your own state// instead, with open={...} and on_close(), and on a tablet say with anchor, an element's// id, what it stands beside (the middle of the screen when there is none). on_pick(value):// called with the action's value, after it has closed. on_close(): called when it has// closed, for whatever reason. Up and Down move along the actions; Tab does too.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_backlet action_sheet_css = css`html:has(.ui-action-sheet[open]) { overflow: hidden; }.ui-action-sheet-host { display: contents; }.ui-action-sheet-anchor { display: inline-flex; }.ui-action-sheet { position: fixed; inset: auto 0 0 0; box-sizing: border-box; flex-direction: column; gap: 8px; width: 100%; max-width: 480px; max-height: 100dvh; margin: 0 auto; border: 0; overflow: visible; padding: 8px 8px max(8px, env(safe-area-inset-bottom)); background: transparent; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); transform: translateY(100%); transition: transform .3s cubic-bezier(.22, 1, .36, 1), overlay .3s allow-discrete, display .3s allow-discrete; overscroll-behavior: contain; touch-action: none; -webkit-touch-callout: none; user-select: none;}.ui-action-sheet[open] { display: flex; transform: translateY(0); }@starting-style { .ui-action-sheet[open] { transform: translateY(100%); } }.ui-action-sheet::backdrop { background: var(--ui-overlay); }.ui-action-sheet-panel { display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-radius: calc(var(--ui-radius) + 6px); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); }.ui-action-sheet-grip { flex: none; width: 40px; height: 5px; margin: 8px auto 2px; border-radius: 999px; background: var(--ui-strong); }.ui-action-sheet-head { flex: none; padding: 8px 20px 12px; border-bottom: 1px solid var(--ui-border); text-align: center; }.ui-action-sheet-head h2 { margin: 0; color: var(--ui-muted); font-size: 14px; font-weight: 600; }.ui-action-sheet-head p { margin: 2px 0 0; color: var(--ui-muted); font-size: 13px; }/* A long list scrolls: the browser may have vertical drags here, and @touchmove says when not. */.ui-action-sheet-list { min-height: 0; overflow: auto; overscroll-behavior: contain; touch-action: pan-y; }.ui-action-sheet-row, .ui-action-sheet-cancel { display: block; box-sizing: border-box; width: 100%; min-height: 56px; padding: 10px 20px; border: 0; background: transparent; color: var(--ui-accent); font: 500 17px/1.3 var(--ui-font); text-align: center; cursor: pointer; touch-action: inherit;}.ui-action-sheet-row + .ui-action-sheet-row { border-top: 1px solid var(--ui-border); }.ui-action-sheet-row:active:not(:disabled), .ui-action-sheet-cancel:active { background: var(--ui-subtle); }@media (hover: hover) { .ui-action-sheet-row:hover:not(:disabled), .ui-action-sheet-cancel:hover { background: var(--ui-subtle); } }.ui-action-sheet-row:focus-visible, .ui-action-sheet-cancel:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: -2px; }.ui-action-sheet-row:disabled { color: var(--ui-muted); opacity: .6; cursor: not-allowed; }.ui-action-sheet-danger { color: var(--ui-danger); }.ui-action-sheet-cancel { flex: none; border-radius: calc(var(--ui-radius) + 6px); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); font-weight: 600; }/* A <dialog> sits over the whole window, so the window's width is the right one to ask, Split View included. */@media (min-width: 600px) { .ui-action-sheet { inset: var(--ui-action-sheet-top, 50%) auto auto var(--ui-action-sheet-left, 50%); width: 320px; margin: 0; padding: 0; gap: 0; max-height: calc(100dvh - 24px); border-radius: calc(var(--ui-radius) + 6px); overflow: hidden; box-shadow: var(--ui-shadow-lg); transform: scale(.94); opacity: 0; transition: transform .16s, opacity .16s, overlay .16s allow-discrete, display .16s allow-discrete; background: var(--ui-surface); touch-action: auto; -webkit-touch-callout: default; } .ui-action-sheet[open] { transform: none; opacity: 1; } @starting-style { .ui-action-sheet[open] { transform: scale(.94); opacity: 0; } } .ui-action-sheet::backdrop { background: transparent; } .ui-action-sheet-grip { display: none; } .ui-action-sheet-panel { border-radius: 0; box-shadow: none; } .ui-action-sheet-cancel { border-top: 1px solid var(--ui-border); border-radius: 0; box-shadow: none; }}@media (prefers-reduced-motion: reduce) { .ui-action-sheet { transition: none; } }`// Plain text, a map, or something that is no action at all, into rows with what they need.fn action_sheet_rows(actions) { let given = type_of(actions) == "list" ? actions : [] let usable = given | filter((a) => { let kind = type_of(a) kind == "str" ? a != "" : kind == "int" || kind == "float" || (kind == "map" && a.label != null && str(a.label) != "") }) usable | map((a) => { let is_map = type_of(a) == "map" let label = str(is_map ? a.label : a) { label: label, value: is_map ? (a.value ?? label) : a, destructive: is_map && a.destructive == true, disabled: is_map && a.disabled == true } })}// Where the card stands on a tablet: under what opened it, or over it when there is no room// below, centred on it and kept on the screen, with a margin. With nothing to stand beside// (anchor null), in the middle.fn action_sheet_place(anchor, w: num, h: num, vw: num, vh: num) { let margin = 12 if anchor == null { return { left: round((vw - w) / 2), top: round((vh - h) / 2) } } let left = max([margin, min([anchor.left + anchor.width / 2 - w / 2, vw - w - margin])]) let below = anchor.bottom + 8 let above = anchor.top - 8 - h let top = below + h <= vh - margin ? below : above >= margin ? above : max([margin, vh - h - margin]) { left: round(left), top: round(top) }}// The next row a key leads to, going round, past disabled rows. -1 when none can be had.fn action_sheet_step(rows, from: int, by: int) { let count = len(rows) let found = -1 let i = from for n in range(count) { i = (i + by + count) % count if found < 0 && !rows[i].disabled { found = i } } found}component ActionSheet(actions = [], title: str? = null, message: str? = null, cancel: str = "Cancel", trigger: str? = null, open: bool = false, anchor: str? = null, on_pick = null, on_close = null) { style global action_sheet_css state dlg = null state list = null state spot = null // what the trigger is in, for a tablet to stand beside state win = null state track = null state dragging = false state moved = false state undo_back = null state chosen = null // [value], while it closes let rows = action_sheet_rows(actions) let phone = () => !win.matchMedia("(min-width: 600px)").matches let stand = () => { if !phone() { let by_id = anchor != null ? win.document.getElementById(anchor) : null let from = by_id ?? spot let at = action_sheet_place(from == null ? null : from.getBoundingClientRect(), dlg.offsetWidth, dlg.offsetHeight, win.innerWidth, win.innerHeight) dlg.style.setProperty("--ui-action-sheet-left", `${at.left}px`) dlg.style.setProperty("--ui-action-sheet-top", `${at.top}px`) } } let resized = (_e) => { stand() } let shut = () => { dlg.close() } let forget = () => { win.removeEventListener("resize", resized) undo_back && undo_back() undo_back = null } let show = () => { chosen = null dlg.showModal() stand() win.addEventListener("resize", resized) undo_back = bottom_sheet_back(win, shut) } if open && dlg != null && !dlg.open { show() } if !open && dlg != null && dlg.open && trigger == null { shut() } let release = () => { dlg.style.removeProperty("transform") dlg.style.removeProperty("transition") } // Whether the list should have this finger: it scrolls, and has somewhere to go that way. let list_owns = () => { let can_scroll = list.scrollHeight > list.clientHeight can_scroll && (track.y < track.y0 || list.scrollTop > 0) } let moving = (e) => { if track != null { track = bottom_sheet_track_move(track, e.clientX, e.clientY, e.timeStamp) if track.lock == "x" && !dragging { track = null } if track != null && track.lock == "y" { if !dragging { if e.target.closest(".ui-action-sheet-list") != null && list_owns() { track = null } else { dragging = true moved = true dlg.setPointerCapture(e.pointerId) dlg.style.transition = "none" } } if dragging { let dy = track.y - track.y0 let y = dy >= 0 ? dy : 0 - bottom_sheet_rubber(0 - dy, 60) dlg.style.transform = `translateY(${round(y, 2)}px)` } } } } let ended = (e) => { if track != null && dragging { let leave = bottom_sheet_dismissed(track.y - track.y0, dlg.offsetHeight, bottom_sheet_speed(track, "y", e.timeStamp)) release() if leave { shut() } } track = null dragging = false } let cancelled = () => { if dragging { release() } track = null dragging = false } let pick = (r) => { if !moved { chosen = [r.value] shut() } moved = false } <div.ui-action-sheet-host> {trigger != null && <span.ui-action-sheet-anchor @mount={(el) => { spot = el }}><Button variant="outline" size="lg" on_click={show}>{trigger}</Button></span>} <dialog.ui-action-sheet aria-label={title ?? "Actions"} @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 dragging = false moved = false release() let was = chosen chosen = null if was != null && on_pick != null { on_pick(was[0]) } on_close && on_close() }} @click={(e) => { let above = phone() ? e.clientY < dlg.getBoundingClientRect().top + 8 : e.clientX < dlg.getBoundingClientRect().left || e.clientX > dlg.getBoundingClientRect().right || e.clientY < dlg.getBoundingClientRect().top || e.clientY > dlg.getBoundingClientRect().bottom if e.target.isSameNode(dlg) && !moved && above { shut() } if e.target.isSameNode(dlg) { moved = false } }} @pointerdown={(e) => { moved = false if e.button == 0 && phone() && !e.target.isSameNode(dlg) { track = bottom_sheet_track(e.clientX, e.clientY, e.timeStamp) } }} @pointermove={moving} @pointerup={ended} @pointercancel={cancelled} @touchmove={(e) => { if dragging && e.cancelable { e.preventDefault() } }} @keydown={(e) => { if e.key == "ArrowDown" || e.key == "ArrowUp" { e.preventDefault() // From the row the key was pressed on, not from what has the focus: a handler // can pause partway (at a loop, or a call to one of the component's own // functions), so a key sent right after another may come before that one // has moved the focus. let on = e.target.dataset.row let here = on != null ? int(on) : -1 let next = action_sheet_step(rows, here < 0 && e.key == "ArrowUp" ? 0 : here, e.key == "ArrowDown" ? 1 : -1) if next >= 0 { dlg.querySelectorAll(".ui-action-sheet-row")[next].focus() } } }}> <div.ui-action-sheet-panel> <span.ui-action-sheet-grip aria-hidden="true"></span> {(title != null || message != null) && <div.ui-action-sheet-head> {title != null && <h2>{title}</h2>} {message != null && <p>{message}</p>} </div>} <div.ui-action-sheet-list @mount={(el) => { list = el }}> {for [i, r] in enumerate(rows) => <button key={r.label} type="button" class={r.destructive ? "ui-action-sheet-row ui-action-sheet-danger" : "ui-action-sheet-row"} data-row={i} disabled={r.disabled} @click={() => pick(r)}>{r.label}</button>} </div> </div> <button.ui-action-sheet-cancel type="button" @click={shut}>{cancel}</button> </dialog> </div>}Related
ContextPreviewA long press lifts a card with the page blurred behind it, showing a preview and actions: slide to one and let go, or let go to close.3 views · 5 downloadsEdgeDrawerA navigation drawer pulled from the screen's edge: follows the finger, snaps by speed, holds the focus, closes on Back; stands beside the content on a tablet.4 views · 7 downloadsLongPressMenuA long press opens a menu at the finger, with a haptic, no text selected and no browser callout.4 views · 5 downloadsMapSheetA list in a sheet over a Leaflet map, each synced to the other, pins and rows, with the sheet dragged down to see more of the map; on a tablet, a list beside it.4 views · 5 downloadsPeekSheetA sheet that stays over a map or page and grows with a drag, with what is behind it still in use; on a tablet, a card down the side.3 views · 8 downloadsPullDownMenuA menu pulled out of a header by a finger, chosen by sliding to an item and letting go, or by its button; on a tablet, a small menu at the end of the header.5 views · 5 downloads
Thread
Thread 0 posts
On ActionSheet: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.