Skip to content

Instantly share code, notes, and snippets.

@YoungElPaso
Created December 15, 2022 17:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save YoungElPaso/e8a5e50dfbbd00fc53e894c8e91942c0 to your computer and use it in GitHub Desktop.
Save YoungElPaso/e8a5e50dfbbd00fc53e894c8e91942c0 to your computer and use it in GitHub Desktop.
X-Fade Open and Close
<div id="wc-demo">
<!-- With awaitChildReady attribute set, mds-placeholder will wait for a child component to fire a childready event to set the 'ready' status, which reflected to an attribute of the same name, allowing some styling (in this case become fully opaque). -->
<mds-placeholder awaitChildReady>
<!-- Custom Element for a Open/Close 'Toggle'. -->
<open-close-toggle animate id="demo"></open-close-toggle>
<hr />
<extra-details activeStatus>
<h3 slot="summary">activeStatus true - so open</h3>
<div>
Content is here.
</div>
</extra-details>
<extra-details>
<h2 slot="summary">Summary goes here.</h2>
Content goes here.
</extra-details>
<!-- Specify autoOpenSelector attribute to trigger auto-opening if the selector is a match on a child element. -->
<extra-details autoOpenSelector=".active">
<h2 slot="summary">This has `.active` content - so open</h2>
<div class="active">
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Hic nemo amet quisquam corrupti laborum deleniti similique rem adipisci, ullam nostrum est sit voluptatibus, laboriosam porro quia officia fuga nihil temporibus?
</div>
</extra-details>
</mds-placeholder>
</div>
<!-- Inline script here to run demo - NOT a part of implementation. -->
<script type="module">
// JS for this Codepen demo specifically:
// Import ion-icons as peer.
import "https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js";
// UGH! Easiest way was to import own JS for web component into HTML...Importing from this very pen!😋
import {
OpenCloseToggle,
ExtraDetails,
Placeholder
} from "https://codepen.io/YoungElPaso/pen/QWxVKye.js";
// DEMO CODE, allows togglingStatus attribute to illustrate WC behavior. In real usage this should be handled by parent state/prop-drilling/context.
function doDemo() {
customElements.define("open-close-toggle", OpenCloseToggle);
customElements.define("extra-details", ExtraDetails);
customElements.define("mds-placeholder", Placeholder);
const demo = document.getElementById("demo");
const demoButton = document.createElement("div");
demoButton.innerHTML =
"Demo: <button>click me to see the toggle in action. I will change it's toggleStatus attribute.</button>";
document.body.appendChild(demoButton);
demoButton.style =
"background: lightgray; margin: 2rem; opacity: 0.75; margin-top: 4rem; padding: 2rem; border-top:1px dashed #aaa;";
demoButton.onclick = function() {
demo.toggleStatus = !demo.toggleStatus;
};
}
doDemo();
// Fade-in when components are registered.
/* wc-demo niceties. */
// see https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/#awaiting-customelements.whendefined()
await Promise.allSettled([
// As mentioned, how to avoid requiring a list? Perhaps cannot? Maybe just settle on a 'root' wc? tho just because its 'root' in HTML doesn't meant it will be defined first...another solution I had thought of before w/ a placeholder is to fire an event that replaces this one at a more explicit interval causing the contents to become visible. Question tho: if a WC has children, in what order are they 'hydrated'? - could this be done with CSS alone? with a :has type selector? placeholder > not-undefined? Or, for every placeholder, when it's first child is defined, change opacity? Then can recursively scope all the way down. Effectively could do just that with slotchange event: see https://stackoverflow.com/questions/48663678/how-to-have-a-connectedcallback-for-when-all-child-custom-elements-have-been-c https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/slotchange_event
customElements.whenDefined('extra-details')
]);
// Commented out below in favour of using placeholder method.
// document.getElementById('wc-demo').style = 'opacity:1';
</script>
// Import lit.
import { html, css, LitElement } from "https://cdn.skypack.dev/lit@2.4.1";
// Creates a toggle-able interface using '+' and '-' to indicate status that can be used anywhere.
class OpenCloseToggle extends LitElement {
static properties = {
// Property/attribute to determine if should animate at all - this should be dynamic since animation can be a bad user experience for some.
animate: { type: Boolean, reflect: true },
// Toggled 'on' or 'off'.
toggleStatus: { type: Boolean, reflect: true }
};
constructor() {
super();
// Set attribute to enable/disable CSS transition; default true.
this.animate = true;
// Attribute for toggling; default is 'off'/false.
this.toggleStatus = false;
}
static styles = css`
:host {
/* Specify a general animation duration variable for the entire component, inheriting a common externally provided duration or using a fallback value. */
--toggle-animation-duration: var(--animation-duration-fast, 0.3s);
}
/* Container div - useful for positioning. */
div[part="toggle-wrapper"] {
display: inline-block;
position: relative;
min-height: 2rem;
min-width: 2rem;
}
/* ion-icon component for icon; positioned absolutely so can cross-fade transition. */
ion-icon {
/* Position icon in center of wrapper. */
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
/* If animate attribute is set, use transition. */
:host([animate]) ion-icon {
transition: opacity var(--toggle-animation-duration);
}
/* If off, show 'on' icon. */
:host(:not([togglestatus])) ion-icon[name="add-sharp"] {
opacity: 1;
}
/* If on, show 'off' icon. */
:host([toggleStatus]) ion-icon[name="remove-sharp"] {
opacity: 1;
}
`;
render() {
return html`
<div part="toggle-wrapper">
<!-- Ionicon open icon markup. -->
<ion-icon aria-hidden="true" name="add-sharp" size="large" alt="open">
open
</ion-icon>
<!-- Ionicon close icon markup. -->
<ion-icon
aria-hidden="true"
name="remove-sharp"
size="large"
alt="close"
>
close
</ion-icon>
</div>
`;
}
}
// Creates a 'extra' details element that includes animation, better toggle, auto-opening criteria etc.
class ExtraDetails extends LitElement {
static properties = {
// Dynamic property to track current state of open/closed status.
// TODO: not sure this is needed anymore, re-evaluate later on next version.
open: {
type: Boolean
},
// Property to see if details element should be open or closed by default. Reflect as attribute.
activeStatus: {
type: Boolean,
reflect: true
},
// Property for autoOpenSelector, allows component to check for children matching that selector.
// Allows for checking child elements for 'activity' and setting parent open/closed.
autoOpenSelector: { type: String }
};
constructor() {
super();
// Inactive by default (same as native details element.)
this.activeStatus = false;
// Track open status after initial load for re-use by sub-components etc.
this.open = this.activeStatus;
}
// Use firstUpdated lifecyle hook to fire a custom event 'childready' - allows parent components to know when a child component is fully loaded and first rendered and respond to event appropriately. See Placeholder for an implementation that does this.
firstUpdated() {
// Create a new custom event to fire.
let childReadyEvent = new CustomEvent("childready", {
bubbles: true,
composed: true
});
// TODO: This in theory would be a good place to have a base class for all MDS components because this firstUpdated method firing an event could be a pretty useful pattern to share - hard to think of a component that should NOT report in like this. Iterate on next minor version.
// Component dispatches event at end of firstUpdate lifecycle hook.
this.dispatchEvent(childReadyEvent);
// During firstUpdated to gather info about height of elements after first render.
this._getHeights();
}
// Uses connectedCallback to compute some properties and set up some event listeners.
connectedCallback() {
super.connectedCallback();
// If autoOpenSelector attr/prop set, use it to check for active children.
if (this.autoOpenSelector) {
// If found, activeChild is true.
let activeChild = this.querySelectorAll(this.autoOpenSelector).length > 0;
// If isActive is false, and there's an activeChild set isActive true.
if (!this.activeStatus && activeChild) {
this.activeStatus = true;
}
}
// Add event listener for window resizing - needed to recompute height of component as reflowing text will change that.
let t = this;
window.addEventListener(
"resize",
function () {
t._handleWindowResize();
},
false
);
}
// Listen for widow resize and adjust height variables.
_handleWindowResize() {
this._getHeights();
}
// Handle toggle using native details element toggle event.
// TODO: the only trouble w/ using the native toggle event is there's no way to animate the 'out' state of the contents since by the time toggle fires, the details element is already 'closed' and contents totally hidden. Listening to click on summary could override this but then everything else has to be handled explicitly by the component. A solution could be to handle BOTH events and postpone triggering the toggle until after some effect has been done. But this is progressive enhancement - add things in layers weighing convenience vs effort. IE these suggestions steps would be high effort and require a re-wiring most of the details behaviours. Would probably require major version change - probably breaks API?
_handleToggle() {
this.activeStatus = this._details.open;
// Re-calculate heights on click as well because FF and Safari don't render
// <details> contents at all when not open, so no height info exists until
// <details> is open.
this._getHeights();
}
// Get the child details element to query for heights, children etc.
get _details() {
return this.renderRoot?.querySelector("details") ?? null;
}
// Calculates the heights required for animating the component on open/shut.
_getHeights() {
// Wrap in requestAnimationFrame to allow contentHeight to be calculable
// when <details> opens - otherwise race condition occurs between setting
// open attribute to 'open' and CSS vars to enable transition on height
// property.
let t = this;
requestAnimationFrame(function () {
// Get the summary element to get it's height.
let summaryElement = t._details.querySelector("summary");
// Get the content element to get it's height.
let contentElement = t._details.querySelector("div");
// Get the height of the summary element.
let summaryHeight = summaryElement.clientHeight;
// Get the height of the content element.
let contentHeight = contentElement.clientHeight;
// Set fullHeight to the sum of both numbers if they exist.
let fullHeight = contentHeight + summaryHeight;
// Set the initHeight CSS var to summaryHeight - the height of the summary UI.
t.style.setProperty("--initHeight", String(summaryHeight) + "px");
// Set the activeHeight CSS var to fullHeight as that is the total height the component should have when active.
t.style.setProperty("--activeHeight", String(fullHeight) + "px");
});
}
render() {
// Template for internal details and sub-elements. Listen to the native 'toggle' event for <details> to react.
return html`
<details ?open=${this.activeStatus} @toggle=${this._handleToggle}>
<summary part="summary">
<!-- open-close-toggle CE for nicer animated toggling; forwards toggle-wrapper part for themeing. -->
<open-close-toggle
exportparts="toggle-wrapper"
?toggleStatus="${this.activeStatus}"
></open-close-toggle>
<slot name="summary"></slot>
</summary>
<div part="contents">
<slot></slot>
</div>
</details>
`;
}
static styles = css`
:host {
/* Specify a general animation duration variable for the entire component, inheriting a common externally provided duration or using a fallback value. */
--ed-animation-duration: var(--animation-duration-fast, 0.3s);
--ed-animation-duration-slow: calc(3 * var(--ed-animation-duration));
display: block;
transition: height var(--ed-animation-duration);
height: var(--initHeight);
overflow: hidden;
}
/* Set height when active attribute set (thus 'open') to activeHeight variable. */
:host([activestatus]) {
height: var(--activeHeight);
}
:host details div[part="contents"] {
opacity: 0;
transition: opacity var(--ed-animation-duration-slow);
}
:host([activestatus]) details div[part="contents"] {
opacity: 1;
}
/* TODO: these rules about summary styling could be styled outside of component in CSS file; using part at the same time as regular details summary is styled. Then again, component should be as self-contained as possible. */
/* Standards compliant method of unsetting 'marker' in details summary element. */
:host details summary {
/* Vertically center items in summary. */
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
}
/* Want any headers inside summary to be inline-block. */
:host details summary * {
display: inline-block;
}
`;
}
// Placeholder provides a wrapping template that reacts when slotted contents inside it are loaded or when it catches a specific 'childready' event. Similar to a 'skeleton' component that can show a loading or interstitial state. No CSS here for it's own initial state; that MUST be specified externally so the placeholder element is ready for styling even if itself is undefined/not yet hydrated. Typical usecase is for 'hiding' child contents until they are fully loaded/hyrdated/first rendered.
class Placeholder extends LitElement {
static properties = {
// Property/attribute for readiness of contents.
ready: { type: Boolean, reflect: true },
// Property/attribute for checking for explicit child readiness.
awaitChildReady: { type: Boolean, reflect: true }
};
constructor() {
super();
// Not ready by default.
this.ready = false;
this.awaitChildReady = false;
}
connectedCallback() {
super.connectedCallback();
// If attribute awaitChildReady is explicitly set, wait for that event.
if (this.awaitChildReady) {
this.addEventListener("childready", function (e) {
// Stop event propagation - don't want childready events bubbling up out of one placeholder component. Nest placeholders if necessary.
e.stopPropagation();
// When event is heard, toggle ready state of placeholder.
this.ready = true;
});
}
}
// Handles slotchange event - when placeholder slot is filled, this event fired. Slot can be filled with LightDOM or better, other components who's custom elements will only be slotted after they are defined (loaded). Therefore the Placeholder is 'ready' when it's children are.
handleSlotChange(e) {
// Only set ready if not explicitly listening for child-ready events.
if (!this.awaitChildReady) {
this.ready = true;
}
}
render() {
// Placeholder has very basic template.
// @slotchange to specify handler for that event. See https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/slotchange_event.
return html`
<slot @slotchange=${this.handleSlotChange}></slot>
`;
}
}
// Export each of the components.
export { OpenCloseToggle, ExtraDetails, Placeholder };
// GOOD TO GO! PORT TO SB NEXT STEP!
/* Establish some CSS to set the WC custom elements up to render nicely on their initial state in any given context and prevent jarring FOUC/layout thrashing. */
/* Fade in animation. */
@keyframes wc-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Each custom element should have some initial styles set externally to themselves to establish layouts etc. */
open-close-toggle,
extra-details {
/* Animate so element fades-in when defined - i.e. when JS loads. */
animation: wc-fade-in 0.6s ease-in;
/* Custom elements need a default display property. `inline-block` is the best default. */
display: inline-block;
}
open-close-toggle {
/* Corresponds to final 'hydrated' rendered width so even if not yet visible, will prevent layout updates/thrashing/re-paint for the rest of the page. */
min-width: 2rem;
}
/* Give extra-details layout but invisibility until defined. */
extra-details {
display: block;
}
extra-details:not(:defined) {
visibility: hidden;
}
/* mds-placeholder initialized as invisible. */
mds-placeholder {
display: block;
opacity: 0;
/* Transition to allow fade-in. */
transition: opacity 1s;
}
/* When mds-placeholder is ready, make visible by changing opacity. */
mds-placeholder[ready] {
opacity: 1;
}
/* External themeing for extra-details 🎩. */
/* Color the toggle-wrapper and thus the icons in it using parts syntax, which allows for theme-type styling of parts in web components. */
extra-details::part(toggle-wrapper) {
color: var(--mds-color-primary-500, red);
}
/* Give the summary a sans-serif McGill font. */
extra-details::part(summary) {
font-family: McGillSans-Medium, sans-serif;
/* Set a sensible default, fixed size. */
font-size: 1rem;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment