/** * Generic Modal System for Jellyfin-style dialogs * Provides reusable modal functionality similar to cardBuilder.js */ window.ModalSystem = (function() { 'use strict'; // Store active modals (insertion order = stack order) const activeModals = new Map(); // Single shared backdrop for the whole stack (max one in the DOM) let sharedBackdropEl = null; let sharedBackdropClickBound = false; // Body scroll lock while any kefin modal is open (replaces non-passive wheel trap) let bodyScrollLockCount = 0; let savedBodyOverflow = null; let savedHtmlOverflow = null; const FOCUSABLE_SELECTOR = [ 'button:not([disabled]):not([tabindex="-1"])', '[href]:not([tabindex="-1"])', 'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])', 'select:not([disabled]):not([tabindex="-1"])', 'textarea:not([disabled]):not([tabindex="-1"])', '[tabindex]:not([tabindex="-1"])', '[role="button"]:not([tabindex="-1"])', ].join(','); function isTvLayout() { return document.documentElement.classList.contains('layout-tv'); } function lockBodyScroll() { if (bodyScrollLockCount === 0) { savedBodyOverflow = document.body.style.overflow; savedHtmlOverflow = document.documentElement.style.overflow; document.body.style.overflow = 'hidden'; document.documentElement.style.overflow = 'hidden'; } bodyScrollLockCount++; } function unlockBodyScroll() { if (bodyScrollLockCount <= 0) return; bodyScrollLockCount--; if (bodyScrollLockCount === 0) { document.body.style.overflow = savedBodyOverflow || ''; document.documentElement.style.overflow = savedHtmlOverflow || ''; savedBodyOverflow = null; savedHtmlOverflow = null; } } /** * Apply Jellyfin's scaleup open animation and clear it when finished. * Clearing avoids a stuck compositor layer that leaves large dialogs soft/blurry. * @param {HTMLElement} dialog */ function applyOpenAnimation(dialog) { if (!dialog) return; dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; const onEnd = (e) => { if (e.target !== dialog) return; if (e.animationName && e.animationName !== 'scaleup') return; dialog.style.animation = ''; dialog.removeEventListener('animationend', onEnd); }; dialog.addEventListener('animationend', onEnd); } function getFocusableElements(root) { if (!root) return []; return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => { if (el.closest('[hidden], [aria-hidden="true"]')) return false; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden') return false; if (el.disabled) return false; return true; }); } function stampShowFocus(root) { if (!root || !isTvLayout()) return; root.querySelectorAll('button, a.emby-button, .emby-button, .paper-icon-button-light, [role="button"]').forEach((el) => { if (el.getAttribute('tabindex') === '-1') return; el.classList.add('show-focus'); }); } function resolveInitialFocus(root, initialFocus) { if (!root) return null; if (initialFocus instanceof HTMLElement && root.contains(initialFocus)) { return initialFocus; } if (typeof initialFocus === 'string') { const bySelector = root.querySelector(initialFocus); if (bySelector) return bySelector; } return ( root.querySelector('[data-autofocus]') || root.querySelector('.is-active') || root.querySelector('.selected') || getFocusableElements(root)[0] || null ); } let anchoredPopoverStylesInjected = false; function ensureAnchoredPopoverStyles() { if (anchoredPopoverStylesInjected) return; anchoredPopoverStylesInjected = true; const style = document.createElement('style'); style.id = 'kefinTweaks-anchoredPopover-styles'; style.textContent = ` .dialogContainer.kefin-modal-anchored-container { contain: none !important; overflow: visible !important; } .dialogContainer.kefin-modal-anchored-container > .dialog.kefin-anchored-popover-dialog { position: relative !important; inset: auto !important; top: auto !important; right: auto !important; bottom: auto !important; left: auto !important; width: auto !important; height: auto !important; max-height: none !important; } `; document.head.appendChild(style); } /** * Document-absolute shell: override Jellyfin fixed full-screen dialogContainer. * @param {HTMLElement|null|undefined} dialogContainer */ function prepareAnchoredDialogContainer(dialogContainer) { if (!dialogContainer) return; ensureAnchoredPopoverStyles(); dialogContainer.classList.add('kefin-modal-anchored-container'); dialogContainer.setAttribute('data-kefin-anchored', 'true'); dialogContainer.style.position = 'absolute'; dialogContainer.style.right = 'auto'; dialogContainer.style.bottom = 'auto'; dialogContainer.style.inset = 'auto'; dialogContainer.style.width = 'auto'; dialogContainer.style.height = 'auto'; dialogContainer.style.overflow = 'visible'; dialogContainer.style.contain = 'none'; dialogContainer.style.pointerEvents = 'none'; if (!dialogContainer.style.zIndex) { dialogContainer.style.zIndex = '1100'; } } /** * Inner dialog is in-flow inside the anchored container (not viewport-fixed / inset fill). * @param {HTMLElement|null|undefined} dialog */ function prepareAnchoredPopoverDialog(dialog) { if (!dialog) return; ensureAnchoredPopoverStyles(); dialog.classList.add('kefin-anchored-popover-dialog'); dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize'); dialog.style.position = 'relative'; dialog.style.inset = 'auto'; dialog.style.top = 'auto'; dialog.style.right = 'auto'; dialog.style.bottom = 'auto'; dialog.style.left = 'auto'; dialog.style.margin = '0'; dialog.style.width = 'auto'; dialog.style.height = 'auto'; dialog.style.maxHeight = 'none'; dialog.style.pointerEvents = 'auto'; dialog.style.overflow = 'visible'; } /** * Place anchored popover shell in document space; dialog lays out inside the container. * @param {HTMLElement|null|undefined} dialogContainer * @param {HTMLElement|null|undefined} dialog * @param {number} leftViewport * @param {number} topViewport */ function positionAnchoredPopoverShell(dialogContainer, dialog, leftViewport, topViewport) { prepareAnchoredDialogContainer(dialogContainer); prepareAnchoredPopoverDialog(dialog); if (!dialogContainer) return; const scrollX = window.scrollX || window.pageXOffset || 0; const scrollY = window.scrollY || window.pageYOffset || 0; dialogContainer.style.left = `${leftViewport + scrollX}px`; dialogContainer.style.top = `${topViewport + scrollY}px`; } /** * Position a dialog near an anchor (document-absolute shell + scroll offsets). * @param {HTMLElement} dialog * @param {HTMLElement} anchor * @param {{ dialogContainer?: HTMLElement|null }} [options] */ function positionNearAnchor(dialog, anchor, options = {}) { if (!dialog || !anchor) return; const margin = 8; const gap = 8; const edgeMargin = 16; const btnRect = anchor.getBoundingClientRect(); const dialogWidth = dialog.offsetWidth || dialog.getBoundingClientRect().width || 200; const dialogHeight = dialog.offsetHeight || dialog.getBoundingClientRect().height || 120; const spaceBelow = window.innerHeight - btnRect.bottom - margin; const spaceAbove = btnRect.top - margin; const placeBelow = spaceBelow >= Math.min(spaceAbove, 160) || spaceBelow >= spaceAbove; let left = btnRect.left; left = Math.min( Math.max(edgeMargin, left), Math.max(edgeMargin, window.innerWidth - dialogWidth - edgeMargin), ); let top; if (placeBelow) { top = btnRect.bottom + gap; } else { top = Math.max(margin, btnRect.top - gap - dialogHeight); } top = Math.min(Math.max(top, margin), window.innerHeight - Math.min(dialogHeight, window.innerHeight - margin * 2) - margin); const dialogContainer = options.dialogContainer || dialog.closest?.('.dialogContainer') || null; positionAnchoredPopoverShell(dialogContainer, dialog, left, top); } function focusElement(el) { if (!el || typeof el.focus !== 'function') return; try { el.focus({ preventScroll: true }); } catch (_) { el.focus(); } } function owningDialog(el) { return el && typeof el.closest === 'function' ? el.closest('.dialog') : null; } function getTopmostModalId() { const ids = Array.from(activeModals.keys()); return ids.length ? ids[ids.length - 1] : null; } /** Backdrop click closes the topmost modal that opted into closeOnBackdrop. */ function onSharedBackdropClick() { const ids = Array.from(activeModals.keys()); for (let i = ids.length - 1; i >= 0; i--) { const modal = activeModals.get(ids[i]); if (modal?.closeOnBackdrop) { closeModal(ids[i]); return; } } } function ensureSharedBackdrop(backdrop) { const existing = sharedBackdropEl && document.body.contains(sharedBackdropEl) ? sharedBackdropEl : document.body.querySelector('.dialogBackdrop[data-kefin-modal-backdrop="true"]'); if (existing) { sharedBackdropEl = existing; if (!sharedBackdropClickBound) { existing.addEventListener('click', onSharedBackdropClick); sharedBackdropClickBound = true; } return existing; } backdrop.setAttribute('data-kefin-modal-backdrop', 'true'); document.body.appendChild(backdrop); sharedBackdropEl = backdrop; if (!sharedBackdropClickBound) { backdrop.addEventListener('click', onSharedBackdropClick); sharedBackdropClickBound = true; } return backdrop; } function getFocusRows(dialogEl) { const rows = Array.from(dialogEl.querySelectorAll('[data-kefin-focus-row]')); const withItems = rows.map((row) => ({ row, items: getFocusableElements(row).filter((el) => el.closest('[data-kefin-focus-row]') === row), })).filter((entry) => entry.items.length); withItems.sort((a, b) => { const at = a.row.getBoundingClientRect(); const bt = b.row.getBoundingClientRect(); if (Math.abs(at.top - bt.top) > 4) return at.top - bt.top; return at.left - bt.left; }); return withItems; } /** * Attach focus trap to a dialog element. Returns a dispose function. * @param {HTMLElement} dialogEl * @param {{ returnFocusEl?: HTMLElement|null, initialFocusEl?: HTMLElement|string|null, onEscape?: Function|null }} options */ function attachFocusTrap(dialogEl, options = {}) { if (!dialogEl) return () => {}; const returnFocusEl = options.returnFocusEl || null; const onEscape = typeof options.onEscape === 'function' ? options.onEscape : null; dialogEl.classList.add('focuscontainer'); stampShowFocus(dialogEl); const focusInitial = () => { const target = resolveInitialFocus(dialogEl, options.initialFocusEl); if (target) { focusElement(target); } else if (typeof dialogEl.focus === 'function') { if (!dialogEl.hasAttribute('tabindex')) dialogEl.setAttribute('tabindex', '-1'); focusElement(dialogEl); } }; requestAnimationFrame(() => { requestAnimationFrame(focusInitial); }); const keydownHandler = (e) => { if (!dialogEl.isConnected) return; const active = document.activeElement; const activeDialog = owningDialog(active); // Nested/child modal owns keys until it closes if (activeDialog && activeDialog !== dialogEl) return; const isNavKey = e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Tab'; if (!dialogEl.contains(active) && e.key !== 'Escape') { if (isNavKey) { e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); focusInitial(); } return; } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); if (onEscape) onEscape(e); return; } const focusables = getFocusableElements(dialogEl); if (!focusables.length) return; const currentIndex = focusables.indexOf(active); const at = currentIndex >= 0 ? currentIndex : 0; const rows = getFocusRows(dialogEl); const useRows = rows.length >= 2; const consume = () => { e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }; if (e.key === 'Tab') { consume(); const next = e.shiftKey ? focusables[(at - 1 + focusables.length) % focusables.length] : focusables[(at + 1) % focusables.length]; focusElement(next); return; } if (useRows && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown')) { consume(); let rowIndex = rows.findIndex((entry) => entry.items.includes(active)); if (rowIndex < 0) { rowIndex = rows.findIndex((entry) => entry.row.contains(active)); } if (rowIndex < 0) rowIndex = 0; const row = rows[rowIndex]; let col = row.items.indexOf(active); if (col < 0) col = 0; if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { const nextCol = e.key === 'ArrowRight' ? (col + 1) % row.items.length : (col - 1 + row.items.length) % row.items.length; focusElement(row.items[nextCol]); return; } const nextRowIndex = e.key === 'ArrowDown' ? (rowIndex + 1) % rows.length : (rowIndex - 1 + rows.length) % rows.length; const nextRow = rows[nextRowIndex]; const nextCol = Math.min(col, nextRow.items.length - 1); focusElement(nextRow.items[nextCol]); return; } if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { consume(); focusElement(focusables[(at + 1) % focusables.length]); return; } if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { consume(); focusElement(focusables[(at - 1 + focusables.length) % focusables.length]); } }; // Document capture so Left/Right are not taken by Jellyfin spatial navigation document.addEventListener('keydown', keydownHandler, true); return () => { document.removeEventListener('keydown', keydownHandler, true); if (returnFocusEl && typeof returnFocusEl.focus === 'function' && document.contains(returnFocusEl)) { focusElement(returnFocusEl); } }; } /** * Create a Jellyfin-style modal dialog * @param {Object} options - Modal configuration * @param {boolean} [options.lockPageScroll=true] - When true, contain overscroll so wheel on the dialog does not chain to the page. Set false for anchored popovers so short lists allow page scroll. * @returns {Object} Modal instance */ function createModal(options = {}) { const { id, title, content, footer, onClose, onOpen, closeOnBackdrop = true, closeOnEscape = true, showCloseButton = true, fixedSize, dialogStyle, returnFocus, initialFocus, trapFocus, anchor = null, dialogClassName = '', // When true (default), contain overscroll so wheel on the dialog does not chain to the page. // Set false for anchored popovers (e.g. multi-query) so short lists let the page scroll. lockPageScroll = true, } = options; if (!id) { throw new Error('Modal ID is required'); } // Remove existing modal if it exists removeModal(id); const useFixedSize = fixedSize === true || (fixedSize !== false && typeof window !== 'undefined' && window.innerWidth < 900); let _showCloseButton = showCloseButton; if (title == null && showCloseButton === false) { _showCloseButton = false; } else if (window.innerWidth < 900 && useFixedSize && showCloseButton !== false && title) { _showCloseButton = true; } const shouldTrap = trapFocus === true || (trapFocus !== false && isTvLayout()); const returnFocusEl = returnFocus instanceof HTMLElement ? returnFocus : document.activeElement instanceof HTMLElement ? document.activeElement : null; // Create modal elements const backdrop = document.createElement('div'); backdrop.className = 'dialogBackdrop dialogBackdropOpened'; backdrop.setAttribute('data-modal-id', id); const dialogContainer = document.createElement('div'); dialogContainer.className = 'dialogContainer'; dialogContainer.setAttribute('data-modal-id', id); const dialog = document.createElement('div'); const extraClasses = dialogClassName ? ` ${dialogClassName}` : ''; dialog.className = `focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog ${useFixedSize ? 'dialog-fixedSize' : 'centeredDialog'} opened${extraClasses}`; dialog.setAttribute('data-history', 'true'); dialog.setAttribute('data-autofocus', 'true'); dialog.setAttribute('data-removeonclose', 'true'); dialog.setAttribute('data-name', 'kefin-modal'); dialog.style.display = 'flex'; dialog.style.flexDirection = 'column'; if (!useFixedSize) { dialog.style.maxHeight = '90vh'; } // Final box size before scaleup so the compositor layer is not soft-scaled after open if (dialogStyle && typeof dialogStyle === 'object') { Object.assign(dialog.style, dialogStyle); } applyOpenAnimation(dialog); // Create header if title is provided let dialogHeader = null; if (title || _showCloseButton) { dialogHeader = document.createElement('div'); dialogHeader.className = 'formDialogHeader'; dialogHeader.style.display = 'flex'; dialogHeader.style.justifyContent = 'space-between'; dialogHeader.style.alignItems = 'center'; dialogHeader.style.padding = '1.25em 1.5em'; dialogHeader.style.borderBottom = '1px solid rgba(255,255,255,0.1)'; dialogHeader.style.flexShrink = '0'; if (title) { const titleElement = document.createElement('h2'); titleElement.style.margin = '0'; titleElement.style.textAlign = 'left'; titleElement.textContent = title; dialogHeader.appendChild(titleElement); } if (_showCloseButton) { const closeButton = document.createElement('button'); closeButton.setAttribute('is', 'paper-icon-button-light'); closeButton.className = 'btnCancel btnClose autoSize paper-icon-button-light'; closeButton.setAttribute('tabindex', '-1'); closeButton.title = 'Close'; closeButton.onclick = () => closeModal(id); const closeIcon = document.createElement('span'); closeIcon.className = 'material-icons close'; closeIcon.setAttribute('aria-hidden', 'true'); closeButton.appendChild(closeIcon); dialogHeader.appendChild(closeButton); } if (dialogHeader.childNodes.length > 0) { dialog.appendChild(dialogHeader); } } // Create scrollable content area const dialogContent = document.createElement('div'); dialogContent.style.padding = '1.25em 1.5em'; dialogContent.style.overflowY = 'auto'; dialogContent.style.flex = '1'; dialogContent.style.minHeight = '0'; if (lockPageScroll) { // Contain overscroll so nested wheel/touch scroll does not chain to the page // (avoids a non-passive wheel listener that delays input while the main thread is busy) dialogContent.style.overscrollBehavior = 'contain'; dialog.style.overscrollBehavior = 'contain'; } // Add content if (content) { if (typeof content === 'string') { dialogContent.innerHTML = content; } else if (content instanceof HTMLElement) { dialogContent.appendChild(content); } } // Create footer if provided let dialogFooter = null; if (footer) { dialogFooter = document.createElement('div'); dialogFooter.className = 'formDialogFooter kefinModalFooter'; dialogFooter.style.padding = '1.25em 1.5em'; dialogFooter.style.borderTop = '1px solid rgba(255,255,255,0.1)'; dialogFooter.style.flexShrink = '0'; if (typeof footer === 'string') { dialogFooter.innerHTML = footer; } else if (footer instanceof HTMLElement) { dialogFooter.appendChild(footer); } dialog.dataset.footer = footer ? 'true' : 'false'; } // Assemble modal dialogContent.setAttribute('data-name', 'kefin-modal-content'); dialog.appendChild(dialogContent); if (dialogFooter) { dialog.appendChild(dialogFooter); } dialogContainer.appendChild(dialog); // Single shared backdrop for the stack; nested modals reuse the same DOM node const resolvedBackdrop = ensureSharedBackdrop(backdrop); document.body.appendChild(dialogContainer); // Create modal instance const modalInstance = { id, backdrop: resolvedBackdrop, dialogContainer, dialog, dialogContent, dialogHeader, dialogFooter, isOpen: true, closeOnBackdrop: !!closeOnBackdrop, lockPageScroll: !!lockPageScroll, onClose: typeof onClose === 'function' ? onClose : null, returnFocusEl, close: () => closeModal(id), updateContent: (newContent) => updateModalContent(id, newContent), addEventListener: (event, handler) => { dialog.addEventListener(event, handler); }, _disposeFocusTrap: null, _escapeHandler: null, }; // Store modal instance activeModals.set(id, modalInstance); //lockBodyScroll(); // dialogContainer click closes this modal; shared backdrop always closes topmost if (closeOnBackdrop) { dialogContainer.addEventListener('click', (e) => { if (e.target === dialogContainer) { closeModal(id); } }); } if (closeOnEscape && !shouldTrap) { const escapeHandler = (e) => { if (e.key === 'Escape') { closeModal(id); } }; document.addEventListener('keydown', escapeHandler); modalInstance._escapeHandler = escapeHandler; } if (anchor instanceof HTMLElement) { dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize'); dialog.style.maxHeight = 'none'; prepareAnchoredDialogContainer(dialogContainer); requestAnimationFrame(() => { positionNearAnchor(dialog, anchor, { dialogContainer }); }); } if (shouldTrap) { modalInstance._disposeFocusTrap = attachFocusTrap(dialog, { returnFocusEl, initialFocusEl: initialFocus, onEscape: closeOnEscape ? () => closeModal(id) : null, }); } else if (isTvLayout()) { stampShowFocus(dialog); } // Call onOpen callback if (onOpen && typeof onOpen === 'function') { onOpen(modalInstance); } return modalInstance; } /** * Close a modal by ID. Closes later-opened (nested) modals first. * @param {string} id - Modal ID * @param {{ fromCascade?: boolean }} [options] */ function closeModal(id, options = {}) { const modal = activeModals.get(id); if (!modal) return; // Close nested modals opened after this one (reverse stack order) if (!options.fromCascade) { const ids = Array.from(activeModals.keys()); const idx = ids.indexOf(id); if (idx >= 0) { for (let i = ids.length - 1; i > idx; i--) { closeModal(ids[i], { fromCascade: true }); } } } // Remove escape handler if it exists if (modal._escapeHandler) { document.removeEventListener('keydown', modal._escapeHandler); modal._escapeHandler = null; } if (typeof modal._disposeFocusTrap === 'function') { modal._disposeFocusTrap(); modal._disposeFocusTrap = null; } else if (modal.returnFocusEl && typeof modal.returnFocusEl.focus === 'function' && document.contains(modal.returnFocusEl)) { try { modal.returnFocusEl.focus({ preventScroll: true }); } catch (_) { modal.returnFocusEl.focus(); } } // Remove dialog container from DOM (dialog stays a child for anchored popovers) if (modal.dialogContainer && modal.dialogContainer.parentNode) { modal.dialogContainer.parentNode.removeChild(modal.dialogContainer); } // Remove from active modals before deciding on shared backdrop activeModals.delete(id); // Shared backdrop stays while any modal remains; remove only when stack is empty if (activeModals.size === 0 && sharedBackdropEl && sharedBackdropEl.parentNode) { sharedBackdropEl.removeEventListener('click', onSharedBackdropClick); sharedBackdropClickBound = false; sharedBackdropEl.parentNode.removeChild(sharedBackdropEl); sharedBackdropEl = null; } //unlockBodyScroll(); // Call onClose callback if modal instance has it if (modal.onClose && typeof modal.onClose === 'function') { modal.onClose(); } } /** * Remove a modal by ID (force removal) * @param {string} id - Modal ID */ function removeModal(id) { const modal = activeModals.get(id); if (modal) { closeModal(id); } } /** * Update modal content * @param {string} id - Modal ID * @param {string|HTMLElement} content - New content */ function updateModalContent(id, content) { const modal = activeModals.get(id); if (!modal) return; if (typeof content === 'string') { modal.dialogContent.innerHTML = content; } else if (content instanceof HTMLElement) { modal.dialogContent.innerHTML = ''; modal.dialogContent.appendChild(content); } stampShowFocus(modal.dialog); } /** * Check if a modal is open * @param {string} id - Modal ID * @returns {boolean} */ function isModalOpen(id) { return activeModals.has(id); } /** * Get all active modal IDs * @returns {Array} */ function getActiveModalIds() { return Array.from(activeModals.keys()); } /** * Close all modals */ function closeAllModals() { const ids = Array.from(activeModals.keys()); ids.forEach(id => closeModal(id)); } // Public API return { create: createModal, close: closeModal, remove: removeModal, updateContent: updateModalContent, isOpen: isModalOpen, getActiveIds: getActiveModalIds, closeAll: closeAllModals, applyOpenAnimation, attachFocusTrap, prepareAnchoredDialogContainer, prepareAnchoredPopoverDialog, positionAnchoredPopoverShell, positionNearAnchor, getFocusableElements, }; })();