minsky-kit
Version:
Kit of components for MINSKY web agency
489 lines (390 loc) • 11.5 kB
JavaScript
// imports
import PluginManager from "../managers/PluginManager";
import Component from "./Component";
import { insertTrapper, removeTrapper } from "../utils/trapFocus";
import { strToCssSelector } from "../utils/css";
import { DEF_GEN_ARGS, generateOverlay } from "../factories/OverlayFactory";
// private statics
// class definition
export default class Overlay extends Component {
// constructor
constructor(args = {}, objectName = "Overlay") {
// debug state
args.debug = args.debug || false;
// capture el settings & nullify args.$el to prevent it to be treated as a dom element
const elArgs = args.$el || {};
elArgs.attributes = elArgs.attributes || {};
args.$el = null;
// block autoInit
const autoInit = typeof args.autoInit === "boolean" ? args.autoInit : true;
args.autoInit = false;
// call super constructor
super(args, objectName);
// log
this.log("Preparing");
// add properties
this.$el = generateOverlay.call(
this,
{ ...(elArgs || {}) },
args.generators || {}
);
this.triggers = [];
this._keepInDom = args.keepInDom || false;
this._$parent = args.$parent || document.body;
this._generators = args.generators || {};
this._plugins = args.plugins || {};
this._blockScroll =
typeof args.blockScroll === "boolean" ? args.blockScroll : true;
this._trapFocus = args.trapFocus || true;
this.quitOnEscape = true;
this._mainMenuId = args.mainMenuId || false;
if (this._trapFocus) {
this._lastActiveElement = null;
}
this._scrollBlocked = false;
// set states using the methods for cleaner & more readable codebae
const baseClass = elArgs.attributes.baseClass || DEF_GEN_ARGS.baseClass;
this.states.add("show", [
{
name: "showing",
duration: 0,
removeClass: [
`${baseClass}--shown`,
`${baseClass}--hiding`,
`${baseClass}--hidden`,
],
addClass: [`${baseClass}--showing`],
},
{
name: "shown",
duration: 0,
removeClass: [
`${baseClass}--showing`,
`${baseClass}--hiding`,
`${baseClass}--hidden`,
],
addClass: [`${baseClass}--shown`],
},
]);
this.states.add("hide", [
{
name: "hiding",
duration: 0,
removeClass: [
`${baseClass}--shown`,
`${baseClass}--showing`,
`${baseClass}--hidden`,
],
addClass: [`${baseClass}--hiding`],
},
{
name: "hidden",
duration: 500,
removeClass: [
`${baseClass}--showing`,
`${baseClass}--hiding`,
`${baseClass}--shown`,
],
addClass: [`${baseClass}--hidden`],
},
]);
// flags
this._visible = null;
// add triggers
if (args.$trigger) this.addTrigger(args.$trigger);
// query close button & add close listener
const closeBtn = this.$el.querySelector(
strToCssSelector(baseClass, "closeBtn")
);
if (closeBtn)
closeBtn.addEventListener(
"click",
this.blm.add("closeBtn.click", onCloseBtnClick)
);
// query backdrop button & add close listener
const backdrop = this.$el.querySelector(
strToCssSelector(baseClass, "backdrop")
);
if (backdrop)
backdrop.addEventListener(
"click",
this.blm.add("backdrop.click", onCloseBtnClick)
);
// add listeners to states
this.states.on("hidden", this.blm.add("onHidden", onHidden));
this.states.on("shown", this.blm.add("onShown", onShown));
// if keep in dom is active => add to dom
if (this._keepInDom && !this.visible) {
this._$parent.appendChild(this.$el);
}
// auto init
if (autoInit) this.initialize(args);
// run plugin init
plugins.run("initialize", args, this);
// set initial state (silently)
this.silent = true;
args.visible === true ? this.show(true) : this.hide(true);
this.silent = false;
// a11y: focus on first menu link
this.$firstMenuLink = this.$el.querySelector('.menu .menu__link');
// a11y: to activate aria-controls on hamburger
if (this._mainMenuId) {
this.$el.id = this._mainMenuId;
}
}
// methods
show(instant = false) {
this.log("Show");
// append element to parent
this._$parent.appendChild(this.$el);
// block scroll
if (this._blockScroll) blockScroll.call(this);
if (this._trapFocus) {
this._lastActiveElement = document.activeElement;
insertTrapper({ wrapper: this.$el });
}
// focus on first menu link if present
if (this.$firstMenuLink) this.$firstMenuLink.focus();
// body listener
if (this._quitOnEscape) listenToBodyKeyUp.call(this, true);
// set show state
this.states.set("show", {
instant,
});
// run plugins
plugins.run("show", instant, this);
// dispatch
this.dispatch("show");
}
hide(instant = false) {
this.log("Hide");
// unblock scroll
unblockScroll.call(this);
// body listener
if (this._quitOnEscape) listenToBodyKeyUp.call(this, false);
if (this._trapFocus) {
removeTrapper({
wrapper: this.$el,
lastActiveElement: this._lastActiveElement,
});
this._lastActiveElement = document.activeElement;
}
// set show state
this.states.set("hide", {
instant,
});
// run plugins
plugins.run("hide", instant, this);
// dispatch
this.dispatch("hide");
}
toggle(instant = false) {
this.log("toggle");
this.visible ? this.hide(instant) : this.show(instant);
// run plugins
plugins.run("toggle", instant, this);
}
addTrigger(trigger) {
// ensure it's an array
if (!Array.isArray(trigger) && !(trigger instanceof NodeList))
trigger = [trigger];
// loop over all triggers
for (const t of trigger) {
// add to trigger list
this.triggers.push(t);
// add trigger class
t.classList.add("js-isOverlayTrigger");
// add listeners
t.addEventListener(
"click",
this.blm.add("trigger.click", onTriggerClick)
);
}
// run plugins
plugins.run("addTrigger", trigger, this);
this.log("Triggers added", trigger);
this.dispatch("triggerAdded");
return this;
}
removeTrigger(trigger) {
// ensure it's an array
if (!Array.isArray(trigger) && !(trigger instanceof NodeList))
trigger = [trigger];
// loop over all triggers
for (const t of trigger) {
// add trigger class
t.classList.add("js-isOverlayTrigger");
// get index of trigger
const index = this.triggers.indexOf(t);
// process item if it's known & remove listeners
if (index > -1) {
this.triggers.slice(index, 1);
t.removeEventListener("click", this.blm.remove("trigger.click"));
}
}
// run plugins
plugins.run("removeTrigger", trigger, this);
this.log("Triggers added", trigger);
// dispatch
this.dispatch("triggerRemoved");
return this;
}
setContent(content) {
const container = this.$el.querySelector(".overlay__content");
container.innerHTML = "";
if (content instanceof Element) {
container.appendChild(content);
} else if (content instanceof NodeList) {
for (const c of content) {
container.appendChild(c);
}
} else {
container.innerHTML = content;
}
// run plugins
plugins.run("setContent", content, this);
// dispatch
this.dispatch("contentChanged");
}
destroy() {
// run plugins
plugins.run("destroy", this);
// remove all trigger listeners
this.removeTrigger(this.triggers.slice(0));
// remove blockScroll
if (this._scrollBlocked) unblockScroll.call(this);
// remove el from dom
this.$el.remove();
// super destroy
super.destroy();
}
// getters & setters
get visible() {
return this._visible;
}
set visible(value) {
value ? this.show() : this.hide();
}
get hiding() {
return this._visible && this.states.state.name === "hide";
}
get showing() {
return !this._visible && this.states.state.name === "show";
}
get quitOnEscape() {
return this._quitOnEscape;
}
set quitOnEscape(value) {
if (this.quitOnEscape !== value) {
this._quitOnEscape = value;
if (this.visible) {
listenToBodyKeyUp.call(this, value);
}
}
}
static get plugins() {
return plugins;
}
// deprecations
get parent() {
this.log(
"warn",
"Deprecation: this._parent is deprecated, use this._$parent instead"
);
return this._$parent;
}
}
// utils
function blockScroll() {
this.log("block scroll");
if (this._scrollBlocked) return;
document.body.style.overflow = "hidden";
document.body.style.top = `-${window.scrollY}px`;
document.body.style.left = `-${window.scrollX}px`;
document.body.style.right = "0";
document.body.style.position = "fixed";
this._scrollBlocked = true;
this.dispatch("scrollBlocked");
}
function unblockScroll() {
this.log("unblock scroll");
if (!this._scrollBlocked) return;
const left = parseInt(document.body.style.left || 0);
const top = parseInt(document.body.style.top || 0);
document.body.style.position = "";
document.body.style.top = "";
document.body.style.left = "";
document.body.style.right = "";
document.body.style.overflow = "";
window.scrollTo(left, top * -1);
this._scrollBlocked = false;
this.dispatch("scrollUnblocked");
}
function listenToBodyKeyUp(listen) {
const listener = this.blm.get("onBodyKeyUp");
if (listen && !listener) {
document.body.addEventListener(
"keyup",
this.blm.add("onBodyKeyUp", onBodyKeyUp)
);
} else if (!listen && listener) {
document.body.removeEventListener("keyup", this.blm.remove("onBodyKeyUp"));
}
}
// handlers (supposed to root a user action to another method)
function onTriggerClick(e) {
this.log("Trigger click", e.currentTarget);
// prevent default
e.preventDefault();
// trigger show
this.toggle();
this.dispatch("triggerClick", {
$trigger: e.currentTarget,
});
}
function onCloseBtnClick(e) {
this.log("Close button click", e.currentTarget);
// prevent default
e.preventDefault();
// trigger show
this.hide();
this.dispatch("closeBtnClick");
}
function onShown() {
this.log("Visible");
this._visible = true;
this.dispatch("shown");
}
function onHidden() {
this.log("Hidden");
// clear self from dom
if (!this._keepInDom && this._$parent.contains(this.$el))
this._$parent.removeChild(this.$el);
// change state
this._visible = false;
// dispatch event
this.dispatch("hidden");
}
// generators
function onBodyKeyUp(e) {
this.log("Key up", e.key);
if (e.key) {
// close on escape if required
if (this.quitOnEscape && e.key.toLowerCase() === "escape") {
this.log("Escape pressed => closing");
this.hide();
}
}
}
// add parent to pluginManager
let plugins = new PluginManager({
Target: Overlay,
});
// deprecated methods
export function generateOverlayHtml(args) {
this.log(
"warn",
"Deprecation warning: 'generateOverlayHtml' in Overlay is deprecated, use 'generateOverlay()` from /factories/OverlayFactory.js instead."
);
return generateOverlay(args);
}