UNPKG

@ulu/frontend

Version:

A versatile SCSS and JavaScript component library offering configurable, accessible components and flexible integration into any project, with SCSS modules suitable for modern JS frameworks.

255 lines (236 loc) 10.9 kB
/** * @module ui/modal-builder * @description Note this module needs to be initialized before dialogs! */ import { ComponentInitializer } from "../utils/system.js"; import { wrapSettingString } from "../settings.js"; import { getName } from "../events/index.js"; import { Resizer } from "./resizer.js"; import { baseAttribute, closeAttribute, defaults as dialogDefaults } from "./dialog.js"; import { getElement } from "@ulu/utils/browser/dom.js"; import { createElementFromHtml } from "@ulu/utils/browser/dom.js"; /** * Modal Builder Component Initializer */ export const initializer = new ComponentInitializer({ type: "modal-builder", baseAttribute: "data-ulu-modal-builder" }); /** * Default builder options (extends dialog defaults, watch name collisions) * - Decided to extend defaults so the interface in HTML is singular * - This is sometimes easier to template (merging and serializing options * in twig for example) * @typedef {object} DefaultModalOptions * @property {string|null} title - The title of the modal. Defaults to `null`. * @property {string|null} titleIcon - The class name for an icon to display in the title. Defaults to `null`. * @property {string} titleClass - Extra class/classes to add to title * @property {string} labelledby - Set the aria-labelledby attribute to a specific title within the modal, to connect to a custom title implementation, if using built in title this will be set automatically * @property {string} describedby - Set the aria-describedby on the dialog, elements id, to tie a specific part of the content to be the accessible description * @property {boolean} nonModal - If `true`, the modal will not prevent interaction with elements behind it. Defaults to `false`. * @property {boolean} documentEnd - If `true`, the modal will be appended to the end of the `document.body`. Defaults to `true`. * @property {boolean} allowResize - If `true`, the modal will be resizable. Defaults to `false`. * @property {"center"|"top-left"|"top-center"|"top-right"|"bottom-left"|"bottom-center"|"bottom-right"} position - The initial position of the modal. Defaults to `"center"`. * @property {boolean} bodyFills - If `true`, the modal body will fill the available space. Defaults to `false`. * @property {boolean} noBackdrop - If `true`, no backdrop will be displayed behind the modal. Defaults to `false`. * @property {"default"|"small"|"large"|"fullscreen"} size - The size of the modal. Defaults to `"default"`. * @property {boolean} print - If `true`, the modal content will be optimized for printing. Defaults to `false`. * @property {boolean} noMinHeight - If `true`, the modal will not have a minimum height. Defaults to `false`. * @property {string} class - Additional CSS class(es) to add to the modal. Defaults to `""`. * @property {string} baseClass - The base CSS class for the modal elements. Defaults to `"modal"`. * @property {string} classCloseIcon - The class name for the close icon. Uses the wrapped setting string. * @property {string} classResizerIcon - The class name for the resizer icon. Uses the wrapped setting string. * @property {string|Node} footerElement - Element or selector to use as the footer (will be moved to dialog on creation, used for DOM API) * @property {string|Node} footerHtml - Markup to use in the footer * @property {boolean} debug - Enables debug logging. Defaults to `false`. * @property {function(object): string} templateCloseIcon - A function that returns the HTML for the close icon. * @property {function(object): string} templateCloseIcon.config - The resolved modal configuration object. * @returns {string} The HTML string for the close icon. * @property {function(object): string} templateResizerIcon - A function that returns the HTML for the resizer icon. * @property {function(object): string} templateResizerIcon.config - The resolved modal configuration object. * @returns {string} The HTML string for the resizer icon. * @property {function(string, DefaultModalOptions): string} template - The default modal template function. * @param {string} template.id - The ID for the new modal. * @param {DefaultModalOptions} template.config - The resolved modal options. * @returns {string} Markup for the modal. */ export const defaults = { title: null, titleIcon: null, titleClass: "", labelledby: null, describedby: null, nonModal: false, documentEnd: true, allowResize: false, position: "center", bodyFills: false, noBackdrop: false, size: "default", print: false, noMinHeight: false, class: "", baseClass: "modal", footerElement: null, footerHtml: null, classCloseIcon: wrapSettingString("iconClassClose"), classResizerIcon: wrapSettingString("iconClassDragX"), classResizerIconBoth: wrapSettingString("iconClassDragBoth"), debug: false, templateCloseIcon(config) { const { baseClass, classCloseIcon } = config; return `<span class="${ baseClass }__close-icon ${ classCloseIcon }" aria-hidden="true"></span>`; }, templateResizerIcon(config) { const { baseClass, classResizerIcon, classResizerIconBoth } = config; const iconClass = config.position === "center" ? classResizerIconBoth : classResizerIcon; return `<span class="${ baseClass }__resizer-icon ${ iconClass }" aria-hidden="true"></span>`; }, /** * Default modal template * @param {String} id ID for new modal * @param {Object} config Resolved options * @returns {String} Markup for modal */ template(id, config) { const { baseClass, describedby, footerHtml } = config; const classes = [ baseClass, `${ baseClass }--${ config.position }`, `${ baseClass }--${ config.size }`, `${ baseClass }--${ config.allowResize ? "resize" : "no-resize" }`, ...(!config.title ? [`${ baseClass }--no-header`] : []), ...(config.bodyFills ? [`${ baseClass }--body-fills`] : []), ...(config.noBackdrop ? [`${ baseClass }--no-backdrop`] : []), ...(config.noMinHeight ? [`${ baseClass }--no-min-height`] : [] ), ...(config.class ? [config.class] : []), ]; const labelledby = config.title ? `${ id }--title` : config.labelledby; return ` <dialog id="${ id }" class="${ classes.join(" ") }" ${ labelledby ? `aria-labelledby="${ labelledby }"` : "" } ${ describedby ? `aria-describedby="${ describedby }"` : "" } > ${ config.title ? ` <header class="${ baseClass }__header"> <h2 id="${ labelledby }" class="${ baseClass }__title ${ config.titleClass }"> ${ config.titleIcon ? `<span class="${ baseClass }__title-icon ${ config.titleIcon }" aria-hidden="true"></span>` : "" } <span class="${ baseClass }__title-text">${ config.title }</span> </h2> <button class="${ baseClass }__close" aria-label="Close modal" ${ closeAttribute } autofocus> ${ config.templateCloseIcon(config) } </button> </header> ` : "" } <div class="${ baseClass }__body" ${ initializer.getAttribute("body") }></div> ${ footerHtml ? `<div class="${ baseClass }__footer">${ footerHtml }</div>`: "" } ${ config.allowResize ? `<button class="${ baseClass }__resizer" type="button" ${ initializer.getAttribute("resizer") }> ${ config.templateResizerIcon(config) } </button>` : "" } </dialog> `; } }; // Current default objects (user can override these) let currentDefaults = { ...defaults }; /** * @param {Object} options Change options used as default for dialogs, can then be overridden by data attribute settings on element */ export function setDefaults(options) { currentDefaults = Object.assign({}, currentDefaults, options); } /** * Initialize everything in document * - This will only initialize elements once, it is safe to call on page changes */ export function init() { initializer.init({ withData: true, events: ["pageModified"], setup({ element, data }) { buildModal(element, data); } }); } /** * * @param {Node} content Content element of the dialog (what is inserted into the body) * @param {Object} options Options for built dialog (see defaults) */ export function buildModal(content, options) { const config = Object.assign({}, currentDefaults, options); const { position } = config; if (config.debug) { initializer.log(config, content); } if (!content.id) { throw new Error("Missing ID on modal"); } const markup = config.template(content.id, config); const modal = createElementFromHtml(markup.trim()); const selectChild = key => modal.querySelector(initializer.attributeSelector(key)); const body = selectChild("body"); const resizerElement = selectChild("resizer"); const dialogOptions = separateDialogOptions(config); // Replace content with new dialog, and then insert the content into the new dialogs body content.removeAttribute("id"); content.removeAttribute("hidden"); content.removeAttribute(initializer.getAttribute()); content.parentNode.replaceChild(modal, content); body.appendChild(content); // Add dialog options for other scripts modal.setAttribute(baseAttribute, JSON.stringify(dialogOptions)); // If they passed a footer element we need to move it in and // make sure it has the class if (config.footerElement) { const footerElement = getElement(config.footerElement); if (footerElement) { footerElement.classList.add(`${ config.baseClass }__footer`); body.after(footerElement); } } let resizer; const resizablePositions = ["left", "right", "center"]; const isCenter = position === "center"; const isRight = position === "right"; if (config.allowResize) { if (resizablePositions.includes(position)) { const resizerOptions = isCenter ? { fromX: "right", fromY: "bottom", multiplier: 2 } : { fromX: isRight ? "left" : "right" }; resizer = new Resizer(modal, resizerElement, resizerOptions); } else { console.warn(`${ position } is not supported for resizing`); } } if (config.print) { let printClone; document.addEventListener(getName("beforePrint"), () => { printClone = content.cloneNode(true); modal.after(printClone); }); document.addEventListener(getName("afterPrint"), () => { printClone.remove(); }); } return { modal, resizer }; } /** * Returns JSON string to embed in data-ulu-dialog for dialog handling * @param {Object} config Config object to pull dialog specific settings from * @returns {Object} */ function separateDialogOptions(config) { return Object.keys(dialogDefaults).reduce((acc, key) => { if (key in config) { acc[key] = config[key]; } return acc; }, {}); }