From 682a014640fcb00dd7e44e22addcf14a517f37c4 Mon Sep 17 00:00:00 2001 From: Tamara Steinwender <tamara.steinwender@tugraz.at> Date: Wed, 23 Nov 2022 16:21:05 +0100 Subject: [PATCH] Add modal webcomponent --- packages/common/index.js | 2 + packages/common/src/i18n/de/translation.json | 4 + packages/common/src/i18n/en/translation.json | 4 + packages/common/src/micromodal.es.js | 515 +++++++++++++++++++ packages/common/src/modal.js | 116 +++++ 5 files changed, 641 insertions(+) create mode 100644 packages/common/src/micromodal.es.js create mode 100644 packages/common/src/modal.js diff --git a/packages/common/index.js b/packages/common/index.js index 8353b600..f254e4ba 100644 --- a/packages/common/index.js +++ b/packages/common/index.js @@ -8,6 +8,7 @@ import {InlineNotification} from './src/inline-notification.js'; import {Translated} from './src/translated'; import {Translation} from './src/translation'; import {AdapterLitElement} from './src/adapter-lit-element.js'; +import {Modal} from "./src/modal"; export {EventBus, createLinkedAbortController, createTimeoutAbortSignal}; export {getIconSVGURL, getIconCSS, Icon}; @@ -19,3 +20,4 @@ export {Translated, Translation}; export * from './src/logger.js'; export * from './src/utils.js'; export {AdapterLitElement}; +export {Modal}; diff --git a/packages/common/src/i18n/de/translation.json b/packages/common/src/i18n/de/translation.json index 51cf793c..77e2d98f 100644 --- a/packages/common/src/i18n/de/translation.json +++ b/packages/common/src/i18n/de/translation.json @@ -7,5 +7,9 @@ "api-documentation-server": "Verbindung zum apiDocumentation API Server {{apiDocUrl}} fehlgeschlagen!", "error-api-server": "Verbindung zum API Server {{apiUrl}} fehlgeschlagen!", "error-hydra-documentation-url-not-set": "Hydra apiDocumentation URL wurden für server {{apiUrl}} nicht gesetzt!" + }, + + "dbp-modal": { + "close": "Schließen" } } diff --git a/packages/common/src/i18n/en/translation.json b/packages/common/src/i18n/en/translation.json index e1036a65..f0d91058 100644 --- a/packages/common/src/i18n/en/translation.json +++ b/packages/common/src/i18n/en/translation.json @@ -7,5 +7,9 @@ "api-documentation-server": "Connection to apiDocumentation server {{apiDocUrl}} failed!", "error-api-server": "Connection to api server {{apiUrl}} failed!", "error-hydra-documentation-url-not-set": "Hydra apiDocumentation url was not set for server {{apiUrl}}!" + }, + + "dbp-modal": { + "close": "close" } } diff --git a/packages/common/src/micromodal.es.js b/packages/common/src/micromodal.es.js new file mode 100644 index 00000000..ce57377e --- /dev/null +++ b/packages/common/src/micromodal.es.js @@ -0,0 +1,515 @@ +// see https://github.com/ghosh/Micromodal/pull/351 + +function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError('Cannot call a class as a function'); + } +} + +function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ('value' in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } +} + +function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + return Constructor; +} + +function _toConsumableArray(arr) { + return ( + _arrayWithoutHoles(arr) || + _iterableToArray(arr) || + _unsupportedIterableToArray(arr) || + _nonIterableSpread() + ); +} + +function _arrayWithoutHoles(arr) { + if (Array.isArray(arr)) return _arrayLikeToArray(arr); +} + +function _iterableToArray(iter) { + if (typeof Symbol !== 'undefined' && Symbol.iterator in Object(iter)) return Array.from(iter); +} + +function _unsupportedIterableToArray(o, minLen) { + if (!o) return; + if (typeof o === 'string') return _arrayLikeToArray(o, minLen); + var n = Object.prototype.toString.call(o).slice(8, -1); + if (n === 'Object' && o.constructor) n = o.constructor.name; + if (n === 'Map' || n === 'Set') return Array.from(n); + if (n === 'Arguments' || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) + return _arrayLikeToArray(o, minLen); +} + +function _arrayLikeToArray(arr, len) { + if (len == null || len > arr.length) len = arr.length; + + for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; + + return arr2; +} + +function _nonIterableSpread() { + throw new TypeError( + 'Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.' + ); +} + +var MicroModal = (function () { + var FOCUSABLE_ELEMENTS = [ + 'a[href]', + 'area[href]', + 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', + 'select:not([disabled]):not([aria-hidden])', + 'textarea:not([disabled]):not([aria-hidden])', + 'button:not([disabled]):not([aria-hidden])', + 'iframe', + 'object', + 'embed', + '[contenteditable]', + '[tabindex]:not([tabindex^="-"])', + ]; + + var Modal = /*#__PURE__*/ (function () { + function Modal(_ref) { + var targetModal = _ref.targetModal, + _ref$triggers = _ref.triggers, + triggers = _ref$triggers === void 0 ? [] : _ref$triggers, + _ref$onShow = _ref.onShow, + onShow = _ref$onShow === void 0 ? function () {} : _ref$onShow, + _ref$onClose = _ref.onClose, + onClose = _ref$onClose === void 0 ? function () {} : _ref$onClose, + _ref$openTrigger = _ref.openTrigger, + openTrigger = + _ref$openTrigger === void 0 ? 'data-micromodal-trigger' : _ref$openTrigger, + _ref$closeTrigger = _ref.closeTrigger, + closeTrigger = + _ref$closeTrigger === void 0 ? 'data-micromodal-close' : _ref$closeTrigger, + _ref$openClass = _ref.openClass, + openClass = _ref$openClass === void 0 ? 'is-open' : _ref$openClass, + _ref$disableScroll = _ref.disableScroll, + disableScroll = _ref$disableScroll === void 0 ? false : _ref$disableScroll, + _ref$disableFocus = _ref.disableFocus, + disableFocus = _ref$disableFocus === void 0 ? false : _ref$disableFocus, + _ref$awaitCloseAnimat = _ref.awaitCloseAnimation, + awaitCloseAnimation = + _ref$awaitCloseAnimat === void 0 ? false : _ref$awaitCloseAnimat, + _ref$awaitOpenAnimati = _ref.awaitOpenAnimation, + awaitOpenAnimation = + _ref$awaitOpenAnimati === void 0 ? false : _ref$awaitOpenAnimati, + _ref$debugMode = _ref.debugMode, + debugMode = _ref$debugMode === void 0 ? false : _ref$debugMode; + + _classCallCheck(this, Modal); + + // Save a reference of the modal + this.modal = this.modal = + typeof targetModal === 'string' + ? document.getElementById(targetModal) + : targetModal; // Save a reference to the passed config + + this.config = { + debugMode: debugMode, + disableScroll: disableScroll, + openTrigger: openTrigger, + closeTrigger: closeTrigger, + openClass: openClass, + onShow: onShow, + onClose: onClose, + awaitCloseAnimation: awaitCloseAnimation, + awaitOpenAnimation: awaitOpenAnimation, + disableFocus: disableFocus, + }; // Register click events only if pre binding eventListeners + + if (triggers.length > 0) + this.registerTriggers.apply(this, _toConsumableArray(triggers)); // pre bind functions for event listeners + + this.onClick = this.onClick.bind(this); + this.onKeydown = this.onKeydown.bind(this); + } + /** + * Loops through all openTriggers and binds click event + * @param {array} triggers [Array of node elements] + * @return {void} + */ + + _createClass(Modal, [ + { + key: 'registerTriggers', + value: function registerTriggers() { + var _this = this; + + for ( + var _len = arguments.length, triggers = new Array(_len), _key = 0; + _key < _len; + _key++ + ) { + triggers[_key] = arguments[_key]; + } + + triggers.filter(Boolean).forEach(function (trigger) { + trigger.addEventListener('click', function (event) { + return _this.showModal(event); + }); + }); + }, + }, + { + key: 'showModal', + value: function showModal() { + var _this2 = this; + + var event = + arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + this.activeElement = document.activeElement; + this.modal.setAttribute('aria-hidden', 'false'); + this.modal.classList.add(this.config.openClass); + this.scrollBehaviour('disable'); + this.addEventListeners(); + + if (this.config.awaitOpenAnimation) { + var handler = function handler() { + _this2.modal.removeEventListener('animationend', handler, false); + + _this2.setFocusToFirstNode(); + }; + + this.modal.addEventListener('animationend', handler, false); + } else { + this.setFocusToFirstNode(); + } + + this.config.onShow(this.modal, this.activeElement, event); + }, + }, + { + key: 'closeModal', + value: function closeModal() { + var event = + arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var modal = this.modal; + this.modal.setAttribute('aria-hidden', 'true'); + this.removeEventListeners(); + this.scrollBehaviour('enable'); + + if (this.activeElement && this.activeElement.focus) { + this.activeElement.focus(); + } + + this.config.onClose(this.modal, this.activeElement, event); + + if (this.config.awaitCloseAnimation) { + var openClass = this.config.openClass; // <- old school ftw + + this.modal.addEventListener( + 'animationend', + function handler() { + modal.classList.remove(openClass); + modal.removeEventListener('animationend', handler, false); + }, + false + ); + } else { + modal.classList.remove(this.config.openClass); + } + }, + }, + { + key: 'closeModalById', + value: function closeModalById(targetModal) { + // added support to pass on an element or an id -> for webcomponents + if (targetModal instanceof HTMLElement) { + this.modal = targetModal; + } else { + this.model = document.getElementById(targetModal); + } + if (this.modal) this.closeModal(); + }, + }, + { + key: 'scrollBehaviour', + value: function scrollBehaviour(toggle) { + if (!this.config.disableScroll) return; + var body = document.querySelector('body'); + + switch (toggle) { + case 'enable': + Object.assign(body.style, { + overflow: '', + }); + break; + + case 'disable': + Object.assign(body.style, { + overflow: 'hidden', + }); + break; + } + }, + }, + { + key: 'addEventListeners', + value: function addEventListeners() { + this.modal.addEventListener('touchstart', this.onClick); + this.modal.addEventListener('click', this.onClick); + document.addEventListener('keydown', this.onKeydown); + }, + }, + { + key: 'removeEventListeners', + value: function removeEventListeners() { + this.modal.removeEventListener('touchstart', this.onClick); + this.modal.removeEventListener('click', this.onClick); + document.removeEventListener('keydown', this.onKeydown); + }, + }, + { + key: 'onClick', + value: function onClick(event) { + if (event.target.hasAttribute(this.config.closeTrigger)) { + this.closeModal(event); + } + }, + }, + { + key: 'onKeydown', + value: function onKeydown(event) { + if (event.keyCode === 27) this.closeModal(event); // esc + + if (event.keyCode === 9) this.retainFocus(event); // tab + }, + }, + { + key: 'getFocusableNodes', + value: function getFocusableNodes() { + var nodes = this.modal.querySelectorAll(FOCUSABLE_ELEMENTS); + return Array.apply(void 0, _toConsumableArray(nodes)); + }, + /** + * Tries to set focus on a node which is not a close trigger + * if no other nodes exist then focuses on first close trigger + */ + }, + { + key: 'setFocusToFirstNode', + value: function setFocusToFirstNode() { + var _this3 = this; + + if (this.config.disableFocus) return; + var focusableNodes = this.getFocusableNodes(); // no focusable nodes + + if (focusableNodes.length === 0) return; // remove nodes on whose click, the modal closes + // could not think of a better name :( + + var nodesWhichAreNotCloseTargets = focusableNodes.filter(function (node) { + return !node.hasAttribute(_this3.config.closeTrigger); + }); + if (nodesWhichAreNotCloseTargets.length > 0) + nodesWhichAreNotCloseTargets[0].focus(); + if (nodesWhichAreNotCloseTargets.length === 0) focusableNodes[0].focus(); + }, + }, + { + key: 'retainFocus', + value: function retainFocus(event) { + var focusableNodes = this.getFocusableNodes(); // no focusable nodes + + if (focusableNodes.length === 0) return; + /** + * Filters nodes which are hidden to prevent + * focus leak outside modal + */ + + focusableNodes = focusableNodes.filter(function (node) { + return node.offsetParent !== null; + }); // if disableFocus is true + + if (!this.modal.contains(document.activeElement)) { + focusableNodes[0].focus(); + } else { + var focusedItemIndex = focusableNodes.indexOf(document.activeElement); + + if (event.shiftKey && focusedItemIndex === 0) { + focusableNodes[focusableNodes.length - 1].focus(); + event.preventDefault(); + } + + if ( + !event.shiftKey && + focusableNodes.length > 0 && + focusedItemIndex === focusableNodes.length - 1 + ) { + focusableNodes[0].focus(); + event.preventDefault(); + } + } + }, + }, + ]); + + return Modal; + })(); + /** + * Modal prototype ends. + * Here on code is responsible for detecting and + * auto binding event handlers on modal triggers + */ + // Keep a reference to the opened modal + + var activeModal = null; + /** + * Generates an associative array of modals and it's + * respective triggers + * @param {array} triggers An array of all triggers + * @param {string} triggerAttr The data-attribute which triggers the module + * @return {array} + */ + + var generateTriggerMap = function generateTriggerMap(triggers, triggerAttr) { + var triggerMap = []; + triggers.forEach(function (trigger) { + var targetModal = trigger.attributes[triggerAttr].value; + if (triggerMap[targetModal] === undefined) triggerMap[targetModal] = []; + triggerMap[targetModal].push(trigger); + }); + return triggerMap; + }; + /** + * Validates whether a modal of the given id exists + * in the DOM + * @param {number} id The id of the modal + * @return {boolean} + */ + + var validateModalPresence = function validateModalPresence(id) { + if (!document.getElementById(id)) { + console.warn( + "MicroModal: \u2757Seems like you have missed %c'".concat(id, "'"), + 'background-color: #f8f9fa;color: #50596c;font-weight: bold;', + 'ID somewhere in your code. Refer example below to resolve it.' + ); + console.warn( + '%cExample:', + 'background-color: #f8f9fa;color: #50596c;font-weight: bold;', + '<div class="modal" id="'.concat(id, '"></div>') + ); + return false; + } + }; + /** + * Validates if there are modal triggers present + * in the DOM + * @param {array} triggers An array of data-triggers + * @return {boolean} + */ + + var validateTriggerPresence = function validateTriggerPresence(triggers) { + if (triggers.length <= 0) { + console.warn( + "MicroModal: \u2757Please specify at least one %c'micromodal-trigger'", + 'background-color: #f8f9fa;color: #50596c;font-weight: bold;', + 'data attribute.' + ); + console.warn( + '%cExample:', + 'background-color: #f8f9fa;color: #50596c;font-weight: bold;', + '<a href="#" data-micromodal-trigger="my-modal"></a>' + ); + return false; + } + }; + /** + * Checks if triggers and their corresponding modals + * are present in the DOM + * @param {array} triggers Array of DOM nodes which have data-triggers + * @param {array} triggerMap Associative array of modals and their triggers + * @return {boolean} + */ + + var validateArgs = function validateArgs(triggers, triggerMap) { + validateTriggerPresence(triggers); + if (!triggerMap) return true; + + for (var id in triggerMap) { + validateModalPresence(id); + } + + return true; + }; + /** + * Binds click handlers to all modal triggers + * @param {object} config [description] + * @return void + */ + + var init = function init(config) { + // Create an config object with default openTrigger + var options = Object.assign( + {}, + { + openTrigger: 'data-micromodal-trigger', + }, + config + ); // Collects all the nodes with the trigger + + var triggers = _toConsumableArray( + document.querySelectorAll('['.concat(options.openTrigger, ']')) + ); // Makes a mappings of modals with their trigger nodes + + var triggerMap = generateTriggerMap(triggers, options.openTrigger); // Checks if modals and triggers exist in dom + + if (options.debugMode === true && validateArgs(triggers, triggerMap) === false) return; // For every target modal creates a new instance + + for (var key in triggerMap) { + var value = triggerMap[key]; + options.targetModal = key; + options.triggers = _toConsumableArray(value); + activeModal = new Modal(options); // eslint-disable-line no-new + } + }; + /** + * Shows a particular modal + * @param {string} targetModal [The id of the modal to display] + * @param {object} config [The configuration object to pass] + * @return {void} + */ + + var show = function show(targetModal, config) { + var options = config || {}; + options.targetModal = targetModal; // Checks if modals and triggers exist in dom + + if (options.debugMode === true && validateModalPresence(targetModal) === false) return; // clear events in case previous modal wasn't close + + if (activeModal) activeModal.removeEventListeners(); // stores reference to active modal + + activeModal = new Modal(options); // eslint-disable-line no-new + + activeModal.showModal(); + }; + /** + * Closes the active modal + * @param {string} targetModal [The id of the modal to close] + * @return {void} + */ + + var close = function close(targetModal) { + targetModal ? activeModal.closeModalById(targetModal) : activeModal.closeModal(); + }; + + return { + init: init, + show: show, + close: close, + }; +})(); +window.MicroModal = MicroModal; + +export default MicroModal; diff --git a/packages/common/src/modal.js b/packages/common/src/modal.js new file mode 100644 index 00000000..48980b25 --- /dev/null +++ b/packages/common/src/modal.js @@ -0,0 +1,116 @@ +import {html, LitElement, css} from 'lit'; +import {createInstance} from './i18n'; +import * as commonStyles from '../styles.js'; +import {Icon} from "./icon"; +import {MiniSpinner} from "./mini-spinner"; +import MicroModal from './micromodal.es'; +import DBPLitElement from "../dbp-lit-element"; + + +export class Modal extends DBPLitElement { + constructor() { + super(); + this._i18n = createInstance(); + this.lang = this._i18n.language; + this.modalId = 'dbp-modal-id'; + this.title = "Modal Title"; + } + + static get properties() { + return { + modalId: {type: String, attribute: 'modal-id'}, + title: {type: String}, + }; + } + + static get scopedElements() { + return { + 'dbp-icon': Icon, + 'dbp-mini-spinner': MiniSpinner, + }; + } + + open() { + MicroModal.show(this._('#' + this.modalId), { + disableScroll: true, + onClose: (modal) => { + // TODO get from parent maybe notify parent with event + }, + }); + } + + static get styles() { + // language=css + return css` + ${commonStyles.getModalDialogCSS()} + + .modal-title { + margin: 0; + padding: 0.25em 0 0 0; + font-weight: 300; + } + + .modal-container{ + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 15px 20px 20px; + } + + .modal-header{ + padding: 0px; + display: flex; + justify-content: space-between; + } + + .modal-content{ + display: flex; + padding-left: 0px; + padding-right: 0px; + overflow: unset; + gap: 1em; + flex-direction: column; + height: 100%; + } + `; + } + + render() { + const i18n = this._i18n; + + return html` + <div class="modal micromodal-slide" id="${this.modalId}" aria-hidden="true"> + <div class="modal-overlay" tabindex="-2" data-micromodal-close> + <div class="modal-container" + role="dialog" + aria-modal="true" + aria-labelledby="show-recipient-modal-title"> + <header class="modal-header"> + <h3 class="modal-title"> + ${this.title} + </h3> + <button + title="${i18n.t('dbp-modal.close')}" + class="modal-close" + aria-label="Close modal" + @click="${() => { + MicroModal.close(this._('#' + this.modalId)); + }}"> + <dbp-icon + title="${i18n.t('dbp-modal.close')}" + name="close" + class="close-icon"></dbp-icon> + </button> + </header> + <main class="modal-content"> + <slot name="content"></slot> + </main> + <footer class="modal-footer"> + <slot name="footer"></slot> + </footer> + </div> + </div> + </div> + `; + } +} -- GitLab