Grid Gallery: Top Strip

tcu-grid-gallery

A media gallery built from up to three rows — top, middle and bottom — each optional and each holding up to four items. Clicking anything opens a full-screen lightbox with swipe, keyboard navigation and captions. Photos and videos mix freely; YouTube and Vimeo both work from an ordinary link.

<div class="tcu-grid-gallery tcu-component">
    <div class="tcu-grid-gallery__row">
        <figure class="tcu-grid-gallery__item">
            <button type="button" class="tcu-grid-gallery__trigger" data-gallery-src="https://placehold.co/800x600">

                <picture>
                    <source media="(min-width: 768px)" srcset="https://placehold.co/800x600" />
                    <img class="tcu-grid-gallery__media" loading="lazy" src="https://placehold.co/768x576" alt="Placeholder Image Description" />
                </picture>

            </button>

            <figcaption class="tcu-grid-gallery__caption">Caption test</figcaption>
        </figure>
        <figure class="tcu-grid-gallery__item">
            <button type="button" class="tcu-grid-gallery__trigger">

                <picture>
                    <img class="tcu-grid-gallery__media" loading="lazy" src="https://placehold.co/768x1152" alt="Placeholder Image Description" />
                </picture>

            </button>

        </figure>
        <figure class="tcu-grid-gallery__item">
            <button type="button" class="tcu-grid-gallery__trigger" data-gallery-src="https://placehold.co/1280x720">

                <picture>
                    <source media="(min-width: 768px)" srcset="https://placehold.co/1280x720" />
                    <img class="tcu-grid-gallery__media" loading="lazy" src="https://placehold.co/768x432" alt="" />
                </picture>

            </button>

            <figcaption class="tcu-grid-gallery__caption">Caption test</figcaption>
        </figure>
        <figure class="tcu-grid-gallery__item">
            <button type="button" class="tcu-grid-gallery__trigger" data-gallery-src="https://placehold.co/1000x1000">

                <picture>
                    <source media="(min-width: 768px)" srcset="https://placehold.co/1000x1000" />
                    <img class="tcu-grid-gallery__media" loading="lazy" src="https://placehold.co/768x768" alt="Placeholder Image Description" />
                </picture>

            </button>

            <figcaption class="tcu-grid-gallery__caption">Caption test</figcaption>
        </figure>
    </div>
