diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..7c439ae --- /dev/null +++ b/License.txt @@ -0,0 +1,19 @@ +Copyright (c) 2024 Ferdinand Kuhl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d502e1 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# modal stack + +This package tries to provide a foundation to provide general utilities to work with dynamically loaded modals. + +It provides a stack of functions to create modals. From buttom to top these are: + +| Name | Description | +|------------------------------------------|------------------------------------------------| +| createModalFromElement(element, options) | Creates Modals from an HTML element | +| createModalFromHtml(html, options) | Creates Modals from an HTML string | +| createModalFromUri(uri, options) | Creates Modals from an uri which provides html | + +And this is the table of the allowed options (in case of the `createModalFromElement` you can provide these options as +data attributes of the element) + +| Name | Description | Default | +|-------------------------------------|---------------------------------------------------------------------|-------------------------------| +| disposeOnHide | remove the dynamically loaded html from dom, if closed | true | +| dynamicModalContainerSelector | container where the html should be appended to | "#dynamic-modals" | +| eventClosed | event name, which gets triggerd on the opening of a modal | "hidden.bs.modal" | +| eventOpened | event name, which gets triggerd on the closing of a modal | "shown.bs.modal" | +| modalFunctionRef | a map modalProxyMethod => modalImplementationMethod | object | +| modalImpl | the modal implementation, for example bootstraps Modal class | {} | +| modalSelector | a selector to detect a modal root element | ".modal" | +| modalStackFormSelector | selector for forms, which should be ajaxified | "form[data-modal-stack-form]" | +| modalStackFormNoInit | skip form ajaxification | false | +| modalStackFormSubmitButtonSimulator | a string in the uri, which will be replaced with the identity value | true | +| unexpectedErrorModalSelector | a string in the uri, which will be replaced with the identity value | "#unexpected-error-modal" | diff --git a/package.json b/package.json new file mode 100644 index 0000000..6c104a2 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "@digicomp/modal-stack", + "version": "0.0.1", + "description": "api to create modals, independent of the specific modal implementation", + "main": "src/modals.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git@digital-competence.de:npm/modal-stack.git" + }, + "keywords": [ + "modal", + "ajax" + ], + "author": "Ferdinand Kuhl", + "license": "MIT", + "dependencies": { + "@github/remote-form": "^0.4.0" + } +} diff --git a/src/modalProxy.js b/src/modalProxy.js new file mode 100644 index 0000000..e1177e4 --- /dev/null +++ b/src/modalProxy.js @@ -0,0 +1,42 @@ +export class ModalProxy +{ + #modalInstance = null; + #modalElement = null; + #modalFunctionRef = { + open: 'show', + close: 'hide', + toggle: 'toggle' + } + + constructor(element, instance, options) + { + this.#modalElement = element; + this.#modalInstance = instance; + Object.assign(this.#modalFunctionRef, options) + } + + get element() + { + return this.#modalElement; + } + + get instance() + { + return this.#modalInstance; + } + + open() + { + return this.#modalInstance[this.#modalFunctionRef.open](); + } + + close() + { + return this.#modalInstance[this.#modalFunctionRef.close](); + } + + toggle() + { + return this.#modalInstance[this.#modalFunctionRef.toggle](); + } +} diff --git a/src/modals.js b/src/modals.js new file mode 100644 index 0000000..ff0e045 --- /dev/null +++ b/src/modals.js @@ -0,0 +1,197 @@ +import {ModalProxy} from './modalProxy'; +import {parseHTML} from './util'; +import {remoteForm} from '@github/remote-form'; + +let lastTaskId = 0; + +export const modalStackSettings = { + disposeOnHide: true, + dynamicModalContainerSelector: '#dynamic-modals', + eventClosed: 'hidden.bs.modal', + eventOpened: 'shown.bs.modal', + modalFunctionRef: { + open: 'show', + close: 'hide', + toggle: 'toggle' + }, + modalImpl: {}, + modalSelector: '.modal', + modalStackFormSelector: 'form[data-modal-stack-form]', + modalStackFormNoInit: false, + modalStackFormSubmitButtonSimulator: true, + unexpectedErrorModalSelector: '#unexpected-error-modal' +}; + +export default function initializeModalStack(options) +{ + Object.assign(modalStackSettings, options); + return { + initializeModalOpener, + initializeAjaxModalForm, + createModalFromUri, + createModalFromHtml, + createModalFromElement + }; +} + +export function createModalFromElement(modalElement, options) +{ + const { + disposeOnHide, + eventClosed, + eventOpened, + modalFunctionRef, + modalImpl, + modalStackFormSelector, + modalStackFormNoInit, + } = options = { ...modalStackSettings, ...modalElement.dataset, ...options }; + if (!modalElement.matches('.modal')) { + throw new TypeError('Your element needs the ".modal" class'); + } + modalElement.addEventListener(eventClosed, (e) => { + modalElement.dispatchEvent(new CustomEvent('modal-stack-modal-closed', { + detail: {originalEvent: e}, + bubbles: true, + })); + }); + modalElement.addEventListener(eventOpened, (e) => { + modalElement.dispatchEvent(new CustomEvent('modal-stack-modal-opened', { + detail: {originalEvent: e}, + bubbles: true, + })); + }); + if (!modalStackFormNoInit) { + Array.from(modalElement.querySelectorAll(modalStackFormSelector)).forEach(form => initializeAjaxModalForm(form, options)); + } + const newModal = new ModalProxy(modalElement, new modalImpl(modalElement, options), modalFunctionRef); + modalElement.modalStackProxy = newModal; + if (disposeOnHide) { + modalElement.addEventListener('modal-stack-modal-closed', () => modalElement.remove()); + } + return newModal; +} + +export function createModalFromHtml(html, options) +{ + const { + dynamicModalContainerSelector, + modalSelector + } = { ...modalStackSettings, ...options }; + const modalContainer = document.querySelector(dynamicModalContainerSelector); + if (modalContainer === null) { + throw new TypeError(`Modal container not found. Selector was: "${dynamicModalContainerSelector}"`); + } + + const element = parseHTML(html).querySelector(modalSelector); + if (element === null) { + throw new TypeError('There was no modal element'); + } + modalContainer.append(element); + dispatchEvent('modal-stack-element-created', {element}); + return createModalFromElement(element, options); +} + +export async function createModalFromUri(href, options) +{ + options = { ...modalStackSettings, ...options }; + + const taskId = ++lastTaskId; + dispatchEvent('modal-stack-fetching', {href, taskId}); + + const html = await fetch(href) + .then(response => { + if (response.status < 200 || response.status >= 300) { + throw new Error('Unexpetected http response'); + } + dispatchEvent('modal-stack-fetching-finished', {href, taskId}); + return response.text() + }) + .then(text => text) + .catch((e) => { + const errorModalElement = document.querySelector(options.unexpectedErrorModalSelector); + if (errorModalElement === null) { + console.error('Unexpected modal missing during error handling'); + return; + } + createModalFromElement(errorModalElement, {disposeOnHide: false}).open(); + // throw the error along, as nobody can work with this result ;) + throw e; + }); + + return createModalFromHtml(html, options); +} + +export function initializeModalOpener(element, options) +{ + const { + ajaxModalUri + } = options = { ...modalStackSettings, ...element.dataset, ...options }; + element.addEventListener('click', async () => { + if (element.dataset.ajaxModalUri === undefined) { + throw new TypeError('Modal opener requires data-ajax-modal-uri'); + } + (await createModalFromUri(element.dataset.ajaxModalUri, options)).open(); + }); +} + +export function initializeAjaxModalForm(form, options) +{ + const { + modalSelector, + modalStackFormSubmitButtonSimulator + } = { ...modalStackSettings, ...form.dataset, ...options }; + if (modalStackFormSubmitButtonSimulator) { + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + form.append(hidden); + form.addEventListener('click', (event) => { + if (!event.target || !event.target.matches('[type="submit"]')) { + return; + } + hidden.value = event.target.value; + hidden.name = event.target.name; + }); + } + remoteForm(form, async (form, wants) => { + form.dispatchEvent(new CustomEvent('modal-stack-form-submit-start', {bubbles: true})); + const parentModal = form.closest(modalSelector); + try { + const response = await wants.html(); + form.dispatchEvent(new CustomEvent('modal-stack-form-submit-finished', {response, bubbles: true})); + const cancelResponseEvent = new CustomEvent('modal-stack-form-response', {response, bubbles: true, cancelable: true}); + if (!form.dispatchEvent(cancelResponseEvent)) { + return; + }; + if (response.status !== 204) { + parentModal.innerHTML = response.html.querySelector(modalSelector).innerHTML; + form.dispatchEvent(new CustomEvent('modal-stack-form-replaced', {response, bubbles: true})); + initializeAjaxModalForm(parentModal.querySelector('form', options)); + return; + } + form.dispatchEvent(new CustomEvent('modal-stack-form-submit-success', {response, bubbles: true})); + parentModal.modalStackProxy.close(); + } catch (e) { + parentModal.modalStackProxy.close(); + const errorModalElement = document.querySelector(options.unexpectedErrorModalSelector); + if (errorModalElement === null) { + console.error('Unexpected modal missing during error handling'); + return; + } + createModalFromElement(errorModalElement, {disposeOnHide: false}).open(); + } + }); +} + +function dispatchEvent(name, details) +{ + if (details === undefined) { + details = {}; + } + console.debug('dispatching event: ' + name); + document.dispatchEvent( + new CustomEvent(name, { + detail: details, + bubbles: true, + }) + ); +} diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..267cd84 --- /dev/null +++ b/src/util.js @@ -0,0 +1,7 @@ +// taken from @github/remoteform +export function parseHTML(html) { + const template = document.createElement('template') + // eslint-disable-next-line github/no-inner-html + template.innerHTML = html + return document.importNode(template.content, true) +}