Commerce

ProductGallery

A product's pictures: one large, the rest as thumbnails that switch it, with the arrow keys, swipes and a counter.

#commerce#image#media#tabs#interactive

Demo

Preview
From the side1 of 5

Showing picture 1: From the side. Click a thumbnail, press the arrow keys, or swipe the picture.

The pack, open2 of 3

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

Thread 0 posts

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

No posts yet. The first one opens the thread.