Skip to content

Commit

Permalink
feat(addon/modifiers): adds mutation-observer modifier for reportin…
Browse files Browse the repository at this point in the history
…g on DOM mutations.
  • Loading branch information
matthewhartstonge committed Nov 14, 2024
1 parent 2fb155c commit ef17137
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 0 deletions.
56 changes: 56 additions & 0 deletions addon/modifiers/mutation-observer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Modifier from 'ember-modifier';
import { assert } from '@ember/debug';
import { registerDestructor } from '@ember/destroyable';

/**
* @modifier mutation-observer
*
* This Ember modifier uses the MutationObserver API to observe changes in the
* DOM of a given element. It initializes a MutationObserver, attaches it to
* the provided DOM element, and invokes a callback whenever a mutation is detected.
* The modifier also automatically cleans up the observer when the element is destroyed.
*
*
* @param {Element} element - The DOM element to observe.
* @param {Function} callback - The callback function to be called when mutations are observed.
* @param {Object} config - Configuration options for MutationObserver, such as `{ childList: true, subtree: true }`.
*
* This modifier allows you to specify the DOM element you want to observe, a callback
* function that gets executed whenever a mutation occurs on that element, and a configuration
* object that defines what types of mutations to observe.
*
* The `config` parameter should be a JSON object that matches the options for
* `MutationObserver.observe`, such as `{ childList: true, attributes: true, subtree: true }`.
*
* @example
* ```hbs
* <div {{mutation-observer this.handleMutation config=(hash childList=true subtree=true)}}>
* <!-- Content that might change -->
* </div>
* ```
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
*/
export default class MutationObserverModifier extends Modifier {
observer;

constructor(owner, args) {
super(owner, args);
// Register cleanup logic to disconnect the observer when destroyed
registerDestructor(this, cleanup);
}

modify(element, [callback], { config = { childList: true } }) {
assert(
'{{mutation-observer}} requires a callback as the first parameter',
typeof callback === 'function'
);

this.observer = new MutationObserver(callback);
this.observer.observe(element, config);
}
}

function cleanup(instance) {
instance.observer?.disconnect();
}
1 change: 1 addition & 0 deletions app/modifiers/mutation-observer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-paper/modifiers/mutation-observer';
117 changes: 117 additions & 0 deletions tests/integration/modifiers/mutation-observer-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { A } from '@ember/array';

function items() {
return A(['ONE', 'TWO', 'THREE']);
}

module('Integration | Modifier | mutation-observer', function (hooks) {
setupRenderingTest(hooks);

test('it does not respond if DOM elements do not change', async function (assert) {
assert.expect(1);

const callMeMaybe = () => {
assert.ok(false, 'No DOM change, callback should not be called');
};
this.set('callMeMaybe', callMeMaybe);

await render(hbs`
<div {{mutation-observer this.callMeMaybe}}></div>
`);

assert.ok(true);
});

test('it responds by default when DOM elements are removed', async function (assert) {
assert.expect(4);

const callMeMaybe = () => {
assert.ok(true, 'Callback has been fired due to DOM removal');
};
this.set('callMeMaybe', callMeMaybe);
this.set('items', items());

await render(hbs`
<div {{mutation-observer this.callMeMaybe}}>
{{#each items as |item|}}
<div class={{item}}>{{item}}</div>
{{/each}}
</div>
`);

this.items.removeAt(1);
await settled();

assert.dom('.ONE').exists();
assert.dom('.TWO').doesNotExist();
assert.dom('.THREE').exists();
});

test('it responds by default when DOM elements are added', async function (assert) {
assert.expect(9);

const callMeMaybe = () => {
assert.ok(true, 'Callback has been fired due to DOM addition');
};
this.set('callMeMaybe', callMeMaybe);
this.set('items', items());

await render(hbs`
<div {{mutation-observer this.callMeMaybe}}>
{{#each this.items as |item|}}
<div class={{item}}>{{item}}</div>
{{/each}}
</div>
`);

assert.dom('.ONE').exists();
assert.dom('.TWO').exists();
assert.dom('.THREE').exists();
assert.dom('.FOUR').doesNotExist();

this.items.addObject('FOUR');
await settled();

assert.dom('.ONE').exists();
assert.dom('.TWO').exists();
assert.dom('.THREE').exists();
assert.dom('.FOUR').exists();
});

test('it responds by default when DOM elements are reordered', async function (assert) {
assert.expect(8);

const callMeMaybe = () => {
assert.ok(true, 'Callback has been fired due to DOM mutation');
};
this.set('callMeMaybe', callMeMaybe);
this.set('items', items());

await render(hbs`
<div id="outer" {{mutation-observer this.callMeMaybe}}>
{{#each this.items as |item|}}
<div class={{item}}>{{item}}</div>
{{/each}}
</div>
`);

assert.dom('.ONE').exists();
assert.dom('.TWO').exists();
assert.dom('.THREE').exists();

this.items.reverseObjects();
await settled();

assert.dom('.ONE').exists();
assert.dom('.TWO').exists();
assert.dom('.THREE').exists();
assert.equal(
this.element.textContent.trim(),
'THREE\n TWO\n ONE'
);
});
});

0 comments on commit ef17137

Please sign in to comment.