UNPKG

joicomponents

Version:

Design patterns to build native web components

178 lines (146 loc) 6.43 kB
# Pattern: MediaQueryAttribute The MediaQueryAttribute is a pattern for controlling the layout and behavior of a web component depending on the viewport size. CSS Media queries is a method to coordinate the setting of an element's: 1. inner style (CSS rules in the shadowDOM), 2. shadowDOM structure (if changes of style and layout cannot be accomplished with CSS alone), 3. inner event listeners (different states of the element require different reactions to events), 4. and outer styles of the host element, its children and/or its descendant elements in the lightDOM. ## Implementation A web component that implements the MediaQueryAttribute pattern consists of: * `size-settings`: a JsonAttribute describing the different thresholds for the media queries. The `size-settings` is given on the form `{"XS": 0, "S": 600, "M": 800, "L": 1024}`. The MediaQueryAttribute has a static, default settings object to fall back on. * `_size`: a StubbornAttribute that reflects the current state of the web component. Every time the viewport passes one of the thresholds specified in `size-settings`, the web component updates the `_size` attribute. * To observe the viewport, the pattern uses the `MediaQueryList.addListener` interface. Every time the `size-settings` changes, the web component updates its list of MediaQuery listeners. Every time the MediaQuery listener is triggered, the web component updates its `_size` attribute. <code-demo src="demo/SmallMediumLarge.html"></code-demo> ```html <script> function postConstructionCallback(cb) { if (document.readyState === "loading") { window.addEventListener("DOMContentLoaded", cb); } else { Promise.resolve().then(function () { Promise.resolve().then(cb) }); } } const defaultSizes = { S: 0, M: 400, L: 800 }; class SmallMediumLarge extends HTMLElement { constructor() { super(); /*media query listener*/ this.__size = undefined; this._sizeSettings = undefined; this._viewportObserver = this.viewportChanged.bind(this); this._mql = undefined; postConstructionCallback(function () { if (!this._mql) //only triggers at startup if no size-setting attribute was given at startup this.setUpMQL(); }.bind(this)); /*event listener*/ this._onScrollListener = this.onScroll.bind(this); } static get observedAttributes() { return ["_size", "size-settings"]; } attributeChangedCallback(name, oldValue, newValue) { if (name === "_size" && newValue !== this.__size) this.setAttribute("_size", this.__size); if (name === "size-settings") this.setUpMQL(); } setUpMQL() { //remove old mql listeners (if any) if (this._mql) { for (let mq of this._mql) mq.removeListener(this._viewportObserver); } //add new mql listeners this._mql = []; const settings = this.getAttribute("size-settings"); this._sizeSettings = settings ? JSON.parse(settings) : defaultSizes; for (let value of Object.values(this._sizeSettings)) { let mq = window.matchMedia('(max-width: ' + value + 'px)'); mq.addListener(this._viewportObserver); this._mql.push(mq); } this.viewportChanged(); } viewportChanged() { const sorted = Object .entries(this._sizeSettings) .sort(([aKey, aValue], [bKey, bValue]) => aValue <= bValue) .filter(([key, value]) => value <= window.innerWidth); if (!sorted.length) { this.__size = null; this.removeAttribute("_size"); } else { this.__size = sorted[sorted.length - 1][0]; this.setAttribute("_size", this.__size); if (this.__size === "S") window.addEventListener("scroll", this._onScrollListener); else window.removeEventListener("scroll", this._onScrollListener); } } onScroll() { console.log(this.id, "only observes scroll when its _size is small"); } } customElements.define("small-medium-large", SmallMediumLarge); </script> <style> small-medium-large { display: block; height: 60%; } small-medium-large[_size="S"] { border-bottom: 5px solid red; } small-medium-large[_size="M"] { border-right: 5px solid orange; } small-medium-large[_size="L"] { border-top: 5px solid green; } </style> <small-medium-large id="one"> default sizes </small-medium-large> <small-medium-large id="two" size-settings='{"S": 0, "M": 200, "L": 400}'> custom sizes </small-medium-large> <i> Att!! If you resize up and down often, you will see that sometimes the MediaQuery listener callback is not triggered (tested in Chrome). </i> ``` ## Problems with the MediaQuery interface Sometimes, MediaQuery listeners do **not** trigger (in Chrome). This causes the component to miss its reaction. To fix this problem, the web component can add a `setInterval` poll calling `viewportChanged()`. This will catch any MediaQuery listeners callbacks that has fallen through the MediaQuery subsystem cracks, although with a delay (on average half the time of the `setInterval`). This is not a perfect solution neither. Adding a `setInterval` listener will draw resources from the app *at all times*. Instead, it is better to use the `resize` events instead. And skip MediaQuery listener. The resize event is safer, it should not intermittently fall through cracks like MediaQuery listeners. And therefore *not* require a `setInterval` listener. The drawback of using the `resize` events is that it will be dispatched *for all* changes of size. This means that *during* window `resize` activity, the `viewportChanged()` JS function will run much more frequently. However, the viewport rarely changes. In most app instances it never changes at all. `resize` is a rare event, and when it occurs, it has and can take all the attention of the user and the app. Thus, *adding a lot more work when the viewport changes* is better than *adding very litte work when the viewport remains still*. In the next chapter [Pattern: ResizeAttribute](2b_Pattern_ResizeAttribute) we describe how this is done. ## References * [Stackoverflow: viewport dimensions](https://stackoverflow.com/questions/1248081/get-the-browser-viewport-dimensions-with-javascript#answer-8876069)