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