-
Notifications
You must be signed in to change notification settings - Fork 5
Anatomy of a behavior
The basic structure of your behaviors are as follows:
<div data-behavior="MyBehavior">
...
</div>
If the behavior module is not already loaded, then then an attempt to import
the module will be made and then the behavior will have its init
run.
import { createBehavior } from '@area17/a17-behaviors';
const MyBehavior = createBehavior(
'MyBehavior',
{
// behavior methods
},
{
// lifecycle methods
init() {
},
enabled() {
},
resized() {
},
mediaQueryUpdated() {
},
intersectionIn() {
},
intersectionOut() {
},
disabled() {
},
destroy() {
},
}
);
export default MyBehavior;
createBehavior
expects 3 parameters, all of them are required:
-
string
- the name of the behavior -
object
- behaviormethods
, can be empty -
object
- behaviorlifecycle
, can be empty
All lifecycle methods are optional, although if you fail to specify an init
the behavior won't be initialised.
init
enabled
resized
mediaQueryUpdated
disabled
intersectionIn
intersectionOut
destroy
manageBehaviors
will run the lifecycle method init
when the node is in the DOM.
If there is a media query for running specified and the current media query qualifies the function to run, or if there is no media query for running set - then after init
the enabled
function runs. enabled
is optional, if you don't need media query switching then you don't need to include this method.
There are optional helper methods, resized
and mediaQueryUpdated
which give you quick hooks to these global events. If you don't need them, don't include them.
disabled
is the antithesis of enabled
. If you set the media option then enabled
runs if the current media query passes and disabled
runs when it doesn't.
And destroy
is the antithesis of init
- this runs when the node is removed from the DOM. Any behavior will be automatically wiped on destroy
.
intersectionIn
and intersectionOut
run when the element comes in and out of the viewport, with a background IntersectionObserver
. Access to the IntersectionObserver
options is via a named option:
import { createBehavior } from '@area17/a17-behaviors';
const MyBehavior = createBehavior(
'MyBehavior',
{
// behavior methods
},
{
// lifecycle methods
init() {
this.options.intersectionOptions = {
rootMargin: '20%',
}
}
}
);
Take the following example:
<div class="mybehavior" data-mybehavior-limit="1312">
<button data-mybehavior-btn>Click me</button>
<ul>
<li data-mybehavior-item>foo</li>
<li data-mybehavior-item>bar</li>
<li data-mybehavior-item>baz</li>
</ul>
</div>
import { createBehavior } from '@area17/a17-behaviors';
const MyBehavior = createBehavior(
'MyBehavior',
{
handleBtnClick(e) {
e.preventDefault();
this.clicked = true;
alert(this.options.limit);
// `this.options` is automatically parsed as an object, based on what is declared in the HTML
// { myoption: "true" } Note: there is no type coercion, so 'true' will be a string not a boolean!
},
},
{
init() {
// this.$node - the DOM node this behavior is attached to
this.clicked = false; // `this.clicked` is an internal variable, it will be available in all methods and lifecycle functions
this.$btn = this.getChild('btn'); // set `this.$btn` to be the first DOM node inside `this.$node` with the data attribute `[data-mybehavior-btn]`
// `this.$btn` is another internal variable, the use of `$` here isn't special it is just to denote a DOM node,
this.$btn.addEventListener('click', this.handleBtnClick); // attach the `handleBtnClick` method as a click handler for `this.$btn`
},
destroy() {
// remove any listeners, intervals etc.
this.$btn.removeEventListener('click', this.handleBtnClick); // clean up
// this.btn is destroyed automatically
},
}
);
export default MyBehavior;
Methods and variables you can use within your lifecycle
functions and behavior methods
are:
this
- is auto-binded to the behavior instance, so for this example this
would be MyBehavior
this.$node
- the node that the data-behavior is attached to, the container node
this.options
- an object of behavior options read from the container node. With the example above it would be { limit: "1312" }
. Note there is no type coercion, everything is passed as a string and so you may need to convert numbers, boolean and JSON to your desired format. Options are scoped to the behavior name, so in this example data-mybehavior-limit="1312"
this.isBreakpoint(breakpoint)
- where breakpoint
is a breakpoint, eg. xlarge
or sm
- compares the given breakpoint with the currently active CSS breakpoint. You can pass modifiers +
and -
to make let isMediumPlus = this.isBreakpoint('md+');
.
Selecting elements within your container.
There are two methods for creating NodeList
's of items related to your behavior:
this.getChild(name, context)
this.getChildren(name, context)
Where name
is the behavior scoped name of the target element - this.getChild('btn')
will look for a node with a data attribute of data-mybehavior-btn
inside of the behavior's container element (essentially it performs this.$node.querySelectorAll(['data-mybehavior-btn'])
).
And context
will extend the search outside of the behavior's container element to the context provided - this.getChild('btn', document)
will look for a node with a data attribute of data-mybehavior-btn
inside of document (essentially it performs document.querySelectorAll(['data-mybehavior-btn'])
).
The main difference between them is that this.getChild()
looks for and returns a single DOMObject
and this.getChildren()
returns a NodeList
.
this.$btn = this.getChild('btn'); // makes a collection using behavior's `this.getChildren('btn')`
// or
this.$btns = this.getChildren('btn'); // makes a collection using behavior's `this.getChildren('btn')`
From 0.3.0
onwards, you can also make behavior collections of any DOM nodes:
this.$btn = this.getChild(document.querySelector('button')) // makes a collection based on a single DOM node
// or
this.$btns = this.getChild(document.querySelectorAll('button')) // makes a collection based on a single DOM node
//
this.$window = this.getChild(window) // makes a collection based on `window`
//
this.$documentElement = this.getChild(document.documentElement) // makes a collection based on `document.documentElement`
Why would you want to do this?
Behavior collections append some extra methods to the returned items to add and remove event listeners with an internal AbortController
signal.
0.3.0+
A common thing we see in behaviors is lots of this.$foo.removeEventListener('bar', this.baz);
in a behavior destroy() method.
Which is the intended use case, but, its easy to forget one and they're a pain to maintain - its a chore.
Another common thing we see in behaviors are utility methods to add event listeners to NodeList
of items, which again is a chore.
And so to make this easier for developers, behavior collection nodes have two additional methods:
.on()
.off()
For adding and removing event listeners to collections, which will automatically be removed when the behavior is destroyed (if the container node is removed from the page).
Using on()
.
this.$btns.on('click', this.handleClick); // adds a click listener to all items in the collection with function `this.handleClick`
// or
this.$btns.on('click', () => {
console.log('hello world'); // adds a click listener to all items in the collection with anonymous function
});
// can also pass options
this.$btns.on('click', this.handleClick, { passive: true }); // adds a click listener to all items in the collection with function `this.handleClick` and `passive: true` option
// or select single items and add
this.$btns[0].on('click', this.handleClick); // adds a click listener to the first item in the collection with function `this.handleClick`
Each of these will be automatically cleaned up on behavior destroy()
.
You could also:
window.addEventListener('resize', () => {
console.log('resize');
}, {
signal: this.__abortController.signal
});
And this would also be cleaned up on behavior destroy()
.
Using off()
- maybe you want to manually clear some event listeners in one of your methods:
this.$btns.off('click', this.handleClick); // removes all `click` listeners with the function `this.handleClick`
this.$btns.off('click'); // removes all `click` listeners, regardless of their associated functions (so you can clear anonymous functions)
this.$btns.off(); // removes all event listeners, regardless of their type and associated function (so you can clear anonymous functions)
this.$btns[0].off('click', this.handleClick); // removes the listener from just the first element
If you select an element that was previously in a selection, it will have on and off methods:
this.$btns = this.getChildren('btn');
console.log(typeof this.$node.querySelector('button').on); // function
Which means you can do:
this.$btns.on('click', (event) => {
event.currentTarget.off('click'); // will remove all 'click' listeners from the clicked button
});
Listeners won't be added twice:
this.$btns.on('click', this.handleClick);
this.$btns.on('click', this.handleClick); // won't add the handler twice
Take this example behavior:
import { createBehavior } from '@area17/a17-behaviors';
const MyBehavior = createBehavior(
'MyBehavior',
{
action1(e) {
...
},
action2(e) {
...
},
action3(e) {
...
},
action4(e) {
...
},
},
{
init() {
this.$action1 = this.getChild('action1');
this.$action2 = this.getChild('action2');
this.$action3 = this.getChild('action3');
this.$action4 = this.getChild('action4');
// + many more
this.$action1.addEventListener('click', this.action1);
this.$action2.addEventListener('click', this.action2);
this.$action3.addEventListener('click', this.action3);
this.$action4.addEventListener('click', this.action4);
// + many more
},
destroy() {
this.$action1.removeEventListener('click', this.action1);
this.$action2.removeEventListener('click', this.action2);
this.$action3.removeEventListener('click', this.action3);
this.$action4.removeEventListener('click', this.action4);
// + many more
},
}
);
export default MyBehavior;
Using on
- could be simplified to:
import { createBehavior } from '@area17/a17-behaviors';
const MyBehavior = createBehavior(
'MyBehavior',
{
action1(e) {
...
},
action2(e) {
...
},
action3(e) {
...
},
action4(e) {
...
},
},
{
init() {
this.$action1 = this.getChild('action1');
this.$action2 = this.getChild('action2');
this.$action3 = this.getChild('action3');
this.$action4 = this.getChild('action4');
// + many more
this.$action1.on('click', this.action1);
this.$action2.on('click', this.action2);
this.$action3.on('click', this.action3);
this.$action4.on('click', this.action4);
// + many more
},
destroy() {
// event listeners automatically cleared using internal AbortController
},
}
);
export default MyBehavior;
The automatic clearing uses AbortController which is mostly used to abort fetch
requests before they have completed. For this task, AbortController
has widespread browser support. But within the context of adding/removing event listeners, it doesn't have exactly the same browser support. We have tested it and seen support in:
- Safari 15.3+ (26 January 2022)
- Firefox 86+ (23 February 2021)
- Chrome 90+ (14 April 2021)
- Edge 90+ (15 April 2021 - is based on Chrome 90)
- Opera 76+ (28 April 2021 - is based on Chrome 90)
- Android browser 7+ (22 August 2016)
If you need additional browser support then you'll need a tiny polyfill to match support as shown on caniuse.
Or, if you don't want to rely on the AbortController
, remember to off()
all your listeners in the destroy()
lifecycle method.
<div class="MyBehavior" data-mybehavior-limit="1312" data-sub1-foo="bar">
<button data-mybehavior-btn>Click me</button>
<ul data-sub1-list>
<li data-mybehavior-item> </li>
<li data-mybehavior-item> </li>
<li data-mybehavior-item> </li>
</ul>
</div>
import { createBehavior } from '@area17/a17-behaviors';
import sub1 from './sub1.js';
import sub3 from './sub3.js';
const MyBehavior = createBehavior(
'MyBehavior',
{
// Behavior methods
},
{
init() {
this.$btn = this.getChild('btn'); // looks for `[data-mybehavior-btn]` element
// sub behaviors
this.addSubBehavior(sub1);
this.addSubBehavior('sub2');
this.addSubBehavior(sub3, this.$btn, { options: {
parentNode: this.$node
}});
},
destroy() {
// remove any listeners, intervals etc.
this.$btn.removeEventListener('click', this.handleBtnClick);
// this.btn is destroyed automatically
// sub behaviors are destroyed automatically
},
}
);
export default MyBehavior;
A behavior can also launch a sub behavior if required. Adding a sub behavior tells the main manageBehaviors
instance to initialise the behavior and it is then tracked as any other, which means any behavior named options and behavior named children in and on the parent node, will be accessible to the sub behaviors.
this.addSubBehavior(sub1);
- runs the imported sub1
behavior init
method. It will be able to see the foo
option set on the parent node, and find the <ul data-sub1-list>
with this.getChild('list')
.
this.addSubBehavior('sub2');
- if a behavior with name sub2
is already imported into the application, either by another behavior, another JS file or in the application.js then it will have its init
method run. If not, then a dynamic import
will attempt to load and init
the behavior.
this.addSubBehavior(sub3);
- runs the imported sub3
behavior init
method, this time passes in which node to attach the behavior to (default is same as parent) and also passes through some options.