368 lines
9.3 KiB
JavaScript
368 lines
9.3 KiB
JavaScript
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 it’s 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() doesn’t 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 haven’t 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) {
|
||
// don’t close if has a media query but it doesn’t 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);
|
||
}
|