Mobile

ApprovalQueue

One request at a time, swiped to approve or reject, with a required reason, undo and a count of what is left.

#mobile#interactive#approval#gesture#swipe#tablet

Demo

Preview

Approvals

4 left of 4

Maya Chen, Design

New laptops for design

4,860.00 USD

Three machines, replacing ones from 2021.

Vendor
Northwind
Budget
Equipment

Why reject New laptops for design?

Nothing yet.

Not done.

[]

A reason each way

1 left of 1

Both ways

Why reject Both ways?

No reasons

1 left of 1

One

Why reject One?

Bad data

1 left of 1

Twin

Why reject Twin?

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

Example

The code of the preview above.

demos/approval_queue.qwe
let approval_queue_demo_requests = [    { id: "r1", title: "New laptops for design", who: "Maya Chen, Design", amount: "4,860.00 USD", detail: "Three machines, replacing ones from 2021.", fields: [{ label: "Vendor", value: "Northwind" }, { label: "Budget", value: "Equipment" }] },    { id: "r2", title: "Team offsite in March", who: "Tom Reyes, Sales", amount: "12,400.50 USD", detail: "Two nights for 14 people." },    { id: "r3", title: "Annual licence renewal", who: "Priya Nair, IT", amount: "980.00 USD" },    { id: "r4", title: "Contractor extension", who: "Lena Fox, Support", detail: "Six more weeks at the same rate." },]component ApprovalQueueDemoLive() {    state told = "Nothing yet."    state done_said = "Not done."    state kept = []    state again = 0    state stir = 0    <div.demo-stack>        <div id="approval-queue-main"><ApprovalQueue key={again} label={`Approvals${stir > 0 ? " (updated)" : ""}`}            requests={approval_queue_demo_requests | map((r) => r.id == "r4" ? { ...r, detail: `Six more weeks at the same rate, note ${stir}.` } : r)} saved={kept} reasons={["Over budget", "Needs a quote"]}            on_change={(l) => { kept = l }}            on_decide={(d) => { told = `${d.id} ${d.decision} [${d.reason}]` }} on_undo={(d) => { told = `undo ${d.id}` }} on_done={(l) => { done_said = `done ${len(l)}` }}/></div>        <p.demo-note id="approval-queue-told">{told}</p>        <p.demo-note id="approval-queue-done">{done_said}</p>        <p.demo-note id="approval-queue-kept">{json.stringify(kept)}</p>        <div.demo-row>            <Button id="approval-queue-remake" size="lg" variant="soft" on_click={() => { again += 1 }}>Make it again from what was kept</Button>            <Button id="approval-queue-stir" size="lg" variant="soft" on_click={() => { stir += 1 }}>A request is updated</Button>        </div>        <div id="approval-queue-both"><ApprovalQueue label="A reason each way" reason_for="both" requests={[{ id: "b1", title: "Both ways" }]}/></div>        <div id="approval-queue-free"><ApprovalQueue label="No reasons" reason_for="none" requests={[{ id: "f1", title: "One" }]}/></div>        <div id="approval-queue-bad"><ApprovalQueue label="Bad data" requests={[null, 3, { id: "x" }, { id: 1, title: "Twin", fields: "many" }, { id: 1, title: "Twin again" }]} saved={[null, { id: 1 }, { id: "gone", decision: "approved" }]}/></div>    </div>}view ApprovalQueueDemo() = <ApprovalQueueDemoLive/>

Source

