diff --git a/README.md b/README.md index 0f5cb3c..476cd85 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,25 @@ Wide screens get one column per day on an hour grid, with tours that start at th For a different event than the page's section, pass its name: `{{< schedule 39c3 >}}`. Without a data file, or without any tours in it, the shortcode renders nothing and the build logs a warning. To silence it, for example for an upcoming event, add `warning-schedule-empty` to `ignoreLogs` in `hugo.yaml`. +## Shortcode: Image gallery + +`{{< gallery >}}` shows a page's photos as square thumbnails, two per row on wide screens and one per row on narrow ones. Clicking a thumbnail opens the full image in a lightbox with its alt text and credit; with more than one image, arrows (on screen, the arrow keys, or swiping) move between them, and Escape, the close button or a click beside the image closes it. + +```markdown +{{< gallery >}} +{{< gallery-image src="hafenrundfahrt1.jpg" alt="Containerschiffe „HMM Dublin“ und „Ever Greet“ im Hafen" credit="© Wikipedia-User CC BY-SA 4.0 Shotbyp4ul" >}} +{{< gallery-image src="hafenrundfahrt2.jpg" alt="Das Speicherstadtrathaus" credit="© Wikipedia-User CC BY-SA 3.0 Magnus Manske" >}} +{{< /gallery >}} +``` + +| Param | Required | Content | +|---|---|---| +| `src` | yes | The image: a file in the page's folder, or a path in `assets/`. It must be a photo Hugo can resize (JPEG, PNG, WebP…), not SVG. | +| `alt` | yes | What the image shows. It is the thumbnail's alt text and the caption in the lightbox. | +| `credit` | no | Photographer and licence, shown under the thumbnail and in the lightbox. | + +Hugo crops the thumbnails to squares (keeping the most interesting part of the picture) at build time; the lightbox shows the original file. Without JavaScript, a thumbnail links to the original file. A missing image, a missing `alt` or a `gallery-image` outside a `gallery` stops the build with an error that names the page. + ## Accessibility check CI runs [pa11y-ci](https://github.com/pa11y/pa11y-ci) with the axe and HTML_CodeSniffer rules for WCAG 2 AA on every push and pull request, in its own job (`accessibility`), so findings show up without blocking the deploy. To run the same check locally (needs Node.js, Python 3 and Chrome or Chromium): diff --git a/assets/css/_custom.css b/assets/css/_custom.css index cf3ec0b..04fd621 100644 --- a/assets/css/_custom.css +++ b/assets/css/_custom.css @@ -1,4 +1,5 @@ @import './_schedule.css'; +@import './_gallery.css'; .header__intro_headline { margin: 0; diff --git a/assets/css/_gallery.css b/assets/css/_gallery.css new file mode 100644 index 0000000..48534f5 --- /dev/null +++ b/assets/css/_gallery.css @@ -0,0 +1,180 @@ +/* Gallery shortcode: square thumbnails, two per row on wide screens, and the lightbox. */ + +.gallery { + container-type: inline-size; + margin-block: var(--spacing-l); +} + +.gallery__list { + display: grid; + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +@container (width >= 40rem) { + .gallery__list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.gallery__item, +.gallery__figure { + margin: 0; +} + +.gallery__link { + display: block; + overflow: hidden; + border-radius: var(--radius-m); + cursor: zoom-in; + + img { + display: block; + width: 100%; + height: auto; + aspect-ratio: 1; + object-fit: cover; + } + + &:focus-visible { + outline: 3px solid var(--color-link); + outline-offset: 2px; + } +} + +@media (prefers-reduced-motion: no-preference) { + .gallery__link img { + transition: scale 0.2s; + } + + .gallery__link:hover img { + scale: 1.03; + } +} + +.gallery__credit { + margin-block-start: 0.25rem; + font-family: var(--ff-alt); + font-size: var(--fs-xs); + color: var(--color-text-meta); +} + +/* Lightbox */ + +/* No scrolling of the page behind it. */ +html:has(.lightbox[open]) { + overflow: hidden; +} + +.lightbox { + width: 100vw; + max-width: none; + height: 100dvh; + max-height: none; + margin: 0; + padding: 1rem; + border: 0; + background: rgb(0 0 0 / 0.92); + color: #fff; + + &[open] { + display: grid; + place-items: center; + } + + &::backdrop { + background: rgb(0 0 0 / 0.6); + } +} + +/* On wider screens, the arrows sit beside the image instead of on it. */ +@media (min-width: 40rem) { + .lightbox { + padding-inline: 5rem; + } +} + +.lightbox__figure { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + max-width: 100%; + max-height: 100%; + margin: 0; +} + +.lightbox__image { + display: block; + min-height: 0; + max-width: 100%; + max-height: calc(100dvh - 7rem); + width: auto; + height: auto; + object-fit: contain; +} + +.lightbox__caption { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.25rem 1rem; + max-width: 50rem; + font-family: var(--ff-alt); + font-size: var(--fs-s); + text-align: center; +} + +.lightbox__credit, +.lightbox__counter { + color: rgb(255 255 255 / 0.75); +} + +.lightbox__counter { + font-variant-numeric: tabular-nums; +} + +.lightbox__button { + position: fixed; + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + padding: 0; + border: 0; + border-radius: 50%; + background: rgb(255 255 255 / 0.15); + color: #fff; + font-size: 2rem; + line-height: 1; + cursor: pointer; + + &:hover { + background: rgb(255 255 255 / 0.3); + } + + &:focus-visible { + outline: 3px solid #fff; + outline-offset: 2px; + } +} + +.lightbox__close { + inset-block-start: 1rem; + inset-inline-end: 1rem; +} + +.lightbox__previous, +.lightbox__next { + inset-block-start: calc(50% - 1.5rem); +} + +.lightbox__previous { + inset-inline-start: 1rem; +} + +.lightbox__next { + inset-inline-end: 1rem; +} diff --git a/assets/js/gallery.js b/assets/js/gallery.js new file mode 100644 index 0000000..051962b --- /dev/null +++ b/assets/js/gallery.js @@ -0,0 +1,116 @@ +// Gallery shortcode: opens the full image in a lightbox (a modal ) with its alt text +// and credit. Arrow buttons, arrow keys and swiping move between the images of the gallery; +// Escape, the close button or a click beside the image close it. +{ + const button = (className, label, symbol) => { + const element = document.createElement('button'); + element.type = 'button'; + element.className = `lightbox__button ${className}`; + element.setAttribute('aria-label', label); + element.innerHTML = ``; + return element; + }; + + const lightbox = (gallery) => { + const links = [...gallery.querySelectorAll('.gallery__link')]; + const labels = gallery.dataset; + let index = 0; + + const dialog = document.createElement('dialog'); + dialog.className = 'lightbox'; + dialog.setAttribute('aria-label', labels.labelDialog); + dialog.innerHTML = ` + `; + const close = button('lightbox__close', labels.labelClose, '×'); + const previous = button('lightbox__previous', labels.labelPrevious, '‹'); + const next = button('lightbox__next', labels.labelNext, '›'); + dialog.append(close); + if (links.length > 1) dialog.append(previous, next); + document.body.append(dialog); + + const image = dialog.querySelector('.lightbox__image'); + const preload = (link) => { + new Image().src = link.href; + }; + + const show = (newIndex) => { + index = (newIndex + links.length) % links.length; + const link = links[index]; + image.src = link.href; + image.width = link.dataset.width; + image.height = link.dataset.height; + image.alt = link.dataset.alt; + // The alt text is shown as the caption, so screen readers get it once, from the image. + dialog.querySelector('.lightbox__alt').textContent = link.dataset.alt; + dialog.querySelector('.lightbox__alt').setAttribute('aria-hidden', 'true'); + dialog.querySelector('.lightbox__credit').textContent = link.dataset.credit ?? ''; + dialog.querySelector('.lightbox__counter').textContent = + links.length > 1 ? `${index + 1} / ${links.length}` : ''; + if (links.length > 1) { + preload(links[(index + 1) % links.length]); + preload(links[(index - 1 + links.length) % links.length]); + } + }; + + links.forEach((link, i) => { + link.addEventListener('click', (event) => { + // Let modified clicks open the image in a new tab or window as usual. + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + show(i); + dialog.showModal(); + close.focus(); + }); + }); + + // Closing goes through here, so that focus goes back to the thumbnail of the image that was + // shown (the browser would restore it to where it was before the lightbox opened). + const hide = () => { + dialog.close(); + links[index].focus(); + }; + + close.addEventListener('click', hide); + // Escape, or the back gesture on Android. + dialog.addEventListener('cancel', (event) => { + event.preventDefault(); + hide(); + }); + previous.addEventListener('click', () => show(index - 1)); + next.addEventListener('click', () => show(index + 1)); + + // A click on the dark area around the image closes the lightbox. + dialog.addEventListener('click', (event) => { + if (event.target === dialog || event.target.classList.contains('lightbox__figure')) hide(); + }); + + dialog.addEventListener('keydown', (event) => { + if (links.length < 2) return; + if (event.key === 'ArrowLeft') show(index - 1); + else if (event.key === 'ArrowRight') show(index + 1); + else return; + event.preventDefault(); + }); + + // Swiping left or right on touch screens. + let startX = null; + dialog.addEventListener('pointerdown', (event) => { + if (event.pointerType !== 'mouse') startX = event.clientX; + }); + dialog.addEventListener('pointerup', (event) => { + if (startX === null || links.length < 2) return; + const distance = event.clientX - startX; + startX = null; + if (Math.abs(distance) > 50) show(index + (distance < 0 ? 1 : -1)); + }); + }; + + document.querySelectorAll('[data-gallery]').forEach(lightbox); +} diff --git a/i18n/de.yaml b/i18n/de.yaml index 856a4a6..0a49b28 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -4,3 +4,8 @@ schedule_day: "Tag {{ .Day }}" schedule_pick_day: "Tag auswählen" schedule_soldout: "ausgebucht" skip_to_content: "Zum Inhalt springen" +gallery_enlarge: "Großansicht" +gallery_dialog: "Bildansicht" +gallery_close: "Schließen" +gallery_previous: "Vorheriges Bild" +gallery_next: "Nächstes Bild" diff --git a/i18n/en.yaml b/i18n/en.yaml index fee4502..8929d38 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -4,3 +4,8 @@ schedule_day: "Day {{ .Day }}" schedule_pick_day: "Choose a day" schedule_soldout: "fully booked" skip_to_content: "Skip to content" +gallery_enlarge: "enlarge" +gallery_dialog: "Image viewer" +gallery_close: "Close" +gallery_previous: "Previous image" +gallery_next: "Next image" diff --git a/layouts/_partials/gallery-script.html b/layouts/_partials/gallery-script.html new file mode 100644 index 0000000..f3a20c0 --- /dev/null +++ b/layouts/_partials/gallery-script.html @@ -0,0 +1,4 @@ +{{- /* Script for the gallery shortcode, included by the base templates on pages that use it. */ -}} +{{- with resources.Get "js/gallery.js" | minify | fingerprint -}} + +{{- end -}} diff --git a/layouts/_shortcodes/gallery-image.html b/layouts/_shortcodes/gallery-image.html new file mode 100644 index 0000000..2b69906 --- /dev/null +++ b/layouts/_shortcodes/gallery-image.html @@ -0,0 +1,37 @@ +{{- /* +One image in a gallery, see gallery.html. Params: + src the image, a file in the page's folder (page resource) or in assets/ + alt what the image shows (required); also shown in the lightbox + credit photographer, licence; shown under the thumbnail and in the lightbox +*/ -}} +{{- if ne .Parent.Name "gallery" -}} + {{- errorf "gallery-image: use it inside {{< gallery >}}: %s" .Position -}} +{{- end -}} +{{- $src := .Get "src" -}} +{{- $alt := .Get "alt" -}} +{{- $credit := .Get "credit" -}} +{{- if not $alt -}} + {{- errorf "gallery-image: %q needs an alt text: %s" $src .Position -}} +{{- end -}} +{{- $image := or (.Page.Resources.Get (strings.TrimPrefix "./" $src)) (resources.Get $src) -}} +{{- if not $image -}} + {{- errorf "gallery-image: no image %q in the page's folder or in assets/: %s" $src .Position -}} +{{- else if or (ne $image.ResourceType "image") (eq $image.MediaType.SubType "svg") -}} + {{- errorf "gallery-image: %q is not a photo Hugo can resize: %s" $src .Position -}} +{{- else -}} +{{- /* Square thumbnails at two sizes, never larger than the image itself. */ -}} +{{- $side := math.Min 800 $image.Width $image.Height | int -}} +{{- $small := $image.Fill (printf "%dx%d Smart" (div $side 2) (div $side 2)) -}} +{{- $large := $image.Fill (printf "%dx%d Smart" $side $side) -}} + +{{- end -}} diff --git a/layouts/_shortcodes/gallery.html b/layouts/_shortcodes/gallery.html new file mode 100644 index 0000000..cda63c5 --- /dev/null +++ b/layouts/_shortcodes/gallery.html @@ -0,0 +1,20 @@ +{{- /* +Image gallery: square thumbnails, two per row on wide screens, that open the full image in a +lightbox (assets/js/gallery.js) with the alt text and credit. Without JavaScript the +thumbnails link to the full image. + + {{< gallery >}} + {{< gallery-image src="photo1.jpg" alt="What the photo shows" credit="© Photographer" >}} + {{< gallery-image src="photo2.jpg" alt="…" credit="…" >}} + {{< /gallery >}} +*/ -}} + +{{- /* chomp */ -}} diff --git a/layouts/baseof.html b/layouts/baseof.html index 1b2fc6e..cf52f12 100644 --- a/layouts/baseof.html +++ b/layouts/baseof.html @@ -14,6 +14,7 @@ {{ partial "head/extra.html" . }} {{ block "head" . }}{{ end }} {{ if .HasShortcode "schedule" }}{{ partial "schedule-script.html" . }}{{ end }} +{{ if .HasShortcode "gallery" }}{{ partial "gallery-script.html" . }}{{ end }} diff --git a/layouts/home-baseof.html b/layouts/home-baseof.html index bdbee20..8b591e8 100644 --- a/layouts/home-baseof.html +++ b/layouts/home-baseof.html @@ -14,6 +14,7 @@ {{ partial "head/extra.html" . }} {{ block "head" . }}{{ end }} {{ if .HasShortcode "schedule" }}{{ partial "schedule-script.html" . }}{{ end }} +{{ if .HasShortcode "gallery" }}{{ partial "gallery-script.html" . }}{{ end }}