-
Notifications
You must be signed in to change notification settings - Fork 227
fix: make image modal keyboard accessible #1260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,44 +1,45 @@ | ||
| <!-- Images that use the standard image format for markdown will be displayed in a | ||
| magnific modal popup. Use format  --> | ||
| <!-- Place this file in layouts > _default > _markup > render-image.html --> | ||
| <!-- Also place the image-modal.css and partial/image-modal.html appropriately. --> | ||
| <!-- PNG and JPEG sources are encoded to WebP at build time and offered through | ||
| <picture>. The original stays as the <img> fallback, so nothing in content has | ||
| to change and browsers without WebP support still get a working image. | ||
| Content is mounted at assets/contentimg (see hugo.toml) because Hugo can only | ||
| process files that are resources. --> | ||
| <!-- Onclick opens the modal, and displays/overlays the clicked image. --> | ||
| <!-- attribution appreciated. github: zjeaton web: https://froglegs.co --> | ||
|
|
||
| {{- $dest := .Destination -}} | ||
| {{- $alt := .Text -}} | ||
| {{- $class := "md-image-responsive" -}} | ||
| {{- with .Title }}{{ $class = printf "md-image-responsive %s" . }}{{ end -}} | ||
| {{- $img := "" -}} | ||
| {{- $ext := lower (path.Ext $dest) -}} | ||
|
|
||
| {{- if and (in (slice ".png" ".jpg" ".jpeg") $ext) (not (urls.Parse $dest).Scheme) (not (hasPrefix $dest "//")) -}} | ||
| {{- $rel := $dest -}} | ||
| {{- if not (hasPrefix $dest "/") -}} | ||
| {{- with .Page.File -}} | ||
| {{- $rel = path.Join (replace .Dir "\\" "/") $dest -}} | ||
| {{- end -}} | ||
| {{- end -}} | ||
| {{- $img = resources.Get (path.Join "contentimg" $rel) -}} | ||
| {{- if not $img -}}{{- $img = resources.Get $rel -}}{{- end -}} | ||
| {{- end -}} | ||
| {{- $dest := .Destination -}} | ||
| {{- $class := .Title -}} | ||
| {{- $img := resources.Get (strings.TrimPrefix "/" $dest) -}} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Guard WebP processing to processable image resources.
🤖 Prompt for AI AgentsSource: MCP tools |
||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| <div class="md__image"> | ||
| {{- with $img }} | ||
| {{- $webp := .Process "webp q85" }} | ||
| <picture> | ||
| <source srcset="{{ $webp.RelPermalink }}" type="image/webp" width="{{ .Width }}" height="{{ .Height }}"> | ||
| <img src="{{ $dest | safeURL }}" onclick="openModal(this)" alt="{{ $alt }}" | ||
| width="{{ .Width }}" height="{{ .Height }}" | ||
| class="{{ $class }}" /> | ||
| </picture> | ||
|
|
||
| <button | ||
| type="button" | ||
| class="md__image-trigger" | ||
| aria-label="Expand image{{ with $alt }}: {{ . }}{{ end }}" | ||
| > | ||
| <picture> | ||
| <source | ||
| srcset="{{ $webp.RelPermalink }}" | ||
| type="image/webp" | ||
| > | ||
| <img | ||
| src="{{ $dest | safeURL }}" | ||
| alt="{{ $alt }}" | ||
| width="{{ .Width }}" | ||
| height="{{ .Height }}" | ||
| class="{{ $class }}" | ||
| /> | ||
| </picture> | ||
| </button> | ||
|
|
||
| {{- else }} | ||
| <img src="{{ $dest | safeURL }}" onclick="openModal(this)" alt="{{ $alt }}" | ||
| class="{{ $class }}" /> | ||
|
|
||
| <button | ||
| type="button" | ||
| class="md__image-trigger" | ||
| aria-label="Expand image{{ with $alt }}: {{ . }}{{ end }}" | ||
| > | ||
| <img | ||
| src="{{ $dest | safeURL }}" | ||
| alt="{{ $alt }}" | ||
| class="{{ $class }}" | ||
| /> | ||
| </button> | ||
|
|
||
| {{- end }} | ||
| </div> | ||
| </div> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,61 +1,153 @@ | ||
| <div id="myModal" class="modal"> | ||
| <button class="modal-close" onclick="closeModal()">close</button> | ||
| <div class="modal-cont"> | ||
| <img class="modal-pic" id="modalPic" onclick="closeModal()" style="max-width: 100%; max-height: 80vh; margin: auto;" alt="Modal-pic"> | ||
| </div> | ||
| </div> | ||
|
|
||
| <script> | ||
| // Open the Modal | ||
| function openModal(imageIdOrElement) { | ||
| var src; | ||
|
|
||
| if (typeof imageIdOrElement === 'string') { | ||
| // If it's a string (image ID), get the source using the ID | ||
| src = document.getElementById(imageIdOrElement).src; | ||
| } else if (imageIdOrElement instanceof HTMLImageElement) { | ||
| // If <img>, get the source directly. currentSrc is what the browser actually | ||
| // chose, so inside a <picture> the modal reuses the already-fetched WebP | ||
| // rather than downloading the larger original. | ||
| src = imageIdOrElement.currentSrc || imageIdOrElement.src; | ||
| } | ||
|
|
||
| if (src && src.includes("#")) { | ||
| src = src.substring(0, src.indexOf("#")); | ||
| } | ||
|
|
||
| document.getElementById("modalPic").src = src; | ||
| document.getElementById("myModal").style.display = "block"; | ||
| } | ||
|
|
||
| // Close the Modal | ||
| <div | ||
| id="myModal" | ||
| class="modal" | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-label="Image preview" | ||
| > | ||
| <button | ||
| type="button" | ||
| class="modal-close" | ||
| onclick="closeModal()" | ||
| aria-label="Close image" | ||
| > | ||
| close | ||
| </button> | ||
|
|
||
| <div class="modal-cont"> | ||
| <img | ||
| class="modal-pic" | ||
| id="modalPic" | ||
| onclick="closeModal()" | ||
| style="max-width: 100%; max-height: 80vh; margin: auto;" | ||
| alt="Image preview" | ||
| > | ||
| </div> | ||
| </div> | ||
|
|
||
| <script> | ||
| var modalTrigger = null; | ||
|
|
||
| function openModal(image) { | ||
| if (!(image instanceof HTMLImageElement)) { | ||
| return; | ||
| } | ||
|
|
||
| var src = image.currentSrc || image.src; | ||
|
|
||
| if (src && src.includes("#")) { | ||
| src = src.substring(0, src.indexOf("#")); | ||
| } | ||
|
|
||
| var modal = document.getElementById("myModal"); | ||
| var modalPic = document.getElementById("modalPic"); | ||
| var closeButton = modal.querySelector(".modal-close"); | ||
|
|
||
| modalTrigger = image.closest(".md__image-trigger"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Restore focus after opening a raw image. When a raw Based on learnings, closing a modal must restore focus to the previously focused element. 🤖 Prompt for AI AgentsSource: Learnings |
||
|
|
||
| modalPic.src = src; | ||
| modalPic.alt = image.alt || "Image preview"; | ||
| modal.style.display = "block"; | ||
|
|
||
| if (closeButton) { | ||
| closeButton.focus(); | ||
| } | ||
| } | ||
|
|
||
| function closeModal() { | ||
| document.getElementById("modalPic").src = ""; | ||
| document.getElementById("myModal").style.display = "none"; | ||
| } | ||
|
|
||
| // Listen for the Escape key to close the modal | ||
| document.addEventListener("keydown", function (event) { | ||
| if (event.key === "Escape") { | ||
| closeModal(); | ||
| } | ||
| }); | ||
|
|
||
| // Attach onclick attribute to all <img> after DOM is loaded | ||
| document.addEventListener("DOMContentLoaded", function () { | ||
| var imgTags = document.querySelectorAll("img"); | ||
| imgTags.forEach(function (img) { | ||
| img.onclick = function () { | ||
| if (img.dataset.modal !== "false") { | ||
| openModal(img); | ||
| } | ||
| }; | ||
| }); | ||
|
|
||
| // Close modal when clicking anywhere inside #myModal | ||
| var modal = document.getElementById("myModal"); | ||
| modal.addEventListener("click", function () { | ||
| closeModal(); | ||
| }); | ||
| }); | ||
| var modal = document.getElementById("myModal"); | ||
| var modalPic = document.getElementById("modalPic"); | ||
| var trigger = modalTrigger; | ||
|
|
||
| modalPic.src = ""; | ||
| modal.style.display = "none"; | ||
| modalTrigger = null; | ||
|
|
||
| if (trigger && document.contains(trigger)) { | ||
| trigger.focus(); | ||
| } | ||
| } | ||
|
|
||
| function isModalOpen() { | ||
| return document.getElementById("myModal").style.display === "block"; | ||
| } | ||
|
|
||
| function trapModalFocus(event) { | ||
| if (event.key !== "Tab" || !isModalOpen()) { | ||
| return; | ||
| } | ||
|
|
||
| var modal = document.getElementById("myModal"); | ||
|
|
||
| var focusableElements = modal.querySelectorAll( | ||
| 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' | ||
| ); | ||
|
|
||
| if (!focusableElements.length) { | ||
| event.preventDefault(); | ||
| return; | ||
| } | ||
|
|
||
| var firstElement = focusableElements[0]; | ||
| var lastElement = focusableElements[focusableElements.length - 1]; | ||
|
|
||
| if (event.shiftKey && document.activeElement === firstElement) { | ||
| event.preventDefault(); | ||
| lastElement.focus(); | ||
| } else if (!event.shiftKey && document.activeElement === lastElement) { | ||
| event.preventDefault(); | ||
| firstElement.focus(); | ||
| } | ||
| } | ||
|
|
||
| document.addEventListener("keydown", function (event) { | ||
| if (event.key === "Escape" && isModalOpen()) { | ||
| event.preventDefault(); | ||
| closeModal(); | ||
| return; | ||
| } | ||
|
|
||
| trapModalFocus(event); | ||
| }); | ||
|
|
||
| document.addEventListener("DOMContentLoaded", function () { | ||
| var modal = document.getElementById("myModal"); | ||
|
|
||
| var imageTriggers = document.querySelectorAll(".md__image-trigger"); | ||
|
|
||
| imageTriggers.forEach(function (trigger) { | ||
| var link = trigger.closest("a"); | ||
| var image = trigger.querySelector("img"); | ||
|
|
||
| if (link && image) { | ||
| image.dataset.modal = "false"; | ||
| trigger.replaceWith(image); | ||
| return; | ||
| } | ||
|
|
||
| trigger.addEventListener("click", function () { | ||
| if (image) { | ||
| openModal(image); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| document.querySelectorAll("img:not(.md__image-trigger img)").forEach(function (img) { | ||
| if (img.dataset.modal === "false" || img.closest("#myModal")) { | ||
| return; | ||
| } | ||
|
|
||
| img.addEventListener("click", function () { | ||
| openModal(img); | ||
| }); | ||
| }); | ||
|
|
||
| if (modal) { | ||
| modal.addEventListener("click", function (event) { | ||
| if (event.target === modal) { | ||
| closeModal(); | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
| </script> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Restore the default responsive class.
$class := .Titleremovesmd-image-responsivefrom images without a title. Images with a title also use the title as the complete class value. This changes the existing responsive image contract. Preservemd-image-responsiveand append.Titlewhen present in both<img>elements. The previous hook used that composition. (raw.githubusercontent.com)🤖 Prompt for AI Agents
Source: MCP tools