leilukin-site/assets/js/details-utils.js

368 lines
9.3 KiB
JavaScript
Raw Normal View History

class DetailsUtilsForceState {
constructor(detail, options = {}) {
this.options = Object.assign({
closeClickOutside: false, // can also be a media query str
forceStateClose: false, // can also be a media query str
forceStateOpen: false, // can also be a media query str
closeEsc: false, // can also be a media query str
forceStateRestore: true,
}, options);
this.detail = detail;
this.summary = detail.querySelector(":scope > summary");
this._previousStates = {};
}
getMatchMedia(el, mq) {
if(!el) return;
if(mq && mq === true) {
return {
matches: true
};
}
if(mq && "matchMedia" in window) {
return window.matchMedia(mq);
}
}
// warning: no error checking if the open/close media queries are configured wrong and overlap in weird ways
init() {
let openMatchMedia = this.getMatchMedia(this.detail, this.options.forceStateOpen);
let closeMatchMedia = this.getMatchMedia(this.detail, this.options.forceStateClose);
// When both force-close and force-open are valid, it toggles state
if( openMatchMedia && openMatchMedia.matches && closeMatchMedia && closeMatchMedia.matches ) {
this.setState(!this.detail.open);
} else {
if( openMatchMedia && openMatchMedia.matches ) {
this.setState(true);
}
if( closeMatchMedia && closeMatchMedia.matches ) {
this.setState(false);
}
}
this.addListener(openMatchMedia, "for-open");
this.addListener(closeMatchMedia, "for-close");
}
addListener(matchmedia, type) {
if(!matchmedia || !("addListener" in matchmedia)) {
return;
}
// Force stated based on force-open/force-close attribute value in a media query listener
matchmedia.addListener(e => {
if(e.matches) {
this._previousStates[type] = this.detail.open;
if(this.detail.open !== (type === "for-open")) {
this.setState(type === "for-open");
}
} else {
if(this.options.forceStateRestore && this._previousStates[type] !== undefined) {
if(this.detail.open !== this._previousStates[type]) {
this.setState(this._previousStates[type]);
}
}
}
});
}
toggle() {
let clickEvent = new MouseEvent("click", {
view: window,
bubbles: true,
cancelable: true
});
this.summary.dispatchEvent(clickEvent);
}
triggerClickToClose() {
if(this.summary && this.options.closeClickOutside) {
this.toggle();
}
}
setState(setOpen) {
if( setOpen ) {
this.detail.setAttribute("open", "open");
} else {
this.detail.removeAttribute("open");
}
}
}
class DetailsUtilsAnimateDetails {
constructor(detail) {
this.duration = {
open: 200,
close: 150
};
this.detail = detail;
this.summary = this.detail.querySelector(":scope > summary");
let contentTarget = this.detail.getAttribute("data-du-animate-target");
if(contentTarget) {
this.content = this.detail.closest(contentTarget);
}
if(!this.content) {
this.content = this.summary.nextElementSibling;
}
if(!this.content) {
// TODO wrap in an element?
throw new Error("For now <details-utils> requires a child element for animation.");
}
this.summary.addEventListener("click", this.onclick.bind(this));
}
parseAnimationFrames(property, ...frames) {
let keyframes = [];
for(let frame of frames) {
let obj = {};
obj[property] = frame;
keyframes.push(obj);
}
return keyframes;
}
getKeyframes(open) {
let frames = this.parseAnimationFrames("maxHeight", "0px", `${this.getContentHeight()}px`);
if(!open) {
return frames.filter(() => true).reverse();
}
return frames;
}
getContentHeight() {
if(this.contentHeight) {
return this.contentHeight;
}
// make sure its open before we measure otherwise it will be 0
if(this.detail.open) {
this.contentHeight = this.content.offsetHeight;
return this.contentHeight;
}
}
animate(open, duration) {
this.isPending = true;
let frames = this.getKeyframes(open);
this.animation = this.content.animate(frames, {
duration,
easing: "ease-out"
});
this.detail.classList.add("details-animating")
this.animation.finished.catch(e => {}).finally(() => {
this.isPending = false;
this.detail.classList.remove("details-animating");
});
// close() has to wait to remove the [open] attribute manually until after the animation runs
// open() doesnt have to wait, it needs [open] added before it animates
if(!open) {
this.animation.finished.catch(e => {}).finally(() => {
this.detail.removeAttribute("open");
});
}
}
open() {
if(this.contentHeight) {
this.animate(true, this.duration.open);
} else {
// must wait a frame if we havent cached the contentHeight
requestAnimationFrame(() => this.animate(true, this.duration.open));
}
}
close() {
this.animate(false, this.duration.close);
}
useAnimation() {
return "matchMedia" in window && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// happens before state change when toggling
onclick(event) {
// do nothing if the click is inside of a link
if(event.target.closest("a[href]") || !this.useAnimation()) {
return;
}
if(this.isPending) {
if(this.animation) {
this.animation.cancel();
}
} else if(this.detail.open) {
// cancel the click because we want to wait to remove [open] until after the animation
event.preventDefault();
this.close();
} else {
this.open();
}
}
}
class DetailsUtils extends HTMLElement {
constructor() {
super();
this.attrs = {
animate: "animate",
closeEsc: "close-esc",
closeClickOutside: "close-click-outside",
forceStateClose: "force-close",
forceStateOpen: "force-open",
forceStateRestore: "force-restore",
toggleDocumentClass: "toggle-document-class",
closeClickOutsideButton: "data-du-close-click",
};
this.options = {};
this._connect();
}
getAttributeValue(name) {
let value = this.getAttribute(name);
if(value === undefined || value === "") {
return true;
} else if(value) {
return value;
}
return false;
}
connectedCallback() {
this._connect();
}
_connect() {
if (this.children.length) {
this._init();
return;
}
// not yet available, watch it for init
this._observer = new MutationObserver(this._init.bind(this));
this._observer.observe(this, { childList: true });
}
_init() {
if(this.initialized) {
return;
}
this.initialized = true;
this.options.closeClickOutside = this.getAttributeValue(this.attrs.closeClickOutside);
this.options.closeEsc = this.getAttributeValue(this.attrs.closeEsc);
this.options.forceStateClose = this.getAttributeValue(this.attrs.forceStateClose);
this.options.forceStateOpen = this.getAttributeValue(this.attrs.forceStateOpen);
this.options.forceStateRestore = this.getAttributeValue(this.attrs.forceStateRestore);
// TODO support nesting <details-utils>
let details = Array.from(this.querySelectorAll(`:scope details`));
for(let detail of details) {
// override initial state based on viewport (if needed)
let fs = new DetailsUtilsForceState(detail, this.options);
fs.init();
if(this.hasAttribute(this.attrs.animate)) {
// animate the menus
new DetailsUtilsAnimateDetails(detail);
}
}
this.bindCloseOnEsc(details);
this.bindClickoutToClose(details);
this.toggleDocumentClassName = this.getAttribute(this.attrs.toggleDocumentClass);
if(this.toggleDocumentClassName) {
this.bindToggleDocumentClass(details);
}
}
bindCloseOnEsc(details) {
if(!this.options.closeEsc) {
return;
}
document.documentElement.addEventListener("keydown", event => {
if(event.keyCode === 27) {
for(let detail of details) {
if (detail.open) {
let fs = new DetailsUtilsForceState(detail, this.options);
let mm = fs.getMatchMedia(detail, this.options.closeEsc);
if(!mm || mm && mm.matches) {
fs.toggle();
}
}
}
}
}, false);
}
isChildOfParent(target, parent) {
while(target && target.parentNode) {
if(target.parentNode === parent) {
return true;
}
target = target.parentNode;
}
return false;
}
onClickoutToClose(detail, event) {
let fs = new DetailsUtilsForceState(detail, this.options);
let mm = fs.getMatchMedia(detail, this.options.closeClickOutside);
if(mm && !mm.matches) {
// dont close if has a media query but it doesnt match current viewport size
// useful for viewport navigation that must stay open (e.g. list of horizontal links)
return;
}
let isCloseButton = event.target.hasAttribute(this.attrs.closeClickOutsideButton);
if((isCloseButton || !this.isChildOfParent(event.target, detail)) && detail.open) {
fs.triggerClickToClose(detail);
}
}
bindClickoutToClose(details) {
// Note: Scoped to document
document.documentElement.addEventListener("mousedown", event => {
for(let detail of details) {
this.onClickoutToClose(detail, event);
}
}, false);
// Note: Scoped to this element only
this.addEventListener("keypress", event => {
if(event.which === 13 || event.which === 32) { // enter, space
for(let detail of details) {
this.onClickoutToClose(detail, event);
}
}
}, false);
}
bindToggleDocumentClass(details) {
for(let detail of details) {
detail.addEventListener("toggle", (event) => {
document.documentElement.classList.toggle( this.toggleDocumentClassName, event.target.open );
});
}
}
}
if(typeof window !== "undefined" && ("customElements" in window)) {
window.customElements.define("details-utils", DetailsUtils);
}