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