<details-utils close-click-outside class="disclosure_container">
    <span class="form-number">
        ADV XXXX (XX-XXXX)<br>Rev. XXXX XX-XXXX
    </span>
    <details class="disclosures">
        <summary>View/Hide disclosures</summary>
        <div class="accordion-content">
            <p>"F&G" is the marketing name for Fidelity & Guaranty Life Insurance Company issuing insurance in the United States outside of New York. Life insurance and annuities issued by Fidelity & Guaranty Life Insurance Company, Des Moines, IA.</p>
            <p>Order of disclosures</p>
            <ul>
                <li>F&G disclosures</li>
                <li>Product or product type disclosures</li>
                <li>Interest crediting option disclosures</li>
                <li>Any additional disclosures such as legal notices, contact
                    your financial professional…, bank disclosures, etc.</li>
            </ul>
            <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