Skip to content

Commit cffb786

Browse files
feat(addon/modifiers): adds mutation-observer modifier for reporting on DOM mutations.
1 parent d532c95 commit cffb786

File tree

3 files changed

+174
-0
lines changed

3 files changed

+174
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Modifier from 'ember-modifier';
2+
import { assert } from '@ember/debug';
3+
import { registerDestructor } from '@ember/destroyable';
4+
5+
/**
6+
* @modifier mutation-observer
7+
*
8+
* This Ember modifier uses the MutationObserver API to observe changes in the
9+
* DOM of a given element. It initializes a MutationObserver, attaches it to
10+
* the provided DOM element, and invokes a callback whenever a mutation is detected.
11+
* The modifier also automatically cleans up the observer when the element is destroyed.
12+
*
13+
*
14+
* @param {Element} element - The DOM element to observe.
15+
* @param {Function} callback - The callback function to be called when mutations are observed.
16+
* @param {Object} config - Configuration options for MutationObserver, such as `{ childList: true, subtree: true }`.
17+
*
18+
* This modifier allows you to specify the DOM element you want to observe, a callback
19+
* function that gets executed whenever a mutation occurs on that element, and a configuration
20+
* object that defines what types of mutations to observe.
21+
*
22+
* The `config` parameter should be a JSON object that matches the options for
23+
* `MutationObserver.observe`, such as `{ childList: true, attributes: true, subtree: true }`.
24+
*
25+
* @example
26+
* ```hbs
27+
* <div {{mutation-observer this.handleMutation config=(hash childList=true subtree=true)}}>
28+
* <!-- Content that might change -->
29+
* </div>
30+
* ```
31+
*
32+
* @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
33+
*/
34+
export default class MutationObserverModifier extends Modifier {
35+
observer;
36+
37+
constructor(owner, args) {
38+
super(owner, args);
39+
// Register cleanup logic to disconnect the observer when destroyed
40+
registerDestructor(this, cleanup);
41+
}
42+
43+
modify(element, [callback], { config = { childList: true } }) {
44+
assert(
45+
'{{mutation-observer}} requires a callback as the first parameter',
46+
typeof callback === 'function'
47+
);
48+
49+
this.observer = new MutationObserver(callback);
50+
this.observer.observe(element, config);
51+
}
52+
}
53+
54+
function cleanup(instance) {
55+
instance.observer?.disconnect();
56+
}

app/modifiers/mutation-observer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from 'ember-paper/modifiers/mutation-observer';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { module, test } from 'qunit';
2+
import { setupRenderingTest } from 'ember-qunit';
3+
import { render, settled } from '@ember/test-helpers';
4+
import { hbs } from 'ember-cli-htmlbars';
5+
import { A } from '@ember/array';
6+
7+
function items() {
8+
return A(['ONE', 'TWO', 'THREE']);
9+
}
10+
11+
module('Integration | Modifier | mutation-observer', function (hooks) {
12+
setupRenderingTest(hooks);
13+
14+
test('it does not respond if DOM elements do not change', async function (assert) {
15+
assert.expect(1);
16+
17+
const callMeMaybe = () => {
18+
assert.ok(false, 'No DOM change, callback should not be called');
19+
};
20+
this.set('callMeMaybe', callMeMaybe);
21+
22+
await render(hbs`
23+
<div {{mutation-observer this.callMeMaybe}}></div>
24+
`);
25+
26+
assert.ok(true);
27+
});
28+
29+
test('it responds by default when DOM elements are removed', async function (assert) {
30+
assert.expect(4);
31+
32+
const callMeMaybe = () => {
33+
assert.ok(true, 'Callback has been fired due to DOM removal');
34+
};
35+
this.set('callMeMaybe', callMeMaybe);
36+
this.set('items', items());
37+
38+
await render(hbs`
39+
<div {{mutation-observer this.callMeMaybe}}>
40+
{{#each items as |item|}}
41+
<div class={{item}}>{{item}}</div>
42+
{{/each}}
43+
</div>
44+
`);
45+
46+
this.items.removeAt(1);
47+
await settled();
48+
49+
assert.dom('.ONE').exists();
50+
assert.dom('.TWO').doesNotExist();
51+
assert.dom('.THREE').exists();
52+
});
53+
54+
test('it responds by default when DOM elements are added', async function (assert) {
55+
assert.expect(9);
56+
57+
const callMeMaybe = () => {
58+
assert.ok(true, 'Callback has been fired due to DOM addition');
59+
};
60+
this.set('callMeMaybe', callMeMaybe);
61+
this.set('items', items());
62+
63+
await render(hbs`
64+
<div {{mutation-observer this.callMeMaybe}}>
65+
{{#each this.items as |item|}}
66+
<div class={{item}}>{{item}}</div>
67+
{{/each}}
68+
</div>
69+
`);
70+
71+
assert.dom('.ONE').exists();
72+
assert.dom('.TWO').exists();
73+
assert.dom('.THREE').exists();
74+
assert.dom('.FOUR').doesNotExist();
75+
76+
this.items.addObject('FOUR');
77+
await settled();
78+
79+
assert.dom('.ONE').exists();
80+
assert.dom('.TWO').exists();
81+
assert.dom('.THREE').exists();
82+
assert.dom('.FOUR').exists();
83+
});
84+
85+
test('it responds by default when DOM elements are reordered', async function (assert) {
86+
assert.expect(8);
87+
88+
const callMeMaybe = () => {
89+
assert.ok(true, 'Callback has been fired due to DOM mutation');
90+
};
91+
this.set('callMeMaybe', callMeMaybe);
92+
this.set('items', items());
93+
94+
await render(hbs`
95+
<div id="outer" {{mutation-observer this.callMeMaybe}}>
96+
{{#each this.items as |item|}}
97+
<div class={{item}}>{{item}}</div>
98+
{{/each}}
99+
</div>
100+
`);
101+
102+
assert.dom('.ONE').exists();
103+
assert.dom('.TWO').exists();
104+
assert.dom('.THREE').exists();
105+
106+
this.items.reverseObjects();
107+
await settled();
108+
109+
assert.dom('.ONE').exists();
110+
assert.dom('.TWO').exists();
111+
assert.dom('.THREE').exists();
112+
assert.equal(
113+
this.element.textContent.trim(),
114+
'THREE\n TWO\n ONE'
115+
);
116+
});
117+
});

0 commit comments

Comments
 (0)