Skip to content

Commit d01d2d6

Browse files
cuberootalexmirea
authored andcommitted
Add a @byContent selector for reparenting child content (#5)
* Initial support for @byContent decorator * Convert indentation to spaces * Add beginnings of a test * Full test for @byContent decorator * Add documentation for the @byContent decorator * Change label in test * Respond to code review for byContent decorator
1 parent 06d2456 commit d01d2d6

File tree

5 files changed

+414
-1
lines changed

5 files changed

+414
-1
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ This utility is reponsible from converting a DOM node to a model. The model is d
129129
* [byBooleanAttrVal](#bybooleanattrval)
130130
* [byJsonAttrVal](#byjsonattrval)
131131
* [byContentVal](#bycontentval)
132+
* [byContent](#bycontent)
132133
* [byChildContentVal](#bychildcontentval)
133134
* [byChildRef](#bychildref)
134135
* [byModel](#bymodel)
@@ -218,6 +219,44 @@ model ~ {
218219
}
219220
```
220221
222+
#### byContent
223+
This decorator allows you to capture a DOM node that is matched by a CSS selector.
224+
This can be used to reparent arbitrary child DOM content, which may not have been
225+
rendered with React, into your web component. Once parsing has occurred, the field
226+
in the model will contain a React component that represents the DOM content that
227+
will be reparented.
228+
229+
The DOM content will be moved when the React component is mounted. And, the content
230+
will be put back in its original location if the React component is later unmounted.
231+
```js
232+
@byContent(attrName:selector) - the CSS selector that will match the child node.
233+
```
234+
```js
235+
class Model extends DOMModel {
236+
@byContent('.content') content;
237+
}
238+
<div id="elem">
239+
<div class="content">
240+
This will be reparented
241+
</div>
242+
</div>
243+
<div id="mount-point"/>
244+
245+
const model = new Model().fromDOM(document.getElementById("elem"));
246+
ReactDOM.render(<div>{ model.content }</div>, document.getElementById("mount-point"))
247+
248+
// Once React has rendered the above component, the DOM will look like this
249+
250+
<div id="elem">
251+
<!-- placeholder for DIV -->
252+
</div>
253+
<div id="mount-point">
254+
<div class="content">
255+
This will be reparented
256+
</div>
257+
</div>
258+
```
259+
221260
#### byChildContentVal
222261
Parse the element looking for an element that matches the given selector and sets value to the `innerText` of that element
223262
```js

lib/dom-model/DOMDecorators.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA
99
OF ANY KIND, either express or implied. See the License for the specific language
1010
governing permissions and limitations under the License.
1111
*/
12+
import DOMNode from './DOMNode';
13+
import EmbedNode from './EmbedNode';
14+
import React from 'react';
15+
16+
let _idCount = 0;
1217

1318
function makeDecorator(callback) {
1419
return function (...args) {
@@ -20,6 +25,41 @@ function makeDecorator(callback) {
2025
}
2126
}
2227

28+
function makeDOMNode(target, selector) {
29+
const dataNode = new DOMNode();
30+
dataNode.node = target;
31+
dataNode.selector = selector;
32+
target._reactComponentDataNode = dataNode;
33+
return dataNode;
34+
}
35+
36+
function findCommentNode(element, selector) {
37+
for(let i = 0; i < element.childNodes.length; i++) {
38+
let node = element.childNodes[i];
39+
if( node.nodeType === Node.COMMENT_NODE
40+
&& node._reactComponentDataNode
41+
&& node._reactComponentDataNode.selector === selector) {
42+
return node;
43+
}
44+
}
45+
}
46+
47+
function queryChildren(element, selector, all = false) {
48+
if (typeof selector !== 'string') {
49+
console.warn('Query selector must be string!');
50+
return;
51+
}
52+
let id = element.id,
53+
guid = element.id = id || 'query_children_' + _idCount++,
54+
attr = '#' + guid + ' > ',
55+
scopedSelector = attr + (selector + '').replace(',', ',' + attr, 'g');
56+
let result = all ? element.querySelectorAll(scopedSelector) : element.querySelector(scopedSelector);
57+
if (!id) {
58+
element.removeAttribute('id');
59+
}
60+
return result;
61+
}
62+
2363
/**
2464
* Parses the element and returns the innerText
2565
*
@@ -36,6 +76,7 @@ let byContentVal = makeDecorator(function() {
3676
}
3777
});
3878

79+
3980
/**
4081
* Parses the element and returns the value of the provided attribute
4182
*
@@ -170,6 +211,48 @@ function attachContentObserver(target, key, element, child, valueFn) {
170211
}
171212
}
172213

214+
/**
215+
* Adds to the model a property that returns a react component that, when
216+
* rendered, will produce the node matched by the given selector. The
217+
* React component will "steal" the DOM node from it's original parent.
218+
* If the React component is later removed from the render tree, the DOM
219+
* node that was stolen will be replaced in its original parent.
220+
*
221+
* @param {String} selector - the selector for the child to turn into a React component
222+
*
223+
* @returns {function} - the decorator function
224+
*/
225+
let byContent = makeDecorator(function(selector) {
226+
return function (target, key, descriptor) {
227+
descriptor.writable = true;
228+
if (target.addProperty) {
229+
target.addProperty(key, (element) => {
230+
if (element && (element instanceof HTMLElement)) {
231+
let node = null;
232+
233+
let valueFn = () => {
234+
if (!node) {
235+
let child = queryChildren(element, selector);
236+
if (child) {
237+
node = <EmbedNode item={ makeDOMNode(child, selector) }/>;
238+
}
239+
}
240+
return node;
241+
}
242+
let result = valueFn();
243+
if (!result) {
244+
// We could not match the selector. That is possibly because
245+
// a rendering engine has not filled in the children yet.
246+
// Watch for DOM mutations that add a matching element
247+
attachContentObserver(target, key, element, element, valueFn);
248+
}
249+
return valueFn();
250+
}
251+
});
252+
}
253+
}
254+
});
255+
173256
/**
174257
* Parses the element and returns the innerText of the child element with the provided name
175258
*
@@ -349,6 +432,7 @@ export {
349432
byAttrVal,
350433
byBooleanAttrVal,
351434
byContentVal,
435+
byContent,
352436
byChildContentVal,
353437
byJsonAttrVal,
354438
byModel,

lib/dom-model/DOMNode.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
Copyright 2018 Adobe. All rights reserved.
3+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License. You may obtain a copy
5+
of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
7+
Unless required by applicable law or agreed to in writing, software distributed under
8+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
OF ANY KIND, either express or implied. See the License for the specific language
10+
governing permissions and limitations under the License.
11+
*/
12+
13+
/* eslint no-constant-condition: "off" */
14+
15+
const domNodeMutationOptions = { childList: true };
16+
17+
function handleDOMNodeMutation(mutations, observer) {
18+
const domNode = observer._domNode;
19+
if (!domNode.stolen) {
20+
return;
21+
}
22+
const node = domNode.node;
23+
const parentNode = node.parentNode;
24+
if (!parentNode) {
25+
domNode.applicationDidRemoveItem();
26+
}
27+
}
28+
/**
29+
* This is a container around a HTMLElement which allows for the element to be removed from the DOM
30+
* replaced with a comment and then returned back to the DOM
31+
*/
32+
export default class DOMNode {
33+
34+
/**
35+
* Removes the node from the DOM and replaces with a comment
36+
* @returns {HTMLElement} - the HTML DOM node
37+
*/
38+
stealNode() {
39+
if (this.stolen) {
40+
return null;
41+
}
42+
43+
const node = this.node;
44+
this.returned = false;
45+
46+
var placeholder = this.placeholder;
47+
if (!placeholder) {
48+
placeholder = this.placeholder = document.createComment('placeholder for ' + node.nodeName);
49+
placeholder._reactComponentDataNode = this;
50+
}
51+
52+
node.parentNode.replaceChild(placeholder, node);
53+
this.stolen = true;
54+
return this.node;
55+
}
56+
57+
/**
58+
* Returns the node to the DOM. It replaceses the comment with the node
59+
*/
60+
returnNode() {
61+
if (!this.stolen) {
62+
return;
63+
}
64+
65+
this.stolen = false;
66+
this.returned = true;
67+
this.stopObserving();
68+
69+
const placeholder = this.placeholder;
70+
const placeholderParent = placeholder.parentNode;
71+
if (placeholderParent) {
72+
placeholderParent.replaceChild(this.node, placeholder);
73+
}
74+
}
75+
76+
/**
77+
* Observes the mutations of the parent node to check if the users are removing the node.
78+
*/
79+
observe() {
80+
if (this.observer) {
81+
this.stopObserving();
82+
}
83+
const observer = this.observer = new MutationObserver(handleDOMNodeMutation);
84+
observer._domNode = this;
85+
observer.observe(this.node.parentNode, domNodeMutationOptions);
86+
}
87+
88+
/**
89+
* Stops the mutation observer
90+
*/
91+
stopObserving() {
92+
const observer = this.observer;
93+
if (observer) {
94+
observer.disconnect();
95+
this.observer = null;
96+
}
97+
}
98+
99+
/**
100+
* Marks that the application removed the item
101+
*/
102+
applicationDidRemoveItem() {
103+
this.stolen = false;
104+
this.returned = true;
105+
this.stopObserving();
106+
this.remove();
107+
}
108+
109+
/**
110+
* Adds the node to the list of child elements.
111+
* It will look for the correct position in the list of the element.
112+
* @param {Array<Object>} - the list of the child element
113+
*/
114+
add(list) {
115+
116+
this.list = list;
117+
var target = this.span || this.node;
118+
do {
119+
target = target.previousSibling;
120+
if (!target) {
121+
// this is the first item, just insert it at the very top.
122+
list.splice(0, 0, this);
123+
return;
124+
}
125+
const data = target._reactComponentDataNode;
126+
if (data) {
127+
// If the element is stolen, then we need to use the placeholder and not
128+
// the actual element. Otherwise the element might actually be added to the same
129+
// list of elements and might use the wrong position.
130+
if (data.stolen && data.placeholder !== target) {
131+
// Continue until we find the right element.
132+
continue;
133+
}
134+
const index = list.indexOf(data);
135+
if (index !== -1) {
136+
list.splice(index + 1, 0, this);
137+
return;
138+
}
139+
}
140+
} while (true);
141+
}
142+
143+
/**
144+
* Removes the node
145+
*/
146+
remove() {
147+
const list = this.list;
148+
if (list) {
149+
list.removeItem(this);
150+
this.list = null;
151+
}
152+
const node = this.node;
153+
if (node._reactComponentDataNode === this) {
154+
node._reactComponentDataNode = null;
155+
}
156+
this.removePlaceholder();
157+
}
158+
159+
/**
160+
* Removes the placeholder of the node
161+
*/
162+
removePlaceholder() {
163+
const placeholder = this.placeholder;
164+
if (placeholder) {
165+
const placeholderParent = placeholder.parentNode;
166+
if (placeholderParent) {
167+
placeholderParent.removeChild(placeholder);
168+
}
169+
}
170+
}
171+
}

lib/dom-model/EmbedNode.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
Copyright 2018 Adobe. All rights reserved.
3+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License. You may obtain a copy
5+
of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
7+
Unless required by applicable law or agreed to in writing, software distributed under
8+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
OF ANY KIND, either express or implied. See the License for the specific language
10+
governing permissions and limitations under the License.
11+
*/
12+
13+
import React, {Component} from 'react';
14+
15+
export default class EmbedNode extends Component {
16+
17+
render() {
18+
return <div ref={ (element) => this.element = element }/>
19+
}
20+
21+
componentDidMount() {
22+
this.parent = this.element.parentElement;
23+
this.stolenNode = this.props.item.stealNode();
24+
this.parent.replaceChild(this.stolenNode, this.element);
25+
}
26+
27+
componentWillUnmount() {
28+
this.parent.replaceChild(this.element, this.stolenNode);
29+
this.props.item.returnNode();
30+
delete this.stolenNode;
31+
}
32+
33+
shouldComponentUpdate() {
34+
// Prevent this node from rendering after it is mounted
35+
return false;
36+
}
37+
38+
get hasStolenNode() {
39+
return this.stolenNode != null
40+
}
41+
}

0 commit comments

Comments
 (0)