diff --git a/content/docs/components/gallery.mdx b/content/docs/components/gallery.mdx index 67eb22f2..48e1c9a0 100644 --- a/content/docs/components/gallery.mdx +++ b/content/docs/components/gallery.mdx @@ -1,33 +1,23 @@ --- title: Gallery -description: Set of items that open pop-up content when activated, ideal for image galleries or exhibits. +description: Build a friendly grid of story cards that open roomy popups for richer MDX content. --- # Gallery -`` renders a responsive matrix of clickable figures. Each `` defines the preview (thumbnail, caption text, metadata) and ships its children into a fullscreen popup so authors can reuse any existing MDX component—Viewer, Map, ImageStory, prose, or custom markup. +`` collects a set of story cards and presents them as a friendly grid. Each `` supplies its own thumbnail, caption, and short summary, then opens a roomy popup where you can reuse Viewer, Map, ImageStory, prose, or any custom MDX block to add depth without sending readers to another page. -- Grid columns collapse automatically using CSS grid with sensible spacing. -- Figures render semantic `
`/`
` markup so cards keep context in document flow. -- Modals use pure CSS targeting, so the build stays SSR-safe with no extra runtimes. Keyboard users activate the same links to open, close, or step through items. -- Accessibility niceties ship inlined: focus stays trapped inside the active popup, `Esc` closes it, and background scrolling is locked until you exit. -- The popup header mirrors the thumbnail so users keep visual context before diving into the richer MDX body. -- Control the overlay footprint with `popupSize="full" | "medium"`—full overtakes the viewport, while medium keeps page chrome visible and adds rounded/shadowed chrome. -- Randomize tile layouts per build with `order="random"`, handy when you want a fresh arrangement for every publish. -- Reference cached manifests (`referencedManifests`) to auto-populate labels, summaries, and thumbnails when you don't want to repeat metadata by hand. - -> Examples pull from the same Northwestern fixtures cached under `.cache/iiif`, so thumbnails and modal content point at manifests you already have locally. - -## Example: Modal media wall +Use it when you want to highlight favorite works, curate a mini exhibit, or gather related pieces into a single scan. The example below pulls from the cached Northwestern manifests and shows how a reader can browse tiles at a glance, tap a card, and explore richer notes inside the popup. ` (or `Gallery.Content`) to reuse the default gutters and the optional flex layout. -| Prop | Type | Required | Notes | -| ---------- | -------- | -------- | --------------------------------------------------------------------- | -| `flex` | boolean | | When `true`, the wrapper switches to a row-based flex layout. | -| `children` | nodes | ✅ | Modal body markup. Works with any MDX content (Viewers, prose, etc.). | +| Prop | Type | Required | Notes | +| ---------- | ------- | -------- | --------------------------------------------------------------------- | +| `flex` | boolean | | When `true`, the wrapper switches to a row-based flex layout. | +| `children` | nodes | ✅ | Modal body markup. Works with any MDX content (Viewers, prose, etc.). | diff --git a/content/docs/releases/releases.data.mjs b/content/docs/releases/releases.data.mjs index 64f05e27..bdfde656 100644 --- a/content/docs/releases/releases.data.mjs +++ b/content/docs/releases/releases.data.mjs @@ -1,4 +1,10 @@ const releases = [ + { + "version": "1.8.9", + "date": "2026-02-20", + "summary": "Refine gallery, add item navigation on popup.", + "highlights": [] + }, { "version": "1.8.8", "date": "2026-02-16", diff --git a/content/docs/releases/releases.json b/content/docs/releases/releases.json index 248744d0..65ea8f7a 100644 --- a/content/docs/releases/releases.json +++ b/content/docs/releases/releases.json @@ -1,4 +1,10 @@ [ + { + "version": "1.8.9", + "date": "2026-02-20", + "summary": "Refine gallery, add item navigation on popup.", + "highlights": [] + }, { "version": "1.8.8", "date": "2026-02-16", diff --git a/package.json b/package.json index 1087601c..224763d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@canopy-iiif/app-root", - "version": "1.8.8", + "version": "1.8.9", "description": "An open-source static site generator designed for fast creation, contextualization, and customization of a discovery-focused digital scholarship and collections website using IIIF APIs.", "private": true, "main": "app/scripts/canopy-build.mjs", diff --git a/packages/app/package.json b/packages/app/package.json index afd3c449..8db7a778 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@canopy-iiif/app", - "version": "1.8.8", + "version": "1.8.9", "private": false, "license": "MIT", "author": "Mat Jordan ", diff --git a/packages/app/ui/src/content/gallery/Gallery.jsx b/packages/app/ui/src/content/gallery/Gallery.jsx index e80f5e54..682a4ba1 100644 --- a/packages/app/ui/src/content/gallery/Gallery.jsx +++ b/packages/app/ui/src/content/gallery/Gallery.jsx @@ -16,6 +16,9 @@ const INLINE_SCRIPT = `(() => { return window.setTimeout(cb, 0); }; let activeModal = null; + const NAV_SELECTOR = '[data-canopy-gallery-nav]'; + const NAV_OPTION_SELECTOR = '[data-canopy-gallery-nav-option]'; + const NAV_ITEM_SELECTOR = '[data-canopy-gallery-nav-item]'; function isVisible(node) { return !!(node && (node.offsetWidth || node.offsetHeight || node.getClientRects().length)); @@ -44,6 +47,40 @@ const INLINE_SCRIPT = `(() => { } } + function resetModalScroll(modal) { + if (!modal) return; + const panel = modal.querySelector('.canopy-gallery__modal-panel'); + if (panel) { + panel.scrollTop = 0; + panel.scrollLeft = 0; + } + } + + function getOptionItem(option) { + if (!option || typeof option.closest !== 'function') return null; + return option.closest(NAV_ITEM_SELECTOR); + } + + function focusActiveNav(modal) { + if (!modal) return false; + const nav = modal.querySelector(NAV_SELECTOR); + if (!nav) return false; + const activeOption = + nav.querySelector(NAV_OPTION_SELECTOR + ':checked') || + nav.querySelector(NAV_OPTION_SELECTOR); + if (!activeOption) return false; + raf(() => { + try { + activeOption.focus({preventScroll: true}); + } catch (_) { + try { + activeOption.focus(); + } catch (err) {} + } + }); + return true; + } + function focusInitial(modal) { if (!modal) return; const focusables = getFocusable(modal); @@ -117,7 +154,10 @@ const INLINE_SCRIPT = `(() => { } activeModal = modal; modal.setAttribute('data-canopy-gallery-active', '1'); - focusInitial(modal); + resetModalScroll(modal); + if (!focusActiveNav(modal)) { + focusInitial(modal); + } return; } if (!activeModal) return; @@ -170,9 +210,266 @@ const INLINE_SCRIPT = `(() => { } } - window.addEventListener('hashchange', syncFromHash); - window.addEventListener('pageshow', syncFromHash); + function initGalleryNav(nav) { + if (!nav || nav.getAttribute('data-canopy-gallery-nav-bound') === '1') { + return; + } + const viewport = + nav.querySelector('[data-canopy-gallery-nav-viewport]') || + nav.querySelector('[data-canopy-gallery-nav-track]'); + if (!viewport) return; + const optionNodes = nav.querySelectorAll(NAV_OPTION_SELECTOR); + const navOptions = optionNodes ? Array.prototype.slice.call(optionNodes) : []; + if (!navOptions.length) return; + nav.setAttribute('data-canopy-gallery-nav-bound', '1'); + const prevBtn = nav.querySelector('[data-canopy-gallery-nav-prev]'); + const nextBtn = nav.querySelector('[data-canopy-gallery-nav-next]'); + + function updateButtons() { + if (prevBtn) prevBtn.disabled = false; + if (nextBtn) nextBtn.disabled = false; + } + + function getOptionLabel(option) { + if (!option) return null; + let label = option.nextElementSibling; + if (label && label.tagName && label.tagName.toLowerCase() === 'label') { + return label; + } + if (option.id) { + try { + label = nav.querySelector('label[for="' + escapeSelector(option.id) + '"]'); + if (label) return label; + } catch (_) {} + } + return null; + } + + function getTargetIdFromOption(option) { + if (!option) return ''; + const dataId = option.getAttribute('data-canopy-gallery-nav-modal'); + if (dataId) return String(dataId); + const value = option.value || option.getAttribute('value') || ''; + return String(value); + } + + function focusOption(option) { + if (!option) return; + try { + option.focus({preventScroll: true}); + } catch (_) { + try { + option.focus(); + } catch (err) {} + } + } + + function getActiveIndex() { + const currentId = window.location.hash.replace(/^#/, ''); + if (!currentId) return 0; + for (let i = 0; i < navOptions.length; i += 1) { + if (getTargetIdFromOption(navOptions[i]) === currentId) { + return i; + } + } + return 0; + } + + function scrollOptionIntoView(option) { + const label = getOptionLabel(option); + const target = label || option; + if (!target) return; + try { + target.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'center'}); + } catch (_) { + try { + target.scrollIntoView(); + } catch (err) {} + } + } + + function activateOption(option, opts = {}) { + if (!option) return; + const targetId = getTargetIdFromOption(option); + if (!targetId) return; + const targetHash = '#' + targetId; + syncActiveState({targetHash, reveal: true, focus: !!opts.focus}); + if (window.location.hash === targetHash) { + try { + window.location.hash = targetHash; + } catch (_) {} + } else { + window.location.hash = targetHash; + } + } + + function openByOffset(direction) { + const total = navOptions.length; + if (!total) return; + const currentIndex = getActiveIndex(); + const nextIndex = (currentIndex + direction + total) % total; + const nextOption = navOptions[nextIndex]; + if (!nextOption) return; + activateOption(nextOption, {focus: true}); + } + + function syncActiveState(options) { + const targetHash = + (options && options.targetHash) || window.location.hash || ''; + const normalized = String(targetHash) + .split('#') + .pop() + .replace(/^#/, ''); + let activeIndex = -1; + let activeOption = null; + navOptions.forEach((option, index) => { + const linkTarget = getTargetIdFromOption(option); + const isActive = normalized && linkTarget === normalized; + const label = getOptionLabel(option); + const navItem = getOptionItem(option); + if (isActive) { + activeIndex = index; + activeOption = option; + option.checked = true; + option.setAttribute('checked', 'checked'); + option.setAttribute('data-canopy-gallery-nav-active', '1'); + option.setAttribute('data-canopy-gallery-nav-selected', '1'); + option.tabIndex = 0; + if (label) { + label.setAttribute('data-canopy-gallery-nav-active', '1'); + } + if (navItem) { + navItem.setAttribute('data-canopy-gallery-nav-selected', '1'); + } + } else { + option.checked = false; + option.removeAttribute('checked'); + option.removeAttribute('data-canopy-gallery-nav-active'); + option.removeAttribute('data-canopy-gallery-nav-selected'); + option.tabIndex = -1; + if (label) { + label.removeAttribute('data-canopy-gallery-nav-active'); + } + if (navItem) { + navItem.removeAttribute('data-canopy-gallery-nav-selected'); + } + } + }); + if (options && options.reveal && activeIndex >= 0) { + scrollOptionIntoView(navOptions[activeIndex]); + } + if (options && options.focus && activeIndex >= 0) { + focusOption(activeOption); + } + } + + if (prevBtn) { + prevBtn.addEventListener('click', function (event) { + event.preventDefault(); + openByOffset(-1); + }); + } + if (nextBtn) { + nextBtn.addEventListener('click', function (event) { + event.preventDefault(); + openByOffset(1); + }); + } + + viewport.addEventListener('scroll', function () { + raf(updateButtons); + }); + window.addEventListener('resize', function () { + raf(updateButtons); + }); + + nav.addEventListener('keydown', function (event) { + const target = event.target || event.srcElement; + const isOption = + target && target.hasAttribute && target.hasAttribute('data-canopy-gallery-nav-option'); + if (!isOption) return; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault(); + openByOffset(1); + return; + } + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault(); + openByOffset(-1); + return; + } + if (event.key === 'Tab' && !event.shiftKey) { + const actions = nav.closest('.canopy-gallery__modal-actions'); + const closeBtn = actions && actions.querySelector('.canopy-gallery__modal-close'); + if (closeBtn) { + event.preventDefault(); + try { + closeBtn.focus({preventScroll: true}); + } catch (_) { + try { + closeBtn.focus(); + } catch (err) {} + } + } + } + }); + + nav.addEventListener('change', function (event) { + const target = event.target || event.srcElement; + if (!target || typeof target.matches !== 'function') return; + if (!target.matches(NAV_OPTION_SELECTOR)) return; + activateOption(target, {focus: true}); + }); + + updateButtons(); + syncActiveState({reveal: true}); + nav.__canopyGalleryNavUpdate = updateButtons; + nav.__canopyGalleryNavRefresh = function (options) { + syncActiveState({ + reveal: options && options.reveal, + focus: options && options.focus, + }); + }; + } + + function bindGalleryNavs() { + const navs = document.querySelectorAll('[data-canopy-gallery-nav]'); + if (!navs || !navs.length) return; + Array.prototype.forEach.call(navs, initGalleryNav); + refreshGalleryNavs({reveal: true}); + } + + function refreshGalleryNavs(options) { + const opts = options || {}; + const navs = document.querySelectorAll('[data-canopy-gallery-nav-bound="1"]'); + if (!navs || !navs.length) return; + Array.prototype.forEach.call(navs, function (nav) { + if (typeof nav.__canopyGalleryNavUpdate === 'function') { + raf(nav.__canopyGalleryNavUpdate); + } + if (typeof nav.__canopyGalleryNavRefresh === 'function') { + raf(function () { + nav.__canopyGalleryNavRefresh({ + reveal: !!opts.reveal, + focus: !!opts.focus, + }); + }); + } + }); + } + + window.addEventListener('hashchange', function () { + syncFromHash(); + refreshGalleryNavs({reveal: true}); + }); + window.addEventListener('pageshow', function () { + syncFromHash(); + bindGalleryNavs(); + refreshGalleryNavs({reveal: true}); + }); syncFromHash(); + bindGalleryNavs(); + refreshGalleryNavs({reveal: true}); })()`; let galleryInstanceCounter = 0; @@ -382,7 +679,7 @@ function buildCaptionContent(itemProps) { ); } -function GalleryModal({item, total, closeTargetId, prevTarget, nextTarget}) { +function GalleryModal({item, closeTargetId, navItems, navGroupName}) { const { props, modalId, @@ -401,7 +698,6 @@ function GalleryModal({item, total, closeTargetId, prevTarget, nextTarget}) { null; const modalTitle = props.popupTitle || props.modalTitle || props.title || `Item ${index + 1}`; - const preview = renderPreview(props); return (
+
- {preview ? ( - - ) : null}
{kicker ? (

{kicker}

@@ -460,36 +765,6 @@ function GalleryModal({item, total, closeTargetId, prevTarget, nextTarget}) { ) : null}
-
@@ -521,6 +796,106 @@ function GalleryFigure({item}) { ); } +function GalleryThumbnailNav({items, activeModalId, groupName}) { + if (!items || items.length < 2) return null; + const radioGroup = groupName || "canopy-gallery-nav"; + return ( + + ); +} + export function GalleryContent({children, flex = false}) { const contentClassName = [ "canopy-gallery-item__content", @@ -577,6 +952,8 @@ export default function Gallery({ .filter(Boolean) .join(" "); + const navGroupName = `${galleryId}-nav`; + return (
- {orderedItems.map((item, index) => { - const total = orderedItems.length; - const prevIndex = (index - 1 + total) % total; - const nextIndex = (index + 1) % total; - return ( - - ); - })} + {orderedItems.map((item) => ( + + ))}