Mobile

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.

#mobile#interactive#tablet#sheet#menu#gesture

Demo

Preview

Holiday photo

Taken on 12 August, 2.4 MB

Choose a size

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.qwe
main.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>}
Thread
39Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.