ui/approval_queue.qwe
// ApprovalQueue: requests dealt with one at a time, as a manager's phone wants them. Each// stands alone on a card with what is needed to judge it. A swipe to the right approves and a// swipe to the left rejects, by a pull past a third of the card or a flick, and a short pull// springs back; the two buttons under it, and the arrow keys, do the same. Where a reason is// required nothing is decided until one is given. Undo takes back the last decision, as far back// as there are decisions, and the count says how many are left.////   component Approvals() {//       state kept = load_decisions()            // a server fn//       <ApprovalQueue requests={rows} saved={kept} reasons={["Over budget", "Needs a quote", "Not this quarter"]}//                      on_change={(list) => { save_decisions(list) }}//                      on_decide={(d) => { send_decision(d) }} on_undo={(d) => { take_back(d) }}/>//   }//// requests: each { id, title, who?, amount?, detail?, fields? }. amount: text, or a decimal, as// money must stay exact: it is shown as given. fields: a list of { label, value } lines. One with no// title or a second with the same id is left out. reason_for: which decisions need a reason:// "reject" (the default), "both" or "none". reasons: words offered to tap in, as text.// least: the fewest letters that count as a reason (3). saved: what on_change gave. on_decide(d):// inside one of your components, as each is decided, { id, decision, reason }, decision// being "approved" or "rejected". on_undo(d): the same, for one taken back: a decision already sent// is the program's to withdraw. on_change(list): all the decisions in order, after any change.// on_done(list): when none are left. label: its name ("Approvals"). class.//// On a phone the reason sheet stands on the on-screen keyboard, by visualViewport, as iOS covers the// page and Android may or may not shrink it. A wide container keeps the list of requests beside the// card, to take them in another order.import ui/theme.qwe use ui_clslet approval_queue_css = css`.ui-approvals { position: relative; container-type: inline-size; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-approvals-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 4px; }.ui-approvals-title { margin: 0; font-size: 18px; font-weight: 700; }.ui-approvals-left { color: var(--ui-muted); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-approvals-bar { height: 4px; margin-bottom: 12px; border-radius: 2px; background: var(--ui-subtle); overflow: hidden; }.ui-approvals-bar i { display: block; height: 100%; background: var(--ui-accent); transition: width .25s; }.ui-approvals-body { display: grid; gap: 14px; }.ui-approvals-side { display: none; }.ui-approvals-main { min-width: 0; }.ui-approvals-stack { position: relative; padding-bottom: 12px; overscroll-behavior-x: contain; }.ui-approvals-peek { position: absolute; inset: 8px 10px 4px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-subtle); }.ui-approvals-peek2 { inset: 16px 20px -4px; }.ui-approvals-card { position: relative; display: grid; gap: 6px; min-height: 240px; padding: 16px; align-content: start; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow); outline: none;  touch-action: pan-y; transition: transform .22s cubic-bezier(.2, .8, .2, 1), opacity .2s; -webkit-tap-highlight-color: transparent; }.ui-approvals-card:focus-visible { box-shadow: var(--ui-focus); }.ui-approvals-moving { transition: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }.ui-approvals-still { transition: none; }.ui-approvals-stamp { position: absolute; top: 14px; padding: 4px 10px; border: 2px solid currentColor; border-radius: var(--ui-radius-sm); font-weight: 700; opacity: 0; pointer-events: none; }.ui-approvals-stamp-yes { left: 14px; color: var(--ui-success); }.ui-approvals-stamp-no { right: 14px; color: var(--ui-danger); }.ui-approvals-who { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-approvals-name { margin: 0; font-size: 20px; font-weight: 650; overflow-wrap: anywhere; }.ui-approvals-amount { margin: 0; font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }.ui-approvals-detail { margin: 0; color: var(--ui-muted); overflow-wrap: anywhere; }.ui-approvals-fields { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 12px; margin: 4px 0 0; font-size: 14px; }.ui-approvals-fields dt { color: var(--ui-muted); }.ui-approvals-fields dd { margin: 0; overflow-wrap: anywhere; }.ui-approvals-keys { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 6px; }.ui-approvals-btn { min-height: 54px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 600 16px/1.1 var(--ui-font); cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }.ui-approvals-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-approvals-btn:disabled { opacity: .5; cursor: default; }.ui-approvals-yes { border-color: transparent; background: var(--ui-success); color: var(--ui-bg); }.ui-approvals-no { border-color: transparent; background: var(--ui-danger); color: var(--ui-bg); }.ui-approvals-undo { width: 100%; min-height: 46px; margin-top: 8px; font-weight: 500; font-size: 15px; }.ui-approvals-done { padding: 28px 12px; text-align: center; }.ui-approvals-done h3 { margin: 0 0 4px; font-size: 20px; }.ui-approvals-done p { margin: 0; color: var(--ui-muted); }.ui-approvals-sheet { width: 100%; max-width: none; max-height: calc(100dvh - var(--ui-approvals-kb, 0px) - 16px); margin: auto 0 var(--ui-approvals-kb, 0px); padding: 16px 16px max(16px, env(safe-area-inset-bottom)); border: 0; border-radius: calc(var(--ui-radius) + 6px) calc(var(--ui-radius) + 6px) 0 0;  background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); overscroll-behavior: contain; box-sizing: border-box; }.ui-approvals-sheet::backdrop { background: var(--ui-overlay); }.ui-approvals-sheet h3 { margin: 0 0 8px; font-size: 18px; overflow-wrap: anywhere; }.ui-approvals-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }.ui-approvals-chip { min-width: 44px; min-height: 44px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-approvals-chip:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-approvals-field { display: block; width: 100%; box-sizing: border-box; min-height: 88px; padding: 10px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-bg); color: var(--ui-fg); font: 16px/1.4 var(--ui-font); resize: vertical; }.ui-approvals-field:focus { outline: none; box-shadow: var(--ui-focus); }.ui-approvals-field[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-approvals-err { margin: 4px 0 0; min-height: 20px; color: var(--ui-danger); font-size: 14px; }.ui-approvals-sheet-keys { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 6px; }.ui-approvals-none { margin: 12px 0; color: var(--ui-muted); }.ui-approvals-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 48px; padding: 6px 10px; border: 0; border-radius: var(--ui-radius-sm); background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; touch-action: manipulation; }.ui-approvals-row[aria-current] { background: var(--ui-accent-soft); color: var(--ui-accent); font-weight: 600; }.ui-approvals-row:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-approvals-row:disabled { color: var(--ui-muted); cursor: default; }.ui-approvals-row span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-approvals-row small { flex: none; }@container (min-width: 620px) {  .ui-approvals-body { grid-template-columns: 260px minmax(0, 1fr); gap: 24px; }  .ui-approvals-side { display: block; align-self: start; max-height: 70dvh; overflow-y: auto; overscroll-behavior: contain; margin: 0; padding: 6px; list-style: none; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }  .ui-approvals-card { min-height: 300px; }}/* A <dialog> stands over the whole window, so the window's width is the one to ask, Split View included. */@media (min-width: 600px) {  .ui-approvals-sheet { width: 440px; margin: auto; border-radius: calc(var(--ui-radius) + 6px); padding-bottom: 16px; }}@media (hover: hover) { .ui-approvals-btn:not(:disabled):not(.ui-approvals-yes):not(.ui-approvals-no):hover, .ui-approvals-chip:hover, .ui-approvals-row:not(:disabled):hover { background: var(--ui-subtle); } }@media (prefers-reduced-motion: reduce) { .ui-approvals-card, .ui-approvals-bar i { transition: none; } }`// The requests that can be shown: with a title, each id once, ids as text.fn approval_queue_clean(requests) {    if type_of(requests) != "list" { return [] }    let named = take(requests, 500) | filter((r) => type_of(r) == "map" && filled(get(r, "title")) != null)    let all = for [i, r] in enumerate(named) => {        let f = get(r, "fields")        {            id: get(r, "id") != null ? str(r.id) : `_${i}`, title: str(r.title), who: get(r, "who"), amount: get(r, "amount"), detail: get(r, "detail"),            fields: type_of(f) == "list" ? f | filter((x) => type_of(x) == "map" && get(x, "label") != null) : [],        }    }    let ids = all | map((r) => r.id)    enumerate(all) | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}// What was decided before, as far as it can be read.fn approval_queue_kept(saved) {    if type_of(saved) != "list" { return [] }    saved | filter((d) => type_of(d) == "map" && get(d, "id") != null && (get(d, "decision") == "approved" || get(d, "decision") == "rejected"))          | map((d) => { id: str(d.id), decision: d.decision, reason: str(get(d, "reason") ?? "") })}fn approval_queue_pending(requests, decided) {    let ids = decided | map((d) => d.id)    requests | filter((r) => !contains(ids, r.id))}// Whether a decision needs a reason first.fn approval_queue_needs(mode: str, decision: str) -> bool = mode == "both" || (mode == "reject" && decision == "rejected")// A reason counts once it has that many letters, not counting what is round them.fn approval_queue_reason_ok(text, least: int) -> bool = type_of(text) == "str" && len(trim(text)) >= (least < 1 ? 1 : least)// What a release comes to: "approved", "rejected" or "", from where the card was let go and how// fast it was going, in pixels a millisecond. A flick from under a third still counts, its own way.fn approval_queue_settle(x: num, v: num, width: num) -> str {    let far = abs(x) >= width * 0.34    let flick = abs(v) >= 0.5 && abs(x) >= 24 && (x > 0) == (v > 0)    if !far && !flick { return "" }    x > 0 ? "approved" : "rejected"}fn approval_queue_sample(s, pos: num, t: num) {    let dt = t - s.at    dt < 32 ? s : { pos, at: t, v: (pos - s.pos) / dt }}// The speed at release, signed: none if the finger stopped before it lifted.fn approval_queue_speed(s, pos: num, t: num, moved: num) -> num {    if t - moved > 80 { return 0 }    let dt = t - s.at    let since = dt >= 8 ? (pos - s.pos) / dt : 0    abs(since) > abs(s.v) ? since : s.v}// How a card leans for a pull, and how strongly a stamp shows.fn approval_queue_tilt(x: num, width: num) -> float {    let t = x / (width > 0 ? width : 1) * 8    round(t > 8 ? 8.0 : (t < -8 ? -8.0 : t), 2)}fn approval_queue_stamp(x: num, width: num) -> float {    let s = abs(x) / ((width > 0 ? width : 1) * 0.34)    round(s > 1 ? 1.0 : s, 2)}// What the on-screen keyboard covers: the part of the window below the visible part.fn approval_queue_covered(inner: num, height: num, top: num) -> num {    let c = round(inner - height - top)    c < 0 ? 0 : c}fn approval_queue_tally(decided) {    let yes = len(decided | filter((d) => d.decision == "approved"))    { yes, no: len(decided) - yes }}fn approval_queue_word(d: str) -> str = d == "approved" ? "Approved" : "Rejected"component ApprovalQueue(requests = [], reason_for: str = "reject", reasons = [], least: int = 3, saved = null, on_decide = null, on_undo = null, on_change = null, on_done = null,                        label: str = "Approvals", class: str? = null) {    style global approval_queue_css    let all = approval_queue_clean(requests)    state decided = approval_queue_kept(saved) | filter((d) => find(all, (r) => r.id == d.id) != null)    state chosen = ""        // a request taken out of turn, on a tablet    state leaving = null     // { id, decision, reason }: a card on its way out    state asking = ""        // the decision a reason is being asked for    state reason = ""    state bad = false    state g = null    state dragged = false    state armed = false    state card = null    state dlg = null    state field = null    state win = null    state said = ""    let pending = approval_queue_pending(all, decided)    let total = len(all)    let left = len(pending)    let cur = find(pending, (r) => r.id == chosen) ?? first(pending)    let tally = approval_queue_tally(decided)    let chips = type_of(reasons) == "list" ? reasons | filter((r) => filled(r) != null) | map((r) => str(r)) : []    let buzz = (kind: str, ms: int) => {        if win != null {            if win.quilloNative != null {                js("quilloNative.haptic", kind)            } else {                if win.navigator.vibrate != null && js("navigator.userActivation.hasBeenActive") == true { js("navigator.vibrate", ms) }            }        }    }    let home = () => {        card.style.transform = ""        card.style.opacity = ""        let stamps = card.querySelectorAll(".ui-approvals-stamp")        stamps[0].style.opacity = ""        stamps[1].style.opacity = ""    }    let fly = (d: str, why: str) => {        if leaving == null && cur != null {            leaving = { id: cur.id, decision: d, reason: why }            card.style.transform = `translateX(${d == "approved" ? 120 : -120}%) rotate(${d == "approved" ? 8 : -8}deg)`            card.style.opacity = "0"        }    }    let ask_reason = (d: str) => {        if leaving == null && cur != null {            if approval_queue_needs(reason_for, d) {                asking = d                reason = ""                bad = false                dlg.showModal()                field.focus()            } else {                fly(d, "")            }        }    }    let press_send = () => {        if approval_queue_reason_ok(reason, least) {            let d = asking            let why = trim(reason)            asking = ""            dlg.close()            fly(d, why)        } else {            bad = true            field.focus()        }    }    let take_back = () => {        if len(decided) > 0 && leaving == null {            let d = last(decided)            decided = slice(decided, 0, len(decided) - 1)            chosen = d.id            said = `${approval_queue_word(d.decision)} taken back.`            on_change && on_change(decided)            on_undo && on_undo(d)            buzz("selection", 8)            js("requestAnimationFrame", (_t = null) => { if card != null { card.focus() } })        }    }    if leaving != null {        every(210, () => {            let was = leaving            let r = find(all, (x) => x.id == was.id)            leaving = null            chosen = ""            // The next card takes the same place, with no sliding back to it.            card.classList.add("ui-approvals-still")            home()            decided = decided + [was]            said = `${approval_queue_word(was.decision)}: ${r.title}. ${left} left.`            on_change && on_change(decided)            on_decide && on_decide(was)            buzz(was.decision == "approved" ? "success" : "warning", 20)            if left == 0 { on_done && on_done(decided) }            js("requestAnimationFrame", (_t = null) => {                if card != null {                    card.classList.remove("ui-approvals-still")                    card.focus()                }            })        })    }    <section class={ui_cls(["ui-approvals", class])} aria-label={label}             @mount={(_me) => {                 let w = js("window")                 win = w             }}>        <div.ui-approvals-head>            <h2.ui-approvals-title>{label}</h2>            <span.ui-approvals-left>{total == 0 ? "None" : left == 0 ? "All done" : `${left} left of ${total}`}</span>        </div>        <div.ui-approvals-bar aria-hidden="true"><i style={`width: ${total > 0 ? round((total - left) * 100 / total, 1) : 0}%`}></i></div>        {total == 0 && <p.ui-approvals-none>Nothing to approve.</p>}        {total > 0 && <div.ui-approvals-body>            <ul.ui-approvals-side aria-label="Requests">                {for r in all => <li key={r.id}>                    <button.ui-approvals-row type="button" disabled={find(decided, (d) => d.id == r.id) != null} aria-current={cur != null && cur.id == r.id ? "true" : null}                            @click={() => { chosen = r.id }}>                        <span>{r.title}</span>                        <small>{find(decided, (d) => d.id == r.id) != null ? approval_queue_word(find(decided, (d) => d.id == r.id).decision) : ""}</small>                    </button>                </li>}            </ul>            <div.ui-approvals-main>                {left == 0 && <div.ui-approvals-done>                    <h3>All done</h3>                    <p>{tally.yes} approved, {tally.no} rejected.</p>                </div>}                {left > 0 && <div.ui-approvals-stack>                    {left > 2 && <div.ui-approvals-peek.ui-approvals-peek2 aria-hidden="true"></div>}                    {left > 1 && <div.ui-approvals-peek aria-hidden="true"></div>}                    <article.ui-approvals-card tabindex="0" aria-label={`${cur.title}. Right arrow approves, left arrow rejects.`}                             @mount={(me) => { card = me }}                             @contextmenu={(e) => { if g != null && g.on { e.preventDefault() } }}                             @keydown={(e) => {                                 if e.target.isSameNode(card) && e.key == "ArrowRight" { e.preventDefault(); ask_reason("approved") }                                 if e.target.isSameNode(card) && e.key == "ArrowLeft" { e.preventDefault(); ask_reason("rejected") }                             }}                             @pointerdown={(e) => {                                 dragged = false                                 let free = e.isPrimary && e.button == 0 && leaving == null && e.target.closest("a, button, input") == null                                 g = free ? { pid: e.pointerId, x: e.clientX, y: e.clientY, on: false, at: 0, moved: e.timeStamp, s: { pos: e.clientX, at: e.timeStamp, v: 0 } } : null                             }}                             @pointermove={(e) => {                                 if g != null && e.pointerId == g.pid {                                     let dx = e.clientX - g.x                                     let dy = e.clientY - g.y                                     if !g.on {                                         // Up and down is the page's, or the card's own if it is long.                                         if abs(dy) > 8 && abs(dy) >= abs(dx) { g = null }                                         if g != null && abs(dx) > 8 && abs(dx) > abs(dy) {                                             card.setPointerCapture(e.pointerId)                                             card.classList.add("ui-approvals-moving")                                             g = { ...g, on: true, slop: dx > 0 ? 8 : -8 }                                         }                                     }                                     if g != null && g.on {                                         let x = dx - g.slop                                         let w = card.offsetWidth                                         card.style.transform = `translateX(${x}px) rotate(${approval_queue_tilt(x, w)}deg)`                                         let stamps = card.querySelectorAll(".ui-approvals-stamp")                                         let shown = approval_queue_stamp(x, w)                                         stamps[0].style.opacity = x > 0 ? shown : 0                                         stamps[1].style.opacity = x < 0 ? shown : 0                                         let past = shown >= 1                                         if past != armed {                                             armed = past                                             if past { buzz("medium", 10) }                                         }                                         g = { ...g, at: x, moved: e.timeStamp, s: approval_queue_sample(g.s, e.clientX, e.timeStamp) }                                     }                                 }                             }}                             @pointerup={(e) => {                                 if g != null && e.pointerId == g.pid {                                     let was = g                                     g = null                                     if was.on {                                         armed = false                                         dragged = e.pointerType == "mouse"                                         card.classList.remove("ui-approvals-moving")                                         let v = approval_queue_speed(was.s, e.clientX, e.timeStamp, was.moved)                                         let end = approval_queue_settle(was.at, v, card.offsetWidth)                                         if end == "" || approval_queue_needs(reason_for, end) { home() }                                         if end != "" { ask_reason(end) }                                     }                                 }                             }}                             @pointercancel={(e) => {                                 if g != null && e.pointerId == g.pid {                                     let was = g                                     g = null                                     if was.on {                                         armed = false                                         card.classList.remove("ui-approvals-moving")                                         home()                                     }                                 }                             }}>                        <span.ui-approvals-stamp.ui-approvals-stamp-yes aria-hidden="true">Approve</span>                        <span.ui-approvals-stamp.ui-approvals-stamp-no aria-hidden="true">Reject</span>                        {cur.who != null && <p.ui-approvals-who>{str(cur.who)}</p>}                        <h3.ui-approvals-name>{cur.title}</h3>                        {cur.amount != null && <p.ui-approvals-amount>{str(cur.amount)}</p>}                        {cur.detail != null && <p.ui-approvals-detail>{str(cur.detail)}</p>}                        {len(cur.fields) > 0 && <dl.ui-approvals-fields>                            {for f in cur.fields => <div key={str(f.label)} style="display: contents"><dt>{str(f.label)}</dt><dd>{str(get(f, "value") ?? "")}</dd></div>}                        </dl>}                    </article>                </div>}                <div.ui-approvals-keys>                    <button.ui-approvals-btn.ui-approvals-no type="button" disabled={left == 0} @click={() => { ask_reason("rejected") }}>Reject</button>                    <button.ui-approvals-btn.ui-approvals-yes type="button" disabled={left == 0} @click={() => { ask_reason("approved") }}>Approve</button>                </div>                <button.ui-approvals-btn.ui-approvals-undo type="button" disabled={len(decided) == 0} @click={() => { take_back() }}>                    {len(decided) > 0 ? `Undo: ${approval_queue_word(last(decided).decision)}` : "Nothing to undo"}                </button>            </div>        </div>}        <dialog.ui-approvals-sheet aria-label={asking == "approved" ? "Why approve" : "Why reject"}                @mount={(me) => {                    dlg = me                    let w = js("window")                    let vv = w.visualViewport                    // What the keyboard covers, each time the visible part of the window changes.                    let fit = (_e = null) => {                        if vv != null {                            me.style.setProperty("--ui-approvals-kb", `${approval_queue_covered(w.innerHeight, vv.height, vv.offsetTop)}px`)                        }                    }                    if vv != null {                        vv.addEventListener("resize", fit)                        vv.addEventListener("scroll", fit)                    }                    () => {                        if vv != null {                            vv.removeEventListener("resize", fit)                            vv.removeEventListener("scroll", fit)                        }                    }                }}                @click={(e) => { if e.target.isSameNode(dlg) { dlg.close() } }}                @close={() => { asking = "" }}>            <h3>{asking == "approved" ? "Why approve" : "Why reject"} {cur != null ? cur.title : ""}?</h3>            {len(chips) > 0 && <div.ui-approvals-chips role="group" aria-label="Common reasons">                {for c in chips => <button.ui-approvals-chip key={c} type="button" @click={() => {                    reason = c                    bad = false                }}>{c}</button>}            </div>}            // Tied by wrapping, as several on a page would share any id written here.            <label>                <span.ui-sr>Reason</span>                <textarea.ui-approvals-field value={reason} aria-invalid={bad ? "true" : "false"} placeholder="Say why"                          @mount={(me) => { field = me }}                          @input={(e) => {                              reason = e.value                              bad = false                          }}></textarea>            </label>            <p.ui-approvals-err role="alert">{bad ? `A reason is needed, of ${least} letters or more.` : ""}</p>            <div.ui-approvals-sheet-keys>                <button.ui-approvals-btn.ui-approvals-cancel type="button" @click={() => { dlg.close() }}>Cancel</button>                <button class={ui_cls(["ui-approvals-btn", "ui-approvals-send", asking == "approved" ? "ui-approvals-yes" : "ui-approvals-no"])} type="button" @click={() => { press_send() }}>{asking == "approved" ? "Approve" : "Reject"}</button>            </div>        </dialog>        <span.ui-sr role="status">{said}</span>    </section>}
Thread
35Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.