/** * @copyright (C) 2023 Open Source Matters, Inc. * @license GNU General Public License version 2 or later; see LICENSE.txt */ // Default template for the popup const popupTemplate = `
`; /** * JoomlaDialog class for Joomla Dialog implementation. * With use of custom element as dialog holder. */ class JoomlaDialog extends HTMLElement { /** * The popup type, supported: inline, iframe, image, ajax. * @type {string} */ // popupType = 'inline'; /** * An optional text for header. * @type {string} */ // textHeader = ''; /** * An optional text for close button. Applied when no Buttons provided. * @type {string} */ // textClose = 'Close'; /** * Content string (html) for inline type popup. * @type {string} */ // popupContent = ''; /** * Source path for iframe, image, ajax. * @type {string} */ // src = ''; /** * An optional list of buttons, to be rendered in footer or header, or bottom or top of the popup body. * Example: * [{label: 'Yes', onClick: () => popup.close()}, * {label: 'No', onClick: () => popup.close(), className: 'btn btn-danger'}, * {label: 'Click me', onClick: () => popup.close(), location: 'header'}] * @type {[]} */ // popupButtons = []; /** * Whether popup can be closed by Esc button. * * @type {boolean} */ // cancelable = true; /** * An optional limit for the popup width, any valid CSS value. * @type {string} */ // width = ''; /** * An optional height for the popup, any valid CSS value. * @type {string} */ // height = ''; /** * An optional Class names for header icon. * * @type {string} */ // iconHeader = ''; /** * A template for the popup. * @type {string|HTMLTemplateElement} */ // popupTemplate = popupTemplate; /** * The element where to attach the dialog, for cases when no parentElement exist, see show(). * This allows to keep the dialog in the same branch of DOM as the popupContent. * @type {string|HTMLElement} */ // preferredParent = null; /** * Class constructor * @param {Object} config */ constructor(config) { super(); // Define default params (doing it here because browser support of public props) this.popupType = this.getAttribute('type') || 'inline'; this.textHeader = this.getAttribute('text-header') || ''; this.iconHeader = ''; this.textClose = 'Close'; this.popupContent = ''; this.src = this.getAttribute('src') || ''; this.popupButtons = []; this.cancelable = !this.hasAttribute('not-cancelable'); this.width = this.getAttribute('width') || ''; this.height = this.getAttribute('height') || ''; this.popupTemplate = popupTemplate; this.preferredParent = null; // @internal. Parent of the popupContent for cases when it is HTMLElement. Need for recovery on destroy(). this.popupContentSrcLocation = null; if (!config) return; // Check configurable properties ['popupType', 'textHeader', 'textClose', 'popupContent', 'src', 'popupButtons', 'cancelable', 'width', 'height', 'popupTemplate', 'iconHeader', 'id', 'preferredParent'].forEach(key => { if (config[key] !== undefined) { this[key] = config[key]; } }); if (config.className) { this.classList.add(...config.className.split(' ')); } } /** * Internal. Connected Callback. */ connectedCallback() { this.renderLayout(); } /** * Internal. Render a main layout, based on given template. * @returns {JoomlaDialog} */ renderLayout() { if (this.dialog) return this; // On close callback const onClose = () => { this.dispatchEvent(new CustomEvent('joomla-dialog:close', { bubbles: true })); }; const onCancel = event => { if (!this.cancelable) { // Prevent closing by Esc event.preventDefault(); } }; // Check for existing layout if (this.firstElementChild && this.firstElementChild.nodeName === 'DIALOG') { this.dialog = this.firstElementChild; this.dialog.addEventListener('cancel', onCancel); this.dialog.addEventListener('close', onClose); this.popupTmplB = this.querySelector('.joomla-dialog-body') || this.dialog; this.popupContentElement = this.popupTmplB; return this; } // Render a template let templateContent; if (this.popupTemplate.tagName && this.popupTemplate.tagName === 'TEMPLATE') { templateContent = this.popupTemplate.content.cloneNode(true); } else { const template = document.createElement('template'); template.innerHTML = this.popupTemplate; templateContent = template.content; } this.dialog = document.createElement('dialog'); this.dialog.appendChild(templateContent); this.dialog.addEventListener('cancel', onCancel); this.dialog.addEventListener('close', onClose); this.appendChild(this.dialog); // Get template parts this.popupTmplH = this.dialog.querySelector('.joomla-dialog-header'); this.popupTmplB = this.dialog.querySelector('.joomla-dialog-body'); this.popupTmplF = this.dialog.querySelector('.joomla-dialog-footer'); this.popupContentElement = null; if (!this.popupTmplB) { throw new Error('The popup body not found in the template.'); } // Set the header if (this.popupTmplH && this.textHeader) { const h = document.createElement('h3'); h.insertAdjacentHTML('afterbegin', this.textHeader); this.popupTmplH.insertAdjacentElement('afterbegin', h); if (this.iconHeader) { const i = document.createElement('span'); i.ariaHidden = true; i.classList.add('header-icon'); i.classList.add(...this.iconHeader.split(' ')); this.popupTmplH.insertAdjacentElement('afterbegin', i); } } // Set the body this.renderBodyContent(); this.setAttribute('type', this.popupType); // Create buttons if any const buttons = this.popupButtons || []; // Add at least one button to close the popup if (!buttons.length) { buttons.push({ label: '', ariaLabel: this.textClose, className: 'button-close btn-close', data: { buttonClose: '' }, onClick: () => this.close(), location: 'header' }); } // Buttons holders const btnHHolder = document.createElement('div'); const btnFHolder = document.createElement('div'); btnHHolder.classList.add('buttons-holder'); btnFHolder.classList.add('buttons-holder'); this.popupButtons.forEach(btnData => { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = btnData.label || ''; btn.ariaLabel = btnData.ariaLabel || null; if (btnData.className) { btn.classList.add(...btnData.className.split(' ')); } else { btn.classList.add('button', 'button-primary', 'btn', 'btn-primary'); } if (btnData.data) { Object.entries(btnData.data).forEach(([k, v]) => { btn.dataset[k] = v; }); if (btnData.data.dialogClose !== undefined) { btnData.onClick = () => this.close(); } if (btnData.data.dialogDestroy !== undefined) { btnData.onClick = () => this.destroy(); } } if (btnData.onClick) { btn.addEventListener('click', btnData.onClick); } if (btnData.location === 'header') { btnHHolder.appendChild(btn); } else { btnFHolder.appendChild(btn); } }); if (btnHHolder.children.length) { if (this.popupType === 'image' && !this.textHeader) { this.popupTmplB.insertAdjacentElement('afterbegin', btnHHolder); } else if (this.popupTmplH) { this.popupTmplH.insertAdjacentElement('beforeend', btnHHolder); } else { this.popupTmplB.insertAdjacentElement('afterbegin', btnHHolder); } } if (btnFHolder.children.length) { (this.popupTmplF || this.popupTmplB).insertAdjacentElement('beforeend', btnFHolder); } // Adjust the sizes if requested if (this.width) { this.dialog.style.width = '100%'; this.dialog.style.maxWidth = this.width; } if (this.height) { this.dialog.style.height = this.height; } // Mark an empty template elements if (this.popupTmplH && !this.popupTmplH.children.length) { this.popupTmplH.classList.add('empty'); } if (this.popupTmplF && !this.popupTmplF.children.length) { this.popupTmplF.classList.add('empty'); } return this; } /** * Internal. Render the body content, based on popupType. * @returns {JoomlaDialog} */ renderBodyContent() { if (!this.popupTmplB || this.popupContentElement) return this; // Callback for loaded content event listener const onLoad = () => { this.classList.add('loaded'); this.classList.remove('loading'); this.popupContentElement.removeEventListener('load', onLoad); this.dispatchEvent(new CustomEvent('joomla-dialog:load')); if (this.popupType === 'inline' || this.popupType === 'ajax') { // Dispatch joomla:updated for inline content this.popupContentElement.dispatchEvent(new CustomEvent('joomla:updated', { bubbles: true, cancelable: true })); } }; this.classList.add('loading'); switch (this.popupType) { // Create an Inline content case 'inline': { let inlineContent = this.popupContent; // Check for content selector: src: '#content-selector' or src: '.content-selector' if (!inlineContent && this.src && (this.src[0] === '.' || this.src[0] === '#')) { inlineContent = document.querySelector(this.src); this.popupContent = inlineContent; } if (inlineContent instanceof HTMLElement) { // Render content provided as HTMLElement if (inlineContent.nodeName === 'TEMPLATE') { this.popupTmplB.appendChild(inlineContent.content.cloneNode(true)); } else { // Store parent reference to be able to recover after the popup is destroyed this.popupContentSrcLocation = { parent: inlineContent.parentElement, nextSibling: inlineContent.nextSibling }; this.popupTmplB.appendChild(inlineContent); } } else { // Render content string this.popupTmplB.insertAdjacentHTML('afterbegin', Joomla.sanitizeHtml(inlineContent)); } this.popupContentElement = this.popupTmplB; onLoad(); break; } // Create an IFrame content case 'iframe': { const frame = document.createElement('iframe'); frame.addEventListener('load', onLoad); frame.src = this.src; // Enlarge default size of iframe, make sure it is usable without extra styling frame.width = '100%'; frame.height = '720'; if (!this.width) { frame.style.maxWidth = '100%'; frame.width = '1024'; } frame.classList.add('iframe-content'); this.popupContentElement = frame; this.popupTmplB.appendChild(frame); break; } // Create an Image content case 'image': { const img = document.createElement('img'); img.addEventListener('load', onLoad); img.src = this.src; this.popupContentElement = img; this.popupTmplB.appendChild(img); break; } // Create an AJAX content case 'ajax': { fetch(this.src).then(response => { if (response.status !== 200) { throw new Error(response.statusText); } return response.text(); }).then(text => { this.popupTmplB.insertAdjacentHTML('afterbegin', Joomla.sanitizeHtml(text)); this.popupContentElement = this.popupTmplB; onLoad(); }).catch(error => { throw error; }); break; } default: { throw new Error('Unknown popup type requested'); } } return this; } /** * Internal. Find an Element to be used as parent element, * for cases when Dialog does not have one already. See show(). * * @returns {HTMLElement|boolean} */ findPreferredParent() { let parent; if (this.preferredParent instanceof HTMLElement) { // We have configured one already parent = this.preferredParent; } else if (this.preferredParent) { // Query Document parent = document.querySelector(this.preferredParent); } else if (this.popupType === 'inline') { // Pick the parent element of the Content let inlineContent = this.popupContent; // Check for content selector: src: '#content-selector' or src: '.content-selector' if (!inlineContent && this.src && (this.src[0] === '.' || this.src[0] === '#')) { inlineContent = document.querySelector(this.src); parent = inlineContent ? inlineContent.parentElement : false; } } return parent || false; } /** * Return the popup header element. * @returns {HTMLElement|boolean} */ getHeader() { this.renderLayout(); return this.popupTmplH || false; } /** * Return the popup body element. * @returns {HTMLElement} */ getBody() { this.renderLayout(); return this.popupTmplB; } /** * Return the popup content element, or body for inline and ajax types. * @returns {HTMLElement} */ getBodyContent() { this.renderLayout(); return this.popupContentElement || this.popupTmplB; } /** * Return the popup footer element. * @returns {HTMLElement|boolean} */ getFooter() { this.renderLayout(); return this.popupTmplF || false; } /** * Open the popup as modal window. * Will append the element to Document body if not appended before. * * @returns {JoomlaDialog} */ show() { // Check whether the element already attached to DOM if (!this.parentElement) { // Check for preferred parent to attach to DOM const parent = this.findPreferredParent(); (parent || document.body).appendChild(this); } this.dialog.showModal(); this.dispatchEvent(new CustomEvent('joomla-dialog:open', { bubbles: true })); return this; } /** * Alias for show() method. * @returns {JoomlaDialog} */ open() { return this.show(); } /** * Closes the popup * * @returns {JoomlaDialog} */ close() { if (!this.dialog) { throw new Error('Calling close for non opened dialog is discouraged.'); } this.dialog.close(); return this; } /** * Alias for close() method. * @returns {JoomlaDialog} */ hide() { return this.close(); } /** * Destroys the popup. */ destroy() { if (!this.dialog) { return; } this.dialog.close(); this.removeChild(this.dialog); this.parentElement.removeChild(this); // Restore original location of the popup content element if (this.popupContentSrcLocation && this.popupContent) { const { parent, nextSibling } = this.popupContentSrcLocation; parent.insertBefore(this.popupContent, nextSibling); } this.dialog = null; this.popupTmplH = null; this.popupTmplB = null; this.popupTmplF = null; this.popupContentElement = null; this.popupContentSrcLocation = null; } /** * Helper method to show an Alert. * * @param {String} message * @param {String} title * @returns {Promise} */ static alert(message, title) { return new Promise(resolve => { const popup = new this(); popup.popupType = 'inline'; popup.popupContent = message; popup.textHeader = title || Joomla.Text._('INFO', 'Info'); popup.popupButtons = [{ label: Joomla.Text._('JOK', 'Okay'), data: { buttonOk: '' }, onClick: () => popup.close() }]; popup.classList.add('joomla-dialog-alert'); popup.addEventListener('joomla-dialog:close', () => { popup.destroy(); resolve(); }); popup.show(); }); } /** * Helper method to show a Confirmation popup. * * @param {String} message * @param {String} title * @returns {Promise} */ static confirm(message, title) { return new Promise(resolve => { let result = false; const popup = new this(); popup.popupType = 'inline'; popup.popupContent = message; popup.textHeader = title || Joomla.Text._('INFO', 'Info'); popup.popupButtons = [{ label: Joomla.Text._('JYES', 'Yes'), data: { buttonOk: '' }, onClick: () => { result = true; popup.destroy(); } }, { label: Joomla.Text._('JNO', 'No'), data: { buttonCancel: '' }, onClick: () => { result = false; popup.destroy(); }, className: 'button button-secondary btn btn-outline-secondary' }]; popup.cancelable = false; popup.classList.add('joomla-dialog-confirm'); popup.addEventListener('joomla-dialog:close', () => resolve(result)); popup.show(); }); } } customElements.define('joomla-dialog', JoomlaDialog); export { JoomlaDialog as default };