</div>
  • Content:
    **Contents**
    
    The JavaScript: [Call map](#call-map) · [How it runs](#how-it-runs) ·
    [State: what lives where](#state-what-lives-where) ·
    [What the platform provides, so we don't](#what-the-platform-provides-so-we-dont)
    
    Reference: [Things to keep in mind](#things-to-keep-in-mind) ·
    [Failure modes](#failure-modes) · [Video](#video)
    
    Authoring: [Item Context](#item-context) · [SCSS Config](#scss-config) ·
    [Markup, layout and sizing](#markup-layout-and-sizing) ·
    [Accessibility](#accessibility)
    
    ---
    
    ## Call map
    
    Twenty-six functions in `_src/js/tcu-grid-gallery.js`, in the order they actually run.
    `▶` marks an asynchronous hop — an event, an observer callback or a promise.
    
    ```
    module load
      SELECTORS · CONFIGURATION · dialogUI · session
      youTubeApiPromise · currentOpenId
      readyState guard ──▶ init
    
    init                                          on DOMContentLoaded
    ├─ posterObserver  (rootMargin 200px)
    │    ──▶ loadMissingPosters
    │          ├─ parseVideoUrl
    │          └─ loadPoster
    └─ one click listener per gallery
         ──▶ handleTriggerClick
               └─ openLightbox
                   ├─ createDialog                first open only
                   │   ├─ createDialogControls
                   │   │   └─ createIconButton     x3
                   │   ├─ 'close'   ──▶ handleDialogClose
                   │   └─ 'keydown' ──▶ handleDialogKeydown
                   ├─ createEmptySlides
                   ├─ showModal()
                   ├─ scrollToSlide
                   ├─ setActiveSlide
                   │   ├─ readTrigger
                   │   ├─ fillSlide                x3, at i, i-1, i+1
                   │   │   ├─ readTrigger
                   │   │   ├─ parseVideoUrl
                   │   │   ├─ createYouTubePlayer ─ loadYouTubeApi ──▶ YT.Player
                   │   │   ├─ createVimeoFrame ──── createEmbedFrame
                   │   │   ├─ createEmbedFrame      unrecognised URL
                   │   │   └─ createSlideImage      plain image
                   │   └─ pauseInactiveVideos
                   └─ watchActiveSlide
                         ──▶ setActiveSlide        loops back, on every scroll
    
    buttons / arrow keys ──▶ stepSlide
                              ├─ wrapToSlide ──▶ scrollToSlide
                              └─ scrollToSlide
                                    └─ prefersReducedMotion
    ```
    
    ---
    
    ## How it runs
    
    ### 1. Page load
    
    `init` runs from a `readyState` guard at the very bottom of the file rather than a bare
    `DOMContentLoaded` listener, because that listener never fires if the event has already
    happened — which it usually has, since the script is deferred.
    
    It queries for galleries and returns immediately if there are none, so a page without one
    pays nothing. For each gallery it does exactly two things: attaches one delegated `click`
    listener, and hands the gallery to a poster observer.
    
    That observer is built inside `init` and lives for the life of the page, which is why it
    is a local rather than something on `session` — `session` is wiped on every close, and
    this needs to outlast that. It is also deliberately separate from the slide observer built
    later. With `rootMargin: '200px'` it fires just before a gallery scrolls into view, then
    `unobserve`s that gallery so the work happens once.
    
    When it fires, `loadMissingPosters` walks the gallery's triggers and picks out only those
    that are videos (`data-gallery-embed`) whose `<img>` has no `src` — an author-supplied
    poster is left alone, costing no request. For each, `parseVideoUrl` normalises the link
    and `loadPoster` fetches a thumbnail. That function forks by provider:
    
    - **YouTube needs no request.** Every thumbnail URL is derivable from the video id, so it
      sets `maxresdefault` directly. The catch is that old uploads do not have one, and a miss
      answers 404 _with a valid 120x90 placeholder image in the body_ — so the `<img>` fires
      `load`, not `error`, and an `onerror` fallback would never run. It listens for `load`
      and re-tests `naturalWidth` instead, falling back once to `mqdefault`.
    - **Vimeo needs a request**, because its thumbnail URL cannot be derived. It builds a
      canonical `vimeo.com/ID` URL for the oEmbed call — not `player.vimeo.com/video/ID`,
      which 404s at that endpoint even though it is the correct URL for the iframe itself —
      and appends `width`, without which the response silently carries a 295px thumbnail.
    
    ### 2. The click
    
    `handleTriggerClick` is delegated, bound once per gallery, so it works no matter when the
    markup arrived. It walks up from the event target to find the trigger, then up again to
    find the gallery, and bails if either is missing.
    
    It then collects **that gallery's** triggers, which is the whole mechanism keeping two
    galleries on one page independent — gallery two counts its own slides, not every image on
    the page.
    
    The `trigger.focus()` line looks redundant next to `showModal()`, which restores focus on
    close by itself. It is there because Safari does not focus a button when you click it, so
    without this there would be nothing to restore to.
    
    ### 3. Opening
    
    `openLightbox` takes the trigger list and the index that was clicked.
    
    On the **first** open only, `createDialog` builds the dialog, the track, the counter, the
    announcer, and — via `createDialogControls` and three calls to `createIconButton` — the
    prev, next and close buttons. It also wires the two handlers that everything later depends
    on: `close` to `handleDialogClose` and `keydown` to `handleDialogKeydown`. The dialog is
    appended to `<body>` and reused by every gallery on the page from then on.
    
    Then, every open:
    
    - `currentOpenId += 1`. This is the token an in-flight YouTube player checks to discover
      that the dialog closed while it was still loading.
    - `createEmptySlides` adds one empty `<figure>` per trigger. The boxes go in up front so
      the track has its scroll-snap points immediately; the media comes later.
    - The lock class goes on `<html>`, then `showModal()`.
    - `scrollToSlide(index, 'instant')` — **after** `showModal()`, which is the first moment
      the slides have a layout and therefore an `offsetLeft` to scroll to.
    - `setActiveSlide(index)` is called directly rather than left to the observer, whose first
      callback is asynchronous and would briefly show slide 1's caption on the way.
    - `watchActiveSlide` builds the slide observer last, once everything is in place.
    
    ### 4. When a slide becomes active
    
    `setActiveSlide` is the single funnel. The observer calls it on every scroll, and
    `openLightbox` calls it once directly, so there is one place where "the active slide
    changed" is handled.
    
    Two guards up front. Returning when the index has not changed makes it cheap to call
    repeatedly. Returning when there is no trigger at that index catches a stale observer
    entry arriving after a close, where `indexOf` gives `-1`.
    
    Then it records the index, reads the item with `readTrigger`, and writes both readouts:
    the visible counter (`3 / 9`) and the `aria-live` announcer (`Slide 3 of 9: <alt>`).
    Finally it calls `fillSlide` three times — the active slide and both neighbours — and
    `pauseInactiveVideos`, which walks the player registry and pauses everything that is not
    the active slide. Because each provider registered its own `pause` when its slide was
    built, that function never has to ask which provider it is dealing with.
    
    ### 5. Filling a slide
    
    `fillSlide` is guarded to run once per slide: if the slide already has a first child, it
    returns. That is what makes it safe for `setActiveSlide` to call it on three indexes every
    time the active slide changes. Out-of-range indexes at either end are also fine.
    
    It marks the slide loading, then branches four ways:
    
    - **YouTube** → `createYouTubePlayer`, which appends a placeholder `<div>` and calls
      `loadYouTubeApi`. That injects the API script once, chaining any existing
      `window.onYouTubeIframeAPIReady` rather than overwriting it, and caches the promise so
      later slides reuse it. When it resolves, the captured `openId` is compared against
      `currentOpenId`: if the dialog closed in the meantime, it stops, because building a
      player on a detached div would leak one nothing can destroy. Otherwise `YT.Player`
      replaces the placeholder with an iframe it owns, and `onReady` does three things —
      reapplies the class (the API replaced our element), registers `pause` and `destroy`, and
      clears the spinner, since there is no element of ours whose `load` event could.
    - **Vimeo** → `createVimeoFrame`, which builds the URL (keeping the `h=` hash so unlisted
      videos still play), delegates the element to `createEmbedFrame`, and registers a `pause`
      that posts a message straight to the iframe. No SDK.
    - **An unrecognised embed URL** → `createEmbedFrame` with the author's string used
      verbatim. This is not an error path; it is what keeps hand-written embed URLs working.
    - **A plain image** → `createSlideImage`, with no width or height set, because the CSS
      caps do the fitting.
    
    For everything except the YouTube path, `load` and `error` both clear the spinner, and
    `media.complete` is checked immediately afterwards — a cached image can finish decoding
    before the listener is attached, which would otherwise strand the spinner over a picture
    that is already painted.
    
    A caption, if the item has one, is appended last as a `<figcaption>` inside the slide.
    
    ### 6. Navigating
    
    The prev and next buttons and `handleDialogKeydown` all land in the same place:
    `stepSlide(±1)`. Escape is deliberately left unmatched in the key handler so the dialog's
    own close behaviour runs.
    
    `stepSlide` adds `total` before taking the modulo, which is what turns a delta of `-1` at
    slide 0 into the last slide rather than a negative index. Wrapping means neither button is
    ever inert.
    
    It then decides how to move. A target more than one slide away can only be a wrap, and
    scrolling smoothly across the whole track would fly past every slide in between — slow,
    disorienting, and the observer would announce each one on the way. So a wrap goes to
    `wrapToSlide`, which fades the track out, repositions instantly while nothing can be seen,
    and fades back in. It bails to a plain instant jump under `prefers-reduced-motion`, where
    there is no transition and therefore no `transitionend` to wait on, and ignores a second
    press while a fade is already running.
    
    An ordinary step goes straight to `scrollToSlide`, which scrolls to the slide's own
    `offsetLeft` rather than computing a delta — `scrollBy` would accumulate sub-pixel error
    on every press and assumes the slide is exactly as wide as the track.
    
    Either way the track moves, the observer notices, and we are back at step 4.
    
    ### 7. Closing
    
    `handleDialogClose` is bound to the dialog's own `close` event, not to the close button.
    That is why Esc, the close button and a programmatic `close()` all converge on one
    teardown path.
    
    Order matters in one place: the players are destroyed **before** the track is emptied,
    because a `YT.Player` holds its own listeners and leaks if its iframe is pulled out from
    under it first.
    
    After that it disconnects and drops the observer, clears the player registry, empties the
    track, and resets the four `session` fields. The announcer is blanked deliberately — so
    that reopening on the same slide still counts as a change worth announcing rather than a
    no-op. Last, the lock class comes off `<html>`.
    
    What it does **not** reset is as deliberate: `dialogUI` keeps its elements for reuse,
    `youTubeApiPromise` keeps the API from being downloaded twice, and `currentOpenId` only
    ever increments, since its whole job is to be different from the value a stale callback
    captured.
    
    ---
    
    ## State: what lives where
    
    The lifetime split is the part to preserve, because it is what makes the teardown
    verifiable at a glance:
    
    ```
    dialogUI            DOM refs — built once on first open, never reset
    session             per-interaction data — cleared on every close
    youTubeApiPromise   module scope — survives close, or the SDK reloads each time
    currentOpenId       module scope — lets an in-flight player abandon itself if the
                        dialog closed while the API was still downloading
    ```
    
    Anything added to `session` is cleared on close. Anything that has to outlive a close
    belongs beside those last two, never in `session`. Relatedly, nothing caches a
    `{src, alt, caption}` record — `readTrigger` re-reads the DOM at the point of use, so the
    markup stays the single source of truth.
    
    ---
    
    ## What the platform provides, so we don't
    
    `showModal()` gives us the top layer — which is why **nothing in this component sets a
    z-index** — plus `::backdrop`, a focus trap, Esc to close, focus returned to the
    triggering button, and the rest of the page marked inert for assistive tech.
    
    CSS `scroll-snap` gives us the swipe, with native momentum and rubber-banding on every
    touch device. Real `<button>` triggers give us Enter, Space and focusability.
    
    So the JS contains **no** focus trap, Esc handler, touch or drag handler, Enter/Space
    handler, or tabindex/role management.
    
    The one gap `showModal()` leaves is background scroll. That's what the
    `tcu-grid-gallery-lock` class on `<html>` is for.
    
    ---
    
    ## Things to keep in mind
    
    A few spots here look like they could be simplified or tidied, but each one is carrying
    weight that isn't obvious from a glance.
    
    - The `null` fallback in `parseVideoUrl` → `fillSlide` is what keeps pre-existing
      authored embed URLs working.
    - `YT.Player` replaces the element it's given, so `onReady` reapplies the class —
      otherwise the player loses all styling.
    - `pauseVideo` doesn't exist until the player is ready, which is why it's registered
      inside `onReady` rather than at construction.
    - A local variable named `session` would shadow the module state object, and
      `session.players.set()` inside `onReady` would then throw. This happened once: the
      spinner never cleared and pause stopped working, while the class assertion above it
      still passed.
    - Players are destroyed before the track is emptied, in `handleDialogClose`, because a
      `YT.Player` leaks its listeners if its iframe is pulled out from under it first.
    - The Twig's conditional `src` avoids `src=""`, which would make the browser re-request
      the page.
    - `youTubeApiPromise` and `currentOpenId` live outside `session` on purpose — `session`
      is cleared on every close, and these two need to outlive that.
    - `scrollToSlide` uses `offsetLeft` rather than a computed delta.
    - `loadPoster` tests `naturalWidth` rather than `onerror`, since a missing thumbnail
      404s with a valid placeholder image in the body — the `<img>` still fires `load`.
      Switching to `onerror` would look tidier and would silently leave every old upload
      showing a 120x90 grey square.
    - The caption and announcer are hidden by clipping, never by `display: none` or
      `visibility: hidden` — both drop the element from the accessibility tree, which would
      leave the announcer holding perfectly correct text that no screen reader ever reads.
      Asserting `textContent` wouldn't catch that; the accessibility tree has to be read.
    - `wrapToSlide` guards for reduced motion because with no transition there's no
      `transitionend`, so without the guard the wrap never completes and the lightbox
      freezes on the last slide.
    - The height chain described under
      [Markup, layout and sizing](#markup-layout-and-sizing) needs to stay definite end to
      end.
    
    ---
    
    ## Failure modes
    
    **Playing a video logs third-party console errors** — by design, from inside the
    providers' own iframes. Not a signal of anything wrong; see [Video](#video).
    
    **A Vimeo poster fetch fails, or the video was deleted** — silent. The `<img>` keeps no
    `src` and renders its alt text.
    
    **A YouTube video is deleted** — every size answers 404 with the 120x90 grey placeholder,
    and the fallback stops after one step, so the grid shows that grey square stretched
    across the slot. A deliberate trade for a shorter fallback.
    
    **Link format not recognised** — falls back to a raw iframe with the author's string.
    Intended, not a bug.
    
    **JS disabled** — the grid still works and author-supplied posters still show.
    Auto-thumbnails show alt text only, and the lightbox doesn't open at all.
    
    **YouTube API blocked** — the slide keeps its spinner. Nothing throws.
    
    **Vimeo video is domain-restricted** — may never become ready off an allow-listed host.
    Looks like a hang rather than an error.
    
    **Keyboard focus inside a playing video** — once focus enters a video iframe, arrow keys
    no longer reach the dialog, because cross-origin iframes capture keyboard events. Shift-Tab
    back to the arrow controls to navigate; Esc and the close button still work. Non-active
    slides are `inert`, so Tab cannot reach a slide that is off screen — without that, focus
    landing in an off-screen slide made the browser scroll it into view, which tripped the
    observer and swapped the active slide out from under you. When the slide holding focus
    becomes inert, focus falls back to the document body and the next Tab starts from the
    dialog's first control.
    
    **Arrow keys pressed faster than ~250ms apart** — presses are lost. `stepSlide` reads
    `activeIndex`, which the observer only updates once a slide is 60% in view. Known, not
    fixed.
    
    ---
    
    ## Video
    
    ### Accepted links
    
    ```
    youtube.com/watch?v=ID          with any extra params
    youtu.be/ID                     youtu.be/ID?t=30
    youtube.com/embed/ID            /shorts/ID, /live/ID
    m.youtube.com/…                 youtube-nocookie.com/embed/ID
    
    vimeo.com/ID
    vimeo.com/ID/HASH               unlisted, the hash is kept
    player.vimeo.com/video/ID       ?h=HASH also works
    ```
    
    Anything else — `vimeo.com/channels/…`, a bare numeric id — returns `null`. That is **not
    an error**: the URL is used verbatim as an iframe `src`, which is what keeps older
    hand-written embed URLs working. Note a bare numeric id does not work here, unlike
    `@video`; this component takes links.
    
    ### Thumbnail sizes
    
    Vimeo has four real widths, and a request in between snaps down to the one below.
    `maxheight` and `height` are both ignored, so width is the only lever — which is why a
    portrait video asked for at 1280 comes back 2276 tall. 640 is what is set: a strip slot
    renders at 285–435px, so that covers it at 2x, and it lands near YouTube's 68KB rather
    than three times it.
    
    | width= | Returns   | Weight   |
    | ------ | --------- | -------- |
    | 295    | 295x524   | 9KB      |
    | 640    | 640x1138  | **72KB** |
    | 960    | 960x1707  | 134KB    |
    | 1280   | 1280x2276 | 203KB    |
    
    For YouTube, only three sizes are guaranteed, and only `mqdefault` and `maxresdefault`
    are true 16:9 — the 4:3 ones have **black letterbox bars baked into the image**. oEmbed is
    no help in choosing: it ignores `maxwidth` and always reports `hqdefault`.
    
    | File                | Size     | Shape     | Always there |
    | ------------------- | -------- | --------- | ------------ |
    | `default.jpg`       | 120x90   | 4:3, bars | yes          |
    | `mqdefault.jpg`     | 320x180  | true 16:9 | yes          |
    | `hqdefault.jpg`     | 480x360  | 4:3, bars | yes          |
    | `sddefault.jpg`     | 640x480  | 4:3, bars | no           |
    | `maxresdefault.jpg` | 1280x720 | true 16:9 | no           |
    
    ### Why YouTube needs the API and Vimeo doesn't
    
    `enablejsapi=1` on a hand-built iframe looks like it should be enough, but the player
    silently ignores every command until the parent completes a `listening` handshake, and
    only the API does that. It is the component's one dependency, injected lazily the first
    time someone reaches a YouTube slide — a lighter cost than it sounds, the loader plus
    `www-widgetapi.js` running about 10KB rather than the 60KB often quoted.
    
    Vimeo accepts `postMessage('{"method":"pause"}')` with no handshake, so its SDK would be
    a second dependency for nothing.
    
    Hand-built iframes carry one `allow` string covering both providers. The legacy
    `allowfullscreen` attribute is deliberately absent: `allow` supersedes it, and setting
    both logs a console warning.
    
    ### Console noise from the players
    
    Playing either provider logs third-party console errors, and none of them come from this
    component. The CORS failure against `googleads.g.doubleclick.net` is YouTube's
    ad-conversion beacon, fired inside its own iframe — the console line names
    `www.youtube.com` as the origin, which is how to tell. The CORB blocks on
    `vimeo.play_video` and `vimeo.watch_video_heartbeat` are Vimeo's analytics heartbeat. The
    `unload` deprecation warning is `f.vimeocdn.com/…/vendor.module.js`, Vimeo's player
    bundle; nothing here registers an unload handler. Measured on a deployed page with both
    videos playing, zero console messages and zero failed requests originated in the page's
    own frame. Cloudflare Turnstile spam and a `player.vimeo.com` 401 appear only under
    browser automation.
    
    ---
    
    ## Item Context
    
    Every item in `top`, `middle` and `bottom` takes the same five keys, and
    `@grid-gallery-item` renders one.
    
    ```json
    { "image_mobile": "/img/photo-400.jpg", "image_desktop": "/img/photo-800.jpg", "alt": "Three students at a table" }
    { "embed": "https://youtu.be/abc123", "alt": "Play video: a tour of the clinic" }
    ```
    
    - `image_mobile` / `image_desktop` — two renditions of one photo, or a video's poster.
      `image_desktop` takes over at 768px and is what the lightbox opens. Both are optional on
      a video item; omit them and the poster fills itself in from the provider.
    - `alt` — required. Use `""` for a decorative image; the caption still shows.
    - `caption` — optional. Appears in the lightbox and is read by screen readers on the page
      even when the lightbox never opens. Write an ampersand as `&amp;`.
    - `embed` — any YouTube or Vimeo link. **Its presence is what makes an item a video.**
      There is no `type` flag.
    
    ## SCSS Config
    
    All nine SCSS variables are `!default`. The ones worth reaching for:
    `$grid-gallery-single-item-height`, `$grid-gallery-gap` and `$grid-gallery-radius`.
    
    At medium and up a row becomes a flex row, and shape comes from how many items share that
    row rather than from which row it is. An item that shares its row gets a 4:3 media and
    crops to it; an item alone in its row keeps the image's own ratio, capped at
    `$grid-gallery-single-item-height`. Nothing declares a width, and no row has a style of
    its own — a one-item `top` renders exactly like a one-item `middle`.
    
    **That cap sits on the media, never on the row.** `max-height` on a row clamps the row's
    own box while the image inside keeps rendering, so it hides nothing and the overflow paints
    over the row below. On the media the `object-fit: cover` already there crops it instead. A
    row is therefore free to grow to whatever its items need — which is what lets a shared row
    exceed `$grid-gallery-single-item-height` on a very wide container, as a 4:3 media legitimately
    does past about 3300px.
    
    The play badge and the three lightbox icons are data-URI variables painted as background
    images, so no SVG markup lives in the template or the JS.
    
    ---
    
    ## Markup, layout and sizing
    
    Most of this is commented at the rule or template that does it. What is not:
    
    Three regions — `top`, `middle`, `bottom` — each a `.tcu-grid-gallery__row` holding
    `<figure>` items that `@grid-gallery-item` renders. The three are interchangeable: same
    element, no modifier between them, each a list of 0–4 items, and an empty one renders
    nothing at all. What a row looks like follows from how many items it holds, never from
    which of the three it is. The default context fills them 4–1–4, which is the arrangement
    the component was drawn for, but nothing in the markup or the CSS enforces it.
    **Order is DOM order**, which is also the lightbox slide order, so what a user sees and
    what they swipe through agree. Nothing reorders with `order:` or `row-reverse`.
    
    ```
    MOBILE  (base)                MEDIUM AND UP  (768px+)
    
    ┌──────────────┐              ┌────┬────┬────┬────┐  top, 0–4 items
    │    item 1    │              ├────┴────┴────┴────┤
    ├──────────────┤              │                   │
    │    item 2    │              │      MIDDLE       │  middle, 0–4 items
    ├──────────────┤              │                   │
    │    MIDDLE    │              ├────┬────┬────┬────┤
    ├──────────────┤              └────┴────┴────┴────┘  bottom, 0–4 items
    │    item 3    │
    └──────────────┘
     everything full width,        rows become flex rows,
     natural aspect ratios,        shared rows crop to 4:3,
     nothing cropped               a lone item keeps its ratio
    ```
    
    `grid-gallery.twig` is the source of truth for DOM shape; the eventual XSL template has
    to match it. Four classes are what the JS binds to, via `SELECTORS`:
    `.tcu-grid-gallery`, `__trigger`, `__media`, `__caption`. Four decisions in that template
    are invisible from reading it:
    
    - **A real `<button>`, not a div with `role="button"`.** Already focusable, already fires
      on Enter and Space — which is why the JS adds no `tabindex`, `role` or keydown handler.
    - **No `aria-label` on the trigger.** The `<img alt>` is its accessible name; a label
      repeating it makes screen readers say it twice. Put any needed verb in the alt.
    - **Caption text lives in element content, not a data attribute**, so an `&` escapes once
      instead of twice. The `<figcaption>` is also a sibling of the button, since it is not
      part of the clickable target.
    - **No dimension attributes anywhere**, for the reason below.
    
    ### Image Sizing
    
    The CMS gives an image path and no dimensions, so the component sizes images with CSS caps
    rather than `width`/`height` attributes. Nothing here depends on knowing an image's size,
    and nothing measures one — the browser already knows the intrinsic size once the file
    arrives, since it needs that to paint.
    
    The slide's padding is the single statement of how much room the image gets, and the
    image's `max-width` and `max-height` resolve against whatever it leaves. Portrait hits the
    height cap, landscape hits the width cap, a small thumbnail hits neither. No JS, no
    measurement, no timing.
    
    **No viewport units**, so mobile browser chrome hiding and showing cannot resize the image
    mid-swipe. The percentage `max-height` only resolves because the height chain is definite:
    
    ```
    viewport → dialog (inset: 0) → track (height: 100%) → slide (flex item, stretches)
    ```
    
    Give the track an auto height and it goes circular: the browser drops the max-height and a
    2400px image renders at full size.
    
    The 96px clearance isn't arbitrary — it has to exceed the caption bar's tallest measured
    state, about 91px when a long caption wraps to three lines on a phone.
    
    **Iframes are the inverse of images.** An `<img>` has intrinsic dimensions, so capping
    both axes fits it; an `<iframe>` has none, so it gets a fixed box and the provider
    letterboxes inside it.
    
    ---
    
    ## Accessibility
    
    Most of this is commented at the code that does it — the counter's `aria-hidden` and the
    track's `role="group"` in `createDialog`, the icon buttons' labels in
    `createIconButton`, and the visually-hidden caption rule in the SCSS. What is not
    recorded anywhere else:
    
    **The dialog's `aria-label`.** Without it screen readers announce a nameless dialog.
    
    **What the announcer reads** — `Slide 3 of 9: <alt>`, not just the position. The number
    alone doesn't tell a screen reader user _what_ they are on.
    
    **The lightbox caption is a `<figcaption>` inside the slide `<figure>`**, so it is
    associated with its image and reads with it rather than after the buttons.
    
  • URL: /components/raw/grid-gallery/BEHAVIOR.md
  • Filesystem Path: _src/components/common/grid-gallery/BEHAVIOR.md
  • Size: 28 KB