diff --git a/resources/css/app.css b/resources/css/app.css index 662598b..72ddf44 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -270,6 +270,99 @@ color: #38bdf8; /* sky-400 */ } +/* ---- + * Docs Image Lightbox + * ---- */ + +.docs-prose :where(img.docs-image-lightbox-trigger) { + cursor: pointer; + -webkit-tap-highlight-color: transparent; + border-radius: 0.5rem; + transition: + box-shadow 0.2s ease-out, + transform 0.2s ease-out; +} + +.docs-prose :where(img.docs-image-lightbox-trigger):focus { + outline: none; +} + +.docs-prose :where(img.docs-image-lightbox-trigger):focus-visible { + outline: 2px solid #38bdf8; + outline-offset: 2px; +} + +.docs-prose :where(img.docs-image-lightbox-trigger):hover { + transform: translateY(-1px); + box-shadow: 0 10px 30px -20px rgba(24, 24, 27, 0.85); +} + +.docs-image-lightbox-open { + overflow: hidden; +} + +.docs-image-lightbox { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + background-color: rgba(9, 9, 11, 0.85); + backdrop-filter: blur(4px); +} + +.docs-image-lightbox__frame { + margin: 0; + max-width: 100%; + max-height: 100%; +} + +.docs-image-lightbox__image { + display: block; + cursor: pointer; + width: auto; + height: auto; + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 6rem); + border-radius: 0.5rem; + box-shadow: 0 20px 45px -25px rgba(0, 0, 0, 0.95); +} + +.docs-image-lightbox__caption { + margin-top: 0.75rem; + color: #f4f4f5; + font-size: 0.875rem; + line-height: 1.25rem; + text-align: center; +} + +.docs-image-lightbox__close { + position: fixed; + top: 1rem; + right: 1rem; + border: 1px solid #3f3f46; + border-radius: 0.5rem; + background-color: rgba(24, 24, 27, 0.85); + color: #fafafa; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.docs-image-lightbox__close:hover { + border-color: #71717a; + background-color: rgba(39, 39, 42, 0.95); +} + +.docs-image-lightbox__close:focus-visible { + outline: 2px solid #38bdf8; + outline-offset: 2px; +} + /* ---- * Docs Page Headings Navigation (Right Sidebar) * Flux docs-style indicator instead of background highlight diff --git a/resources/js/app.js b/resources/js/app.js index 39c902c..a31b0c6 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -133,8 +133,124 @@ hljs.registerLanguage('sh', extendedBash); hljs.registerLanguage('yaml', yaml); hljs.registerLanguage('yml', yaml); +const DOCS_IMAGE_SELECTOR = '.docs-prose img'; +const DOCS_IMAGE_TRIGGER_CLASS = 'docs-image-lightbox-trigger'; + +let docsImageLightbox = null; +let docsImageLightboxActiveElement = null; +let docsImageLightboxShouldRestoreFocus = false; + +const closeDocsImageLightbox = ({ skipFocusRestore = false } = {}) => { + if (!docsImageLightbox) { + return; + } + + docsImageLightbox.remove(); + docsImageLightbox = null; + document.body.classList.remove('docs-image-lightbox-open'); + + if (!skipFocusRestore && docsImageLightboxShouldRestoreFocus && docsImageLightboxActiveElement instanceof HTMLElement) { + docsImageLightboxActiveElement.focus(); + } + docsImageLightboxActiveElement = null; + docsImageLightboxShouldRestoreFocus = false; +}; + +const openDocsImageLightbox = (sourceImage, { restoreFocus = false } = {}) => { + closeDocsImageLightbox({ skipFocusRestore: true }); + + docsImageLightboxActiveElement = sourceImage; + docsImageLightboxShouldRestoreFocus = restoreFocus; + + const overlay = document.createElement('div'); + overlay.className = 'docs-image-lightbox'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Expanded documentation image'); + + const frame = document.createElement('figure'); + frame.className = 'docs-image-lightbox__frame'; + + const image = document.createElement('img'); + image.className = 'docs-image-lightbox__image'; + image.src = sourceImage.currentSrc || sourceImage.src; + image.alt = sourceImage.alt || ''; + frame.append(image); + + if (sourceImage.alt?.trim()) { + const caption = document.createElement('figcaption'); + caption.className = 'docs-image-lightbox__caption'; + caption.textContent = sourceImage.alt.trim(); + frame.append(caption); + } + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'docs-image-lightbox__close'; + closeButton.setAttribute('aria-label', 'Close image preview'); + closeButton.textContent = 'Close'; + + overlay.append(frame, closeButton); + overlay.addEventListener('click', (event) => { + if (event.target === overlay || event.target === closeButton || event.target === image) { + closeDocsImageLightbox(); + } + }); + + document.body.append(overlay); + document.body.classList.add('docs-image-lightbox-open'); + closeButton.focus(); + + docsImageLightbox = overlay; +}; + +const isDocsImage = (element) => element instanceof HTMLImageElement && element.matches(DOCS_IMAGE_SELECTOR); + +const prepareDocsLightboxImages = () => { + document.querySelectorAll(DOCS_IMAGE_SELECTOR).forEach((image) => { + image.classList.add(DOCS_IMAGE_TRIGGER_CLASS); + image.setAttribute('title', image.getAttribute('title') || 'Click to expand'); + + if (!image.closest('a') && !image.hasAttribute('tabindex')) { + image.tabIndex = 0; + } + }); +}; + +document.addEventListener('click', (event) => { + const image = event.target instanceof HTMLImageElement ? event.target : null; + if (!isDocsImage(image)) { + return; + } + + event.preventDefault(); + openDocsImageLightbox(image, { restoreFocus: false }); +}); + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && docsImageLightbox) { + event.preventDefault(); + closeDocsImageLightbox(); + return; + } + + if (!['Enter', ' '].includes(event.key)) { + return; + } + + const activeElement = document.activeElement; + if (!isDocsImage(activeElement)) { + return; + } + + event.preventDefault(); + openDocsImageLightbox(activeElement, { restoreFocus: true }); +}); + // Use livewire:navigated for SPA compatibility (fires on initial load AND after navigation) document.addEventListener('livewire:navigated', () => { + prepareDocsLightboxImages(); + document.querySelectorAll('pre code:not(.hljs)').forEach((el) => { hljs.highlightElement(el); }); diff --git a/resources/views/components/docs/content.blade.php b/resources/views/components/docs/content.blade.php index c297de7..6b14510 100644 --- a/resources/views/components/docs/content.blade.php +++ b/resources/views/components/docs/content.blade.php @@ -2,6 +2,6 @@ 'content', ]) -
+
{!! $content !!}