First working version

This commit is contained in:
Ferdinand Kuhl 2024-06-13 23:27:32 +02:00
parent 7bc448cd1c
commit 4e925b0918
6 changed files with 315 additions and 0 deletions

19
License.txt Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2024 Ferdinand Kuhl <f.kuhl@digital-competence.de>
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.

28
README.md Normal file
View file

@ -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" |

22
package.json Normal file
View file

@ -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"
}
}

42
src/modalProxy.js Normal file
View file

@ -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]();
}
}

197
src/modals.js Normal file
View file

@ -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,
})
);
}

7
src/util.js Normal file
View file

@ -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)
}