Last active
March 4, 2024 19:39
-
-
Save NickGard/1c28c28921732a816760dce326f94fe9 to your computer and use it in GitHub Desktop.
This custom html element brings Media Query powers to markup.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* # 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