From 3ed3b4bc32aeefd7a3be8908c7160ea8314d408f Mon Sep 17 00:00:00 2001 From: Mat Jordan Date: Sun, 15 Feb 2026 18:04:59 -0500 Subject: [PATCH 1/4] Refine gallery styling. --- content/docs/components/gallery.mdx | 15 ++-- .../app/ui/src/content/gallery/Gallery.jsx | 60 ++++++++-------- .../app/ui/styles/components/_gallery.scss | 72 ++++++++++--------- 3 files changed, 78 insertions(+), 69 deletions(-) diff --git a/content/docs/components/gallery.mdx b/content/docs/components/gallery.mdx index 67eb22f2..dd39c8ca 100644 --- a/content/docs/components/gallery.mdx +++ b/content/docs/components/gallery.mdx @@ -25,9 +25,10 @@ description: Set of items that open pop-up content when activated, ideal for ima referencedManifests={[ "https://api.dc.library.northwestern.edu/api/v2/works/213f2874-afca-452d-9bf3-ef0fe85a81ae?as=iiif", ]} - title="Seated woman writing" - summary="Mounted watercolor on mica." - thumbnail="https://api.dc.library.northwestern.edu/api/v2/works/213f2874-afca-452d-9bf3-ef0fe85a81ae/thumbnail" + caption="Embassy of Hyderbeck to Calcutta, 1800" + title="Embassy of Hyderbeck to Calcutta, 1800" + summary="This large-scale mezzotint and stipple engraving depicts a diplomatic procession traveling through the Indian landscape en route to Calcutta. At the center of the composition, elaborately dressed figures ride in howdahs atop horses, accompanied by soldiers, attendants, and local travelers carrying baskets and goods. Flags, spears, and musical instruments emphasize the ceremonial nature of the embassy, while ruins and distant architecture situate the scene within a historicized South Asian setting. Based on a painting by Johan Joseph Zoffany and engraved by Richard Earlom in London, the print translates a moment of cross-cultural encounter into a highly detailed image intended for British audiences, reflecting late eighteenth-century imperial interests in India and the East India Company’s diplomatic presence." + thumbnail="https://images.collections.yale.edu/iiif/2/ycba:999f94b5-2285-4c99-bc01-786e1bbc942f/full/600,/0/default.jpg" > ` (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/packages/app/ui/src/content/gallery/Gallery.jsx b/packages/app/ui/src/content/gallery/Gallery.jsx index e80f5e54..1dcfd768 100644 --- a/packages/app/ui/src/content/gallery/Gallery.jsx +++ b/packages/app/ui/src/content/gallery/Gallery.jsx @@ -416,6 +416,36 @@ function GalleryModal({item, total, closeTargetId, prevTarget, nextTarget}) { >
+
+ {total > 1 ? ( + + ) : null} + + Close + +
{preview ? ( -
diff --git a/packages/app/ui/styles/components/_gallery.scss b/packages/app/ui/styles/components/_gallery.scss index a9122bb0..0b042865 100644 --- a/packages/app/ui/styles/components/_gallery.scss +++ b/packages/app/ui/styles/components/_gallery.scss @@ -173,20 +173,17 @@ pointer-events: none; visibility: hidden; z-index: 200; + transform: translate3d(-200vw, 0, 0); &:target, &[data-canopy-gallery-active="1"] { opacity: 1; pointer-events: auto; visibility: visible; + transform: none; } } - &__modal:not([data-canopy-gallery-active]) { - width: 0; - height: 0; - } - &__modal-scrim { min-height: 100vh; display: block; @@ -195,6 +192,7 @@ } &__modal-panel { + position: relative; background: #fff; color: var(--color-gray-900); border-radius: 0; @@ -207,19 +205,41 @@ box-shadow: none; } + &__modal-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; + z-index: 5; + width: 100%; + position: fixed; + top: var(--canopy-gallery-sticky-offset, 0); + padding: 1rem clamp(1.25rem, 3vw, 2.5rem); + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0.618) 75%, + rgba(255, 255, 255, 0) + ); + margin-left: 0; + } + &__modal-header { border: none; - padding-top: 3rem; + padding-top: clamp(2rem, 4vw, 3rem); padding-bottom: 0; padding-left: clamp(1.25rem, 3vw, 2.5rem); padding-right: clamp(1.25rem, 3vw, 2.5rem); display: flex; - gap: clamp(1rem, 2vw, 2rem); + gap: 1rem; + align-items: flex-start; + flex-wrap: wrap; } &__modal-thumb { flex: 0 0 auto; - width: 100px; + width: 50px; aspect-ratio: 1 / 1; border-radius: 0.5rem; overflow: hidden; @@ -251,30 +271,19 @@ } &__modal-title { - margin: 0.618rem 0 0; - font-size: 1.618rem; + margin: 0.382rem 0 0; + font-size: 1.382rem; line-height: 1.2; } &__modal-summary { margin: 0; - font-size: 1.2222rem; color: var(--color-gray-muted); } &__modal-body { padding: clamp(1rem, 3vw, 2.5rem); - flex: 1; - overflow: auto; - } - - &__modal-footer { - padding: clamp(1rem, 2vw, 1.5rem) clamp(1.25rem, 3vw, 2.5rem); - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 1rem; - border-top: 1px solid rgba(15, 23, 42, 0.08); + flex: 0 0 auto; } &__modal-close { @@ -282,13 +291,15 @@ border-radius: 999px; padding: 0.35rem 1.25rem; font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; } &__modal-nav { - margin-left: auto; display: flex; align-items: center; - gap: 1rem; + gap: 0.5rem; } &__modal-nav-link { @@ -324,10 +335,10 @@ width: 100%; } - &__modal-nav { - width: 100%; - justify-content: space-between; - margin-left: 0; + &__modal-actions { + gap: 0.25rem; + top: 0; + padding: 0.5rem clamp(1rem, 4vw, 2rem) 0.25rem; } } } @@ -361,14 +372,11 @@ body[data-canopy-gallery-locked="1"] { } } -.canopy-gallery-item__content { - width: 100%; -} - .canopy-gallery-item__content_flex { display: flex; flex-direction: row; gap: 2rem; + margin-left: calc(50px + 1.5rem); aside { min-width: 400px; From 448e97324f0c6963f2c2d1c352664c7cce3d5993 Mon Sep 17 00:00:00 2001 From: Mat Jordan Date: Tue, 17 Feb 2026 16:58:50 -0500 Subject: [PATCH 2/4] Refine styling of gallery popup. --- .../app/ui/src/content/gallery/Gallery.jsx | 447 ++++++++++++++++-- .../app/ui/styles/components/_gallery.scss | 159 ++++++- 2 files changed, 537 insertions(+), 69 deletions(-) diff --git a/packages/app/ui/src/content/gallery/Gallery.jsx b/packages/app/ui/src/content/gallery/Gallery.jsx index 1dcfd768..36e0ffe2 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 (
- {total > 1 ? ( - - ) : null} +
- {preview ? ( - - ) : null}
{kicker ? (

{kicker}

@@ -521,6 +796,90 @@ 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 +936,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) => ( + + ))}