mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
810 lines
30 KiB
JavaScript
810 lines
30 KiB
JavaScript
/**
|
|
* 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
|
|
* @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 = '',
|
|
} = 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';
|
|
// 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,
|
|
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<string>}
|
|
*/
|
|
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,
|
|
};
|
|
})();
|