Lisbon
Portugal, three nights
A stack of cards flung left or right with a turn of the wrist, undo, and buttons that do the same.
Portugal, three nights
5 of 5 left.
Nothing chosen yet.
2 of 2 left.
0 of 0 left.
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/swipe_deck.qweimport ui/theme.qwe use theme_cssuse ui/swipe_deck.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>The code of the preview above.
let swipe_deck_demo_cards = [ { id: "lis", title: "Lisbon", subtitle: "Portugal, three nights", color: "linear-gradient(135deg, #f6b26b, #e06666)" }, { id: "osl", title: "Oslo", subtitle: "Norway, a long weekend", color: "linear-gradient(135deg, #6fa8dc, #3d5a98)" }, { id: "kyo", title: "Kyoto", subtitle: "Japan, a week", color: "linear-gradient(135deg, #b6d7a8, #38761d)" }, { id: "lim", title: "Lima", subtitle: "Peru, ten days", color: "linear-gradient(135deg, #ffd966, #bf9000)" }, { id: "cpt", title: "Cape Town", subtitle: "South Africa, two weeks", color: "linear-gradient(135deg, #b4a7d6, #674ea7)" },]component SwipeDeckDemoLive() { state log = "Nothing chosen yet." state over = false state cards = swipe_deck_demo_cards <div.demo-stack> <SwipeDeck cards={cards} left_label="Skip" right_label="Keep" height={340} label="Trips" on_swipe={(id, way) => { log = `${way}: ${id}` }} on_undo={(id) => { log = `undo: ${id}`; over = false }} on_empty={() => { over = true }}/> <p.demo-note id="swipe-deck-log">{log}{over ? " All done." : ""}</p> // For the test, as live data does in mid-drag: the cards changing, and one arriving at the front. <button id="swipe-deck-again" type="button" hidden="true" @click={() => { cards = cards | map((c) => { ...c, subtitle: `${c.subtitle}, updated` }) }}>Again</button> <button id="swipe-deck-front" type="button" hidden="true" @click={() => { cards = [{ id: "new", title: "Reykjavik", subtitle: "Iceland, a week", color: "linear-gradient(135deg, #a2c4c9, #134f5c)" }] + cards }}>Front</button> // Bad data, for the test to look at: nothing here should stop the page. <div id="swipe-deck-bad" hidden="true"><SwipeDeck cards={[null, "x", { id: 7 }, { id: 2, title: "One" }, { id: 2, title: "Again" }, { title: "No id" }]}/><SwipeDeck cards="none"/></div> </div>}view SwipeDeckDemo() = <SwipeDeckDemoLive/>// SwipeDeck: a stack of cards to go through one at a time, each flung left or right, as// in a dating or a flashcard app. The top card follows the finger and turns as it goes,// a word on it says which way it is heading, and it leaves when it is past 38% of the// deck's width or thrown fast enough; short of that it springs back. Undo takes back as many// as were made, last first. The buttons and the arrow keys do all of it without a swipe,// and in a wide container the choices made stand in two lists beside the deck.//// component Picks() {// state kept = []// <SwipeDeck cards={places} left_label="Skip" right_label="Keep"// on_swipe={(id, way) => { if way == "right" { kept = kept + [id] } }}/>// }//// cards: each { id, title, subtitle?, body?, image?, alt?, color? }: image is an address, and// color any CSS colour behind a card without one. A card without a title is left out, and// a second card with an id already seen too. left_label, right_label: the words for each// way, on the card and the buttons. height: the stack's height in pixels (380).// on_swipe(id, way): inside one of your components, a card has gone, "left" or "right".// on_undo(id): that card came back. on_empty(): the last card went. label: the deck's name// for screen readers. empty_text: what shows when none are left. class.import ui/theme.qwe use ui_clslet swipe_deck_css = css`.ui-swipe-deck { container-type: inline-size; color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }.ui-swipe-deck-inner { display: grid; gap: 14px; }.ui-swipe-deck-stack { position: relative; width: 100%; max-width: 420px; margin: 0 auto; touch-action: pan-y; outline: none; border-radius: calc(var(--ui-radius) + 4px); overscroll-behavior-x: contain; }.ui-swipe-deck-stack:focus-visible { box-shadow: var(--ui-focus); }/* The slot takes its place in the stack from markup, the card its movement from the finger: a style attribute is rewritten whole when what it reads changes, so on one element a card arriving would throw a held card home. */.ui-swipe-deck-slot { position: absolute; inset: 0 0 20px 0; z-index: calc(3 - var(--i)); transform: translateY(calc(var(--i) * 9px)) scale(calc(1 - var(--i) * .04)); transform-origin: 50% 100%; transition: transform .24s cubic-bezier(.2, .8, .2, 1); }.ui-swipe-deck-card { position: absolute; inset: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 4px); background: var(--ui-surface); box-shadow: var(--ui-shadow); transform-origin: 50% 100%; transition: transform .24s cubic-bezier(.2, .8, .2, 1); user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }.ui-swipe-deck-moving .ui-swipe-deck-top { transition: none; }.ui-swipe-deck-flying .ui-swipe-deck-top { transition: transform .22s ease-in; }.ui-swipe-deck-top { cursor: grab; }.ui-swipe-deck-moving .ui-swipe-deck-top { cursor: grabbing; }.ui-swipe-deck-back-left { animation: ui-swipe-deck-back-left .26s cubic-bezier(.2, .8, .2, 1); }.ui-swipe-deck-back-right { animation: ui-swipe-deck-back-right .26s cubic-bezier(.2, .8, .2, 1); }@keyframes ui-swipe-deck-back-left { from { transform: translateX(-120%) rotate(-12deg); } }@keyframes ui-swipe-deck-back-right { from { transform: translateX(120%) rotate(12deg); } }.ui-swipe-deck-pic { flex: 1; min-height: 0; background: var(--c, var(--ui-subtle)); }.ui-swipe-deck-pic img { display: block; width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; pointer-events: none; }.ui-swipe-deck-text { flex: none; padding: 12px 16px 14px; }.ui-swipe-deck-text h3 { margin: 0; font-size: 19px; line-height: 1.25; }.ui-swipe-deck-text p { margin: 2px 0 0; color: var(--ui-muted); font-size: 14px; }.ui-swipe-deck-stamp { position: absolute; top: 18px; padding: 2px 10px; border: 3px solid currentColor; border-radius: var(--ui-radius-sm); background: var(--ui-surface); font-size: 20px; font-weight: 700; opacity: 0; pointer-events: none; }.ui-swipe-deck-stamp-right { left: 16px; color: var(--ui-success); transform: rotate(-10deg); opacity: var(--right, 0); }.ui-swipe-deck-stamp-left { right: 16px; color: var(--ui-danger); transform: rotate(10deg); opacity: var(--left, 0); }.ui-swipe-deck-none { position: absolute; inset: 0 0 20px 0; display: grid; place-items: center; padding: 20px; border: 1px dashed var(--ui-border); border-radius: calc(var(--ui-radius) + 4px); color: var(--ui-muted); text-align: center; }.ui-swipe-deck-bar { display: flex; justify-content: center; gap: 10px; }.ui-swipe-deck-btn { min-width: 76px; min-height: 48px; padding: 0 16px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 600 15px/1 var(--ui-font); cursor: pointer; }.ui-swipe-deck-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-swipe-deck-btn[aria-disabled="true"] { color: var(--ui-muted); opacity: .6; cursor: default; }.ui-swipe-deck-left { color: var(--ui-danger); }.ui-swipe-deck-right { color: var(--ui-success); }.ui-swipe-deck-count { margin: 0; color: var(--ui-muted); font-size: 13px; text-align: center; }.ui-swipe-deck-side { display: none; }.ui-swipe-deck-side h4 { margin: 0 0 4px; font-size: 13px; color: var(--ui-muted); text-transform: uppercase; letter-spacing: .05em; }.ui-swipe-deck-side ul { margin: 0 0 14px; padding: 0; list-style: none; }.ui-swipe-deck-side li { padding: 3px 0; border-bottom: 1px solid var(--ui-border); }@media (hover: hover) { .ui-swipe-deck-btn:not([aria-disabled="true"]):hover { background: var(--ui-subtle); } }@container (min-width: 560px) { .ui-swipe-deck-inner { grid-template-columns: minmax(0, 1fr) 200px; grid-template-rows: auto auto auto; column-gap: 28px; } .ui-swipe-deck-side { display: block; grid-column: 2; grid-row: 1 / span 3; }}@media (prefers-reduced-motion: reduce) { .ui-swipe-deck-slot, .ui-swipe-deck-card, .ui-swipe-deck-flying .ui-swipe-deck-top { transition: none; } .ui-swipe-deck-back-left, .ui-swipe-deck-back-right { animation: none; }}`// The cards that can be shown: those with a title, each id once, ids as text.fn swipe_deck_clean(cards) { if type_of(cards) != "list" { return [] } let named = cards | filter((c) => type_of(c) == "map" && get(c, "title") != null) // One without an id gets its place, marked so it cannot be taken for a real id of that number. let all = enumerate(named) | map((p) => { ...p[1], id: get(p[1], "id") != null ? str(p[1].id) : `_${p[0]}` }) let ids = all | map((c) => c.id) enumerate(all) | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}fn swipe_deck_axis(dx: num, dy: num, slop: num) -> str { if abs(dx) < slop && abs(dy) < slop { return "" } abs(dx) > abs(dy) ? "x" : "y"}// How far the card turns, in degrees, for a card moved dx: 12 at a full width, 16 at most.fn swipe_deck_tilt(dx: num, width: num) -> num { if width <= 0 { return 0 } let turn = dx / width * 12 round(max([-16, min([16, turn])]), 2)}// Where a release at dx with speed v (pixels a millisecond, signed) sends the card: "left",// "right", or "" to spring back. A throw counts from a small distance, one against the way// the card has gone takes it back, and otherwise it must be past 38% of the width.fn swipe_deck_decide(dx: num, v: num, width: num) -> str { let way = dx >= 0 ? "right" : "left" if abs(v) >= 0.5 && abs(dx) >= 24 { return v * dx > 0 ? way : "" } abs(dx) >= width * 0.38 ? way : ""}fn swipe_deck_sample(s, pos: num, now: num) { let dt = now - s.at dt < 32 ? s : { pos, at: now, v: (pos - s.pos) / dt }}// The top card's element. This and the two below are top-level fns, not the component's// own `let` functions: one of those that calls a method of the page, or a prop, gives a// Promise, and a handler calling it would go on late, after the next click had come.fn swipe_deck_top(stack) = stack.querySelector(".ui-swipe-deck-top")// A held card drawn x, y from home, tilted, its stamp showing as far as it has gone.fn swipe_deck_hold(stack, card, x: num, y: num) { if card != null { let w = stack.offsetWidth card.style.transform = x != 0 || y != 0 ? `translate(${x}px, ${y}px) rotate(${swipe_deck_tilt(x, w)}deg)` : "" let p = w > 0 ? min([1, abs(x) / (w * 0.38)]) : 0 card.style.setProperty("--right", x > 0 ? `${p}` : "0") card.style.setProperty("--left", x < 0 ? `${p}` : "0") }}// on_swipe and on_empty, for a card that has landed: { id, way, rest }, or null for none.fn swipe_deck_tell(landed, on_swipe, on_empty) { if landed != null { on_swipe && on_swipe(landed.id, landed.way) if landed.rest == 0 { on_empty && on_empty() } }}// The speed at release, signed: none if the finger stopped before it lifted.fn swipe_deck_speed(s, pos: num, now: num, moved: num) -> num { if now - moved > 80 { return 0 } let dt = now - s.at let since = dt >= 8 ? (pos - s.pos) / dt : 0 abs(since) > abs(s.v) ? since : s.v}component SwipeDeck(cards = [], left_label: str = "Skip", right_label: str = "Keep", height: int = 380, label: str = "Cards", empty_text: str = "No more cards.", on_swipe = null, on_undo = null, on_empty = null, class: str? = null) { style global swipe_deck_css let deck = swipe_deck_clean(cards) state history = [] // what was done, oldest first: { id, way, title } state moving = false state flying = null // the way the top card is leaving state back = null // { id, way } of a card just brought back, for its way in state said = "" state stack = null state g = null let done = history | map((h) => h.id) let waiting = deck | filter((c) => !contains(done, c.id)) let shown = take(waiting, 3) // The card in flight lands. It calls nothing but builtins, so it is done at once and what // follows it sees the deck as it now is; swipe_deck_tell gives the props the news. let finish = () => { if flying != null && len(waiting) > 0 { let c = waiting[0] let way = flying let rest = len(waiting) - 1 // now: waiting follows history as soon as that is set flying = null history = history + [{ id: c.id, way, title: c.title }] said = `${way == "right" ? right_label : left_label}: ${c.title}. ${rest} left.` return { id: c.id, way, rest } } null } let fling = (way: str) => { swipe_deck_tell(finish(), on_swipe, on_empty) if len(waiting) > 0 { moving = false flying = way let w = stack.offsetWidth * 1.5 let card = swipe_deck_top(stack) if card != null { card.style.transform = `translate(${way == "right" ? w : -w}px, 30px) rotate(${way == "right" ? 18 : -18}deg)` } } } let undo = () => { swipe_deck_tell(finish(), on_swipe, on_empty) if len(history) > 0 { let h = history[len(history) - 1] back = { id: h.id, way: h.way } history = slice(history, 0, len(history) - 1) said = `Back: ${h.title}. ${len(waiting)} left.` on_undo && on_undo(h.id) } } if flying != null { every(210, () => { swipe_deck_tell(finish(), on_swipe, on_empty) }) } <div class={ui_cls(["ui-swipe-deck", class])}> <div.ui-swipe-deck-inner> <div class={ui_cls(["ui-swipe-deck-stack", moving && "ui-swipe-deck-moving", flying != null && "ui-swipe-deck-flying"])} style={`height: ${height}px`} tabindex="0" role="group" aria-label={label} aria-roledescription="deck of cards" @mount={(me) => { stack = me }} @keydown={(e) => { if e.key == "ArrowLeft" { e.preventDefault(); fling("left") } if e.key == "ArrowRight" { e.preventDefault(); fling("right") } if e.key == "Backspace" || (e.key == "z" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); undo() } }} @pointerdown={(e) => { g = e.isPrimary && e.button == 0 && flying == null && len(waiting) > 0 ? { id: e.pointerId, x: e.clientX, y: e.clientY, on: false, dx: 0, moved: e.timeStamp, s: { pos: e.clientX, at: e.timeStamp, v: 0 } } : null }} @pointermove={(e) => { if g != null && e.pointerId == g.id { let dx = e.clientX - g.x let dy = e.clientY - g.y if !g.on { let way = swipe_deck_axis(dx, dy, 8) if way == "y" { g = null } if way == "x" { stack.setPointerCapture(e.pointerId) back = null moving = true // This card, whatever comes to stand on top of it while it is held. g = { ...g, on: true, card: swipe_deck_top(stack) } } } if g != null && g.on { swipe_deck_hold(stack, g.card, dx, dy * 0.4) g = { ...g, dx, moved: e.timeStamp, s: swipe_deck_sample(g.s, e.clientX, e.timeStamp) } } } }} @pointerup={(e) => { if g != null && e.pointerId == g.id { let was = g g = null if was.on { let way = swipe_deck_decide(was.dx, swipe_deck_speed(was.s, e.clientX, e.timeStamp, was.moved), stack.offsetWidth) moving = false let still_top = was.card != null && swipe_deck_top(stack) != null && was.card.isSameNode(swipe_deck_top(stack)) if way != "" && still_top { fling(way) } else { swipe_deck_hold(stack, was.card, 0, 0) } } } }} @pointercancel={(e) => { if g != null && e.pointerId == g.id { let was = g g = null moving = false if was.on { swipe_deck_hold(stack, was.card, 0, 0) } } }}> {len(waiting) == 0 && <div.ui-swipe-deck-none>{empty_text}</div>} {for [i, c] in enumerate(shown) => <div.ui-swipe-deck-slot key={c.id} style={`--i: ${i}`} aria-hidden={i > 0 ? "true" : null}><article class={ui_cls(["ui-swipe-deck-card", i == 0 && "ui-swipe-deck-top", back != null && i == 0 && back.id == c.id && `ui-swipe-deck-back-${back.way}`])}> <div.ui-swipe-deck-pic style={get(c, "color") != null ? `--c: ${c.color}` : null}> {get(c, "image") != null && <img src={c.image} alt={get(c, "alt") ?? ""} draggable="false"/>} </div> <div.ui-swipe-deck-text> <h3>{c.title}</h3> {get(c, "subtitle") != null && <p>{c.subtitle}</p>} {get(c, "body") != null && <p>{c.body}</p>} </div> <span.ui-swipe-deck-stamp.ui-swipe-deck-stamp-right aria-hidden="true">{right_label}</span> <span.ui-swipe-deck-stamp.ui-swipe-deck-stamp-left aria-hidden="true">{left_label}</span> </article></div>} </div> <div.ui-swipe-deck-bar> <button.ui-swipe-deck-btn.ui-swipe-deck-left type="button" aria-disabled={len(waiting) == 0 ? "true" : null} @click={() => { if len(waiting) > 0 { fling("left") } }}>{left_label}</button> <button.ui-swipe-deck-btn.ui-swipe-deck-undo type="button" aria-disabled={len(history) == 0 ? "true" : null} @click={() => { undo() }}>Undo</button> <button.ui-swipe-deck-btn.ui-swipe-deck-right type="button" aria-disabled={len(waiting) == 0 ? "true" : null} @click={() => { if len(waiting) > 0 { fling("right") } }}>{right_label}</button> </div> <p.ui-swipe-deck-count role="status">{said != "" ? said : `${len(waiting)} of ${len(deck)} left.`}</p> <div.ui-swipe-deck-side> <h4>{right_label}</h4> <ul.ui-swipe-deck-kept>{for h in history | filter((h) => h.way == "right") => <li key={h.id}>{h.title}</li>}</ul> <h4>{left_label}</h4> <ul.ui-swipe-deck-passed>{for h in history | filter((h) => h.way == "left") => <li key={h.id}>{h.title}</li>}</ul> </div> </div> </div>}On SwipeDeck: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.