diff --git a/blocks/dynamic/index.js b/blocks/dynamic/index.js index b2f8ced..ae03afc 100644 --- a/blocks/dynamic/index.js +++ b/blocks/dynamic/index.js @@ -1,5 +1,8 @@ -/** Conditionally loads and runs dynamic blocks (e.g. tabs from section metadata). */ +/** Conditionally loads and runs dynamic blocks (e.g. tabs, modal). */ export default async function dynamicBlocks(main) { + const { setupFragmentModal } = await import('../modal/modal.js'); + setupFragmentModal(); + const hasTabSections = main?.querySelectorAll('.section[data-tab-id]').length > 0; if (!hasTabSections) return; diff --git a/blocks/modal/modal.css b/blocks/modal/modal.css new file mode 100644 index 0000000..e121799 --- /dev/null +++ b/blocks/modal/modal.css @@ -0,0 +1,71 @@ +/* Modal – opens fragment links in a dialog */ + +.modal { + position: fixed; + inset: 0; + z-index: 1400; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-s); +} + +.modal[hidden] { + display: none; +} + +.modal .modal-backdrop { + position: absolute; + inset: 0; + background: rgb(0 0 0 / 45%); +} + +.modal .modal-dialog { + position: relative; + width: min(920px, calc(100vw - var(--space-xl))); + max-height: calc(100vh - var(--space-xl)); + margin: 0; + overflow: auto; + border-radius: 12px; + background: light-dark(var(--color-light), var(--color-gray-900)); + box-shadow: 0 12px 30px rgb(0 0 0 / 26%); + padding: var(--space-l); +} + +.modal .modal-close { + position: sticky; + top: 0; + margin-left: auto; + display: block; + width: 32px; + height: 32px; + border: 0; + border-radius: 999px; + background: light-dark(var(--color-gray-100), var(--color-gray-700)); + color: inherit; + font-size: 24px; + line-height: 1; + cursor: pointer; +} + +.modal .modal-close:hover { + opacity: 0.9; +} + +.modal .modal-content .modal-main { + display: block; + min-height: 0; + max-width: 100%; + padding: 0; +} + +.modal .modal-content .modal-main > div, +.modal .modal-content .modal-main div[data-status] { + display: block; +} + +@media (width >= 600px) { + .modal { + padding: var(--space-m); + } +} diff --git a/blocks/modal/modal.js b/blocks/modal/modal.js new file mode 100644 index 0000000..e73dd47 --- /dev/null +++ b/blocks/modal/modal.js @@ -0,0 +1,95 @@ +/* + * Modal Block – opens fragment links in a dialog instead of navigating. + */ + +import { loadFragment } from '../fragment/fragment.js'; +import { loadCSS } from '../../scripts/aem.js'; +import { createTag } from '../../scripts/shared.js'; +import dynamicBlocks from '../dynamic/index.js'; + +const FRAGMENT_PREFIX = '/fragments/'; + +function getFragmentPath(href = '') { + try { + const url = new URL(href, window.location.origin); + if (!url.pathname.startsWith(FRAGMENT_PREFIX)) return null; + return `${url.pathname}${url.search}`; + } catch { + return null; + } +} + +export function setupFragmentModal() { + if (window.__fragmentModalReady) return; + window.__fragmentModalReady = true; + + loadCSS(`${window.hlx.codeBasePath}/blocks/modal/modal.css`); + + const closeBtn = createTag('button', { type: 'button', class: 'modal-close', 'aria-label': 'Close dialog' }, '×'); + const content = createTag('div', { class: 'modal-content' }); + const dialog = createTag('div', { + class: 'modal-dialog', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': 'Dialog', + }, [closeBtn, content]); + const backdrop = createTag('div', { class: 'modal-backdrop', 'aria-hidden': 'true' }); + const root = createTag('div', { class: 'modal', hidden: 'true' }, [backdrop, dialog]); + document.body.append(root); + let previousOverflow = ''; + let previousFocus = null; + + const close = () => { + root.hidden = true; + content.replaceChildren(); + document.body.style.overflow = previousOverflow; + if (previousFocus?.focus) previousFocus.focus(); + }; + + const open = async (path) => { + previousFocus = document.activeElement; + root.hidden = false; + previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + content.textContent = 'Loading...'; + closeBtn.focus(); + + try { + const fragment = await loadFragment(path); + if (fragment) { + const main = createTag('main', { class: 'modal-main' }); + main.append(...fragment.childNodes); + content.replaceChildren(main); + await dynamicBlocks(main); + } else { + content.textContent = 'Unable to load this content right now.'; + } + dialog.scrollTop = 0; + } catch { + content.textContent = 'Unable to load this content right now.'; + } + }; + + closeBtn.addEventListener('click', close); + backdrop.addEventListener('click', close); + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !root.hidden) close(); + }); + + document.addEventListener('click', (e) => { + const link = e.target.closest('main a[href*="/fragments/"]'); + if (!link) return; + if (link.closest('header, footer, nav, .modal')) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + if (link.target === '_blank') return; + const path = getFragmentPath(link.href); + if (!path) return; + e.preventDefault(); + open(path); + }); +} + +export default function decorate(block) { + setupFragmentModal(); + block.style.display = 'none'; +}