Skip to content

Anatomy of a behavior

Mike Byrne edited this page Mar 8, 2023 · 3 revisions

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;

Parameters

createBehavior expects 3 parameters, all of them are required:

  1. string - the name of the behavior
  2. object - behavior methods, can be empty
  3. object - behavior lifecycle, can be empty

Lifecycle methods:

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%',
      }
    }
  }
);

Methods, variables and this

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+');.

Behavior collections

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.

Behavior collection methods

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.

Sub Behaviors

<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>&nbsp;</li>
    <li data-mybehavior-item>&nbsp;</li>
    <li data-mybehavior-item>&nbsp;</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.