<details-utils close-click-outside animate>
    <details>
        <summary>Accordion Label</summary>
        <div class="accordion-content">
            <p>Lorem ipsum dolor sit amet,consectetur adipiscing elit. Nulla et iaculis nulla. Mauris vel ligula magna.
                Maecenas elementum bibendum nibh, in suscipit lacus pharetra mattis. Maecenas molestie imperdiet nisi, et
                suscipit turpis</p>
        </div>
    </details>
</details-utils>
  • Content:
    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);
    }
    
  • URL: /components/raw/accordion/details-utils.js
  • Filesystem Path: components/02-components/accordion/details-utils.js
  • Size: 9.5 KB
  • Content:
    p {
      margin: 0;
    }
    
    .dropdown {
      width: fit-content;
      display: inline-block;
    }
    
    .dropdown-content {
      display: none;
      position: absolute;
      background-color: var(--purple-800-brand);
      border-radius: 6px;
      min-width: 160px;
      box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.25);
      z-index: 1;
    }
    
    .dropdown-content a {
      float: none;
      color: var(--white);
      padding: .5em;
      text-decoration: none;
      display: block;
      text-align: left;
      font-size: 18px;
    }
    
    .dropdown-content a:hover {
      background-color: var(--white);
      color: var(--purple-800-brand);
      border-radius: 6px;
    }
    
    .dropdown:hover .dropdown-content {
      display: block;
    }
    
    .nav-drop-gray .dropdown-content {
      background-color: var(--white);
    }
    
    .nav-drop-gray .dropdown-content a{
      color: var(--text-color);
    }
    
    .nav-drop-gray .dropdown-content a:hover {
      background-color: var(--gray-200);
    }
    
    .nav-drop-primary .dropdown-content {
      background-color: var(--white);
    }
    
    .nav-drop-primary .dropdown-content a {
      color: var(--purple-800-brand);
    }
    
    .nav-drop-primary .dropdown-content a:hover {
      background-color: var(--purple-800-brand);
      color: var(--white);
    }
    
    .nav-drop-primary .btn-outline {
      border-color: var(--white);
    }
    
    .nav-drop-primary .btn-outline:hover, .nav-drop-primary .btn-outline:focus {
      background-color: var(--white);
      color: var(--purple-800-brand);
      border-color: var(--purple-800-brand);
    }
    
    details {
      font-size: 18px;
      border: 2px solid var(--purple-800-brand);
      border-radius: 6px;
      width: 60ch;
      max-width: 90vw;
    }
    
    summary {
      background-color: var(--purple-800-brand);
      color: var(--white);
      padding: .5em;
    }
    
    .accordion-content p, .faq-content p {
      padding: .5em;
    }
    
    .faq {
      border: none;
    }
    
    .faq summary {
      background-color: var(--white);
      color: var(--text-color);
    }
    
    .faq-content {
      border: 2px solid var(--text-color);
      border-radius: 6px;
    }
    
    spicy-sections {
      /* Big hand-wave over how we'd ultimately express this, but for this custom element, this is how you inform when you'd like to emply which affodances 'collapse', 'tab-bar' and 'exclusive-collapse' are the available affordances.  Anything else is, effectively "plain" or "none". It is only read once.
      */
      --const-mq-affordances: [screen and (max-width: 40em)] collapse | [screen and (min-width: 60em)] tab-bar;
      display: block;
      padding: 1em;
    }
    
    /* just normal css/dom any heading works */
    h2 {
      margin: 0;
      font-size: 18px;
    }
    
    /* you can tell there are none employed */
    h2:not([affordance] *) {
      margin-bottom: 0.5em;
    }
    
    /* content panels when plain */
    h2:not([affordance] *):not(:first-child) {
      margin-top: 1.5em;
    }
    
    /* content panels, always */
    spicy-sections h2+* {
      margin-top: 0;
    }
    
    /* accordion buttons */
    [affordance="collapse"] {
      --heading-size: 1em;
    }
    
    /* the pseudo element created around the tabs, when it exists */
    ::part(tab-list) {
      display: flex;
    }
    
    /* only when they're tabs */
    [affordance="tab-bar"] h2 {
      padding: 0.5em;
      cursor: pointer;
      border: 2px solid var(--white);
    }
    
    [affordance="tab-bar"] h2:hover {
      border-radius: 6px 6px 0 0;
      border-color: var(--purple-800-brand);
    }
    
    /* Only when they're tabs and selected */
    [affordance="tab-bar"] h2[tabindex="0"] {
      background-color: var(--purple-800-brand);
      border-radius: 6px 6px 0 0;
      color: var(--white);
      border: 2px solid var(--purple-800-brand);
    }
    
    /* Tabs that aren't selected */
    [affordance="tab-bar"] h2:not([tabindex="0"]) {
      color: var(--purple-800-brand);
    }
    
    /* content, when it's tabs */
    [affordance="tab-bar"] [role="tabpanel"] {
      border: 2px solid var(--purple-800-brand);
      padding: .5em;
      border-radius: 6px;
      font-size: 18px;
      color: var(--gray-700-brand);
      width: 300px;
    }
  • URL: /components/raw/accordion/dropdowns.css
  • Filesystem Path: components/02-components/accordion/dropdowns.css
  • Size: 3.7 KB

Usage Guidelines

  • Use when you want to chunk information together and hide non-essential information
  • Accordion Label must be short, clear, and understandable to indicate what’s inside