├── .gitignore ├── logo.png ├── src ├── index.js ├── extendBehavior.js ├── createBehavior.js └── manageBehaviors.js ├── rollup.config.js ├── package.json ├── LICENSE ├── README.md ├── CODE_OF_CONDUCT.md └── dist ├── esm └── index.js └── cjs └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/area17/a17-behaviors/HEAD/logo.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { default as manageBehaviors } from './manageBehaviors' 2 | import { default as createBehavior } from './createBehavior' 3 | import { default as extendBehavior } from './extendBehavior' 4 | 5 | export { manageBehaviors, createBehavior, extendBehavior } 6 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | 4 | export default [ 5 | { 6 | input: 'src/index.js', 7 | plugins: [nodeResolve(), commonjs()], 8 | output: [ 9 | { 10 | dir: 'dist/esm', 11 | format: 'esm', 12 | exports: 'named' 13 | }, 14 | { 15 | dir: 'dist/cjs', 16 | format: 'cjs', 17 | exports: 'named' 18 | } 19 | ] 20 | } 21 | ] 22 | -------------------------------------------------------------------------------- /src/extendBehavior.js: -------------------------------------------------------------------------------- 1 | import createBehavior from './createBehavior'; 2 | 3 | /** 4 | * Extend an existing a behavior instance 5 | * @param {module} behavior - behavior you want to extend 6 | * @param {string} name - Name of the extended behavior used for declaration: data-behavior="name" 7 | * @param {object} methods - define methods of the behavior 8 | * @param {object} lifecycle - Register behavior lifecycle 9 | * @returns {Behavior} 10 | * 11 | * NB: methods or lifestyle fns with the same name will overwrite originals 12 | */ 13 | function extendBehavior(behavior, name, methods = {}, lifecycle = {}) { 14 | const newMethods = Object.assign(Object.assign({}, behavior.prototype.methods), methods); 15 | const newLifecycle = Object.assign(Object.assign({}, behavior.prototype.lifecycle), lifecycle); 16 | 17 | return createBehavior(name, newMethods, newLifecycle); 18 | } 19 | 20 | export default extendBehavior; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@area17/a17-behaviors", 3 | "version": "0.5.1", 4 | "description": "JavaScript framework to attach JavaScript events and interactions to DOM Nodes", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "rollup -c -w", 8 | "build": "rollup -c", 9 | "prepare": "npm run build" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/area17/a17-behaviors" 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "main": "dist/cjs/index.js", 19 | "module": "dist/esm/index.js", 20 | "author": "A17 (https://area17.com/)", 21 | "license": "MIT", 22 | "homepage": "https://github.com/area17/a17-behaviors#readme", 23 | "bugs": { 24 | "url": "https://github.com/area17/a17-behaviors/issues" 25 | }, 26 | "dependencies": { 27 | "@area17/a17-helpers": "^3.3.2" 28 | }, 29 | "devDependencies": { 30 | "rollup": "^4.12.0", 31 | "@rollup/plugin-node-resolve": "^15.2.3", 32 | "@rollup/plugin-commonjs": "^25.0.7" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 AREA 17 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![A17 Behaviors](logo.png?raw=true "A17 Behaviors") 2 | 3 |   4 | 5 | A17 Behaviors is a lightweight JavaScript framework designed to seamlessly attach behaviors — such as interactions, event listeners, and manipulations — to DOM nodes using declarative `data-behavior` attributes. This approach promotes modularity, code clarity, and maintainability in your front-end development. 6 | 7 | Clearly showing an element's associated behaviors enhances discoverability. Instead of searching through lengthy JavaScript files or guessing which scripts are attached to which DOM nodes, declared behaviors let you quickly identify the relevant code and streamline your development process. 8 | 9 | ## Key Features 10 | 11 | - Declarative binding via `data-behavior` 12 | - Lazy initialization with `data-behavior-lazy` when elements enter the viewport 13 | - Optional dynamic behavior loading (via Vite or Webpack) 14 | - Modular architecture that separates behavior logic 15 | - Automatic init/destroy of behaviors for dynamic DOM changes 16 | - Lifecycle events: `init`, `resized`, `enabled`, `mediaQueryUpdated`, `intersectionIn`, `intersectionOut`, `disabled` and `destroy` 17 | - Easily extend existing behaviors with `extendBehavior` 18 | - Built to be fast: written in vanilla JavaScript 19 | - Tiny filesize: 14kb minified / 4kb gzipped 20 | 21 | ## Installation 22 | 23 | Install via npm: 24 | 25 | ```shell 26 | npm install @area17/a17-behaviors 27 | ``` 28 | 29 | ## Usage Example 30 | 31 | ```html 32 | 33 | ``` 34 | 35 | With a corresponding behavior: 36 | 37 | ```JavaScript 38 | import { createBehavior } from '@area17/a17-behaviors'; 39 | 40 | const showAlert = createBehavior('showAlert', 41 | { 42 | alert(val) { 43 | window.alert('Hello world!'); 44 | } 45 | }, 46 | { 47 | init() { 48 | this.$node.addEventListener('click', this.alert); 49 | }, 50 | destroy() { 51 | this.$node.removeEventListener('click', this.alert); 52 | } 53 | } 54 | ); 55 | 56 | export default showAlert; 57 | ``` 58 | 59 | And managed from a central `application.js`: 60 | 61 | ```JavaScript 62 | import { manageBehaviors } from '@area17/a17-behaviors'; 63 | import showAlert from './behaviors/showAlert'; 64 | 65 | document.addEventListener('DOMContentLoaded', () => { 66 | manageBehaviors.init({ 67 | showAlert 68 | }); 69 | }); 70 | ``` 71 | 72 | In this example, clicking the button will trigger an alert saying "Hello world!". 73 | 74 | ## How It Works 75 | 76 | `manageBehaviors` uses `MutationObserver`, `IntersectionObserver`, and a debounced `resize` listener to track DOM changes. It detects when elements are added, removed, or enter/exit the viewport and triggers lifecycle methods on attached behaviors. 77 | 78 | `createBehavior` defines a behavior, giving you a logical structure with clearly scoped methods and lifecycle hooks tied to specific DOM nodes. 79 | 80 | `extendBehavior` lets you create variations of an existing behavior by overriding or adding properties and methods. 81 | 82 | ## Wiki 83 | 84 | See the [Wiki](https://github.com/area17/a17-behaviors/wiki) for: 85 | 86 | - Full API reference 87 | - Advanced usage (dynamic content, lazy-loading behaviors) 88 | - FAQ and troubleshooting 89 | - Best practices 90 | 91 | ## Browser Support 92 | 93 | Usage of `MutationObserver` and `IntersectionObserver` requires support of browsers from 2019 onwards. 94 | 95 | ## License 96 | 97 | MIT 98 | 99 | ## Contribution 100 | 101 | ### Code of Conduct 102 | 103 | AREA 17 is dedicated to building a welcoming, diverse, safe community. We expect everyone participating in the AREA 17 community to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it. Please follow it. 104 | 105 | ### Bug reports and features submission 106 | 107 | To submit an issue or request a feature, please do so on [Github](https://github.com/area17/a17-behaviors/issues). 108 | 109 | If you file a bug report, your issue should contain a title and a clear description of the issue. You should also include as much relevant information as possible and a code sample that demonstrates the issue. The goal of a bug report is to make it easy for yourself - and others - to replicate the bug and develop a fix. 110 | 111 | Remember, bug reports are created in the hope that others with the same problem will be able to collaborate with you on solving it. Do not expect that the bug report will automatically see any activity or that others will jump to fix it. Creating a bug report serves to help yourself and others start on the path of fixing the problem. 112 | 113 | ## Versioning scheme 114 | 115 | Our A17 Behaviors follows [Semantic Versioning](https://semver.org/). Major releases are released only when breaking changes are necessary, while minor and patch releases may be released as often as every week. Minor and patch releases should never contain breaking changes. 116 | 117 | When referencing A17 Behaviors from your application, you should always use a version constraint such as `^1.0`, since major releases of A17 Behaviors will include breaking changes. 118 | 119 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | dev@area17.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/createBehavior.js: -------------------------------------------------------------------------------- 1 | import purgeProperties from '@area17/a17-helpers/src/purgeProperties'; 2 | import isBreakpoint from '@area17/a17-helpers/src/isBreakpoint'; 3 | import manageBehaviors from './manageBehaviors'; 4 | 5 | /** 6 | * Behavior 7 | * @typedef {Object.|BehaviorDef} Behavior 8 | * @property {HTMLElement} $node - Dom node associated to the behavior 9 | * @property {string} name - Name of the behavior 10 | * @property {Object} options 11 | * @property {Lifecycle} lifecycle 12 | */ 13 | 14 | /** 15 | * Behavior lifecycle 16 | * @typedef {Object} Lifecycle 17 | * @property {BehaviorLifecycleFn} [init] - Init function called when behavior is created 18 | * @property {BehaviorLifecycleFn} [enabled] - Triggered when behavior state changed (ex: mediaquery update) 19 | * @property {BehaviorLifecycleFn} [disabled] - Triggered when behavior state changed (ex: mediaquery update) 20 | * @property {BehaviorLifecycleFn} [mediaQueryUpdated] - Triggered when mediaquery change 21 | * @property {BehaviorLifecycleFn} [intersectionIn] - Triggered when behavior is visible (enable intersection observer) 22 | * @property {BehaviorLifecycleFn} [intersectionOut] - Triggered when behavior is hidden (enable intersection observer) 23 | * @property {BehaviorLifecycleFn} [resized] - Triggered when window is resized 24 | * @property {BehaviorLifecycleFn} [destroy] - Triggered before behavior will be destroyed and removed 25 | */ 26 | 27 | /** 28 | * @typedef {function} BehaviorLifecycleFn 29 | * @this Behavior 30 | */ 31 | 32 | /** 33 | * @typedef {function} BehaviorDefFn 34 | * @this Behavior 35 | */ 36 | 37 | /** 38 | * Behavior definition 39 | * @typedef {Object.} BehaviorDef 40 | */ 41 | 42 | /** 43 | * Behavior constructor 44 | * @constructor 45 | * @param {HTMLElement} node - A DOM element 46 | * @param config - behavior options 47 | * @returns {Behavior} 48 | */ 49 | function Behavior(node, config = {}) { 50 | if (!node || !(node instanceof Element)) { 51 | throw new Error('Node argument is required'); 52 | } 53 | 54 | this.$node = this.getChild(node); 55 | this.options = Object.assign({ 56 | intersectionOptions: { 57 | rootMargin: '20%', 58 | } 59 | }, config.options || {}); 60 | 61 | this.__isEnabled = false; 62 | this.__children = config.children; 63 | this.__breakpoints = config.breakpoints; 64 | this.__abortController = new AbortController(); 65 | 66 | // Auto-bind all custom methods to "this" 67 | this.customMethodNames.forEach(methodName => { 68 | this[methodName] = this.methods[methodName].bind(this); 69 | }); 70 | 71 | this._binds = {}; 72 | this._data = new Proxy(this._binds, { 73 | set: (target, key, value) => { 74 | this.updateBinds(key, value); 75 | target[key] = value; 76 | return true; 77 | } 78 | }); 79 | 80 | this.__isIntersecting = false; 81 | this.__intersectionObserver = null; 82 | 83 | return this; 84 | } 85 | 86 | /** 87 | * 88 | * @type {Behavior} 89 | */ 90 | Behavior.prototype = Object.freeze({ 91 | updateBinds(key, value) { 92 | // TODO: cache these before hand? 93 | const targetEls = this.$node.querySelectorAll('[data-' + this.name.toLowerCase() + '-bindel*=' + key + ']'); 94 | targetEls.forEach((target) => { 95 | target.innerHTML = value; 96 | }); 97 | // TODO: cache these before hand? 98 | const targetAttrs = this.$node.querySelectorAll('[data-' + this.name.toLowerCase() + '-bindattr*="' + key + ':"]'); 99 | targetAttrs.forEach((target) => { 100 | let bindings = target.dataset[this.name.toLowerCase() + 'Bindattr']; 101 | bindings.split(',').forEach((pair) => { 102 | pair = pair.split(':'); 103 | if (pair[0] === key) { 104 | if (pair[1] === 'class') { 105 | // TODO: needs to know what the initial class was to remove it - fix? 106 | if (this._binds[key] !== value) { 107 | target.classList.remove(this._binds[key]); 108 | } 109 | if (value) { 110 | target.classList.add(value); 111 | } 112 | } else { 113 | target.setAttribute(pair[1], value); 114 | } 115 | } 116 | }); 117 | }); 118 | }, 119 | init() { 120 | // Get options from data attributes on node 121 | const regex = new RegExp('^data-' + this.name + '-(.*)', 'i'); 122 | for (let i = 0; i < this.$node.attributes.length; i++) { 123 | const attr = this.$node.attributes[i]; 124 | const matches = regex.exec(attr.nodeName); 125 | 126 | if (matches != null && matches.length >= 2) { 127 | if (this.options[matches[1]]) { 128 | console.warn( 129 | `Ignoring ${ 130 | matches[1] 131 | } option, as it already exists on the ${name} behavior. Please choose another name.` 132 | ); 133 | } 134 | this.options[matches[1]] = attr.value; 135 | } 136 | } 137 | 138 | // Behavior-specific lifecycle 139 | if (typeof this.lifecycle?.init === 'function') { 140 | this.lifecycle.init.call(this); 141 | } 142 | 143 | if (typeof this.lifecycle?.resized === 'function') { 144 | this.__resizedBind = this.__resized.bind(this); 145 | window.addEventListener('resized', this.__resizedBind); 146 | } 147 | 148 | if (typeof this.lifecycle.mediaQueryUpdated === 'function' || this.options.media) { 149 | this.__mediaQueryUpdatedBind = this.__mediaQueryUpdated.bind(this); 150 | window.addEventListener('mediaQueryUpdated', this.__mediaQueryUpdatedBind); 151 | } 152 | 153 | if (this.options.media) { 154 | this.__toggleEnabled(); 155 | } else { 156 | this.enable(); 157 | } 158 | 159 | this.__intersections(); 160 | }, 161 | destroy() { 162 | this.__abortController.abort(); 163 | 164 | if (this.__isEnabled === true) { 165 | this.disable(); 166 | } 167 | 168 | // Behavior-specific lifecycle 169 | if (typeof this.lifecycle?.destroy === 'function') { 170 | this.lifecycle.destroy.call(this); 171 | } 172 | 173 | if (typeof this.lifecycle.resized === 'function') { 174 | window.removeEventListener('resized', this.__resizedBind); 175 | } 176 | 177 | if (typeof this.lifecycle.mediaQueryUpdated === 'function' || this.options.media) { 178 | window.removeEventListener('mediaQueryUpdated', this.__mediaQueryUpdatedBind); 179 | } 180 | 181 | if (this.lifecycle.intersectionIn != null || this.lifecycle.intersectionOut != null) { 182 | this.__intersectionObserver.unobserve(this.$node); 183 | this.__intersectionObserver.disconnect(); 184 | } 185 | 186 | purgeProperties(this); 187 | }, 188 | /** 189 | * Look for a child of the behavior: data-behaviorName-childName 190 | * @param {string} childName 191 | * @param {HTMLElement} context - Define the ancestor where search begin, default is current node 192 | * @param {boolean} multi - Define usage between querySelectorAll and querySelector 193 | * @returns {HTMLElement|null} 194 | */ 195 | getChild(selector, context, multi = false) { 196 | // lets make a selection 197 | let selection; 198 | // 199 | if (this.__children != null && this.__children[selector] != null) { 200 | // if the selector matches a pre-selected set, set to that set 201 | // TODO: confirm what this is and its usage 202 | selection = this.__children[selector]; 203 | } else if (selector instanceof NodeList) { 204 | // if a node list has been passed, use it 205 | selection = selector; 206 | multi = true; 207 | } else if (selector instanceof Element || selector instanceof HTMLDocument || selector === window) { 208 | // if a single node, the document or the window is passed, set to that 209 | selection = selector; 210 | multi = false; 211 | } else { 212 | // else, lets find named children within the container 213 | if (context == null) { 214 | // set a default context of the container node 215 | context = this.$node; 216 | } 217 | // find 218 | selection = context[multi ? 'querySelectorAll' : 'querySelector']( 219 | '[data-' + this.name.toLowerCase() + '-' + selector.toLowerCase() + ']' 220 | ); 221 | } 222 | 223 | if (multi && selection?.length > 0) { 224 | // apply on/off methods to the selected DOM node list 225 | selection.on = (type, fn, opt) => { 226 | selection.forEach(el => { 227 | this.__on(el, type, fn, opt); 228 | }); 229 | }; 230 | selection.off = (type, fn) => { 231 | selection.forEach(el => { 232 | this.__off(el, type, fn); 233 | }); 234 | }; 235 | // and apply to the individual nodes within 236 | selection.forEach(el => { 237 | el.on = el.on ? el.on : (type, fn, opt) => { 238 | this.__on(el, type, fn, opt); 239 | }; 240 | el.off = el.off ? el.off : (type, fn) => { 241 | this.__off(el, type, fn); 242 | }; 243 | }); 244 | } else if(selection) { 245 | // apply on/off methods to the singular selected node 246 | selection.on = selection.on ? selection.on : (type, fn, opt) => { 247 | this.__on(selection, type, fn, opt); 248 | }; 249 | selection.off = selection.off ? selection.off : (type, fn) => { 250 | this.__off(selection, type, fn); 251 | }; 252 | } 253 | 254 | // return to variable assignment 255 | return selection; 256 | }, 257 | /** 258 | * Look for children of the behavior: data-behaviorName-childName 259 | * @param {string} childName 260 | * @param {HTMLElement} context - Define the ancestor where search begin, default is current node 261 | * @returns {HTMLElement|null} 262 | */ 263 | getChildren(childName, context) { 264 | return this.getChild(childName, context, true); 265 | }, 266 | isEnabled() { 267 | return this.__isEnabled; 268 | }, 269 | enable() { 270 | this.__isEnabled = true; 271 | if (typeof this.lifecycle.enabled === 'function') { 272 | this.lifecycle.enabled.call(this); 273 | } 274 | }, 275 | disable() { 276 | this.__isEnabled = false; 277 | if (typeof this.lifecycle.disabled === 'function') { 278 | this.lifecycle.disabled.call(this); 279 | } 280 | }, 281 | addSubBehavior(SubBehavior, node = this.$node, config = {}) { 282 | const mb = manageBehaviors; 283 | if (typeof SubBehavior === 'string') { 284 | mb.initBehavior(SubBehavior, node, config); 285 | } else { 286 | mb.add(SubBehavior); 287 | mb.initBehavior(SubBehavior.prototype.behaviorName, node, config); 288 | } 289 | }, 290 | /** 291 | * Check if breakpoint passed in param is the current one 292 | * @param {string} bp - Breakpoint to check 293 | * @returns {boolean} 294 | */ 295 | isBreakpoint(bp) { 296 | return isBreakpoint(bp, this.__breakpoints); 297 | }, 298 | __on(el, type, fn, opt) { 299 | if (typeof opt === 'boolean' && opt === true) { 300 | opt = { 301 | passive: true 302 | }; 303 | } 304 | const options = { 305 | signal: this.__abortController.signal, 306 | ...opt 307 | }; 308 | if (!el.attachedListeners) { 309 | el.attachedListeners = {}; 310 | } 311 | // check if el already has this listener 312 | let found = Object.values(el.attachedListeners).find(listener => listener.type === type && listener.fn === fn); 313 | if (!found) { 314 | el.attachedListeners[Object.values(el.attachedListeners).length] = { 315 | type: type, 316 | fn: fn, 317 | }; 318 | el.addEventListener(type, fn, options); 319 | } 320 | }, 321 | __off(el, type, fn) { 322 | if (el.attachedListeners) { 323 | Object.keys(el.attachedListeners).forEach(key => { 324 | const thisListener = el.attachedListeners[key]; 325 | if ( 326 | (!type && !fn) || // off() 327 | (type === thisListener.type && !fn) || // match type with no fn 328 | (type === thisListener.type && fn === thisListener.fn) // match both type and fn 329 | ) { 330 | delete el.attachedListeners[key]; 331 | el.removeEventListener(thisListener.type, thisListener.fn); 332 | } 333 | }); 334 | } else { 335 | el.removeEventListener(type, fn); 336 | } 337 | }, 338 | __toggleEnabled() { 339 | const isValidMQ = isBreakpoint(this.options.media, this.__breakpoints); 340 | if (isValidMQ && !this.__isEnabled) { 341 | this.enable(); 342 | } else if (!isValidMQ && this.__isEnabled) { 343 | this.disable(); 344 | } 345 | }, 346 | __mediaQueryUpdated(e) { 347 | if (typeof this.lifecycle?.mediaQueryUpdated === 'function') { 348 | this.lifecycle.mediaQueryUpdated.call(this, e); 349 | } 350 | if (this.options.media) { 351 | this.__toggleEnabled(); 352 | } 353 | }, 354 | __resized(e) { 355 | if (typeof this.lifecycle?.resized === 'function') { 356 | this.lifecycle.resized.call(this, e); 357 | } 358 | }, 359 | __intersections() { 360 | if (this.lifecycle.intersectionIn != null || this.lifecycle.intersectionOut != null) { 361 | this.__intersectionObserver = new IntersectionObserver(entries => { 362 | entries.forEach(entry => { 363 | if (entry.target === this.$node) { 364 | if (entry.isIntersecting) { 365 | if (!this.__isIntersecting && typeof this.lifecycle.intersectionIn === 'function') { 366 | this.__isIntersecting = true; 367 | this.lifecycle.intersectionIn.call(this); 368 | } 369 | } else { 370 | if (this.__isIntersecting && typeof this.lifecycle.intersectionOut === 'function') { 371 | this.__isIntersecting = false; 372 | this.lifecycle.intersectionOut.call(this); 373 | } 374 | } 375 | } 376 | }); 377 | }, this.options.intersectionOptions); 378 | this.__intersectionObserver.observe(this.$node); 379 | } 380 | } 381 | }); 382 | 383 | /** 384 | * Create a behavior instance 385 | * @param {string} name - Name of the behavior used for declaration: data-behavior="name" 386 | * @param {object} methods - define methods of the behavior 387 | * @param {object} lifecycle - Register behavior lifecycle 388 | * @returns {Behavior} 389 | */ 390 | const createBehavior = (name, methods = {}, lifecycle = {}) => { 391 | /** 392 | * 393 | * @param args 394 | */ 395 | const fn = function(...args) { 396 | Behavior.apply(this, args); 397 | }; 398 | 399 | const customMethodNames = []; 400 | 401 | const customProperties = { 402 | name: { 403 | get() { 404 | return this.behaviorName; 405 | }, 406 | }, 407 | behaviorName: { 408 | value: name, 409 | writable: true, 410 | }, 411 | lifecycle: { 412 | value: lifecycle, 413 | }, 414 | methods: { 415 | value: methods, 416 | }, 417 | customMethodNames: { 418 | value: customMethodNames, 419 | }, 420 | }; 421 | 422 | // Expose the definition properties as 'this[methodName]' 423 | const methodsKeys = Object.keys(methods); 424 | methodsKeys.forEach(key => { 425 | customMethodNames.push(key); 426 | }); 427 | 428 | fn.prototype = Object.create(Behavior.prototype, customProperties); 429 | 430 | return fn; 431 | }; 432 | 433 | export default createBehavior; 434 | -------------------------------------------------------------------------------- /src/manageBehaviors.js: -------------------------------------------------------------------------------- 1 | import resized from '@area17/a17-helpers/src/resized'; 2 | import getCurrentMediaQuery from '@area17/a17-helpers/src/getCurrentMediaQuery'; 3 | import isBreakpoint from '@area17/a17-helpers/src/isBreakpoint'; 4 | import createBehavior from './createBehavior'; 5 | 6 | let options = { 7 | dataAttr: 'behavior', 8 | lazyAttr: 'behavior-lazy', 9 | intersectionOptions: { 10 | rootMargin: '20%', 11 | }, 12 | breakpoints: ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'], 13 | dynamicBehaviors: {}, 14 | }; 15 | 16 | let loadedBehaviorNames = []; 17 | let observingBehaviors = false; 18 | const loadedBehaviors = {}; 19 | const activeBehaviors = new Map(); 20 | const behaviorsAwaitingImport = new Map(); 21 | let io; 22 | const ioEntries = new Map(); // need to keep a separate map of intersection observer entries as `io.takeRecords()` always returns an empty array, seems broken in all browsers 🤷🏻‍♂️ 23 | const intersecting = new Map(); 24 | 25 | /** 26 | * getBehaviorNames 27 | * 28 | * Data attribute names can be written in any case, 29 | * but `node.dataset` names are lowercase 30 | * with camel casing for names split by - 31 | * eg: `data-foo-bar` becomes `node.dataset.fooBar` 32 | * 33 | * @param {HTMLElement} bNode - node to grab behavior names from 34 | * @param {string} attr - name of attribute to pick 35 | * @returns {string[]} 36 | */ 37 | function getBehaviorNames(bNode, attr) { 38 | attr = attr.toLowerCase().replace(/-([a-zA-Z0-9])/ig, (match, p1) => { 39 | return p1.toUpperCase(); 40 | }); 41 | if (bNode.dataset && bNode.dataset[attr]) { 42 | return bNode.dataset[attr].split(' ').filter(bName => bName); 43 | } else { 44 | return []; 45 | } 46 | } 47 | 48 | /** 49 | * importFailed 50 | * 51 | * 52 | * Either the imported module didn't look like a behavior module 53 | * or nothing could be found to import 54 | * 55 | * @param {string }bName - name of behavior that failed to import 56 | */ 57 | function importFailed(bName) { 58 | // remove name from loaded behavior names index 59 | // maybe it'll be included via a script tag later 60 | const bNameIndex = loadedBehaviorNames.indexOf(bName); 61 | if (bNameIndex > -1) { 62 | loadedBehaviorNames.splice(bNameIndex, 1); 63 | } 64 | } 65 | 66 | /** 67 | * destroyBehavior 68 | * 69 | * 70 | * All good things must come to an end... 71 | * Ok so likely the node has been removed, possibly by 72 | * a deletion or ajax type page change 73 | * 74 | * @param {string} bName - name of behavior to destroy 75 | * @param {string} bNode - node to destroy behavior on 76 | */ 77 | function destroyBehavior(bName, bNode) { 78 | const nodeBehaviors = activeBehaviors.get(bNode); 79 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 80 | console.warn(`No behavior '${bName}' instance on:`, bNode); 81 | return; 82 | } 83 | 84 | /** 85 | * run destroy method, remove, delete 86 | * `destroy()` is an internal method of a behavior in `createBehavior`. Individual behaviors may 87 | * also have their own `destroy` methods (called by 88 | * the `createBehavior` `destroy`) 89 | */ 90 | nodeBehaviors[bName].destroy(); 91 | delete nodeBehaviors[bName]; 92 | if (Object.keys(nodeBehaviors).length === 0) { 93 | activeBehaviors.delete(bNode); 94 | } 95 | } 96 | 97 | /** 98 | * destroyBehaviors 99 | * 100 | * if a node with behaviors is removed from the DOM, 101 | * clean up to save resources 102 | * 103 | * @param {HTMLElement} rNode -node to destroy behaviors on (and inside of) 104 | */ 105 | function destroyBehaviors(rNode) { 106 | const bNodes = Array.from(activeBehaviors.keys()); 107 | bNodes.push(rNode); 108 | bNodes.forEach(bNode => { 109 | // is the active node the removed node 110 | // or does the removed node contain the active node? 111 | if (rNode === bNode || rNode.contains(bNode)) { 112 | // get behaviors on node 113 | const bNodeActiveBehaviors = activeBehaviors.get(bNode); 114 | // if some, destroy 115 | if (bNodeActiveBehaviors) { 116 | Object.keys(bNodeActiveBehaviors).forEach(bName => { 117 | destroyBehavior(bName, bNode); 118 | // stop intersection observer from watching node 119 | io.unobserve(bNode); 120 | ioEntries.delete(bNode); 121 | intersecting.delete(bNode); 122 | }); 123 | } 124 | } 125 | }); 126 | } 127 | 128 | /** 129 | * importBehavior 130 | * 131 | * Use `import` to bring in a behavior module and run it. 132 | * This runs if there is no loaded behavior of this name. 133 | * After import, the behavior is initialised on the node 134 | * 135 | * @param {string} bName - name of behavior 136 | * @param {HTMLElement} bNode - node to initialise behavior on 137 | */ 138 | function importBehavior(bName, bNode) { 139 | // first check we haven't already got this behavior module 140 | if (loadedBehaviorNames.indexOf(bName) > -1) { 141 | // if no, store a list of nodes awaiting this behavior to load 142 | const awaitingImport = behaviorsAwaitingImport.get(bName) || []; 143 | if (!awaitingImport.includes(bNode)) { 144 | awaitingImport.push(bNode); 145 | } 146 | behaviorsAwaitingImport.set(bName, awaitingImport); 147 | return; 148 | } 149 | // push to our store of loaded behaviors 150 | loadedBehaviorNames.push(bName); 151 | // import 152 | // process.env variables set in webpack/vite config 153 | // 154 | if (process.env.BUILD === 'vite') { 155 | try { 156 | /** 157 | * For BEHAVIORS_COMPONENT_PATHS usage, 158 | * we need to pass a vite import in the app.js - options.dynamicBehaviors 159 | * and then we look for the import in the globbed dynamicBehaviors 160 | * if exists, run it 161 | * 162 | * NB: using vite glob from app.js as vite glob import only allows literal strings 163 | * and does not allow variables to be passed to it 164 | * see: https://vite.dev/guide/features#glob-import 165 | */ 166 | options.dynamicBehaviors[process.env.BEHAVIORS_COMPONENT_PATHS[bName]]().then(module => { 167 | behaviorImported(bName, bNode, module); 168 | }).catch(err => { 169 | console.warn(`Behavior '${bName}' load failed - possible error with the behavior or a malformed module`); 170 | // fail, clean up 171 | importFailed(bName); 172 | }); 173 | } catch(errV1) { 174 | try { 175 | /** 176 | * Vite bundler requires a known start point for imports 177 | * Fortunately it can use a defined alias in the config 178 | * Webkit uses aliases differently and continues on to the 179 | * imports below (but may throw build warnings attempting this) 180 | */ 181 | options.dynamicBehaviors[`${process.env.BEHAVIORS_PATH}/${bName}.${process.env.BEHAVIORS_EXTENSION}`]().then(module => { 182 | behaviorImported(bName, bNode, module); 183 | }).catch(err => { 184 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Vite has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Vite config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 185 | // fail, clean up 186 | importFailed(bName); 187 | }); 188 | } catch(errV2) { 189 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Vite has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Vite config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 190 | // fail, clean up 191 | importFailed(bName); 192 | } 193 | } 194 | } else { 195 | try { 196 | /** 197 | * If process.env.BUILD not set to 'vite' but Vite is being used it will fail to import 198 | * because Vite bundler rises a warning because import url start with a variable 199 | * @see: https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations 200 | * Warning will be hidden with the below directive vite-ignore 201 | * 202 | * If you're inspecting how these dynamic import work with Webpack, you may wonder why 203 | * we don't just send full paths through in `process.env.BEHAVIORS_COMPONENT_PATHS` 204 | * such as `component: '/components/component/component.js',` 205 | * instead of building a path from `process.env.BEHAVIORS_PATH` and `process.env.BEHAVIORS_COMPONENT_PATHS[bName]` 206 | * - and that would be a good question... 207 | * It seems we need to construct the path this way to let Webpack peek into the directory to map it out. 208 | * Then Webpack doesn't like building if you include the JS filename and file extension 209 | * and I have no idea why... 210 | */ 211 | import( 212 | /* @vite-ignore */ 213 | `${process.env.BEHAVIORS_PATH}/${(process.env.BEHAVIORS_COMPONENT_PATHS[bName]||'').replace(/^\/|\/$/ig,'')}/${bName}.${process.env.BEHAVIORS_EXTENSION}` 214 | ).then(module => { 215 | behaviorImported(bName, bNode, module); 216 | }).catch(err => { 217 | console.warn(`No loaded behavior: ${bName}`); 218 | // fail, clean up 219 | importFailed(bName); 220 | }); 221 | } catch(errW1) { 222 | try { 223 | import( 224 | /** 225 | * Vite bundler rises a warning because import url start with a variable 226 | * @see: https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations 227 | * Warning will be hidden with the below directive vite-ignore 228 | */ 229 | /* @vite-ignore */ 230 | `${process.env.BEHAVIORS_PATH}/${bName}.${process.env.BEHAVIORS_EXTENSION}` 231 | ).then(module => { 232 | behaviorImported(bName, bNode, module); 233 | }).catch(err => { 234 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Webpack has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Webpack config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 235 | // fail, clean up 236 | importFailed(bName); 237 | }); 238 | } catch(errW2) { 239 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Webpack has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Webpack config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 240 | // fail, clean up 241 | importFailed(bName); 242 | } 243 | } 244 | } 245 | } 246 | 247 | /** 248 | * behaviorImported 249 | * 250 | * Run when a dynamic import is successfully imported, 251 | * sets up and runs the behavior on the node 252 | * 253 | * @param {string} bName - name of behavior 254 | * @param {HTMLElement} bNode - node to initialise behavior on 255 | * @param module - imported behavior module 256 | */ 257 | function behaviorImported(bName, bNode, module) { 258 | // does what we loaded look right? 259 | if (module.default && typeof module.default === 'function') { 260 | // import complete, go go go 261 | loadedBehaviors[bName] = module.default; 262 | initBehavior(bName, bNode); 263 | // check for other instances of this behavior that where awaiting load 264 | if (behaviorsAwaitingImport.get(bName)) { 265 | behaviorsAwaitingImport.get(bName).forEach(node => { 266 | initBehavior(bName, node); 267 | }); 268 | behaviorsAwaitingImport.delete(bName); 269 | } 270 | } else { 271 | console.warn(`Tried to import ${bName}, but it seems to not be a behavior`); 272 | // fail, clean up 273 | importFailed(bName); 274 | } 275 | } 276 | 277 | /** 278 | * createBehaviors 279 | * 280 | * assign behaviors to nodes 281 | * 282 | * @param {HTMLElement} node - node to check for behaviors on elements 283 | */ 284 | function createBehaviors(node) { 285 | // Ignore text or comment nodes 286 | if (!('querySelectorAll' in node)) { 287 | return; 288 | } 289 | 290 | // first check for "critical" behavior nodes 291 | // these will be run immediately on discovery 292 | const behaviorNodes = [node, ...node.querySelectorAll(`[data-${options.dataAttr}]`)]; 293 | behaviorNodes.forEach(bNode => { 294 | // an element can have multiple behaviors 295 | const bNames = getBehaviorNames(bNode, options.dataAttr); 296 | // loop them 297 | if (bNames) { 298 | bNames.forEach(bName => { 299 | initBehavior(bName, bNode); 300 | }); 301 | } 302 | }); 303 | 304 | // now check for "lazy" behaviors 305 | // these are triggered via an intersection observer 306 | // these have optional breakpoints at which to trigger 307 | const lazyBehaviorNodes = [node, ...node.querySelectorAll(`[data-${options.lazyAttr}]`)]; 308 | lazyBehaviorNodes.forEach(bNode => { 309 | // look for lazy behavior names 310 | const bNames = getBehaviorNames(bNode, options.lazyAttr); 311 | const bMap = new Map(); 312 | bNames.forEach(bName => { 313 | // check for a lazy behavior breakpoint trigger 314 | const behaviorMedia = bNode.dataset[`${bName.toLowerCase()}Lazymedia`]; 315 | // store 316 | bMap.set(bName, behaviorMedia || false); 317 | }); 318 | // store and observe 319 | if (bNode !== document) { 320 | ioEntries.set(bNode, bMap); 321 | intersecting.set(bNode, false); 322 | io.observe(bNode); 323 | } 324 | }); 325 | } 326 | 327 | /** 328 | * observeBehaviors 329 | * 330 | * runs a `MutationObserver`, which watches for DOM changes 331 | * when a DOM change happens, insertion or deletion, 332 | * the call back runs, informing us of what changed 333 | */ 334 | function observeBehaviors() { 335 | // flag to stop multiple MutationObserver 336 | observingBehaviors = true; 337 | // set up MutationObserver 338 | const mo = new MutationObserver(mutations => { 339 | // report on what changed 340 | mutations.forEach(mutation => { 341 | mutation.removedNodes.forEach(node => { 342 | destroyBehaviors(node); 343 | }); 344 | mutation.addedNodes.forEach(node => { 345 | createBehaviors(node); 346 | }); 347 | }); 348 | }); 349 | // observe changes to the entire document 350 | mo.observe(document.body, { 351 | childList: true, 352 | subtree: true, 353 | attributes: false, 354 | characterData: false, 355 | }); 356 | } 357 | 358 | /** 359 | * loopLazyBehaviorNodes 360 | * 361 | * Looks at the nodes that have lazy behaviors, checks 362 | * if they're intersecting, optionally checks the breakpoint 363 | * and initialises if needed. Cleans up after itself, by 364 | * removing the intersection observer observing of the node 365 | * if all lazy behaviors on a node have been initialised 366 | * 367 | * @param {HTMLElement[]} bNodes - elements to check for lazy behaviors 368 | */ 369 | function loopLazyBehaviorNodes(bNodes) { 370 | bNodes.forEach(bNode => { 371 | // first, check if this node is being intersected 372 | if (intersecting.get(bNode) !== undefined && intersecting.get(bNode) === false) { 373 | return; 374 | } 375 | // now check to see if we have any lazy behavior names 376 | let lazyBNames = ioEntries.get(bNode); 377 | if (!lazyBNames) { 378 | return; 379 | } 380 | // 381 | lazyBNames.forEach((bMedia, bName) => { 382 | // if no lazy behavior breakpoint trigger, 383 | // or if the current breakpoint matches 384 | if (!bMedia || isBreakpoint(bMedia, options.breakpoints)) { 385 | // run behavior on node 386 | initBehavior(bName, bNode); 387 | // remove this behavior from the list of lazy behaviors 388 | lazyBNames.delete(bName); 389 | // if there are no more lazy behaviors left on the node 390 | // stop observing the node 391 | // else update the ioEntries 392 | if (lazyBNames.size === 0) { 393 | io.unobserve(bNode); 394 | ioEntries.delete(bNode); 395 | } else { 396 | ioEntries.set(bNode, lazyBNames); 397 | } 398 | } 399 | }); 400 | // end loopLazyBehaviorNodes bNodes loop 401 | }); 402 | } 403 | 404 | /** 405 | * intersection 406 | * 407 | * The intersection observer call back, 408 | * sets a value in the intersecting map true/false 409 | * and if an entry is intersecting, checks if needs to 410 | * init any lazy behaviors 411 | * 412 | * @param {IntersectionObserverEntry[]} entries 413 | */ 414 | function intersection(entries) { 415 | entries.forEach(entry => { 416 | if (entry.isIntersecting) { 417 | intersecting.set(entry.target, true); 418 | loopLazyBehaviorNodes([entry.target]); 419 | } else { 420 | intersecting.set(entry.target, false); 421 | } 422 | }); 423 | } 424 | 425 | /** 426 | * mediaQueryUpdated 427 | * 428 | * If a resize has happened with enough size that a 429 | * breakpoint has changed, checks to see if any lazy 430 | * behaviors need to be initialised or not 431 | */ 432 | function mediaQueryUpdated() { 433 | loopLazyBehaviorNodes(Array.from(ioEntries.keys())); 434 | } 435 | 436 | 437 | /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Public methods */ 438 | 439 | 440 | /** 441 | * initBehavior 442 | * 443 | * Return behavior instance if behavior is already loaded 444 | * 445 | * Run the `init` method inside a behavior, 446 | * the internal one in `createBehavior`, which then 447 | * runs the behaviors `init` life cycle method 448 | * 449 | * @param {string} bName - name of behavior 450 | * @param {HTMLElement} bNode - node to initialise behavior on 451 | * @param config 452 | * @returns {Behavior|void} 453 | */ 454 | function initBehavior(bName, bNode, config = {}) { 455 | // first check we have a loaded behavior 456 | if (!loadedBehaviors[bName]) { 457 | // if not, attempt to import it 458 | importBehavior(bName, bNode); 459 | return; 460 | } 461 | // merge breakpoints into config 462 | config = { 463 | breakpoints: options.breakpoints, 464 | ...config 465 | }; 466 | // now check that this behavior isn't already 467 | // running on this node 468 | const nodeBehaviors = activeBehaviors.get(bNode) || {}; 469 | if (Object.keys(nodeBehaviors).length === 0 || !nodeBehaviors[bName]) { 470 | const instance = new loadedBehaviors[bName](bNode, config); 471 | // update internal store of whats running 472 | nodeBehaviors[bName] = instance; 473 | activeBehaviors.set(bNode, nodeBehaviors); 474 | // init method in the behavior 475 | try { 476 | instance.init(); 477 | return instance; 478 | } catch(err) { 479 | console.log(`Error in behavior '${ bName }' on:`, bNode); 480 | console.log(err); 481 | } 482 | } 483 | } 484 | 485 | /** 486 | * addBehaviors 487 | * 488 | * Adds each behavior to memory, to be initialised to a DOM node when the 489 | * corresponding DOM node exists 490 | * 491 | * Can pass 492 | * - a singular behavior as created by `createBehavior`, 493 | * - a behavior object which will be passed to `createBehavior` 494 | * - a behavior module 495 | * - a collection of behavior modules 496 | * 497 | * @param {function|string} behaviors 498 | */ 499 | function addBehaviors(behaviors) { 500 | // if singular behavior added, sort into module like structure 501 | if (typeof behaviors === 'function' && behaviors.prototype.behaviorName) { 502 | behaviors = { [behaviors.prototype.behaviorName]: behaviors }; 503 | } 504 | // if an uncompiled behavior object is passed, create it 505 | if (typeof behaviors === 'string' && arguments.length > 1) { 506 | behaviors = { [behaviors]: createBehavior(...arguments) }; 507 | } 508 | // process 509 | const unique = Object.keys(behaviors).filter((o) => loadedBehaviorNames.indexOf(o) === -1); 510 | if (unique.length) { 511 | // we have new unique behaviors, store them 512 | loadedBehaviorNames = loadedBehaviorNames.concat(unique); 513 | unique.forEach(bName => { 514 | loadedBehaviors[bName] = behaviors[bName]; 515 | }); 516 | } 517 | } 518 | 519 | /** 520 | * nodeBehaviors 521 | * 522 | * Is returned as public method when webpack is set to development mode 523 | * 524 | * Returns all active behaviors on a node 525 | * 526 | * @param {string} bNode - node on which to get active behaviors on 527 | * @returns {Object.} 528 | */ 529 | function nodeBehaviors(bNode) { 530 | const nodeBehaviors = activeBehaviors.get(bNode); 531 | if (!nodeBehaviors) { 532 | console.warn(`No behaviors on:`, bNode); 533 | } else { 534 | return nodeBehaviors; 535 | } 536 | } 537 | 538 | /** 539 | * behaviorProperties 540 | * 541 | * Is returned as public method when webpack is set to development mode 542 | * 543 | * Returns all properties of a behavior 544 | * 545 | * @param {string} bName - name of behavior to return properties of 546 | * @param {string} bNode - node on which the behavior is running 547 | * @returns {Behavior|void} 548 | */ 549 | function behaviorProperties(bName, bNode) { 550 | const nodeBehaviors = activeBehaviors.get(bNode); 551 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 552 | console.warn(`No behavior '${bName}' instance on:`, bNode); 553 | } else { 554 | return activeBehaviors.get(bNode)[bName]; 555 | } 556 | } 557 | 558 | /** 559 | * behaviorProp 560 | * 561 | * Is returned as public method when webpack is set to development mode 562 | * 563 | * Returns specific property of a behavior on a node, or runs a method 564 | * or sets a property on a behavior if a value is set. For debuggging. 565 | * 566 | * @param {string} bName - name of behavior to return properties of 567 | * @param {string} bNode - node on which the behavior is running 568 | * @param {string} prop - property to return or set 569 | * @param [value] - value to set 570 | * @returns {*} 571 | */ 572 | function behaviorProp(bName, bNode, prop, value) { 573 | const nodeBehaviors = activeBehaviors.get(bNode); 574 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 575 | console.warn(`No behavior '${bName}' instance on:`, bNode); 576 | } else if (activeBehaviors.get(bNode)[bName][prop]) { 577 | if (value && typeof value === 'function') { 578 | return activeBehaviors.get(bNode)[bName][prop]; 579 | } else if (value) { 580 | activeBehaviors.get(bNode)[bName][prop] = value; 581 | } else { 582 | return activeBehaviors.get(bNode)[bName][prop]; 583 | } 584 | } else { 585 | console.warn(`No property '${prop}' in behavior '${bName}' instance on:`, bNode); 586 | } 587 | } 588 | 589 | /* 590 | init 591 | 592 | gets this show on the road 593 | 594 | loadedBehaviorsModule - optional behaviors module to load on init 595 | opts - any options for this instance 596 | */ 597 | /** 598 | * init 599 | * 600 | * gets this show on the road 601 | * 602 | * @param [loadedBehaviorsModule] - optional behaviors module to load on init 603 | * @param opts - any options for this instance 604 | */ 605 | function init(loadedBehaviorsModule, opts = {}) { 606 | options = { 607 | ...options, ...opts 608 | } 609 | 610 | // on resize, check 611 | resized(); 612 | 613 | // set up intersection observer 614 | io = new IntersectionObserver(intersection, options.intersectionOptions); 615 | 616 | // if fn run with supplied behaviors, lets add them and begin 617 | if (loadedBehaviorsModule) { 618 | addBehaviors(loadedBehaviorsModule); 619 | } 620 | 621 | // try and apply behaviors to any DOM node that needs them 622 | createBehaviors(document); 623 | 624 | // start the mutation observer looking for DOM changes 625 | if (!observingBehaviors) { 626 | observeBehaviors(); 627 | } 628 | 629 | // watch for break point changes 630 | window.addEventListener('mediaQueryUpdated', mediaQueryUpdated); 631 | } 632 | 633 | /** 634 | * addAndInit 635 | * 636 | * Can pass 637 | * - a singular behavior as created by `createBehavior`, 638 | * - a behavior object which will be passed to `createBehavior` 639 | * - a behavior module 640 | * - a collection of behavior modules 641 | * 642 | * @param [behaviors] - optional behaviors module to load on init 643 | * (all arguments are passed to addBehaviors) 644 | */ 645 | function addAndInit() { 646 | if (arguments) { 647 | addBehaviors.apply(null, arguments); 648 | 649 | // try and apply behaviors to any DOM node that needs them 650 | createBehaviors(document); 651 | } 652 | } 653 | 654 | // expose public methods, essentially returning 655 | 656 | let exportObj = { 657 | init: init, 658 | add: addAndInit, 659 | initBehavior: initBehavior, 660 | get currentBreakpoint() { 661 | return getCurrentMediaQuery(); 662 | } 663 | } 664 | 665 | try { 666 | if (process.env.MODE === 'development') { 667 | Object.defineProperty(exportObj, 'loaded', { 668 | get: () => { 669 | return loadedBehaviorNames; 670 | } 671 | }); 672 | exportObj.activeBehaviors = activeBehaviors; 673 | exportObj.active = activeBehaviors; 674 | exportObj.getBehaviors = nodeBehaviors; 675 | exportObj.getProps = behaviorProperties; 676 | exportObj.getProp = behaviorProp; 677 | exportObj.setProp = behaviorProp; 678 | exportObj.callMethod = behaviorProp; 679 | } 680 | } catch(err) { 681 | // no process.env.mode 682 | } 683 | 684 | export default exportObj; 685 | -------------------------------------------------------------------------------- /dist/esm/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * getCurrentMediaQuery : Returns the current media query in use by reading a CSS :root variable. Useful for running JS functions at certain breakpoints without holding breakpoint size information in CSS and JS. 3 | * 4 | * @returns {string} Media query string 5 | */ 6 | const getCurrentMediaQuery = function() { 7 | // Doc: https://github.com/area17/js-helpers/wiki/getCurrentMediaQuery 8 | if(typeof window === 'undefined') return ''; 9 | return getComputedStyle(document.documentElement).getPropertyValue('--breakpoint').trim().replace(/"/g, ''); 10 | }; 11 | 12 | /** 13 | * resized : Debounces window resize, also checks if current media query has changed 14 | * @example document.addEventListener('resized', function(event) { console.log(event.detail.breakpoint); }); 15 | */ 16 | const resized = function() { 17 | // Doc: https://github.com/area17/js-helpers/wiki/resized 18 | 19 | var resizeTimer; 20 | var resizedDelay = 250; 21 | var mediaQuery = getCurrentMediaQuery(); 22 | 23 | function informApp() { 24 | // check media query 25 | var newMediaQuery = getCurrentMediaQuery(); 26 | 27 | // tell everything resized happened 28 | window.dispatchEvent(new CustomEvent('resized', { 29 | detail: { 30 | breakpoint: newMediaQuery 31 | } 32 | })); 33 | 34 | // if media query changed, tell everything 35 | if (newMediaQuery !== mediaQuery) { 36 | if (window.A17) { 37 | window.A17.currentMediaQuery = newMediaQuery; 38 | } 39 | window.dispatchEvent(new CustomEvent('mediaQueryUpdated', { 40 | detail: { 41 | breakpoint: newMediaQuery, 42 | prevBreakpoint: mediaQuery 43 | } 44 | })); 45 | mediaQuery = newMediaQuery; 46 | } 47 | } 48 | 49 | window.addEventListener('resize', function() { 50 | clearTimeout(resizeTimer); 51 | resizeTimer = setTimeout(informApp, resizedDelay); 52 | }); 53 | 54 | // Firefox doesn't fire a `resize` event on text scaling 55 | // yet when doing so, its likely you'll cycle through your breakpoints 56 | // effectively you're resizing, even if the window doesn't change in dimensions 57 | // so, set up a `ResizeObserver` on the `document.documentElement` (the HTML tag) 58 | // and listen for it changing in someway and trigger a resize event 59 | // 60 | // the assumption being that your page will likely change in height 61 | // as the content resizes with a responsive layout 62 | // this isn't infallible... 63 | // its possible you have some sort of fixed height site 64 | if (typeof ResizeObserver === 'function') { 65 | let resizedTimer = null; 66 | const resizeObserver = new ResizeObserver((entries) => { 67 | clearTimeout(resizedTimer); 68 | resizedTimer = setTimeout(() => { 69 | if (window.A17.currentMediaQuery !== getCurrentMediaQuery()) { 70 | window.dispatchEvent(new Event('resize')); 71 | } 72 | }, resizedDelay + 1); 73 | }); 74 | 75 | resizeObserver.observe(document.documentElement); 76 | } 77 | 78 | if (mediaQuery === '') { 79 | window.requestAnimationFrame(informApp); 80 | } else if (window.A17) { 81 | window.A17.currentMediaQuery = mediaQuery; 82 | } 83 | }; 84 | 85 | /** 86 | * isBreakpoint : Checks if the current breakpoint matches the passed breakpoint. It supports querying with or without +/- modifiers. 87 | * 88 | * @param {string} breakpoint The breakpoint to check against 89 | * @param {string[]} [breakpoints] Array of breakpoint names to test against 90 | * @returns {boolean} Returns true if the breakpoint matches, false if not 91 | */ 92 | const isBreakpoint = function (breakpoint, breakpoints) { 93 | // Doc: https://github.com/area17/js-helpers/wiki/isBreakpoint 94 | 95 | // bail if no breakpoint is passed 96 | if (!breakpoint) { 97 | console.error('You need to pass a breakpoint name!'); 98 | return false 99 | } 100 | 101 | // we only want to look for a specific modifier and make sure it is at the end of the string 102 | const regExp = new RegExp('\\+$|\\-$'); 103 | 104 | // bps must be in order from smallest to largest 105 | let bps = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; 106 | 107 | // override the breakpoints if the option is set on the global A17 object 108 | if (window.A17 && window.A17.breakpoints) { 109 | if (Array.isArray(window.A17.breakpoints)) { 110 | bps = window.A17.breakpoints; 111 | } else { 112 | console.warn('A17.breakpoints should be an array. Using defaults.'); 113 | } 114 | } 115 | 116 | // override the breakpoints if a set of breakpoints is passed through as a parameter (added for A17-behaviors to allow usage with no globals) 117 | if (breakpoints) { 118 | if (Array.isArray(breakpoints)) { 119 | bps = breakpoints; 120 | } else { 121 | console.warn('isBreakpoint breakpoints should be an array. Using defaults.'); 122 | } 123 | } 124 | 125 | // store current breakpoint in use 126 | const currentBp = getCurrentMediaQuery(); 127 | 128 | // store the index of the current breakpoint 129 | const currentBpIndex = bps.indexOf(currentBp); 130 | 131 | // check to see if bp has a + or - modifier 132 | const hasModifier = regExp.exec(breakpoint); 133 | 134 | // store modifier value 135 | const modifier = hasModifier ? hasModifier[0] : false; 136 | 137 | // store the trimmed breakpoint name if a modifier exists, if not, store the full queried breakpoint name 138 | const bpName = hasModifier ? breakpoint.slice(0, -1) : breakpoint; 139 | 140 | // store the index of the queried breakpoint 141 | const bpIndex = bps.indexOf(bpName); 142 | 143 | // let people know if the breakpoint name is unrecognized 144 | if (bpIndex < 0) { 145 | console.warn( 146 | 'Unrecognized breakpoint. Supported breakpoints are: ' + bps.join(', ') 147 | ); 148 | return false 149 | } 150 | 151 | // compare the modifier with the index of the current breakpoint in the bps array with the index of the queried breakpoint. 152 | // if no modifier is set, compare the queried breakpoint name with the current breakpoint name 153 | if ( 154 | (modifier === '+' && currentBpIndex >= bpIndex) || 155 | (modifier === '-' && currentBpIndex <= bpIndex) || 156 | (!modifier && breakpoint === currentBp) 157 | ) { 158 | return true 159 | } 160 | 161 | // the current breakpoint isn’t the one you’re looking for 162 | return false 163 | }; 164 | 165 | /** 166 | * Removes all properties from an object, useful in cleaning up when destroying a method 167 | */ 168 | const purgeProperties = function(obj) { 169 | // Doc: https://github.com/area17/js-helpers/wiki/purgeProperties 170 | for (var prop in obj) { 171 | if (obj.hasOwnProperty(prop)) { 172 | delete obj[prop]; 173 | } 174 | } 175 | 176 | // alternatives considered: https://jsperf.com/deleting-properties-from-an-object 177 | }; 178 | 179 | /** 180 | * Behavior 181 | * @typedef {Object.|BehaviorDef} Behavior 182 | * @property {HTMLElement} $node - Dom node associated to the behavior 183 | * @property {string} name - Name of the behavior 184 | * @property {Object} options 185 | * @property {Lifecycle} lifecycle 186 | */ 187 | 188 | /** 189 | * Behavior lifecycle 190 | * @typedef {Object} Lifecycle 191 | * @property {BehaviorLifecycleFn} [init] - Init function called when behavior is created 192 | * @property {BehaviorLifecycleFn} [enabled] - Triggered when behavior state changed (ex: mediaquery update) 193 | * @property {BehaviorLifecycleFn} [disabled] - Triggered when behavior state changed (ex: mediaquery update) 194 | * @property {BehaviorLifecycleFn} [mediaQueryUpdated] - Triggered when mediaquery change 195 | * @property {BehaviorLifecycleFn} [intersectionIn] - Triggered when behavior is visible (enable intersection observer) 196 | * @property {BehaviorLifecycleFn} [intersectionOut] - Triggered when behavior is hidden (enable intersection observer) 197 | * @property {BehaviorLifecycleFn} [resized] - Triggered when window is resized 198 | * @property {BehaviorLifecycleFn} [destroy] - Triggered before behavior will be destroyed and removed 199 | */ 200 | 201 | /** 202 | * @typedef {function} BehaviorLifecycleFn 203 | * @this Behavior 204 | */ 205 | 206 | /** 207 | * @typedef {function} BehaviorDefFn 208 | * @this Behavior 209 | */ 210 | 211 | /** 212 | * Behavior definition 213 | * @typedef {Object.} BehaviorDef 214 | */ 215 | 216 | /** 217 | * Behavior constructor 218 | * @constructor 219 | * @param {HTMLElement} node - A DOM element 220 | * @param config - behavior options 221 | * @returns {Behavior} 222 | */ 223 | function Behavior(node, config = {}) { 224 | if (!node || !(node instanceof Element)) { 225 | throw new Error('Node argument is required'); 226 | } 227 | 228 | this.$node = this.getChild(node); 229 | this.options = Object.assign({ 230 | intersectionOptions: { 231 | rootMargin: '20%', 232 | } 233 | }, config.options || {}); 234 | 235 | this.__isEnabled = false; 236 | this.__children = config.children; 237 | this.__breakpoints = config.breakpoints; 238 | this.__abortController = new AbortController(); 239 | 240 | // Auto-bind all custom methods to "this" 241 | this.customMethodNames.forEach(methodName => { 242 | this[methodName] = this.methods[methodName].bind(this); 243 | }); 244 | 245 | this._binds = {}; 246 | this._data = new Proxy(this._binds, { 247 | set: (target, key, value) => { 248 | this.updateBinds(key, value); 249 | target[key] = value; 250 | return true; 251 | } 252 | }); 253 | 254 | this.__isIntersecting = false; 255 | this.__intersectionObserver = null; 256 | 257 | return this; 258 | } 259 | 260 | /** 261 | * 262 | * @type {Behavior} 263 | */ 264 | Behavior.prototype = Object.freeze({ 265 | updateBinds(key, value) { 266 | // TODO: cache these before hand? 267 | const targetEls = this.$node.querySelectorAll('[data-' + this.name.toLowerCase() + '-bindel*=' + key + ']'); 268 | targetEls.forEach((target) => { 269 | target.innerHTML = value; 270 | }); 271 | // TODO: cache these before hand? 272 | const targetAttrs = this.$node.querySelectorAll('[data-' + this.name.toLowerCase() + '-bindattr*="' + key + ':"]'); 273 | targetAttrs.forEach((target) => { 274 | let bindings = target.dataset[this.name.toLowerCase() + 'Bindattr']; 275 | bindings.split(',').forEach((pair) => { 276 | pair = pair.split(':'); 277 | if (pair[0] === key) { 278 | if (pair[1] === 'class') { 279 | // TODO: needs to know what the initial class was to remove it - fix? 280 | if (this._binds[key] !== value) { 281 | target.classList.remove(this._binds[key]); 282 | } 283 | if (value) { 284 | target.classList.add(value); 285 | } 286 | } else { 287 | target.setAttribute(pair[1], value); 288 | } 289 | } 290 | }); 291 | }); 292 | }, 293 | init() { 294 | // Get options from data attributes on node 295 | const regex = new RegExp('^data-' + this.name + '-(.*)', 'i'); 296 | for (let i = 0; i < this.$node.attributes.length; i++) { 297 | const attr = this.$node.attributes[i]; 298 | const matches = regex.exec(attr.nodeName); 299 | 300 | if (matches != null && matches.length >= 2) { 301 | if (this.options[matches[1]]) { 302 | console.warn( 303 | `Ignoring ${ 304 | matches[1] 305 | } option, as it already exists on the ${name} behavior. Please choose another name.` 306 | ); 307 | } 308 | this.options[matches[1]] = attr.value; 309 | } 310 | } 311 | 312 | // Behavior-specific lifecycle 313 | if (typeof this.lifecycle?.init === 'function') { 314 | this.lifecycle.init.call(this); 315 | } 316 | 317 | if (typeof this.lifecycle?.resized === 'function') { 318 | this.__resizedBind = this.__resized.bind(this); 319 | window.addEventListener('resized', this.__resizedBind); 320 | } 321 | 322 | if (typeof this.lifecycle.mediaQueryUpdated === 'function' || this.options.media) { 323 | this.__mediaQueryUpdatedBind = this.__mediaQueryUpdated.bind(this); 324 | window.addEventListener('mediaQueryUpdated', this.__mediaQueryUpdatedBind); 325 | } 326 | 327 | if (this.options.media) { 328 | this.__toggleEnabled(); 329 | } else { 330 | this.enable(); 331 | } 332 | 333 | this.__intersections(); 334 | }, 335 | destroy() { 336 | this.__abortController.abort(); 337 | 338 | if (this.__isEnabled === true) { 339 | this.disable(); 340 | } 341 | 342 | // Behavior-specific lifecycle 343 | if (typeof this.lifecycle?.destroy === 'function') { 344 | this.lifecycle.destroy.call(this); 345 | } 346 | 347 | if (typeof this.lifecycle.resized === 'function') { 348 | window.removeEventListener('resized', this.__resizedBind); 349 | } 350 | 351 | if (typeof this.lifecycle.mediaQueryUpdated === 'function' || this.options.media) { 352 | window.removeEventListener('mediaQueryUpdated', this.__mediaQueryUpdatedBind); 353 | } 354 | 355 | if (this.lifecycle.intersectionIn != null || this.lifecycle.intersectionOut != null) { 356 | this.__intersectionObserver.unobserve(this.$node); 357 | this.__intersectionObserver.disconnect(); 358 | } 359 | 360 | purgeProperties(this); 361 | }, 362 | /** 363 | * Look for a child of the behavior: data-behaviorName-childName 364 | * @param {string} childName 365 | * @param {HTMLElement} context - Define the ancestor where search begin, default is current node 366 | * @param {boolean} multi - Define usage between querySelectorAll and querySelector 367 | * @returns {HTMLElement|null} 368 | */ 369 | getChild(selector, context, multi = false) { 370 | // lets make a selection 371 | let selection; 372 | // 373 | if (this.__children != null && this.__children[selector] != null) { 374 | // if the selector matches a pre-selected set, set to that set 375 | // TODO: confirm what this is and its usage 376 | selection = this.__children[selector]; 377 | } else if (selector instanceof NodeList) { 378 | // if a node list has been passed, use it 379 | selection = selector; 380 | multi = true; 381 | } else if (selector instanceof Element || selector instanceof HTMLDocument || selector === window) { 382 | // if a single node, the document or the window is passed, set to that 383 | selection = selector; 384 | multi = false; 385 | } else { 386 | // else, lets find named children within the container 387 | if (context == null) { 388 | // set a default context of the container node 389 | context = this.$node; 390 | } 391 | // find 392 | selection = context[multi ? 'querySelectorAll' : 'querySelector']( 393 | '[data-' + this.name.toLowerCase() + '-' + selector.toLowerCase() + ']' 394 | ); 395 | } 396 | 397 | if (multi && selection?.length > 0) { 398 | // apply on/off methods to the selected DOM node list 399 | selection.on = (type, fn, opt) => { 400 | selection.forEach(el => { 401 | this.__on(el, type, fn, opt); 402 | }); 403 | }; 404 | selection.off = (type, fn) => { 405 | selection.forEach(el => { 406 | this.__off(el, type, fn); 407 | }); 408 | }; 409 | // and apply to the individual nodes within 410 | selection.forEach(el => { 411 | el.on = el.on ? el.on : (type, fn, opt) => { 412 | this.__on(el, type, fn, opt); 413 | }; 414 | el.off = el.off ? el.off : (type, fn) => { 415 | this.__off(el, type, fn); 416 | }; 417 | }); 418 | } else if(selection) { 419 | // apply on/off methods to the singular selected node 420 | selection.on = selection.on ? selection.on : (type, fn, opt) => { 421 | this.__on(selection, type, fn, opt); 422 | }; 423 | selection.off = selection.off ? selection.off : (type, fn) => { 424 | this.__off(selection, type, fn); 425 | }; 426 | } 427 | 428 | // return to variable assignment 429 | return selection; 430 | }, 431 | /** 432 | * Look for children of the behavior: data-behaviorName-childName 433 | * @param {string} childName 434 | * @param {HTMLElement} context - Define the ancestor where search begin, default is current node 435 | * @returns {HTMLElement|null} 436 | */ 437 | getChildren(childName, context) { 438 | return this.getChild(childName, context, true); 439 | }, 440 | isEnabled() { 441 | return this.__isEnabled; 442 | }, 443 | enable() { 444 | this.__isEnabled = true; 445 | if (typeof this.lifecycle.enabled === 'function') { 446 | this.lifecycle.enabled.call(this); 447 | } 448 | }, 449 | disable() { 450 | this.__isEnabled = false; 451 | if (typeof this.lifecycle.disabled === 'function') { 452 | this.lifecycle.disabled.call(this); 453 | } 454 | }, 455 | addSubBehavior(SubBehavior, node = this.$node, config = {}) { 456 | const mb = exportObj; 457 | if (typeof SubBehavior === 'string') { 458 | mb.initBehavior(SubBehavior, node, config); 459 | } else { 460 | mb.add(SubBehavior); 461 | mb.initBehavior(SubBehavior.prototype.behaviorName, node, config); 462 | } 463 | }, 464 | /** 465 | * Check if breakpoint passed in param is the current one 466 | * @param {string} bp - Breakpoint to check 467 | * @returns {boolean} 468 | */ 469 | isBreakpoint(bp) { 470 | return isBreakpoint(bp, this.__breakpoints); 471 | }, 472 | __on(el, type, fn, opt) { 473 | if (typeof opt === 'boolean' && opt === true) { 474 | opt = { 475 | passive: true 476 | }; 477 | } 478 | const options = { 479 | signal: this.__abortController.signal, 480 | ...opt 481 | }; 482 | if (!el.attachedListeners) { 483 | el.attachedListeners = {}; 484 | } 485 | // check if el already has this listener 486 | let found = Object.values(el.attachedListeners).find(listener => listener.type === type && listener.fn === fn); 487 | if (!found) { 488 | el.attachedListeners[Object.values(el.attachedListeners).length] = { 489 | type: type, 490 | fn: fn, 491 | }; 492 | el.addEventListener(type, fn, options); 493 | } 494 | }, 495 | __off(el, type, fn) { 496 | if (el.attachedListeners) { 497 | Object.keys(el.attachedListeners).forEach(key => { 498 | const thisListener = el.attachedListeners[key]; 499 | if ( 500 | (!type && !fn) || // off() 501 | (type === thisListener.type && !fn) || // match type with no fn 502 | (type === thisListener.type && fn === thisListener.fn) // match both type and fn 503 | ) { 504 | delete el.attachedListeners[key]; 505 | el.removeEventListener(thisListener.type, thisListener.fn); 506 | } 507 | }); 508 | } else { 509 | el.removeEventListener(type, fn); 510 | } 511 | }, 512 | __toggleEnabled() { 513 | const isValidMQ = isBreakpoint(this.options.media, this.__breakpoints); 514 | if (isValidMQ && !this.__isEnabled) { 515 | this.enable(); 516 | } else if (!isValidMQ && this.__isEnabled) { 517 | this.disable(); 518 | } 519 | }, 520 | __mediaQueryUpdated(e) { 521 | if (typeof this.lifecycle?.mediaQueryUpdated === 'function') { 522 | this.lifecycle.mediaQueryUpdated.call(this, e); 523 | } 524 | if (this.options.media) { 525 | this.__toggleEnabled(); 526 | } 527 | }, 528 | __resized(e) { 529 | if (typeof this.lifecycle?.resized === 'function') { 530 | this.lifecycle.resized.call(this, e); 531 | } 532 | }, 533 | __intersections() { 534 | if (this.lifecycle.intersectionIn != null || this.lifecycle.intersectionOut != null) { 535 | this.__intersectionObserver = new IntersectionObserver(entries => { 536 | entries.forEach(entry => { 537 | if (entry.target === this.$node) { 538 | if (entry.isIntersecting) { 539 | if (!this.__isIntersecting && typeof this.lifecycle.intersectionIn === 'function') { 540 | this.__isIntersecting = true; 541 | this.lifecycle.intersectionIn.call(this); 542 | } 543 | } else { 544 | if (this.__isIntersecting && typeof this.lifecycle.intersectionOut === 'function') { 545 | this.__isIntersecting = false; 546 | this.lifecycle.intersectionOut.call(this); 547 | } 548 | } 549 | } 550 | }); 551 | }, this.options.intersectionOptions); 552 | this.__intersectionObserver.observe(this.$node); 553 | } 554 | } 555 | }); 556 | 557 | /** 558 | * Create a behavior instance 559 | * @param {string} name - Name of the behavior used for declaration: data-behavior="name" 560 | * @param {object} methods - define methods of the behavior 561 | * @param {object} lifecycle - Register behavior lifecycle 562 | * @returns {Behavior} 563 | */ 564 | const createBehavior = (name, methods = {}, lifecycle = {}) => { 565 | /** 566 | * 567 | * @param args 568 | */ 569 | const fn = function(...args) { 570 | Behavior.apply(this, args); 571 | }; 572 | 573 | const customMethodNames = []; 574 | 575 | const customProperties = { 576 | name: { 577 | get() { 578 | return this.behaviorName; 579 | }, 580 | }, 581 | behaviorName: { 582 | value: name, 583 | writable: true, 584 | }, 585 | lifecycle: { 586 | value: lifecycle, 587 | }, 588 | methods: { 589 | value: methods, 590 | }, 591 | customMethodNames: { 592 | value: customMethodNames, 593 | }, 594 | }; 595 | 596 | // Expose the definition properties as 'this[methodName]' 597 | const methodsKeys = Object.keys(methods); 598 | methodsKeys.forEach(key => { 599 | customMethodNames.push(key); 600 | }); 601 | 602 | fn.prototype = Object.create(Behavior.prototype, customProperties); 603 | 604 | return fn; 605 | }; 606 | 607 | let options = { 608 | dataAttr: 'behavior', 609 | lazyAttr: 'behavior-lazy', 610 | intersectionOptions: { 611 | rootMargin: '20%', 612 | }, 613 | breakpoints: ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'], 614 | dynamicBehaviors: {}, 615 | }; 616 | 617 | let loadedBehaviorNames = []; 618 | let observingBehaviors = false; 619 | const loadedBehaviors = {}; 620 | const activeBehaviors = new Map(); 621 | const behaviorsAwaitingImport = new Map(); 622 | let io; 623 | const ioEntries = new Map(); // need to keep a separate map of intersection observer entries as `io.takeRecords()` always returns an empty array, seems broken in all browsers 🤷🏻‍♂️ 624 | const intersecting = new Map(); 625 | 626 | /** 627 | * getBehaviorNames 628 | * 629 | * Data attribute names can be written in any case, 630 | * but `node.dataset` names are lowercase 631 | * with camel casing for names split by - 632 | * eg: `data-foo-bar` becomes `node.dataset.fooBar` 633 | * 634 | * @param {HTMLElement} bNode - node to grab behavior names from 635 | * @param {string} attr - name of attribute to pick 636 | * @returns {string[]} 637 | */ 638 | function getBehaviorNames(bNode, attr) { 639 | attr = attr.toLowerCase().replace(/-([a-zA-Z0-9])/ig, (match, p1) => { 640 | return p1.toUpperCase(); 641 | }); 642 | if (bNode.dataset && bNode.dataset[attr]) { 643 | return bNode.dataset[attr].split(' ').filter(bName => bName); 644 | } else { 645 | return []; 646 | } 647 | } 648 | 649 | /** 650 | * importFailed 651 | * 652 | * 653 | * Either the imported module didn't look like a behavior module 654 | * or nothing could be found to import 655 | * 656 | * @param {string }bName - name of behavior that failed to import 657 | */ 658 | function importFailed(bName) { 659 | // remove name from loaded behavior names index 660 | // maybe it'll be included via a script tag later 661 | const bNameIndex = loadedBehaviorNames.indexOf(bName); 662 | if (bNameIndex > -1) { 663 | loadedBehaviorNames.splice(bNameIndex, 1); 664 | } 665 | } 666 | 667 | /** 668 | * destroyBehavior 669 | * 670 | * 671 | * All good things must come to an end... 672 | * Ok so likely the node has been removed, possibly by 673 | * a deletion or ajax type page change 674 | * 675 | * @param {string} bName - name of behavior to destroy 676 | * @param {string} bNode - node to destroy behavior on 677 | */ 678 | function destroyBehavior(bName, bNode) { 679 | const nodeBehaviors = activeBehaviors.get(bNode); 680 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 681 | console.warn(`No behavior '${bName}' instance on:`, bNode); 682 | return; 683 | } 684 | 685 | /** 686 | * run destroy method, remove, delete 687 | * `destroy()` is an internal method of a behavior in `createBehavior`. Individual behaviors may 688 | * also have their own `destroy` methods (called by 689 | * the `createBehavior` `destroy`) 690 | */ 691 | nodeBehaviors[bName].destroy(); 692 | delete nodeBehaviors[bName]; 693 | if (Object.keys(nodeBehaviors).length === 0) { 694 | activeBehaviors.delete(bNode); 695 | } 696 | } 697 | 698 | /** 699 | * destroyBehaviors 700 | * 701 | * if a node with behaviors is removed from the DOM, 702 | * clean up to save resources 703 | * 704 | * @param {HTMLElement} rNode -node to destroy behaviors on (and inside of) 705 | */ 706 | function destroyBehaviors(rNode) { 707 | const bNodes = Array.from(activeBehaviors.keys()); 708 | bNodes.push(rNode); 709 | bNodes.forEach(bNode => { 710 | // is the active node the removed node 711 | // or does the removed node contain the active node? 712 | if (rNode === bNode || rNode.contains(bNode)) { 713 | // get behaviors on node 714 | const bNodeActiveBehaviors = activeBehaviors.get(bNode); 715 | // if some, destroy 716 | if (bNodeActiveBehaviors) { 717 | Object.keys(bNodeActiveBehaviors).forEach(bName => { 718 | destroyBehavior(bName, bNode); 719 | // stop intersection observer from watching node 720 | io.unobserve(bNode); 721 | ioEntries.delete(bNode); 722 | intersecting.delete(bNode); 723 | }); 724 | } 725 | } 726 | }); 727 | } 728 | 729 | /** 730 | * importBehavior 731 | * 732 | * Use `import` to bring in a behavior module and run it. 733 | * This runs if there is no loaded behavior of this name. 734 | * After import, the behavior is initialised on the node 735 | * 736 | * @param {string} bName - name of behavior 737 | * @param {HTMLElement} bNode - node to initialise behavior on 738 | */ 739 | function importBehavior(bName, bNode) { 740 | // first check we haven't already got this behavior module 741 | if (loadedBehaviorNames.indexOf(bName) > -1) { 742 | // if no, store a list of nodes awaiting this behavior to load 743 | const awaitingImport = behaviorsAwaitingImport.get(bName) || []; 744 | if (!awaitingImport.includes(bNode)) { 745 | awaitingImport.push(bNode); 746 | } 747 | behaviorsAwaitingImport.set(bName, awaitingImport); 748 | return; 749 | } 750 | // push to our store of loaded behaviors 751 | loadedBehaviorNames.push(bName); 752 | // import 753 | // process.env variables set in webpack/vite config 754 | // 755 | if (process.env.BUILD === 'vite') { 756 | try { 757 | /** 758 | * For BEHAVIORS_COMPONENT_PATHS usage, 759 | * we need to pass a vite import in the app.js - options.dynamicBehaviors 760 | * and then we look for the import in the globbed dynamicBehaviors 761 | * if exists, run it 762 | * 763 | * NB: using vite glob from app.js as vite glob import only allows literal strings 764 | * and does not allow variables to be passed to it 765 | * see: https://vite.dev/guide/features#glob-import 766 | */ 767 | options.dynamicBehaviors[process.env.BEHAVIORS_COMPONENT_PATHS[bName]]().then(module => { 768 | behaviorImported(bName, bNode, module); 769 | }).catch(err => { 770 | console.warn(`Behavior '${bName}' load failed - possible error with the behavior or a malformed module`); 771 | // fail, clean up 772 | importFailed(bName); 773 | }); 774 | } catch(errV1) { 775 | try { 776 | /** 777 | * Vite bundler requires a known start point for imports 778 | * Fortunately it can use a defined alias in the config 779 | * Webkit uses aliases differently and continues on to the 780 | * imports below (but may throw build warnings attempting this) 781 | */ 782 | options.dynamicBehaviors[`${process.env.BEHAVIORS_PATH}/${bName}.${process.env.BEHAVIORS_EXTENSION}`]().then(module => { 783 | behaviorImported(bName, bNode, module); 784 | }).catch(err => { 785 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Vite has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Vite config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 786 | // fail, clean up 787 | importFailed(bName); 788 | }); 789 | } catch(errV2) { 790 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Vite has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Vite config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 791 | // fail, clean up 792 | importFailed(bName); 793 | } 794 | } 795 | } else { 796 | try { 797 | /** 798 | * If process.env.BUILD not set to 'vite' but Vite is being used it will fail to import 799 | * because Vite bundler rises a warning because import url start with a variable 800 | * @see: https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations 801 | * Warning will be hidden with the below directive vite-ignore 802 | * 803 | * If you're inspecting how these dynamic import work with Webpack, you may wonder why 804 | * we don't just send full paths through in `process.env.BEHAVIORS_COMPONENT_PATHS` 805 | * such as `component: '/components/component/component.js',` 806 | * instead of building a path from `process.env.BEHAVIORS_PATH` and `process.env.BEHAVIORS_COMPONENT_PATHS[bName]` 807 | * - and that would be a good question... 808 | * It seems we need to construct the path this way to let Webpack peek into the directory to map it out. 809 | * Then Webpack doesn't like building if you include the JS filename and file extension 810 | * and I have no idea why... 811 | */ 812 | import( 813 | /* @vite-ignore */ 814 | `${process.env.BEHAVIORS_PATH}/${(process.env.BEHAVIORS_COMPONENT_PATHS[bName]||'').replace(/^\/|\/$/ig,'')}/${bName}.${process.env.BEHAVIORS_EXTENSION}` 815 | ).then(module => { 816 | behaviorImported(bName, bNode, module); 817 | }).catch(err => { 818 | console.warn(`No loaded behavior: ${bName}`); 819 | // fail, clean up 820 | importFailed(bName); 821 | }); 822 | } catch(errW1) { 823 | try { 824 | import( 825 | /** 826 | * Vite bundler rises a warning because import url start with a variable 827 | * @see: https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations 828 | * Warning will be hidden with the below directive vite-ignore 829 | */ 830 | /* @vite-ignore */ 831 | `${process.env.BEHAVIORS_PATH}/${bName}.${process.env.BEHAVIORS_EXTENSION}` 832 | ).then(module => { 833 | behaviorImported(bName, bNode, module); 834 | }).catch(err => { 835 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Webpack has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Webpack config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 836 | // fail, clean up 837 | importFailed(bName); 838 | }); 839 | } catch(errW2) { 840 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Webpack has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Webpack config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 841 | // fail, clean up 842 | importFailed(bName); 843 | } 844 | } 845 | } 846 | } 847 | 848 | /** 849 | * behaviorImported 850 | * 851 | * Run when a dynamic import is successfully imported, 852 | * sets up and runs the behavior on the node 853 | * 854 | * @param {string} bName - name of behavior 855 | * @param {HTMLElement} bNode - node to initialise behavior on 856 | * @param module - imported behavior module 857 | */ 858 | function behaviorImported(bName, bNode, module) { 859 | // does what we loaded look right? 860 | if (module.default && typeof module.default === 'function') { 861 | // import complete, go go go 862 | loadedBehaviors[bName] = module.default; 863 | initBehavior(bName, bNode); 864 | // check for other instances of this behavior that where awaiting load 865 | if (behaviorsAwaitingImport.get(bName)) { 866 | behaviorsAwaitingImport.get(bName).forEach(node => { 867 | initBehavior(bName, node); 868 | }); 869 | behaviorsAwaitingImport.delete(bName); 870 | } 871 | } else { 872 | console.warn(`Tried to import ${bName}, but it seems to not be a behavior`); 873 | // fail, clean up 874 | importFailed(bName); 875 | } 876 | } 877 | 878 | /** 879 | * createBehaviors 880 | * 881 | * assign behaviors to nodes 882 | * 883 | * @param {HTMLElement} node - node to check for behaviors on elements 884 | */ 885 | function createBehaviors(node) { 886 | // Ignore text or comment nodes 887 | if (!('querySelectorAll' in node)) { 888 | return; 889 | } 890 | 891 | // first check for "critical" behavior nodes 892 | // these will be run immediately on discovery 893 | const behaviorNodes = [node, ...node.querySelectorAll(`[data-${options.dataAttr}]`)]; 894 | behaviorNodes.forEach(bNode => { 895 | // an element can have multiple behaviors 896 | const bNames = getBehaviorNames(bNode, options.dataAttr); 897 | // loop them 898 | if (bNames) { 899 | bNames.forEach(bName => { 900 | initBehavior(bName, bNode); 901 | }); 902 | } 903 | }); 904 | 905 | // now check for "lazy" behaviors 906 | // these are triggered via an intersection observer 907 | // these have optional breakpoints at which to trigger 908 | const lazyBehaviorNodes = [node, ...node.querySelectorAll(`[data-${options.lazyAttr}]`)]; 909 | lazyBehaviorNodes.forEach(bNode => { 910 | // look for lazy behavior names 911 | const bNames = getBehaviorNames(bNode, options.lazyAttr); 912 | const bMap = new Map(); 913 | bNames.forEach(bName => { 914 | // check for a lazy behavior breakpoint trigger 915 | const behaviorMedia = bNode.dataset[`${bName.toLowerCase()}Lazymedia`]; 916 | // store 917 | bMap.set(bName, behaviorMedia || false); 918 | }); 919 | // store and observe 920 | if (bNode !== document) { 921 | ioEntries.set(bNode, bMap); 922 | intersecting.set(bNode, false); 923 | io.observe(bNode); 924 | } 925 | }); 926 | } 927 | 928 | /** 929 | * observeBehaviors 930 | * 931 | * runs a `MutationObserver`, which watches for DOM changes 932 | * when a DOM change happens, insertion or deletion, 933 | * the call back runs, informing us of what changed 934 | */ 935 | function observeBehaviors() { 936 | // flag to stop multiple MutationObserver 937 | observingBehaviors = true; 938 | // set up MutationObserver 939 | const mo = new MutationObserver(mutations => { 940 | // report on what changed 941 | mutations.forEach(mutation => { 942 | mutation.removedNodes.forEach(node => { 943 | destroyBehaviors(node); 944 | }); 945 | mutation.addedNodes.forEach(node => { 946 | createBehaviors(node); 947 | }); 948 | }); 949 | }); 950 | // observe changes to the entire document 951 | mo.observe(document.body, { 952 | childList: true, 953 | subtree: true, 954 | attributes: false, 955 | characterData: false, 956 | }); 957 | } 958 | 959 | /** 960 | * loopLazyBehaviorNodes 961 | * 962 | * Looks at the nodes that have lazy behaviors, checks 963 | * if they're intersecting, optionally checks the breakpoint 964 | * and initialises if needed. Cleans up after itself, by 965 | * removing the intersection observer observing of the node 966 | * if all lazy behaviors on a node have been initialised 967 | * 968 | * @param {HTMLElement[]} bNodes - elements to check for lazy behaviors 969 | */ 970 | function loopLazyBehaviorNodes(bNodes) { 971 | bNodes.forEach(bNode => { 972 | // first, check if this node is being intersected 973 | if (intersecting.get(bNode) !== undefined && intersecting.get(bNode) === false) { 974 | return; 975 | } 976 | // now check to see if we have any lazy behavior names 977 | let lazyBNames = ioEntries.get(bNode); 978 | if (!lazyBNames) { 979 | return; 980 | } 981 | // 982 | lazyBNames.forEach((bMedia, bName) => { 983 | // if no lazy behavior breakpoint trigger, 984 | // or if the current breakpoint matches 985 | if (!bMedia || isBreakpoint(bMedia, options.breakpoints)) { 986 | // run behavior on node 987 | initBehavior(bName, bNode); 988 | // remove this behavior from the list of lazy behaviors 989 | lazyBNames.delete(bName); 990 | // if there are no more lazy behaviors left on the node 991 | // stop observing the node 992 | // else update the ioEntries 993 | if (lazyBNames.size === 0) { 994 | io.unobserve(bNode); 995 | ioEntries.delete(bNode); 996 | } else { 997 | ioEntries.set(bNode, lazyBNames); 998 | } 999 | } 1000 | }); 1001 | // end loopLazyBehaviorNodes bNodes loop 1002 | }); 1003 | } 1004 | 1005 | /** 1006 | * intersection 1007 | * 1008 | * The intersection observer call back, 1009 | * sets a value in the intersecting map true/false 1010 | * and if an entry is intersecting, checks if needs to 1011 | * init any lazy behaviors 1012 | * 1013 | * @param {IntersectionObserverEntry[]} entries 1014 | */ 1015 | function intersection(entries) { 1016 | entries.forEach(entry => { 1017 | if (entry.isIntersecting) { 1018 | intersecting.set(entry.target, true); 1019 | loopLazyBehaviorNodes([entry.target]); 1020 | } else { 1021 | intersecting.set(entry.target, false); 1022 | } 1023 | }); 1024 | } 1025 | 1026 | /** 1027 | * mediaQueryUpdated 1028 | * 1029 | * If a resize has happened with enough size that a 1030 | * breakpoint has changed, checks to see if any lazy 1031 | * behaviors need to be initialised or not 1032 | */ 1033 | function mediaQueryUpdated() { 1034 | loopLazyBehaviorNodes(Array.from(ioEntries.keys())); 1035 | } 1036 | 1037 | 1038 | /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Public methods */ 1039 | 1040 | 1041 | /** 1042 | * initBehavior 1043 | * 1044 | * Return behavior instance if behavior is already loaded 1045 | * 1046 | * Run the `init` method inside a behavior, 1047 | * the internal one in `createBehavior`, which then 1048 | * runs the behaviors `init` life cycle method 1049 | * 1050 | * @param {string} bName - name of behavior 1051 | * @param {HTMLElement} bNode - node to initialise behavior on 1052 | * @param config 1053 | * @returns {Behavior|void} 1054 | */ 1055 | function initBehavior(bName, bNode, config = {}) { 1056 | // first check we have a loaded behavior 1057 | if (!loadedBehaviors[bName]) { 1058 | // if not, attempt to import it 1059 | importBehavior(bName, bNode); 1060 | return; 1061 | } 1062 | // merge breakpoints into config 1063 | config = { 1064 | breakpoints: options.breakpoints, 1065 | ...config 1066 | }; 1067 | // now check that this behavior isn't already 1068 | // running on this node 1069 | const nodeBehaviors = activeBehaviors.get(bNode) || {}; 1070 | if (Object.keys(nodeBehaviors).length === 0 || !nodeBehaviors[bName]) { 1071 | const instance = new loadedBehaviors[bName](bNode, config); 1072 | // update internal store of whats running 1073 | nodeBehaviors[bName] = instance; 1074 | activeBehaviors.set(bNode, nodeBehaviors); 1075 | // init method in the behavior 1076 | try { 1077 | instance.init(); 1078 | return instance; 1079 | } catch(err) { 1080 | console.log(`Error in behavior '${ bName }' on:`, bNode); 1081 | console.log(err); 1082 | } 1083 | } 1084 | } 1085 | 1086 | /** 1087 | * addBehaviors 1088 | * 1089 | * Adds each behavior to memory, to be initialised to a DOM node when the 1090 | * corresponding DOM node exists 1091 | * 1092 | * Can pass 1093 | * - a singular behavior as created by `createBehavior`, 1094 | * - a behavior object which will be passed to `createBehavior` 1095 | * - a behavior module 1096 | * - a collection of behavior modules 1097 | * 1098 | * @param {function|string} behaviors 1099 | */ 1100 | function addBehaviors(behaviors) { 1101 | // if singular behavior added, sort into module like structure 1102 | if (typeof behaviors === 'function' && behaviors.prototype.behaviorName) { 1103 | behaviors = { [behaviors.prototype.behaviorName]: behaviors }; 1104 | } 1105 | // if an uncompiled behavior object is passed, create it 1106 | if (typeof behaviors === 'string' && arguments.length > 1) { 1107 | behaviors = { [behaviors]: createBehavior(...arguments) }; 1108 | } 1109 | // process 1110 | const unique = Object.keys(behaviors).filter((o) => loadedBehaviorNames.indexOf(o) === -1); 1111 | if (unique.length) { 1112 | // we have new unique behaviors, store them 1113 | loadedBehaviorNames = loadedBehaviorNames.concat(unique); 1114 | unique.forEach(bName => { 1115 | loadedBehaviors[bName] = behaviors[bName]; 1116 | }); 1117 | } 1118 | } 1119 | 1120 | /** 1121 | * nodeBehaviors 1122 | * 1123 | * Is returned as public method when webpack is set to development mode 1124 | * 1125 | * Returns all active behaviors on a node 1126 | * 1127 | * @param {string} bNode - node on which to get active behaviors on 1128 | * @returns {Object.} 1129 | */ 1130 | function nodeBehaviors(bNode) { 1131 | const nodeBehaviors = activeBehaviors.get(bNode); 1132 | if (!nodeBehaviors) { 1133 | console.warn(`No behaviors on:`, bNode); 1134 | } else { 1135 | return nodeBehaviors; 1136 | } 1137 | } 1138 | 1139 | /** 1140 | * behaviorProperties 1141 | * 1142 | * Is returned as public method when webpack is set to development mode 1143 | * 1144 | * Returns all properties of a behavior 1145 | * 1146 | * @param {string} bName - name of behavior to return properties of 1147 | * @param {string} bNode - node on which the behavior is running 1148 | * @returns {Behavior|void} 1149 | */ 1150 | function behaviorProperties(bName, bNode) { 1151 | const nodeBehaviors = activeBehaviors.get(bNode); 1152 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 1153 | console.warn(`No behavior '${bName}' instance on:`, bNode); 1154 | } else { 1155 | return activeBehaviors.get(bNode)[bName]; 1156 | } 1157 | } 1158 | 1159 | /** 1160 | * behaviorProp 1161 | * 1162 | * Is returned as public method when webpack is set to development mode 1163 | * 1164 | * Returns specific property of a behavior on a node, or runs a method 1165 | * or sets a property on a behavior if a value is set. For debuggging. 1166 | * 1167 | * @param {string} bName - name of behavior to return properties of 1168 | * @param {string} bNode - node on which the behavior is running 1169 | * @param {string} prop - property to return or set 1170 | * @param [value] - value to set 1171 | * @returns {*} 1172 | */ 1173 | function behaviorProp(bName, bNode, prop, value) { 1174 | const nodeBehaviors = activeBehaviors.get(bNode); 1175 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 1176 | console.warn(`No behavior '${bName}' instance on:`, bNode); 1177 | } else if (activeBehaviors.get(bNode)[bName][prop]) { 1178 | if (value && typeof value === 'function') { 1179 | return activeBehaviors.get(bNode)[bName][prop]; 1180 | } else if (value) { 1181 | activeBehaviors.get(bNode)[bName][prop] = value; 1182 | } else { 1183 | return activeBehaviors.get(bNode)[bName][prop]; 1184 | } 1185 | } else { 1186 | console.warn(`No property '${prop}' in behavior '${bName}' instance on:`, bNode); 1187 | } 1188 | } 1189 | 1190 | /* 1191 | init 1192 | 1193 | gets this show on the road 1194 | 1195 | loadedBehaviorsModule - optional behaviors module to load on init 1196 | opts - any options for this instance 1197 | */ 1198 | /** 1199 | * init 1200 | * 1201 | * gets this show on the road 1202 | * 1203 | * @param [loadedBehaviorsModule] - optional behaviors module to load on init 1204 | * @param opts - any options for this instance 1205 | */ 1206 | function init(loadedBehaviorsModule, opts = {}) { 1207 | options = { 1208 | ...options, ...opts 1209 | }; 1210 | 1211 | // on resize, check 1212 | resized(); 1213 | 1214 | // set up intersection observer 1215 | io = new IntersectionObserver(intersection, options.intersectionOptions); 1216 | 1217 | // if fn run with supplied behaviors, lets add them and begin 1218 | if (loadedBehaviorsModule) { 1219 | addBehaviors(loadedBehaviorsModule); 1220 | } 1221 | 1222 | // try and apply behaviors to any DOM node that needs them 1223 | createBehaviors(document); 1224 | 1225 | // start the mutation observer looking for DOM changes 1226 | if (!observingBehaviors) { 1227 | observeBehaviors(); 1228 | } 1229 | 1230 | // watch for break point changes 1231 | window.addEventListener('mediaQueryUpdated', mediaQueryUpdated); 1232 | } 1233 | 1234 | /** 1235 | * addAndInit 1236 | * 1237 | * Can pass 1238 | * - a singular behavior as created by `createBehavior`, 1239 | * - a behavior object which will be passed to `createBehavior` 1240 | * - a behavior module 1241 | * - a collection of behavior modules 1242 | * 1243 | * @param [behaviors] - optional behaviors module to load on init 1244 | * (all arguments are passed to addBehaviors) 1245 | */ 1246 | function addAndInit() { 1247 | if (arguments) { 1248 | addBehaviors.apply(null, arguments); 1249 | 1250 | // try and apply behaviors to any DOM node that needs them 1251 | createBehaviors(document); 1252 | } 1253 | } 1254 | 1255 | // expose public methods, essentially returning 1256 | 1257 | let exportObj = { 1258 | init: init, 1259 | add: addAndInit, 1260 | initBehavior: initBehavior, 1261 | get currentBreakpoint() { 1262 | return getCurrentMediaQuery(); 1263 | } 1264 | }; 1265 | 1266 | try { 1267 | if (process.env.MODE === 'development') { 1268 | Object.defineProperty(exportObj, 'loaded', { 1269 | get: () => { 1270 | return loadedBehaviorNames; 1271 | } 1272 | }); 1273 | exportObj.activeBehaviors = activeBehaviors; 1274 | exportObj.active = activeBehaviors; 1275 | exportObj.getBehaviors = nodeBehaviors; 1276 | exportObj.getProps = behaviorProperties; 1277 | exportObj.getProp = behaviorProp; 1278 | exportObj.setProp = behaviorProp; 1279 | exportObj.callMethod = behaviorProp; 1280 | } 1281 | } catch(err) { 1282 | // no process.env.mode 1283 | } 1284 | 1285 | /** 1286 | * Extend an existing a behavior instance 1287 | * @param {module} behavior - behavior you want to extend 1288 | * @param {string} name - Name of the extended behavior used for declaration: data-behavior="name" 1289 | * @param {object} methods - define methods of the behavior 1290 | * @param {object} lifecycle - Register behavior lifecycle 1291 | * @returns {Behavior} 1292 | * 1293 | * NB: methods or lifestyle fns with the same name will overwrite originals 1294 | */ 1295 | function extendBehavior(behavior, name, methods = {}, lifecycle = {}) { 1296 | const newMethods = Object.assign(Object.assign({}, behavior.prototype.methods), methods); 1297 | const newLifecycle = Object.assign(Object.assign({}, behavior.prototype.lifecycle), lifecycle); 1298 | 1299 | return createBehavior(name, newMethods, newLifecycle); 1300 | } 1301 | 1302 | export { createBehavior, extendBehavior, exportObj as manageBehaviors }; 1303 | -------------------------------------------------------------------------------- /dist/cjs/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * getCurrentMediaQuery : Returns the current media query in use by reading a CSS :root variable. Useful for running JS functions at certain breakpoints without holding breakpoint size information in CSS and JS. 5 | * 6 | * @returns {string} Media query string 7 | */ 8 | const getCurrentMediaQuery = function() { 9 | // Doc: https://github.com/area17/js-helpers/wiki/getCurrentMediaQuery 10 | if(typeof window === 'undefined') return ''; 11 | return getComputedStyle(document.documentElement).getPropertyValue('--breakpoint').trim().replace(/"/g, ''); 12 | }; 13 | 14 | /** 15 | * resized : Debounces window resize, also checks if current media query has changed 16 | * @example document.addEventListener('resized', function(event) { console.log(event.detail.breakpoint); }); 17 | */ 18 | const resized = function() { 19 | // Doc: https://github.com/area17/js-helpers/wiki/resized 20 | 21 | var resizeTimer; 22 | var resizedDelay = 250; 23 | var mediaQuery = getCurrentMediaQuery(); 24 | 25 | function informApp() { 26 | // check media query 27 | var newMediaQuery = getCurrentMediaQuery(); 28 | 29 | // tell everything resized happened 30 | window.dispatchEvent(new CustomEvent('resized', { 31 | detail: { 32 | breakpoint: newMediaQuery 33 | } 34 | })); 35 | 36 | // if media query changed, tell everything 37 | if (newMediaQuery !== mediaQuery) { 38 | if (window.A17) { 39 | window.A17.currentMediaQuery = newMediaQuery; 40 | } 41 | window.dispatchEvent(new CustomEvent('mediaQueryUpdated', { 42 | detail: { 43 | breakpoint: newMediaQuery, 44 | prevBreakpoint: mediaQuery 45 | } 46 | })); 47 | mediaQuery = newMediaQuery; 48 | } 49 | } 50 | 51 | window.addEventListener('resize', function() { 52 | clearTimeout(resizeTimer); 53 | resizeTimer = setTimeout(informApp, resizedDelay); 54 | }); 55 | 56 | // Firefox doesn't fire a `resize` event on text scaling 57 | // yet when doing so, its likely you'll cycle through your breakpoints 58 | // effectively you're resizing, even if the window doesn't change in dimensions 59 | // so, set up a `ResizeObserver` on the `document.documentElement` (the HTML tag) 60 | // and listen for it changing in someway and trigger a resize event 61 | // 62 | // the assumption being that your page will likely change in height 63 | // as the content resizes with a responsive layout 64 | // this isn't infallible... 65 | // its possible you have some sort of fixed height site 66 | if (typeof ResizeObserver === 'function') { 67 | let resizedTimer = null; 68 | const resizeObserver = new ResizeObserver((entries) => { 69 | clearTimeout(resizedTimer); 70 | resizedTimer = setTimeout(() => { 71 | if (window.A17.currentMediaQuery !== getCurrentMediaQuery()) { 72 | window.dispatchEvent(new Event('resize')); 73 | } 74 | }, resizedDelay + 1); 75 | }); 76 | 77 | resizeObserver.observe(document.documentElement); 78 | } 79 | 80 | if (mediaQuery === '') { 81 | window.requestAnimationFrame(informApp); 82 | } else if (window.A17) { 83 | window.A17.currentMediaQuery = mediaQuery; 84 | } 85 | }; 86 | 87 | /** 88 | * isBreakpoint : Checks if the current breakpoint matches the passed breakpoint. It supports querying with or without +/- modifiers. 89 | * 90 | * @param {string} breakpoint The breakpoint to check against 91 | * @param {string[]} [breakpoints] Array of breakpoint names to test against 92 | * @returns {boolean} Returns true if the breakpoint matches, false if not 93 | */ 94 | const isBreakpoint = function (breakpoint, breakpoints) { 95 | // Doc: https://github.com/area17/js-helpers/wiki/isBreakpoint 96 | 97 | // bail if no breakpoint is passed 98 | if (!breakpoint) { 99 | console.error('You need to pass a breakpoint name!'); 100 | return false 101 | } 102 | 103 | // we only want to look for a specific modifier and make sure it is at the end of the string 104 | const regExp = new RegExp('\\+$|\\-$'); 105 | 106 | // bps must be in order from smallest to largest 107 | let bps = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; 108 | 109 | // override the breakpoints if the option is set on the global A17 object 110 | if (window.A17 && window.A17.breakpoints) { 111 | if (Array.isArray(window.A17.breakpoints)) { 112 | bps = window.A17.breakpoints; 113 | } else { 114 | console.warn('A17.breakpoints should be an array. Using defaults.'); 115 | } 116 | } 117 | 118 | // override the breakpoints if a set of breakpoints is passed through as a parameter (added for A17-behaviors to allow usage with no globals) 119 | if (breakpoints) { 120 | if (Array.isArray(breakpoints)) { 121 | bps = breakpoints; 122 | } else { 123 | console.warn('isBreakpoint breakpoints should be an array. Using defaults.'); 124 | } 125 | } 126 | 127 | // store current breakpoint in use 128 | const currentBp = getCurrentMediaQuery(); 129 | 130 | // store the index of the current breakpoint 131 | const currentBpIndex = bps.indexOf(currentBp); 132 | 133 | // check to see if bp has a + or - modifier 134 | const hasModifier = regExp.exec(breakpoint); 135 | 136 | // store modifier value 137 | const modifier = hasModifier ? hasModifier[0] : false; 138 | 139 | // store the trimmed breakpoint name if a modifier exists, if not, store the full queried breakpoint name 140 | const bpName = hasModifier ? breakpoint.slice(0, -1) : breakpoint; 141 | 142 | // store the index of the queried breakpoint 143 | const bpIndex = bps.indexOf(bpName); 144 | 145 | // let people know if the breakpoint name is unrecognized 146 | if (bpIndex < 0) { 147 | console.warn( 148 | 'Unrecognized breakpoint. Supported breakpoints are: ' + bps.join(', ') 149 | ); 150 | return false 151 | } 152 | 153 | // compare the modifier with the index of the current breakpoint in the bps array with the index of the queried breakpoint. 154 | // if no modifier is set, compare the queried breakpoint name with the current breakpoint name 155 | if ( 156 | (modifier === '+' && currentBpIndex >= bpIndex) || 157 | (modifier === '-' && currentBpIndex <= bpIndex) || 158 | (!modifier && breakpoint === currentBp) 159 | ) { 160 | return true 161 | } 162 | 163 | // the current breakpoint isn’t the one you’re looking for 164 | return false 165 | }; 166 | 167 | /** 168 | * Removes all properties from an object, useful in cleaning up when destroying a method 169 | */ 170 | const purgeProperties = function(obj) { 171 | // Doc: https://github.com/area17/js-helpers/wiki/purgeProperties 172 | for (var prop in obj) { 173 | if (obj.hasOwnProperty(prop)) { 174 | delete obj[prop]; 175 | } 176 | } 177 | 178 | // alternatives considered: https://jsperf.com/deleting-properties-from-an-object 179 | }; 180 | 181 | /** 182 | * Behavior 183 | * @typedef {Object.|BehaviorDef} Behavior 184 | * @property {HTMLElement} $node - Dom node associated to the behavior 185 | * @property {string} name - Name of the behavior 186 | * @property {Object} options 187 | * @property {Lifecycle} lifecycle 188 | */ 189 | 190 | /** 191 | * Behavior lifecycle 192 | * @typedef {Object} Lifecycle 193 | * @property {BehaviorLifecycleFn} [init] - Init function called when behavior is created 194 | * @property {BehaviorLifecycleFn} [enabled] - Triggered when behavior state changed (ex: mediaquery update) 195 | * @property {BehaviorLifecycleFn} [disabled] - Triggered when behavior state changed (ex: mediaquery update) 196 | * @property {BehaviorLifecycleFn} [mediaQueryUpdated] - Triggered when mediaquery change 197 | * @property {BehaviorLifecycleFn} [intersectionIn] - Triggered when behavior is visible (enable intersection observer) 198 | * @property {BehaviorLifecycleFn} [intersectionOut] - Triggered when behavior is hidden (enable intersection observer) 199 | * @property {BehaviorLifecycleFn} [resized] - Triggered when window is resized 200 | * @property {BehaviorLifecycleFn} [destroy] - Triggered before behavior will be destroyed and removed 201 | */ 202 | 203 | /** 204 | * @typedef {function} BehaviorLifecycleFn 205 | * @this Behavior 206 | */ 207 | 208 | /** 209 | * @typedef {function} BehaviorDefFn 210 | * @this Behavior 211 | */ 212 | 213 | /** 214 | * Behavior definition 215 | * @typedef {Object.} BehaviorDef 216 | */ 217 | 218 | /** 219 | * Behavior constructor 220 | * @constructor 221 | * @param {HTMLElement} node - A DOM element 222 | * @param config - behavior options 223 | * @returns {Behavior} 224 | */ 225 | function Behavior(node, config = {}) { 226 | if (!node || !(node instanceof Element)) { 227 | throw new Error('Node argument is required'); 228 | } 229 | 230 | this.$node = this.getChild(node); 231 | this.options = Object.assign({ 232 | intersectionOptions: { 233 | rootMargin: '20%', 234 | } 235 | }, config.options || {}); 236 | 237 | this.__isEnabled = false; 238 | this.__children = config.children; 239 | this.__breakpoints = config.breakpoints; 240 | this.__abortController = new AbortController(); 241 | 242 | // Auto-bind all custom methods to "this" 243 | this.customMethodNames.forEach(methodName => { 244 | this[methodName] = this.methods[methodName].bind(this); 245 | }); 246 | 247 | this._binds = {}; 248 | this._data = new Proxy(this._binds, { 249 | set: (target, key, value) => { 250 | this.updateBinds(key, value); 251 | target[key] = value; 252 | return true; 253 | } 254 | }); 255 | 256 | this.__isIntersecting = false; 257 | this.__intersectionObserver = null; 258 | 259 | return this; 260 | } 261 | 262 | /** 263 | * 264 | * @type {Behavior} 265 | */ 266 | Behavior.prototype = Object.freeze({ 267 | updateBinds(key, value) { 268 | // TODO: cache these before hand? 269 | const targetEls = this.$node.querySelectorAll('[data-' + this.name.toLowerCase() + '-bindel*=' + key + ']'); 270 | targetEls.forEach((target) => { 271 | target.innerHTML = value; 272 | }); 273 | // TODO: cache these before hand? 274 | const targetAttrs = this.$node.querySelectorAll('[data-' + this.name.toLowerCase() + '-bindattr*="' + key + ':"]'); 275 | targetAttrs.forEach((target) => { 276 | let bindings = target.dataset[this.name.toLowerCase() + 'Bindattr']; 277 | bindings.split(',').forEach((pair) => { 278 | pair = pair.split(':'); 279 | if (pair[0] === key) { 280 | if (pair[1] === 'class') { 281 | // TODO: needs to know what the initial class was to remove it - fix? 282 | if (this._binds[key] !== value) { 283 | target.classList.remove(this._binds[key]); 284 | } 285 | if (value) { 286 | target.classList.add(value); 287 | } 288 | } else { 289 | target.setAttribute(pair[1], value); 290 | } 291 | } 292 | }); 293 | }); 294 | }, 295 | init() { 296 | // Get options from data attributes on node 297 | const regex = new RegExp('^data-' + this.name + '-(.*)', 'i'); 298 | for (let i = 0; i < this.$node.attributes.length; i++) { 299 | const attr = this.$node.attributes[i]; 300 | const matches = regex.exec(attr.nodeName); 301 | 302 | if (matches != null && matches.length >= 2) { 303 | if (this.options[matches[1]]) { 304 | console.warn( 305 | `Ignoring ${ 306 | matches[1] 307 | } option, as it already exists on the ${name} behavior. Please choose another name.` 308 | ); 309 | } 310 | this.options[matches[1]] = attr.value; 311 | } 312 | } 313 | 314 | // Behavior-specific lifecycle 315 | if (typeof this.lifecycle?.init === 'function') { 316 | this.lifecycle.init.call(this); 317 | } 318 | 319 | if (typeof this.lifecycle?.resized === 'function') { 320 | this.__resizedBind = this.__resized.bind(this); 321 | window.addEventListener('resized', this.__resizedBind); 322 | } 323 | 324 | if (typeof this.lifecycle.mediaQueryUpdated === 'function' || this.options.media) { 325 | this.__mediaQueryUpdatedBind = this.__mediaQueryUpdated.bind(this); 326 | window.addEventListener('mediaQueryUpdated', this.__mediaQueryUpdatedBind); 327 | } 328 | 329 | if (this.options.media) { 330 | this.__toggleEnabled(); 331 | } else { 332 | this.enable(); 333 | } 334 | 335 | this.__intersections(); 336 | }, 337 | destroy() { 338 | this.__abortController.abort(); 339 | 340 | if (this.__isEnabled === true) { 341 | this.disable(); 342 | } 343 | 344 | // Behavior-specific lifecycle 345 | if (typeof this.lifecycle?.destroy === 'function') { 346 | this.lifecycle.destroy.call(this); 347 | } 348 | 349 | if (typeof this.lifecycle.resized === 'function') { 350 | window.removeEventListener('resized', this.__resizedBind); 351 | } 352 | 353 | if (typeof this.lifecycle.mediaQueryUpdated === 'function' || this.options.media) { 354 | window.removeEventListener('mediaQueryUpdated', this.__mediaQueryUpdatedBind); 355 | } 356 | 357 | if (this.lifecycle.intersectionIn != null || this.lifecycle.intersectionOut != null) { 358 | this.__intersectionObserver.unobserve(this.$node); 359 | this.__intersectionObserver.disconnect(); 360 | } 361 | 362 | purgeProperties(this); 363 | }, 364 | /** 365 | * Look for a child of the behavior: data-behaviorName-childName 366 | * @param {string} childName 367 | * @param {HTMLElement} context - Define the ancestor where search begin, default is current node 368 | * @param {boolean} multi - Define usage between querySelectorAll and querySelector 369 | * @returns {HTMLElement|null} 370 | */ 371 | getChild(selector, context, multi = false) { 372 | // lets make a selection 373 | let selection; 374 | // 375 | if (this.__children != null && this.__children[selector] != null) { 376 | // if the selector matches a pre-selected set, set to that set 377 | // TODO: confirm what this is and its usage 378 | selection = this.__children[selector]; 379 | } else if (selector instanceof NodeList) { 380 | // if a node list has been passed, use it 381 | selection = selector; 382 | multi = true; 383 | } else if (selector instanceof Element || selector instanceof HTMLDocument || selector === window) { 384 | // if a single node, the document or the window is passed, set to that 385 | selection = selector; 386 | multi = false; 387 | } else { 388 | // else, lets find named children within the container 389 | if (context == null) { 390 | // set a default context of the container node 391 | context = this.$node; 392 | } 393 | // find 394 | selection = context[multi ? 'querySelectorAll' : 'querySelector']( 395 | '[data-' + this.name.toLowerCase() + '-' + selector.toLowerCase() + ']' 396 | ); 397 | } 398 | 399 | if (multi && selection?.length > 0) { 400 | // apply on/off methods to the selected DOM node list 401 | selection.on = (type, fn, opt) => { 402 | selection.forEach(el => { 403 | this.__on(el, type, fn, opt); 404 | }); 405 | }; 406 | selection.off = (type, fn) => { 407 | selection.forEach(el => { 408 | this.__off(el, type, fn); 409 | }); 410 | }; 411 | // and apply to the individual nodes within 412 | selection.forEach(el => { 413 | el.on = el.on ? el.on : (type, fn, opt) => { 414 | this.__on(el, type, fn, opt); 415 | }; 416 | el.off = el.off ? el.off : (type, fn) => { 417 | this.__off(el, type, fn); 418 | }; 419 | }); 420 | } else if(selection) { 421 | // apply on/off methods to the singular selected node 422 | selection.on = selection.on ? selection.on : (type, fn, opt) => { 423 | this.__on(selection, type, fn, opt); 424 | }; 425 | selection.off = selection.off ? selection.off : (type, fn) => { 426 | this.__off(selection, type, fn); 427 | }; 428 | } 429 | 430 | // return to variable assignment 431 | return selection; 432 | }, 433 | /** 434 | * Look for children of the behavior: data-behaviorName-childName 435 | * @param {string} childName 436 | * @param {HTMLElement} context - Define the ancestor where search begin, default is current node 437 | * @returns {HTMLElement|null} 438 | */ 439 | getChildren(childName, context) { 440 | return this.getChild(childName, context, true); 441 | }, 442 | isEnabled() { 443 | return this.__isEnabled; 444 | }, 445 | enable() { 446 | this.__isEnabled = true; 447 | if (typeof this.lifecycle.enabled === 'function') { 448 | this.lifecycle.enabled.call(this); 449 | } 450 | }, 451 | disable() { 452 | this.__isEnabled = false; 453 | if (typeof this.lifecycle.disabled === 'function') { 454 | this.lifecycle.disabled.call(this); 455 | } 456 | }, 457 | addSubBehavior(SubBehavior, node = this.$node, config = {}) { 458 | const mb = exportObj; 459 | if (typeof SubBehavior === 'string') { 460 | mb.initBehavior(SubBehavior, node, config); 461 | } else { 462 | mb.add(SubBehavior); 463 | mb.initBehavior(SubBehavior.prototype.behaviorName, node, config); 464 | } 465 | }, 466 | /** 467 | * Check if breakpoint passed in param is the current one 468 | * @param {string} bp - Breakpoint to check 469 | * @returns {boolean} 470 | */ 471 | isBreakpoint(bp) { 472 | return isBreakpoint(bp, this.__breakpoints); 473 | }, 474 | __on(el, type, fn, opt) { 475 | if (typeof opt === 'boolean' && opt === true) { 476 | opt = { 477 | passive: true 478 | }; 479 | } 480 | const options = { 481 | signal: this.__abortController.signal, 482 | ...opt 483 | }; 484 | if (!el.attachedListeners) { 485 | el.attachedListeners = {}; 486 | } 487 | // check if el already has this listener 488 | let found = Object.values(el.attachedListeners).find(listener => listener.type === type && listener.fn === fn); 489 | if (!found) { 490 | el.attachedListeners[Object.values(el.attachedListeners).length] = { 491 | type: type, 492 | fn: fn, 493 | }; 494 | el.addEventListener(type, fn, options); 495 | } 496 | }, 497 | __off(el, type, fn) { 498 | if (el.attachedListeners) { 499 | Object.keys(el.attachedListeners).forEach(key => { 500 | const thisListener = el.attachedListeners[key]; 501 | if ( 502 | (!type && !fn) || // off() 503 | (type === thisListener.type && !fn) || // match type with no fn 504 | (type === thisListener.type && fn === thisListener.fn) // match both type and fn 505 | ) { 506 | delete el.attachedListeners[key]; 507 | el.removeEventListener(thisListener.type, thisListener.fn); 508 | } 509 | }); 510 | } else { 511 | el.removeEventListener(type, fn); 512 | } 513 | }, 514 | __toggleEnabled() { 515 | const isValidMQ = isBreakpoint(this.options.media, this.__breakpoints); 516 | if (isValidMQ && !this.__isEnabled) { 517 | this.enable(); 518 | } else if (!isValidMQ && this.__isEnabled) { 519 | this.disable(); 520 | } 521 | }, 522 | __mediaQueryUpdated(e) { 523 | if (typeof this.lifecycle?.mediaQueryUpdated === 'function') { 524 | this.lifecycle.mediaQueryUpdated.call(this, e); 525 | } 526 | if (this.options.media) { 527 | this.__toggleEnabled(); 528 | } 529 | }, 530 | __resized(e) { 531 | if (typeof this.lifecycle?.resized === 'function') { 532 | this.lifecycle.resized.call(this, e); 533 | } 534 | }, 535 | __intersections() { 536 | if (this.lifecycle.intersectionIn != null || this.lifecycle.intersectionOut != null) { 537 | this.__intersectionObserver = new IntersectionObserver(entries => { 538 | entries.forEach(entry => { 539 | if (entry.target === this.$node) { 540 | if (entry.isIntersecting) { 541 | if (!this.__isIntersecting && typeof this.lifecycle.intersectionIn === 'function') { 542 | this.__isIntersecting = true; 543 | this.lifecycle.intersectionIn.call(this); 544 | } 545 | } else { 546 | if (this.__isIntersecting && typeof this.lifecycle.intersectionOut === 'function') { 547 | this.__isIntersecting = false; 548 | this.lifecycle.intersectionOut.call(this); 549 | } 550 | } 551 | } 552 | }); 553 | }, this.options.intersectionOptions); 554 | this.__intersectionObserver.observe(this.$node); 555 | } 556 | } 557 | }); 558 | 559 | /** 560 | * Create a behavior instance 561 | * @param {string} name - Name of the behavior used for declaration: data-behavior="name" 562 | * @param {object} methods - define methods of the behavior 563 | * @param {object} lifecycle - Register behavior lifecycle 564 | * @returns {Behavior} 565 | */ 566 | const createBehavior = (name, methods = {}, lifecycle = {}) => { 567 | /** 568 | * 569 | * @param args 570 | */ 571 | const fn = function(...args) { 572 | Behavior.apply(this, args); 573 | }; 574 | 575 | const customMethodNames = []; 576 | 577 | const customProperties = { 578 | name: { 579 | get() { 580 | return this.behaviorName; 581 | }, 582 | }, 583 | behaviorName: { 584 | value: name, 585 | writable: true, 586 | }, 587 | lifecycle: { 588 | value: lifecycle, 589 | }, 590 | methods: { 591 | value: methods, 592 | }, 593 | customMethodNames: { 594 | value: customMethodNames, 595 | }, 596 | }; 597 | 598 | // Expose the definition properties as 'this[methodName]' 599 | const methodsKeys = Object.keys(methods); 600 | methodsKeys.forEach(key => { 601 | customMethodNames.push(key); 602 | }); 603 | 604 | fn.prototype = Object.create(Behavior.prototype, customProperties); 605 | 606 | return fn; 607 | }; 608 | 609 | let options = { 610 | dataAttr: 'behavior', 611 | lazyAttr: 'behavior-lazy', 612 | intersectionOptions: { 613 | rootMargin: '20%', 614 | }, 615 | breakpoints: ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'], 616 | dynamicBehaviors: {}, 617 | }; 618 | 619 | let loadedBehaviorNames = []; 620 | let observingBehaviors = false; 621 | const loadedBehaviors = {}; 622 | const activeBehaviors = new Map(); 623 | const behaviorsAwaitingImport = new Map(); 624 | let io; 625 | const ioEntries = new Map(); // need to keep a separate map of intersection observer entries as `io.takeRecords()` always returns an empty array, seems broken in all browsers 🤷🏻‍♂️ 626 | const intersecting = new Map(); 627 | 628 | /** 629 | * getBehaviorNames 630 | * 631 | * Data attribute names can be written in any case, 632 | * but `node.dataset` names are lowercase 633 | * with camel casing for names split by - 634 | * eg: `data-foo-bar` becomes `node.dataset.fooBar` 635 | * 636 | * @param {HTMLElement} bNode - node to grab behavior names from 637 | * @param {string} attr - name of attribute to pick 638 | * @returns {string[]} 639 | */ 640 | function getBehaviorNames(bNode, attr) { 641 | attr = attr.toLowerCase().replace(/-([a-zA-Z0-9])/ig, (match, p1) => { 642 | return p1.toUpperCase(); 643 | }); 644 | if (bNode.dataset && bNode.dataset[attr]) { 645 | return bNode.dataset[attr].split(' ').filter(bName => bName); 646 | } else { 647 | return []; 648 | } 649 | } 650 | 651 | /** 652 | * importFailed 653 | * 654 | * 655 | * Either the imported module didn't look like a behavior module 656 | * or nothing could be found to import 657 | * 658 | * @param {string }bName - name of behavior that failed to import 659 | */ 660 | function importFailed(bName) { 661 | // remove name from loaded behavior names index 662 | // maybe it'll be included via a script tag later 663 | const bNameIndex = loadedBehaviorNames.indexOf(bName); 664 | if (bNameIndex > -1) { 665 | loadedBehaviorNames.splice(bNameIndex, 1); 666 | } 667 | } 668 | 669 | /** 670 | * destroyBehavior 671 | * 672 | * 673 | * All good things must come to an end... 674 | * Ok so likely the node has been removed, possibly by 675 | * a deletion or ajax type page change 676 | * 677 | * @param {string} bName - name of behavior to destroy 678 | * @param {string} bNode - node to destroy behavior on 679 | */ 680 | function destroyBehavior(bName, bNode) { 681 | const nodeBehaviors = activeBehaviors.get(bNode); 682 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 683 | console.warn(`No behavior '${bName}' instance on:`, bNode); 684 | return; 685 | } 686 | 687 | /** 688 | * run destroy method, remove, delete 689 | * `destroy()` is an internal method of a behavior in `createBehavior`. Individual behaviors may 690 | * also have their own `destroy` methods (called by 691 | * the `createBehavior` `destroy`) 692 | */ 693 | nodeBehaviors[bName].destroy(); 694 | delete nodeBehaviors[bName]; 695 | if (Object.keys(nodeBehaviors).length === 0) { 696 | activeBehaviors.delete(bNode); 697 | } 698 | } 699 | 700 | /** 701 | * destroyBehaviors 702 | * 703 | * if a node with behaviors is removed from the DOM, 704 | * clean up to save resources 705 | * 706 | * @param {HTMLElement} rNode -node to destroy behaviors on (and inside of) 707 | */ 708 | function destroyBehaviors(rNode) { 709 | const bNodes = Array.from(activeBehaviors.keys()); 710 | bNodes.push(rNode); 711 | bNodes.forEach(bNode => { 712 | // is the active node the removed node 713 | // or does the removed node contain the active node? 714 | if (rNode === bNode || rNode.contains(bNode)) { 715 | // get behaviors on node 716 | const bNodeActiveBehaviors = activeBehaviors.get(bNode); 717 | // if some, destroy 718 | if (bNodeActiveBehaviors) { 719 | Object.keys(bNodeActiveBehaviors).forEach(bName => { 720 | destroyBehavior(bName, bNode); 721 | // stop intersection observer from watching node 722 | io.unobserve(bNode); 723 | ioEntries.delete(bNode); 724 | intersecting.delete(bNode); 725 | }); 726 | } 727 | } 728 | }); 729 | } 730 | 731 | /** 732 | * importBehavior 733 | * 734 | * Use `import` to bring in a behavior module and run it. 735 | * This runs if there is no loaded behavior of this name. 736 | * After import, the behavior is initialised on the node 737 | * 738 | * @param {string} bName - name of behavior 739 | * @param {HTMLElement} bNode - node to initialise behavior on 740 | */ 741 | function importBehavior(bName, bNode) { 742 | // first check we haven't already got this behavior module 743 | if (loadedBehaviorNames.indexOf(bName) > -1) { 744 | // if no, store a list of nodes awaiting this behavior to load 745 | const awaitingImport = behaviorsAwaitingImport.get(bName) || []; 746 | if (!awaitingImport.includes(bNode)) { 747 | awaitingImport.push(bNode); 748 | } 749 | behaviorsAwaitingImport.set(bName, awaitingImport); 750 | return; 751 | } 752 | // push to our store of loaded behaviors 753 | loadedBehaviorNames.push(bName); 754 | // import 755 | // process.env variables set in webpack/vite config 756 | // 757 | if (process.env.BUILD === 'vite') { 758 | try { 759 | /** 760 | * For BEHAVIORS_COMPONENT_PATHS usage, 761 | * we need to pass a vite import in the app.js - options.dynamicBehaviors 762 | * and then we look for the import in the globbed dynamicBehaviors 763 | * if exists, run it 764 | * 765 | * NB: using vite glob from app.js as vite glob import only allows literal strings 766 | * and does not allow variables to be passed to it 767 | * see: https://vite.dev/guide/features#glob-import 768 | */ 769 | options.dynamicBehaviors[process.env.BEHAVIORS_COMPONENT_PATHS[bName]]().then(module => { 770 | behaviorImported(bName, bNode, module); 771 | }).catch(err => { 772 | console.warn(`Behavior '${bName}' load failed - possible error with the behavior or a malformed module`); 773 | // fail, clean up 774 | importFailed(bName); 775 | }); 776 | } catch(errV1) { 777 | try { 778 | /** 779 | * Vite bundler requires a known start point for imports 780 | * Fortunately it can use a defined alias in the config 781 | * Webkit uses aliases differently and continues on to the 782 | * imports below (but may throw build warnings attempting this) 783 | */ 784 | options.dynamicBehaviors[`${process.env.BEHAVIORS_PATH}/${bName}.${process.env.BEHAVIORS_EXTENSION}`]().then(module => { 785 | behaviorImported(bName, bNode, module); 786 | }).catch(err => { 787 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Vite has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Vite config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 788 | // fail, clean up 789 | importFailed(bName); 790 | }); 791 | } catch(errV2) { 792 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Vite has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Vite config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 793 | // fail, clean up 794 | importFailed(bName); 795 | } 796 | } 797 | } else { 798 | try { 799 | /** 800 | * If process.env.BUILD not set to 'vite' but Vite is being used it will fail to import 801 | * because Vite bundler rises a warning because import url start with a variable 802 | * @see: https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations 803 | * Warning will be hidden with the below directive vite-ignore 804 | * 805 | * If you're inspecting how these dynamic import work with Webpack, you may wonder why 806 | * we don't just send full paths through in `process.env.BEHAVIORS_COMPONENT_PATHS` 807 | * such as `component: '/components/component/component.js',` 808 | * instead of building a path from `process.env.BEHAVIORS_PATH` and `process.env.BEHAVIORS_COMPONENT_PATHS[bName]` 809 | * - and that would be a good question... 810 | * It seems we need to construct the path this way to let Webpack peek into the directory to map it out. 811 | * Then Webpack doesn't like building if you include the JS filename and file extension 812 | * and I have no idea why... 813 | */ 814 | import( 815 | /* @vite-ignore */ 816 | `${process.env.BEHAVIORS_PATH}/${(process.env.BEHAVIORS_COMPONENT_PATHS[bName]||'').replace(/^\/|\/$/ig,'')}/${bName}.${process.env.BEHAVIORS_EXTENSION}` 817 | ).then(module => { 818 | behaviorImported(bName, bNode, module); 819 | }).catch(err => { 820 | console.warn(`No loaded behavior: ${bName}`); 821 | // fail, clean up 822 | importFailed(bName); 823 | }); 824 | } catch(errW1) { 825 | try { 826 | import( 827 | /** 828 | * Vite bundler rises a warning because import url start with a variable 829 | * @see: https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations 830 | * Warning will be hidden with the below directive vite-ignore 831 | */ 832 | /* @vite-ignore */ 833 | `${process.env.BEHAVIORS_PATH}/${bName}.${process.env.BEHAVIORS_EXTENSION}` 834 | ).then(module => { 835 | behaviorImported(bName, bNode, module); 836 | }).catch(err => { 837 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Webpack has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Webpack config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 838 | // fail, clean up 839 | importFailed(bName); 840 | }); 841 | } catch(errW2) { 842 | console.warn(`Behavior '${bName}' load failed. \nIt maybe the behavior doesn't exist, is malformed or errored. Check for typos and check Webpack has generated your file. \nIf you are using dynamically imported behaviors, you may also want to check your Webpack config. See https://github.com/area17/a17-behaviors/wiki/Setup#webpack-config`); 843 | // fail, clean up 844 | importFailed(bName); 845 | } 846 | } 847 | } 848 | } 849 | 850 | /** 851 | * behaviorImported 852 | * 853 | * Run when a dynamic import is successfully imported, 854 | * sets up and runs the behavior on the node 855 | * 856 | * @param {string} bName - name of behavior 857 | * @param {HTMLElement} bNode - node to initialise behavior on 858 | * @param module - imported behavior module 859 | */ 860 | function behaviorImported(bName, bNode, module) { 861 | // does what we loaded look right? 862 | if (module.default && typeof module.default === 'function') { 863 | // import complete, go go go 864 | loadedBehaviors[bName] = module.default; 865 | initBehavior(bName, bNode); 866 | // check for other instances of this behavior that where awaiting load 867 | if (behaviorsAwaitingImport.get(bName)) { 868 | behaviorsAwaitingImport.get(bName).forEach(node => { 869 | initBehavior(bName, node); 870 | }); 871 | behaviorsAwaitingImport.delete(bName); 872 | } 873 | } else { 874 | console.warn(`Tried to import ${bName}, but it seems to not be a behavior`); 875 | // fail, clean up 876 | importFailed(bName); 877 | } 878 | } 879 | 880 | /** 881 | * createBehaviors 882 | * 883 | * assign behaviors to nodes 884 | * 885 | * @param {HTMLElement} node - node to check for behaviors on elements 886 | */ 887 | function createBehaviors(node) { 888 | // Ignore text or comment nodes 889 | if (!('querySelectorAll' in node)) { 890 | return; 891 | } 892 | 893 | // first check for "critical" behavior nodes 894 | // these will be run immediately on discovery 895 | const behaviorNodes = [node, ...node.querySelectorAll(`[data-${options.dataAttr}]`)]; 896 | behaviorNodes.forEach(bNode => { 897 | // an element can have multiple behaviors 898 | const bNames = getBehaviorNames(bNode, options.dataAttr); 899 | // loop them 900 | if (bNames) { 901 | bNames.forEach(bName => { 902 | initBehavior(bName, bNode); 903 | }); 904 | } 905 | }); 906 | 907 | // now check for "lazy" behaviors 908 | // these are triggered via an intersection observer 909 | // these have optional breakpoints at which to trigger 910 | const lazyBehaviorNodes = [node, ...node.querySelectorAll(`[data-${options.lazyAttr}]`)]; 911 | lazyBehaviorNodes.forEach(bNode => { 912 | // look for lazy behavior names 913 | const bNames = getBehaviorNames(bNode, options.lazyAttr); 914 | const bMap = new Map(); 915 | bNames.forEach(bName => { 916 | // check for a lazy behavior breakpoint trigger 917 | const behaviorMedia = bNode.dataset[`${bName.toLowerCase()}Lazymedia`]; 918 | // store 919 | bMap.set(bName, behaviorMedia || false); 920 | }); 921 | // store and observe 922 | if (bNode !== document) { 923 | ioEntries.set(bNode, bMap); 924 | intersecting.set(bNode, false); 925 | io.observe(bNode); 926 | } 927 | }); 928 | } 929 | 930 | /** 931 | * observeBehaviors 932 | * 933 | * runs a `MutationObserver`, which watches for DOM changes 934 | * when a DOM change happens, insertion or deletion, 935 | * the call back runs, informing us of what changed 936 | */ 937 | function observeBehaviors() { 938 | // flag to stop multiple MutationObserver 939 | observingBehaviors = true; 940 | // set up MutationObserver 941 | const mo = new MutationObserver(mutations => { 942 | // report on what changed 943 | mutations.forEach(mutation => { 944 | mutation.removedNodes.forEach(node => { 945 | destroyBehaviors(node); 946 | }); 947 | mutation.addedNodes.forEach(node => { 948 | createBehaviors(node); 949 | }); 950 | }); 951 | }); 952 | // observe changes to the entire document 953 | mo.observe(document.body, { 954 | childList: true, 955 | subtree: true, 956 | attributes: false, 957 | characterData: false, 958 | }); 959 | } 960 | 961 | /** 962 | * loopLazyBehaviorNodes 963 | * 964 | * Looks at the nodes that have lazy behaviors, checks 965 | * if they're intersecting, optionally checks the breakpoint 966 | * and initialises if needed. Cleans up after itself, by 967 | * removing the intersection observer observing of the node 968 | * if all lazy behaviors on a node have been initialised 969 | * 970 | * @param {HTMLElement[]} bNodes - elements to check for lazy behaviors 971 | */ 972 | function loopLazyBehaviorNodes(bNodes) { 973 | bNodes.forEach(bNode => { 974 | // first, check if this node is being intersected 975 | if (intersecting.get(bNode) !== undefined && intersecting.get(bNode) === false) { 976 | return; 977 | } 978 | // now check to see if we have any lazy behavior names 979 | let lazyBNames = ioEntries.get(bNode); 980 | if (!lazyBNames) { 981 | return; 982 | } 983 | // 984 | lazyBNames.forEach((bMedia, bName) => { 985 | // if no lazy behavior breakpoint trigger, 986 | // or if the current breakpoint matches 987 | if (!bMedia || isBreakpoint(bMedia, options.breakpoints)) { 988 | // run behavior on node 989 | initBehavior(bName, bNode); 990 | // remove this behavior from the list of lazy behaviors 991 | lazyBNames.delete(bName); 992 | // if there are no more lazy behaviors left on the node 993 | // stop observing the node 994 | // else update the ioEntries 995 | if (lazyBNames.size === 0) { 996 | io.unobserve(bNode); 997 | ioEntries.delete(bNode); 998 | } else { 999 | ioEntries.set(bNode, lazyBNames); 1000 | } 1001 | } 1002 | }); 1003 | // end loopLazyBehaviorNodes bNodes loop 1004 | }); 1005 | } 1006 | 1007 | /** 1008 | * intersection 1009 | * 1010 | * The intersection observer call back, 1011 | * sets a value in the intersecting map true/false 1012 | * and if an entry is intersecting, checks if needs to 1013 | * init any lazy behaviors 1014 | * 1015 | * @param {IntersectionObserverEntry[]} entries 1016 | */ 1017 | function intersection(entries) { 1018 | entries.forEach(entry => { 1019 | if (entry.isIntersecting) { 1020 | intersecting.set(entry.target, true); 1021 | loopLazyBehaviorNodes([entry.target]); 1022 | } else { 1023 | intersecting.set(entry.target, false); 1024 | } 1025 | }); 1026 | } 1027 | 1028 | /** 1029 | * mediaQueryUpdated 1030 | * 1031 | * If a resize has happened with enough size that a 1032 | * breakpoint has changed, checks to see if any lazy 1033 | * behaviors need to be initialised or not 1034 | */ 1035 | function mediaQueryUpdated() { 1036 | loopLazyBehaviorNodes(Array.from(ioEntries.keys())); 1037 | } 1038 | 1039 | 1040 | /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Public methods */ 1041 | 1042 | 1043 | /** 1044 | * initBehavior 1045 | * 1046 | * Return behavior instance if behavior is already loaded 1047 | * 1048 | * Run the `init` method inside a behavior, 1049 | * the internal one in `createBehavior`, which then 1050 | * runs the behaviors `init` life cycle method 1051 | * 1052 | * @param {string} bName - name of behavior 1053 | * @param {HTMLElement} bNode - node to initialise behavior on 1054 | * @param config 1055 | * @returns {Behavior|void} 1056 | */ 1057 | function initBehavior(bName, bNode, config = {}) { 1058 | // first check we have a loaded behavior 1059 | if (!loadedBehaviors[bName]) { 1060 | // if not, attempt to import it 1061 | importBehavior(bName, bNode); 1062 | return; 1063 | } 1064 | // merge breakpoints into config 1065 | config = { 1066 | breakpoints: options.breakpoints, 1067 | ...config 1068 | }; 1069 | // now check that this behavior isn't already 1070 | // running on this node 1071 | const nodeBehaviors = activeBehaviors.get(bNode) || {}; 1072 | if (Object.keys(nodeBehaviors).length === 0 || !nodeBehaviors[bName]) { 1073 | const instance = new loadedBehaviors[bName](bNode, config); 1074 | // update internal store of whats running 1075 | nodeBehaviors[bName] = instance; 1076 | activeBehaviors.set(bNode, nodeBehaviors); 1077 | // init method in the behavior 1078 | try { 1079 | instance.init(); 1080 | return instance; 1081 | } catch(err) { 1082 | console.log(`Error in behavior '${ bName }' on:`, bNode); 1083 | console.log(err); 1084 | } 1085 | } 1086 | } 1087 | 1088 | /** 1089 | * addBehaviors 1090 | * 1091 | * Adds each behavior to memory, to be initialised to a DOM node when the 1092 | * corresponding DOM node exists 1093 | * 1094 | * Can pass 1095 | * - a singular behavior as created by `createBehavior`, 1096 | * - a behavior object which will be passed to `createBehavior` 1097 | * - a behavior module 1098 | * - a collection of behavior modules 1099 | * 1100 | * @param {function|string} behaviors 1101 | */ 1102 | function addBehaviors(behaviors) { 1103 | // if singular behavior added, sort into module like structure 1104 | if (typeof behaviors === 'function' && behaviors.prototype.behaviorName) { 1105 | behaviors = { [behaviors.prototype.behaviorName]: behaviors }; 1106 | } 1107 | // if an uncompiled behavior object is passed, create it 1108 | if (typeof behaviors === 'string' && arguments.length > 1) { 1109 | behaviors = { [behaviors]: createBehavior(...arguments) }; 1110 | } 1111 | // process 1112 | const unique = Object.keys(behaviors).filter((o) => loadedBehaviorNames.indexOf(o) === -1); 1113 | if (unique.length) { 1114 | // we have new unique behaviors, store them 1115 | loadedBehaviorNames = loadedBehaviorNames.concat(unique); 1116 | unique.forEach(bName => { 1117 | loadedBehaviors[bName] = behaviors[bName]; 1118 | }); 1119 | } 1120 | } 1121 | 1122 | /** 1123 | * nodeBehaviors 1124 | * 1125 | * Is returned as public method when webpack is set to development mode 1126 | * 1127 | * Returns all active behaviors on a node 1128 | * 1129 | * @param {string} bNode - node on which to get active behaviors on 1130 | * @returns {Object.} 1131 | */ 1132 | function nodeBehaviors(bNode) { 1133 | const nodeBehaviors = activeBehaviors.get(bNode); 1134 | if (!nodeBehaviors) { 1135 | console.warn(`No behaviors on:`, bNode); 1136 | } else { 1137 | return nodeBehaviors; 1138 | } 1139 | } 1140 | 1141 | /** 1142 | * behaviorProperties 1143 | * 1144 | * Is returned as public method when webpack is set to development mode 1145 | * 1146 | * Returns all properties of a behavior 1147 | * 1148 | * @param {string} bName - name of behavior to return properties of 1149 | * @param {string} bNode - node on which the behavior is running 1150 | * @returns {Behavior|void} 1151 | */ 1152 | function behaviorProperties(bName, bNode) { 1153 | const nodeBehaviors = activeBehaviors.get(bNode); 1154 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 1155 | console.warn(`No behavior '${bName}' instance on:`, bNode); 1156 | } else { 1157 | return activeBehaviors.get(bNode)[bName]; 1158 | } 1159 | } 1160 | 1161 | /** 1162 | * behaviorProp 1163 | * 1164 | * Is returned as public method when webpack is set to development mode 1165 | * 1166 | * Returns specific property of a behavior on a node, or runs a method 1167 | * or sets a property on a behavior if a value is set. For debuggging. 1168 | * 1169 | * @param {string} bName - name of behavior to return properties of 1170 | * @param {string} bNode - node on which the behavior is running 1171 | * @param {string} prop - property to return or set 1172 | * @param [value] - value to set 1173 | * @returns {*} 1174 | */ 1175 | function behaviorProp(bName, bNode, prop, value) { 1176 | const nodeBehaviors = activeBehaviors.get(bNode); 1177 | if (!nodeBehaviors || !nodeBehaviors[bName]) { 1178 | console.warn(`No behavior '${bName}' instance on:`, bNode); 1179 | } else if (activeBehaviors.get(bNode)[bName][prop]) { 1180 | if (value && typeof value === 'function') { 1181 | return activeBehaviors.get(bNode)[bName][prop]; 1182 | } else if (value) { 1183 | activeBehaviors.get(bNode)[bName][prop] = value; 1184 | } else { 1185 | return activeBehaviors.get(bNode)[bName][prop]; 1186 | } 1187 | } else { 1188 | console.warn(`No property '${prop}' in behavior '${bName}' instance on:`, bNode); 1189 | } 1190 | } 1191 | 1192 | /* 1193 | init 1194 | 1195 | gets this show on the road 1196 | 1197 | loadedBehaviorsModule - optional behaviors module to load on init 1198 | opts - any options for this instance 1199 | */ 1200 | /** 1201 | * init 1202 | * 1203 | * gets this show on the road 1204 | * 1205 | * @param [loadedBehaviorsModule] - optional behaviors module to load on init 1206 | * @param opts - any options for this instance 1207 | */ 1208 | function init(loadedBehaviorsModule, opts = {}) { 1209 | options = { 1210 | ...options, ...opts 1211 | }; 1212 | 1213 | // on resize, check 1214 | resized(); 1215 | 1216 | // set up intersection observer 1217 | io = new IntersectionObserver(intersection, options.intersectionOptions); 1218 | 1219 | // if fn run with supplied behaviors, lets add them and begin 1220 | if (loadedBehaviorsModule) { 1221 | addBehaviors(loadedBehaviorsModule); 1222 | } 1223 | 1224 | // try and apply behaviors to any DOM node that needs them 1225 | createBehaviors(document); 1226 | 1227 | // start the mutation observer looking for DOM changes 1228 | if (!observingBehaviors) { 1229 | observeBehaviors(); 1230 | } 1231 | 1232 | // watch for break point changes 1233 | window.addEventListener('mediaQueryUpdated', mediaQueryUpdated); 1234 | } 1235 | 1236 | /** 1237 | * addAndInit 1238 | * 1239 | * Can pass 1240 | * - a singular behavior as created by `createBehavior`, 1241 | * - a behavior object which will be passed to `createBehavior` 1242 | * - a behavior module 1243 | * - a collection of behavior modules 1244 | * 1245 | * @param [behaviors] - optional behaviors module to load on init 1246 | * (all arguments are passed to addBehaviors) 1247 | */ 1248 | function addAndInit() { 1249 | if (arguments) { 1250 | addBehaviors.apply(null, arguments); 1251 | 1252 | // try and apply behaviors to any DOM node that needs them 1253 | createBehaviors(document); 1254 | } 1255 | } 1256 | 1257 | // expose public methods, essentially returning 1258 | 1259 | let exportObj = { 1260 | init: init, 1261 | add: addAndInit, 1262 | initBehavior: initBehavior, 1263 | get currentBreakpoint() { 1264 | return getCurrentMediaQuery(); 1265 | } 1266 | }; 1267 | 1268 | try { 1269 | if (process.env.MODE === 'development') { 1270 | Object.defineProperty(exportObj, 'loaded', { 1271 | get: () => { 1272 | return loadedBehaviorNames; 1273 | } 1274 | }); 1275 | exportObj.activeBehaviors = activeBehaviors; 1276 | exportObj.active = activeBehaviors; 1277 | exportObj.getBehaviors = nodeBehaviors; 1278 | exportObj.getProps = behaviorProperties; 1279 | exportObj.getProp = behaviorProp; 1280 | exportObj.setProp = behaviorProp; 1281 | exportObj.callMethod = behaviorProp; 1282 | } 1283 | } catch(err) { 1284 | // no process.env.mode 1285 | } 1286 | 1287 | /** 1288 | * Extend an existing a behavior instance 1289 | * @param {module} behavior - behavior you want to extend 1290 | * @param {string} name - Name of the extended behavior used for declaration: data-behavior="name" 1291 | * @param {object} methods - define methods of the behavior 1292 | * @param {object} lifecycle - Register behavior lifecycle 1293 | * @returns {Behavior} 1294 | * 1295 | * NB: methods or lifestyle fns with the same name will overwrite originals 1296 | */ 1297 | function extendBehavior(behavior, name, methods = {}, lifecycle = {}) { 1298 | const newMethods = Object.assign(Object.assign({}, behavior.prototype.methods), methods); 1299 | const newLifecycle = Object.assign(Object.assign({}, behavior.prototype.lifecycle), lifecycle); 1300 | 1301 | return createBehavior(name, newMethods, newLifecycle); 1302 | } 1303 | 1304 | exports.createBehavior = createBehavior; 1305 | exports.extendBehavior = extendBehavior; 1306 | exports.manageBehaviors = exportObj; 1307 | --------------------------------------------------------------------------------