@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.
217 lines (191 loc) • 6.24 kB
JavaScript
/**
* @module ui/dialog
*/
import { getName } from "../events/index.js";
import { ComponentInitializer } from "../utils/system.js";
import { wasClickOutside, preventScroll as setupPreventScroll } from "@ulu/utils/browser/dom.js";
import { pauseVideos as pauseYoutubeVideos, prepVideos as prepYoutubeVideos } from "../utils/pause-youtube-video.js";
/**
* Base attribute for a dialog
*/
export const baseAttribute = "data-ulu-dialog"; // Exposed for modal builder
/**
* Dialog Component Initializer
*/
export const initializer = new ComponentInitializer({ type: "dialog", baseAttribute });
/**
* Attribute for close buttons within a dialog
*/
export const closeAttribute = initializer.getAttribute("close"); // Exposed for modal builder
/**
* Dialog Defaults
* - Can be overridden using data-attributes
*/
export const defaults = {
/**
* Use non-modal interface for dialog
*/
nonModal: false,
/**
* Move the dialog to the document end (hoist out of content)
* - helpful if dialogs are within editor body, etc
*/
documentEnd: false,
/**
* Requires styling that reduces any padding/border on dialog
*/
clickOutsideCloses: true,
/**
* Whether or not to pause videos when dialog closes (currently just youtube and native)
*/
pauseVideos: true,
/**
* When open and not non-modal, the body is prevented from scrolling (defaults to true).
*/
preventScroll: true,
/**
* Compensate for layout shift when preventing scroll. Which adds padding equal to scrollbars
* width while dialog is open
*/
preventScrollShift: true,
};
// 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() {
// Initialize all the dialogs
initializer.init({
events: ["pageModified"],
withData: true,
setup({ element, initialize, data }) {
setupDialog(element, data);
initialize();
}
});
// Initialize all triggers (things that trigger opening a dialog)
initializer.init({
key: "trigger",
events: ["pageModified"],
withData: true,
setup({ element, initialize, data: dialogId }) {
setupTrigger(element, dialogId);
initialize();
}
});
}
/**
* Setup click handlers on a trigger
* @param {Node} trigger Trigger button element
* @param {String} dialogId The dialog's id to open
*/
export function setupTrigger(trigger, dialogId) {
trigger.addEventListener("click", handleTrigger);
function handleTrigger(event) {
// If a link is used (not recommended) we need to prevent
// the page from scrolling
const closestLink = event.target.closest("a");
if (closestLink) {
event.preventDefault();
}
const dialog = document.getElementById(dialogId);
if (!dialog) {
console.error("Could not locate dialog (id)", dialogId);
return;
}
if (dialog?.tagName?.toLowerCase() !== "dialog") {
console.error("Attempted to trigger non <dialog> element. Did you mean to use modal builder?" );
return;
}
const options = getDialogOptions(dialog);
dialog[options.nonModal ? "show" : "showModal"]();
}
}
/**
* Setup click handlers for a dialog
* @param {Node} dialog
*/
export function setupDialog(dialog, userOptions) {
const options = Object.assign({}, currentDefaults, userOptions);
const body = document.body;
const { preventScrollShift: preventShift } = options;
// Stores active pointerId for resizer until after the whole pointer event series
// is finished which is after the click is complete
let activeResizePointer;
dialog.addEventListener(getName("resizer:start"), handleResizeStart);
dialog.addEventListener(getName("resizer:end"), handleResizeEnd);
dialog.addEventListener("click", handleClicks);
if (options.documentEnd) {
body.appendChild(dialog);
}
if (options.pauseVideos) {
prepVideos(dialog);
}
// Allow preventScroll if it is a modal dialog
// Caching value of overflow before setting so we don't assume what it's initial value is
if (!options.nonModal && options.preventScroll) {
// Cache restore function
let restoreScroll;
// Toggle prevent scroll
dialog.addEventListener("toggle", (event) => {
const isOpen = event.newState === "open";
if (isOpen) {
restoreScroll = setupPreventScroll({ preventShift });
} else if (restoreScroll) {
restoreScroll();
}
});
}
function handleClicks(event) {
const { target } = event;
const targetIsDialog = target === dialog;
const closeFromButton = target.closest(initializer.attributeSelector("close"));
const allowCloseOutside = !activeResizePointer && options.clickOutsideCloses;
const closeFromOutside = allowCloseOutside && targetIsDialog && wasClickOutside(dialog, event);
if (closeFromOutside || closeFromButton) {
if (options.pauseVideos) {
pauseVideos(dialog);
}
dialog.close();
}
}
function handleResizeStart(event) {
activeResizePointer = event.pointerId;
}
function handleResizeEnd(event) {
if (activeResizePointer === event.pointerId) {
// next event cycle (after click/pointer events finish in current)
setTimeout(() => { activeResizePointer = null;}, 0);
}
}
}
/**
* For a given dialog, get it's options (from data attribute)
* @param {Node} dialog
* @returns {Object}
*/
export function getDialogOptions(dialog) {
return Object.assign({}, currentDefaults, initializer.getData(dialog));
}
/**
* Pause native and youtube videos for a given dialog
*/
function prepVideos(dialog) {
prepYoutubeVideos(dialog);
}
/**
* Prep videos to be paused for a given dialog
*/
function pauseVideos(dialog) {
pauseYoutubeVideos(dialog);
const nativeVideos = dialog.querySelectorAll("video");
nativeVideos.forEach(video => video.pause());
}