Skip to content

Instantly share code, notes, and snippets.

@enwin
Last active January 7, 2023 00:56
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 enwin/589ac5ee48b733f4bcd83af99fd9e5c4 to your computer and use it in GitHub Desktop.
Save enwin/589ac5ee48b733f4bcd83af99fd9e5c4 to your computer and use it in GitHub Desktop.
Class to watch UI changes: width, height, scrollY, breakpoint based on the passed layout, page load, inputType
export default [
['small', `(min-width: ${576 / 16}rem)`],
['medium', `(min-width: ${768 / 16}rem)`],
['large', `(min-width: ${992 / 16}rem)`],
['xlarge', `(min-width: ${1200 / 16}rem)`],
['2xlarge', `(min-width: ${1400 / 16}rem)`],
]
class UI {
constructor(layouts) {
this.properties = {
breakpoint: '',
height: undefined,
inputType: '',
loaded: false,
scrollY: 0,
width: undefined,
};
this.layouts = layouts;
this.tick = {};
this.observers = {};
// listen to browser resize
window.addEventListener('resize', () => {
this.resized();
});
// listen to page scroll
window.addEventListener(
'scroll',
() => {
this.scrolled();
},
{
passive: true,
}
);
// listen to pointer to detect current use of the page
if (window.PointerEvent) {
window.addEventListener(
'pointerdown',
(event) => {
this.detectInputType(event);
},
{
passive: true,
}
);
window.addEventListener(
'pointermove',
(event) => {
this.detectInputType(event);
},
{
passive: true,
}
);
// fallback for old browsers
} else {
window.addEventListener(
'mousemove',
(event) => {
this.detectInputTypeFallback(event);
},
{
passive: true,
}
);
window.addEventListener(
'touchstart',
(event) => {
this.detectInputTypeFallback(event);
},
{
passive: true,
}
);
}
// listen to keyboard to detect current use of the page
window.addEventListener(
'keydown',
(event) => {
this.handleKeys(event);
},
{
passive: true,
}
);
// listen to page load event
if (document.readyState === 'complete') {
this.handleLoad();
} else {
window.addEventListener(
'load',
() => {
this.handleLoad();
},
{ once: true }
);
}
// register breakpoints
this.breakpoints = this.layouts
.map(([name, mqString]) => {
const mqBreakpoint = window.matchMedia(mqString);
mqBreakpoint.name = name;
if ('addEventListener' in mqBreakpoint) {
mqBreakpoint.addEventListener('change', () => this.matched());
} else {
// old listener syntax
mqBreakpoint.addListener(() => this.matched());
}
return mqBreakpoint;
})
.reverse();
// trigger the callbacks on setup
this.matched();
this.resized();
this.scrolled();
}
detectInputType(event) {
this.inputType = event.pointerType;
}
detectInputTypeFallback(event) {
this.inputType = event.type === 'mousemove' ? 'mouse' : 'touch';
}
handleKeys() {
this.inputType = 'keyboard';
}
handleLoad() {
this.loaded = true;
}
/**
* loop over the breakpoints to get which one matches or fallback to the 'root' breakpoint
*/
matched() {
const matchingMql = this.breakpoints.find((mql) => mql.matches);
const name = matchingMql ? matchingMql.name : 'root';
this.breakpoint = name;
}
/**
* method to listen for property change
* @param {string} property width | height | scrollY | breakpoint | inputType | loaded
* @param {function} callback function to call when the property changes
* @param {boolean} immediate call the callback with the current value of the property
* @returns function to call to stop listening to the property change
*/
observe(property, callback, immediate = false) {
// skip unobservable properties
if (!this.properties[property]) {
return;
}
// create the property observer list if it doesnt exist
if (!this.observers[property]) {
this.observers[property] = [];
}
// add the callback to the propery observer list
const length = this.observers[property].push(callback);
// create the function to use to stop listening to the property
const unobserver = () => {
this.observers[property].splice(length - 1, 1);
};
// trigger the callback with the current value
if (immediate && this.properties[property]) {
callback(this.properties[property]);
}
return unobserver;
}
resized() {
this.height = window.innerHeight;
this.width = window.innerWidth;
}
scrolled() {
this.scrollY = window.scrollY;
}
/**
* trigger the callbacks for a given property
* @param {string} property property to trigger
* @param {string|number} value value to send to the callback
*/
signal(property, value) {
// skip unobserved properties
if (!this.observers[property] || !this.observers[property].length) {
return;
}
// only trigger one callback per animation frame
if (this.tick[property]) {
window.cancelAnimationFrame(this.tick[property]);
}
// request the next frame to trigger the callbacks
this.tick[property] = window.requestAnimationFrame(() => {
if (this.observers[property]) {
this.observers[property].forEach((callback) => callback(value));
}
this.tick[property] = null;
});
}
set breakpoint(value) {
if (value === this.properties.breakpoint) {
return;
}
this.properties.breakpoint = value;
this.signal('breakpoint', value);
}
get breakpoint() {
return this.properties.breakpoint;
}
set height(value) {
if (value === this.properties.height) {
return;
}
this.properties.height = value;
this.signal('height', value);
}
get height() {
return this.properties.height;
}
set loaded(value) {
if (value === this.properties.loaded) {
return;
}
this.properties.loaded = value;
this.signal('loaded', value);
}
get loaded() {
return this.properties.loaded;
}
set inputType(value) {
if (value === this.properties.inputType) {
return;
}
this.properties.inputType = value;
// set the custom data-input attribute so CSS can use it
document.documentElement.setAttribute('data-input', value);
this.signal('inputType', value);
}
get inputType() {
return this.properties.inputType;
}
set scrollY(value) {
if (value === this.properties.scrollY) {
return;
}
this.properties.scrollY = value;
this.signal('scrollY', value);
}
get scrollY() {
return this.properties.scrollY;
}
set width(value) {
if (value === this.properties.width) {
return;
}
this.properties.width = value;
this.signal('width', value);
}
get width() {
return this.properties.width;
}
}
export default UI;
import UI from './ui.js';
import layouts from './layouts.js';
export default new UI(layouts);
@enwin
Copy link
Author

enwin commented Jan 7, 2023

Now any script can do:

import UI from './watcher.js';

// listen to width change
const unobserveWidth = UI.observe('width', function(width){
  console.log('width changed', width);
});

// stop listening
unobserveWidth();

// listen to breakpoint change and call callback with current value
UI.observe('breakpoint', function(layout){
  console.log('layout is', layout);
}, true);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment