// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG
// SPDX-License-Identifier: AGPL-3.0-only
/**
* Modal dialog component for Insight UI.
*
* Creates accessible modal dialogs with focus trapping, backdrop click to close,
* and scroll blocking. Only one modal can be open at a time.
*
* @example
* // HTML structure
*
*
*
Modal content
*
*
*/
export class Modal {
/** @type {WeakMap} Weak references to prevent multiple initialization */
static instances = new WeakMap();
/** @type {Modal|null} Currently open modal instance */
static currentOpen = null;
/**
* Creates a new Modal instance.
*
* @param {HTMLElement} trigger - The trigger button element with data-insight-modal attribute
*/
constructor(trigger) {
// If an instance for this element already exists, return it
if (Modal.instances.has(trigger)) {
return Modal.instances.get(trigger);
}
this.trigger = trigger;
this.targetId = trigger.getAttribute('data-insight-modal');
this.modal = document.getElementById(this.targetId);
if (!this.modal) return;
// Store bound handlers for cleanup
this.boundButtonClick = null;
this.boundCloseButtons = [];
this.boundModalClick = null;
this.boundKeyDown = null;
this.releaseFocusTrap = null;
this.triggerElement = null;
this.bindEvents();
this.trigger.__insightInstance = this;
Modal.instances.set(trigger, this);
debugLog("New modal created: ", this.trigger, this.modal);
}
/**
* Binds event listeners to the trigger, close buttons, and modal backdrop.
*/
bindEvents() {
this.boundButtonClick = (e) => {
e.preventDefault();
this.open();
};
this.trigger.addEventListener('click', this.boundButtonClick);
this.modal.querySelectorAll('[data-insight-dismiss="modal"]').forEach(closeBtn => {
const handler = (e) => {
e.preventDefault();
this.close();
};
this.boundCloseButtons.push({ element: closeBtn, handler });
closeBtn.addEventListener('click', handler);
});
this.boundModalClick = (e) => {
if (e.target === this.modal) this.close();
};
this.modal.addEventListener('click', this.boundModalClick);
}
/**
* Opens the modal dialog.
* Closes any other open modal, blocks scroll, and traps focus.
*/
open() {
if (Modal.currentOpen && Modal.currentOpen !== this) {
Modal.currentOpen.close();
}
// Store trigger for focus return
this.triggerElement = document.activeElement;
this.modal.style.display = 'block';
Modal.currentOpen = this;
InsightUI.utils.blockScroll();
this.releaseFocusTrap = InsightUI.utils.trapFocus(this.modal);
// Add Escape key handler
this.boundKeyDown = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.close();
}
};
document.addEventListener('keydown', this.boundKeyDown);
}
/**
* Closes the modal dialog and restores scroll.
*/
close() {
// Remove Escape key handler
if (this.boundKeyDown) {
document.removeEventListener('keydown', this.boundKeyDown);
this.boundKeyDown = null;
}
this.modal.style.display = 'none';
if (Modal.currentOpen === this) {
Modal.currentOpen = null;
}
// Release focus trap
if (this.releaseFocusTrap) {
this.releaseFocusTrap();
this.releaseFocusTrap = null;
}
InsightUI.utils.unblockScroll();
// Return focus to trigger element
if (this.triggerElement && typeof this.triggerElement.focus === 'function') {
this.triggerElement.focus();
}
this.triggerElement = null;
}
/**
* Destroys the modal instance and removes all event listeners.
* Call this before removing the element from DOM.
*/
destroy() {
debugLog("Destroy modal: ", this.trigger, this.modal);
// Close modal if open
if (Modal.currentOpen === this) {
this.close();
}
// Remove button click handler
if (this.boundButtonClick) {
this.trigger.removeEventListener('click', this.boundButtonClick);
}
// Remove close button handlers
this.boundCloseButtons.forEach(({ element, handler }) => {
element.removeEventListener('click', handler);
});
this.boundCloseButtons = [];
// Remove modal backdrop click handler
if (this.boundModalClick) {
this.modal.removeEventListener('click', this.boundModalClick);
}
// Remove keydown handler
if (this.boundKeyDown) {
document.removeEventListener('keydown', this.boundKeyDown);
}
Modal.instances.delete(this.trigger);
delete this.trigger.__insightInstance;
this.trigger = null;
this.modal = null;
}
/**
* Initializes all modal instances on the page.
*
* @static
*/
static initAll() {
document.querySelectorAll('[data-insight-modal]').forEach(openButton => new Modal(openButton));
}
}