diff --git a/addon/modifiers/mutation-observer.js b/addon/modifiers/mutation-observer.js new file mode 100644 index 000000000..e05e17eef --- /dev/null +++ b/addon/modifiers/mutation-observer.js @@ -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 + *
+ * + *
+ * ``` + * + * @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(); +} diff --git a/app/modifiers/mutation-observer.js b/app/modifiers/mutation-observer.js new file mode 100644 index 000000000..b6b597d4f --- /dev/null +++ b/app/modifiers/mutation-observer.js @@ -0,0 +1 @@ +export { default } from 'ember-paper/modifiers/mutation-observer'; diff --git a/tests/integration/modifiers/mutation-observer-test.js b/tests/integration/modifiers/mutation-observer-test.js new file mode 100644 index 000000000..24fa89c2d --- /dev/null +++ b/tests/integration/modifiers/mutation-observer-test.js @@ -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` +
+ `); + + 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` +
+ {{#each items as |item|}} +
{{item}}
+ {{/each}} +
+ `); + + 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` +
+ {{#each this.items as |item|}} +
{{item}}
+ {{/each}} +
+ `); + + 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` +
+ {{#each this.items as |item|}} +
{{item}}
+ {{/each}} +
+ `); + + 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' + ); + }); +});