ProductGallery
A product's pictures: one large, the rest as thumbnails that switch it, with the arrow keys, swipes and a counter.
Demo
Showing picture 1: From the side. Click a thumbnail, press the arrow keys, or swipe the picture.
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/product_gallery.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/product_gallery.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/product_gallery.qwe
let product_gallery_demo_shots = [ { src: "https://picsum.photos/seed/shoe1/600/600", alt: "From the side" }, { src: "https://picsum.photos/seed/shoe2/600/600", alt: "From above" }, { src: "https://picsum.photos/seed/shoe3/600/600", alt: "The sole" }, { src: "https://picsum.photos/seed/shoe4/600/600", alt: "Worn on a trail" }, { src: "https://picsum.photos/seed/shoe5/600/600", alt: "In the box" },]component ProductGalleryDemoLive() { state at = 0 <div.demo-stack> <div style="width: 320px"> <ProductGallery label="Trail runner" images={product_gallery_demo_shots} on_change={(i) => { at = i }}/> </div> <p.demo-note>Showing picture {at + 1}: {product_gallery_demo_shots[at].alt}. Click a thumbnail, press the arrow keys, or swipe the picture.</p> </div>}view ProductGalleryDemo() = <div.demo-row style="align-items: flex-start; gap: 40px"> <ProductGalleryDemoLive/> <div style="width: 360px"> <ProductGallery label="Day pack" thumbs="left" ratio="4/3" selected={1} arrows={false} images={[ { src: "https://picsum.photos/seed/pack1/800/600", alt: "The pack, front" }, { src: "https://picsum.photos/seed/pack2/800/600", alt: "The pack, open" }, { src: "https://picsum.photos/seed/pack3/800/600", alt: "The pack, worn" }, ]}/> </div> </div>Source
ui/product_gallery.qwe
// ProductGallery: a product's pictures, one large with the others as thumbnails// under it (or beside it): a click or the arrow keys switch, and a swipe on the// large one. Screen readers hear the thumbnails as tabs and the large picture as// their panel, with "2 of 5" as it changes.//// <ProductGallery label="Trail runner" images={[// { src: "/p/1.jpg", alt: "From the side" },// { src: "/p/2.jpg", thumb: "/p/2-sm.jpg", alt: "The sole" }]}/>//// images: a list of { src, alt, thumb? }: src is the large picture, thumb a// smaller one for the strip (src when there is none), alt what it shows.// label: the product's name, for screen readers. selected: the index shown// first (0). thumbs: "bottom" (default) or "left". ratio: the large picture's// shape ("1"; "4/3", "3/4"). fit: "cover" (default) or "contain". arrows: the// previous and next buttons over the picture (true). on_change(index): inside// one of your components, given the index shown.import ui/theme.qwe use ui_clslet product_gallery_css = css`.ui-pgallery { display: grid; gap: 10px; min-width: 0; font: 13px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-pgallery-left { grid-template-columns: 64px 1fr; }.ui-pgallery-left .ui-pgallery-thumbs { order: -1; flex-direction: column; }.ui-pgallery-stage { position: relative; aspect-ratio: var(--r, 1); overflow: hidden; border-radius: var(--ui-radius); background: var(--ui-subtle); touch-action: pan-y; }.ui-pgallery-stage img { display: block; width: 100%; height: 100%; object-fit: var(--fit, cover); user-select: none; -webkit-user-drag: none; }.ui-pgallery-stage:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-pgallery-count { position: absolute; right: 10px; bottom: 10px; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--ui-fg) 70%, transparent); color: var(--ui-bg); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-pgallery-nav { position: absolute; top: 50%; display: grid; place-items: center; width: 36px; height: 36px; margin-top: -18px; padding: 0; border: 1px solid var(--ui-border); border-radius: 50%; background: color-mix(in srgb, var(--ui-surface) 90%, transparent); color: var(--ui-fg); box-shadow: var(--ui-shadow); cursor: pointer; opacity: 0; transition: opacity .15s, background .15s; }.ui-pgallery-stage:hover .ui-pgallery-nav, .ui-pgallery-nav:focus-visible, .ui-pgallery-stage:focus-within .ui-pgallery-nav { opacity: 1; }.ui-pgallery-nav:hover { background: var(--ui-surface); }.ui-pgallery-nav:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-pgallery-nav svg { width: 18px; height: 18px; }.ui-pgallery-prev { left: 10px; }.ui-pgallery-next { right: 10px; }.ui-pgallery-thumbs { display: flex; gap: 8px; margin: 0; padding: 2px; overflow: auto; scrollbar-width: thin; }.ui-pgallery-thumb { flex: none; width: 60px; height: 60px; padding: 0; overflow: hidden; border: 2px solid transparent; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); cursor: pointer; opacity: .7; transition: opacity .15s, border-color .15s; }.ui-pgallery-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }.ui-pgallery-thumb:hover { opacity: 1; }.ui-pgallery-thumb[aria-selected="true"] { opacity: 1; border-color: var(--ui-accent); }.ui-pgallery-thumb:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (prefers-reduced-motion: reduce) { .ui-pgallery-nav, .ui-pgallery-thumb { transition: none; } }`component ProductGallery(images: list = [], label: str = "Product", selected: int = 0, thumbs: str = "bottom", ratio: str = "1", fit: str = "cover", arrows: bool = true, on_change = null, class: str? = null) { style global product_gallery_css state at = selected state sx = null let n = len(images) let cur = images[at] ?? { src: "", alt: "" } let go = (i: int, e) => { if n == 0 { return null } let to = (i + n) % n if to != at { at = to on_change && on_change(to) } if e != null { let tab = e.target.closest(".ui-pgallery").querySelector(`[data-i="${to}"]`) tab && tab.focus() } } let keys = (e) => { let to = match e.key { "ArrowRight" | "ArrowDown" => at + 1 "ArrowLeft" | "ArrowUp" => at - 1 "Home" => 0 "End" => n - 1 _ => null } if to != null { e.preventDefault() go(to, e) } } <div class={ui_cls(["ui-pgallery", thumbs == "left" && "ui-pgallery-left", class])}> <div.ui-pgallery-stage id={`ui-pgallery-${re.replace(lower(label), "[^a-z0-9]+", "-")}-panel`} role="tabpanel" aria-label={`${label}: ${cur.alt ?? ""}`} tabindex="0" style={`--r: ${ratio}; --fit: ${fit}`} @keydown={keys} @pointerdown={(e) => { sx = e.clientX }} @pointerup={(e) => { if sx != null && abs(e.clientX - sx) > 40 { go(e.clientX < sx ? at + 1 : at - 1, null) } sx = null }}> {n > 0 && <img src={cur.src} alt={cur.alt ?? ""}/>} {arrows && n > 1 && <button.ui-pgallery-nav.ui-pgallery-prev type="button" aria-label="Previous picture" @click={() => { go(at - 1, null) }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6"/></svg> </button>} {arrows && n > 1 && <button.ui-pgallery-nav.ui-pgallery-next type="button" aria-label="Next picture" @click={() => { go(at + 1, null) }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg> </button>} {n > 1 && <span.ui-pgallery-count aria-live="polite">{at + 1}<span.ui-sr> of </span><span aria-hidden="true"> / </span>{n}</span>} </div> {n > 1 && <div.ui-pgallery-thumbs role="tablist" aria-label={`Pictures of ${label}`} @keydown={keys}> {for [i, im] in enumerate(images) => <button.ui-pgallery-thumb key={im.src} type="button" role="tab" data-i={i} aria-selected={i == at ? "true" : "false"} aria-label={im.alt ?? `Picture ${i + 1}`} tabindex={i == at ? "0" : "-1"} @click={() => { go(i, null) }}> <img src={get(im, "thumb") ?? im.src} alt="" loading="lazy"/> </button>} </div>} </div>}Related
ZoomImageA picture that zooms in where the pointer is; a click holds it, and the keyboard zooms and moves around too.6 views · 4 downloadsProductCardA product in a shop's grid: picture, name, price with the discount, stars, a badge and Add to cart, as a plain form or a callback.6 views · 6 downloadsAspectRatioKeeps what it holds at a shape (16/9, 4/3, 1): an image or a video fills a box of the right height.9 views · 7 downloadsBrowserFrameA mock browser window around its content, with the address in its bar, for screenshots and demos.7 views · 6 downloadsCarouselSlides one at a time: buttons, dots, swiping, the keyboard, and optional autoplay that knows when to stop.8 views · 7 downloadsLightboxThumbnails that open full-screen, with captions, a counter, the arrow keys, swipes and focus put back.5 views · 6 downloads
Thread
Thread 0 posts
On ProductGallery: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.