Skip to content

Instantly share code, notes, and snippets.

@NickGard
Last active March 4, 2024 19:39
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 NickGard/1c28c28921732a816760dce326f94fe9 to your computer and use it in GitHub Desktop.
Save NickGard/1c28c28921732a816760dce326f94fe9 to your computer and use it in GitHub Desktop.
This custom html element brings Media Query powers to markup.
/**
* # OnlyWhen brings Media Queries to HTML.
*
* It solves two use-cases via a simple API.
*
* ## Use case #1: remove nodes from the DOM.
*
* What's the problem this solves? With CSS Media Queries, DOM nodes are typically
* _hidden_ but still participate in DOM operations, like queries and IDREF resolutions.
* To include the nodes only when a particular media query matches, use the attributes
* "media" and, optionally, "keep-changes".
*
* @attribute media: any valid media query string
* @attribute keep-changes: boolean attribute that evaluates to true for any value except
* the string "false"
*
* @example
* <only-when media="(min-width: 48rem)">
* <p>I only exist in the DOM when the screen is at least 48rem wide</p>
* </only-when>
*
* @example
* <only-when media="(orientation: landscape)" keep-changes>
* <p>I only exist in the DOM in the landscape orientation</p>
* <ul>
* <li>if you add some list item programatically</li>
* <li>and then rotate your device to make these nodes disappear</li>
* <li>when you rotate back, all of your changes will still be here</li>
* <li>because this uses the "keep-changes" attribute</li>
* </ul>
* </only-when>
*
*
* ## Use case #2: adjust attributes.
*
* What's the problem? CSS Media Queries can modify styles but they cannot affect
* markup. To make changes to attributes, include <source> elements with the attributes
* "media", "attr", and "value". Only the first non-source element child gets the
* attributes applied to it. Only the first source (per "attr") whose "media"
* matches will be applied. The attributes on the target element will be restored if
* no source's "media" matches.
*
* @example
* <!-- This input will be a range slider with a step of 1 and a max of 50 when
* the device orientation is landscape. Otherwise it will be a text input with
* numeric inputmode and only accepting numbers. -->
* <only-when>
* <source media="(orientation: landscape)" attr="type" value="range">
* <source media="(orientation: landscape)" attr="step" value="1">
* <source media="(orientation: landscape)" attr="max" value="50">
* <source media="(orientation: portrait)" attr="type" value="text">
* <source media="(orientation: portrait)" attr="pattern" value="%d+">
* <source media="(orientation: portrait)" attr="inputmode" value="numeric">
*
* <input required name="upper-limit">
* </only-when>
*
* @example
* <!-- These use-cases can be combined -->
* <only-when media="(max-width: 48rem)">
* <source media="(orientation: landscape)" attr="capture" value="environment">
* <source media="(orientation: portrait)" attr="capture" value="user">
*
* <input type="file" accept="image/*">
* </only-when>
*
* @example
* <!-- Use the <or-else> child to render something when the media query _doesn't_ match -->
* <only-when media="(min-width: 48rem)">
* <p>I only exist in the DOM when the screen is at least 48rem wide</p>
* <or-else>
* <p>I only exist in the DOM when the screen is at most 48rem wide</p>
* </or-else>
* </only-when>
*
*/
class OnlyWhen extends HTMLElement {
static observedAttributes = ["media", "keep-changes"];
#children = null;
#antiChildren = null;
#existenceMediaQuery = null;
#attributeMediaQueries = [];
#initialAttributes = new Map();
#sourceAttributeChangeObserver = null;
#resetSourcesObserver = null;
#copyChildren() {
const children = new DocumentFragment();
const antiChildren = new DocumentFragment();
for (const child of this.children) {
if (child.matches('or-else')) {
antiChildren.appendChild(child.cloneNode(true));
} else {
children.appendChild(child.cloneNode(true));
}
}
this.#children = children;
this.#antiChildren = antiChildren;
}
#evaluateAttributes = () => {
// the first non-source element
const targetElement = this.querySelector(':scope > :not(source)');
if (!targetElement) return;
const evaluatedAttributes = new Set();
Array.from(this.querySelectorAll(':scope > source')).forEach(sourceElement => {
const attr = sourceElement.getAttribute('attr');
const value = sourceElement.getAttribute('value');
const media = sourceElement.getAttribute('media');
if (!evaluatedAttributes.has(attr) && window.matchMedia(media).matches) {
if (!this.#initialAttributes.has(attr)) {
this.#initialAttributes.set(attr, targetElement.getAttribute(attr));
}
targetElement.setAttribute(attr, value);
evaluatedAttributes.add(attr);
}
});
// reset the initial attributes if they never evaluated to any of the sources' values
this.#initialAttributes.forEach((value, attr) => {
if (!evaluatedAttributes.has(attr)) {
targetElement.setAttribute(attr, value);
}
})
}
#evaluateExistenceMediaQuery = () => {
if (!this.#existenceMediaQuery) return;
if (this.#existenceMediaQuery.matches) {
this.replaceChildren(this.#children.cloneNode(true));
} else {
// copy changes to children if desired
if (this.hasAttribute('keep-changes') && this.getAttribute('keep-changes') !== 'false') {
this.#copyChildren();
}
// remove children or render anti-children
this.replaceChildren(this.#antiChildren?.cloneNode(true));
}
}
connectedCallback() {
this.#evaluateAttributes();
this.#setSourceListenersAndObservers();
// if any source element is added or removed:
// 1. reset listeners and observers
// 2. reevaluate attributes
this.#resetSourcesObserver = new MutationObserver(() => {
this.#destroySourceListenersAndObservers();
this.#setSourceListenersAndObservers();
this.#evaluateAttributes();
});
this.#resetSourcesObserver.observe(this, {childList: true});
}
#getSourceAttributeChangeObserver = () => {
if (!this.#sourceAttributeChangeObserver) {
this.#sourceAttributeChangeObserver = new MutationObserver(this.#evaluateAttributes);
}
return this.#sourceAttributeChangeObserver;
}
#setSourceListenersAndObservers = () => {
Array.from(this.querySelectorAll(':scope > source')).forEach(sourceElement => {
// if the source element changes its attributes, reevalute attributes
this.#getSourceAttributeChangeObserver().observe(sourceElement, {attributes: true, attributeFilter: ['media', 'attr', 'value']});
// if the source element's generated media query dispatches a change event, reevaluate attributes
const mq = window.matchMedia(sourceElement.getAttribute('media'));
mq.addEventListener('change', this.#evaluateAttributes);
this.#attributeMediaQueries.push(mq);
});
}
#destroySourceListenersAndObservers = () => {
this.#getSourceAttributeChangeObserver().disconnect();
let mq = this.#attributeMediaQueries.pop();
while (mq) {
mq.removeEventListener('change', this.#evaluateAttributes);
mq = this.#attributeMediaQueries.pop();
}
}
disconnectedCallback() {
this.#destroySourceListenersAndObservers();
this.#destroyExistenceMediaQuery();
}
#destroyExistenceMediaQuery = () => {
if (this.#existenceMediaQuery) {
this.#existenceMediaQuery.removeEventListener('change', this.#evaluateExistenceMediaQuery);
}
}
#createExistenceMediaQuery = (media) => {
this.#existenceMediaQuery = window.matchMedia(media);
this.#existenceMediaQuery.addEventListener('change', this.#evaluateExistenceMediaQuery);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'media') {
// remove old listener if it exists
this.#destroyExistenceMediaQuery();
// create the new media query listener
this.#createExistenceMediaQuery(newValue);
// if setting for the first time, copy current children and toggle them
if (oldValue == null) {
this.#copyChildren();
this.#evaluateExistenceMediaQuery();
}
}
}
}
customElements.define("only-when", OnlyWhen);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment