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

368 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}