formatchange
Version:
Utility that monitors named CSS media-query breakpoints and triggers event callbacks when a media-format change occurs.
441 lines (334 loc) • 13.6 kB
Markdown
# FormatChange
**... Smart window resize events for sites with responsive UI.**
FormatChange makes it trivially easy to tie tailor-made scripting effects to (CSS defined) **named** query breakpoints – and respond intelligently when users tilt their phones and tablets, or resize their browser windows.
You can subscribe/unsubscribe to notifications (custom event firing) whenever
it detects that a new (named) CSS query breakpoint has become active.
<!-- prettier-ignore-start -->
- [HOWTO / API](#howto--api)
- [0: Install](#0-install)
- [1: Name your CSS breakpoints](#1-name-your-css-breakpoints)
- [2: Configure and initialize FormatChange](#2-configure-and-initialize-formatchange)
- [3: Getting the current media format](#3-getting-the-current-media-format)
- [4: Subscribe to formatchange events.](#4-subscribe-to-formatchange-events)
- [5: Start, stop, refresh!](#5-start-stop-refresh)
- [Helpers](#helpers)
- [React `makeFormatMonitorHook`](#react-makeformatmonitorhook)
- [`makeGroups` Helper](#makegroups-helper)
- [jQuery Plugin](#jquery-plugin)
- [React `withMediaProps` HOC](#react-withmediaprops-hoc)
<!-- prettier-ignore-end -->
## HOWTO / API
### 0: Install
```sh
yarn add formatchange
# or...
npm install formatchange
```
### 1: Name your CSS breakpoints
FormatChange Monitors changes in a hidden Element's `::after { content: '' }` value, as defined in your page's CSS code.
So, first off, give a single name to each query breakpoint (format) you want your script to respond to.
```css
screen {
#mediaformat::after {
content: "phone";
}
}
screen and (min-width: 500px) {
#mediaformat::after {
content: "phablet";
}
}
screen and (min-width: 700px) {
#mediaformat::after {
content: "tablet";
}
}
screen and (min-width: 950px) {
#mediaformat::after {
content: "netbook";
}
}
screen and (min-width: 1350px) {
#mediaformat::after {
content: "widescreen";
}
}
```
### 2: Configure and initialize FormatChange
FormatChange is a constructor, but is very understanding about being called as a normal function.
```js
import { FormatChange } from "formatchange";
var formatMonitor = new FormatChange();
```
The constructor accepts two optional Object arguments: `formatGroups` and `options`.
`formatGroups` allows you to optionally split the formats defined by your CSS into named groups – which can be convenient when handling format-change events (more on that below).
```js
var formatGroups = {
Small: { phone: 1, phablet: 1 },
Large: { tablet: 1, netbook: 1, widescreen: 1 },
};
```
The available `options` are as follows (showing default values):
```js
var options = {
// Optionally supply a pre-existing element to query
elm: null,
// DOM id of the element to query. (Used if `elm` is missing)
elmId: "mediaformat",
// Tag-name used when auto-generating an element to query
elmTagName: "del",
// Set to `true` if you want to `.start()` manually
defer: false,
// A custom `window` object/scope to monitor.
win: window,
// Set to `true` to disable `window.onresize` evend binding
// and run `.check()` manually
manual: false,
};
```
Then this:
```js
var formatMonitor = new FormatChange(formatGroups, options);
```
**NOTE:** All option and formatGroups defaults can be changed via `FormatChange.prototype.*`
### 3: Getting the current media format
As soon as FormatChange starts monitoring the viewport (on instantiation by default, or on `.start()` if the `defer` option is used) it writes information about the current media format into `formatMonitor.media`.
```js
var media = formatMonitor.media;
```
`media.is` – contains the name of the current media format. E.g. 'tablet', 'phone' or 'widescreen', etc.
`media.was` – starts out `undefined` but once a format change is detected it contains the name of the last media format.
If you have defined any `formatGroups` (as per example above) you'll also be provided with a set of dynamically defined boolean flags indicating if `media.is` is part of that group.
So, using the above example settings and CSS, a 768px wide viewport would result in a `media` object with these initial property values:
```js
media.is === 'tablet',
media.was === undefined,
// for the 'Small' group:
media.isSmall === false,
media.wasSmall === false,
media.becameSmall === false,
media.leftSmall === false,
// for the 'Large' group:
media.isLarge === true,
media.wasLarge === false,
media.becameLarge === true,
media.leftLarge === false,
```
Then if the user resizes the viewport width down to 360px, the `media` properties change to this:
```js
media.is === 'phone',
media.was === 'tablet',
// for the 'Small' group:
media.isSmall === true,
media.wasSmall === false,
media.becameSmall === true,
media.leftSmall === false,
// for the 'Large' group:
media.isLarge === false,
media.wasLarge === true,
media.becameLarge === false,
media.leftLarge === true,
```
A second reszie, now to 550px wide viewport, results in this:
```js
media.is === 'phablet',
media.was === 'phone',
// for the 'Small' group:
media.isSmall === true,
media.wasSmall === true,
media.becameSmall === false,
media.leftSmall === false,
// for the 'Large' group:
media.isLarge === false,
media.wasLarge === false,
media.becameLarge === false,
media.leftLarge === false,
```
If we now decide to add a new format group "Funky", the appropriate boolean flags for that group `.(is|was|became|left)Funky` are created (either next time a format change is detected or once `.refresh()` has been called), like so:
```js
formatMonitor.formatGroups.Funky = { phone: 1, tablet: 1, widescreen: 1 };
formatMonitor.refresh();
// formatMonitor.refresh(true); // to force-trigger a "formatchange" event.
alert(media.is); // --> "phablet"
alert(media.was); // --> "phone"
alert(media.isFunky); // --> false
alert(media.wasFunky); // --> true
alert(media.becameFunky); // --> false
alert(media.leftFunky); // --> true
```
### 4: Subscribe to formatchange events.
Whenever FormatChange detects a new `format` it runs any callbacks that have `.subscribe()`d to be notified, passing them a reference to the `formatMonitor.media` object.
```js
formatMonitor.subscribe(myEventCallback);
function myEventCallback(media) {
// media === formatMonitor.media
if (media.is === "phone") {
// init mobile menu
}
if (media.was === "tablet") {
// tear down tablet UI
}
}
```
Each callback is immediately run upon subscription if `formatMonitor.isRunning() === true` – so no separate "initialization" is required.
If the callback should not be run immediately, then pass `false` as a second parameter to `.subscribe()` – like so: `formatMonitor.subscribe( myEventCallback, false )`
Subscriptions can be cancelled any time:
```js
formatMonitor.unsubscribe(myEventCallback);
```
### 5: Start, stop, refresh!
`formatMonitor.isRunning()` tells you if the `window.onresize` monitoring is active or not. If your monitor is set to `manual`, it simply tells you if it has been started.
Call `formatMonitor.stop()` any time to stop monitoring.
This does NOT unbind any subscribed "formatchange" event callbacks – only stops the onResize CSS-polling and triggering of events
`formatMonitor.start()` Binds the `window.onresize` event handler to poll the CSS and trigger event callbacks.
This method is called internally when a `FormatChange` instance is created – unless the `defer` option is passed.
Starting and stopping does not delete or reset the `media` object. This means that restarting (i.e. `.start()` after a `.stop()`) will not re-trigger a 'formatchange' event – unless the window size (or CSS) changed in the meantime – or if if a "hard-refresh" argument is passed (i.e. `.start(true)`).
`formatMonitor.check()` quickly queries if the format has changed and triggers "formatchange" event if needed. This is the method to use with the `manual` option.
`formatMonitor.refresh()` refreshes the `media` object and triggers "formatchange" event when appropriate – unless a "hard-refresh" boolean argument is passed (i.e. `.refresh(true)`).
## Helpers
FormatChange comes with a few helpers.
### React `makeFormatMonitorHook`
A factory function that generates a react hook that is bound to a specific `FormatChange` monitor instance.
```js
import { FormatChange } from "formatchange";
import { makeFormatMonitorHook } from "formatchange/react";
var formatMonitor = new FormatChange(/* groups, options */);
export const useFormatMonitor = makeFormatMonitorHook(formatMonitor);
// elsewhere off in some React component file
export const MyComponent = (props) => {
const [isPhone, setPhoneFormat] = React.useState(false);
useFormatMonitor((media) => {
setPhoneFormat(media.is === "phone");
});
return <div>Phone format: {String(isPhone)}</div>;
};
```
The generated hook returns `FormatChange` instance's `media` object, in case you want to use it directly.
(NOTE: The object may or may not be initialized yet.)
You can also pass a getter callback which returns the `FormatChange` instance.
This may be the preferred signature for JS libraries that want to provide side-effect free `imort`s.
```js
let _formatMonitor;
export const useFormatMonitor = makeFormatMonitorHook(() => {
if (!_formatMonitor) {
_formatMonitor = new FormatChange(/* groups, options */);
}
return _formatMonitor;
});
```
### `makeGroups` Helper
This opinionated/esoteric helper takes a normalized config object and creates a `formatGroup` object that fits into the FormatChange constructor.
This can be useful when your media-format config is stored in a .json file that is then read and interpreted by multiple sources.
Example use:
```js
import { makeGroups } from "formatchange/makeGroups";
const mediaFormats = {
desktop: { minW: 900, group: "Large" },
tablet: { minW: 700, maxW: 900, group: ["Large", "Handheld"] },
phone: { maxW: 480, group: ["Small", "Handheld"] },
// Formats without `group` are ignored by `makeGroups`
tablet_up: { minW: 700 },
phone_tablet: { maxW: 900 },
};
const groupConfig = makeGroups(mediaFormats);
console.log(groupConfig);
/*
{
Small: { phone: true },
Large: { tablet: true, desktop: true },
Handheld: { tablet: true, phone: true },
}
*/
const myFormatMonitor = new FormatChange(groupConfig);
```
### jQuery Plugin
FormatChange provides a convenient jQuery plugin.
```js
import { jQueryPlugin } from "formatchange/jquery";
jQueryPlugin();
```
This adds a `jQuery.formatChange()` utility method, that generates and returns `new FormatChange()` instances, and allows you to bind `formatchange` events handlers using jQuery's `.on` and `.off` methods. Like so:
```js
// initialize/instantiate FormatChange
var formatMonitor = $.formatChange(formatGroups, options);
$(window).on("formatchange", function (e, media) {
// media === formatMonitor.media
if (media.is === "phone") {
// init mobile menu
}
if (media.was === "tablet") {
// tear down tablet UI
}
});
```
(Note: Event handlers are auto-triggered upon binding – if `formatMonitor.isRunning() === true` – so no separate "initialization" is required. The auto-triggering occurs after a `setTimeout` of 0 ms, if the handler hasn't been triggerd manually in the meantime.)
`jQuery.formatChange()` accepts the same arguments as the `FormatChange` constructor.
In addition it accepts an `eventName` option – which in turn results in the creation of a separate `FormatChange` instance with its own hidden element, its own CSS breakpoint names and `formatGroups`, etc...
```css
#aspectformat::after {
content: "default";
}
screen and (max-width: 500px) and (min-height: 800px) {
#aspectformat::after {
content: "portrait";
}
}
screen and (min-width: 800px) and (max-height: 500px) {
#aspectformat::after {
content: "landscape";
}
}
```
```js
var aspectMonitor = $.formatChange(null, {
elmId: "aspectformat",
eventName: "aspectchange",
});
$(window).on("aspectchange", function (e, aMedia) {
// aMedia === aspectMonitor.media;
if (aMedia.is === "portrait") {
// do stuff...
} else if (aMedia.was === "default") {
// do stuff...
}
});
```
You can also pass custom jQuery instances and/or custom default event name to `jQueryPlugin()`:
```js
const myJQuery = jQuery.noConflict();
jQueryPlugin(myJQuery, "myDefaultEventName");
var formatMonitor = myJQuery.formatChange();
myJQuery(window).on("myDefaultEventName", function (e, media) {
// ...
});
```
### React `withMediaProps` HOC
Learn by example:
```js
import { FormatChange } from "formatchange";
import { withMediaProps } from "formatchange/react";
const myMonitor = new FormatChange();
// Mapper function that returns an object with media-related
// props to be spread on the wrapped component
const media2Props = (media) => ({
isSmall: media.is === "phone" || media.is === "phablet",
});
// With static class-method
class Foo extends React.Component {
static getPropsFromMedia(media) {
return media2Props(media);
}
/* ... */
}
const MonitoredFoo = withMediaProps(Foo, myMonitor);
// With mapper as a third HOC parameter
class Bar extends React.Component {
/* ... */
}
const MonitoredBar = withMediaProps(Bar, myMonitor, media2Props);
// With dumb default mapper `(media) => media`
class Baz extends React.Component {
/* ... */
}
const MonitoredBaz = withMediaProps(Baz, myMonitor);
```