aurelia / aurelia/templating

Add support for `@containerless` elements and `@templateController` attributes to `@children` and `@child` decorators

Open
#717 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
113
Forks
101
PR merge metrics
No merged PRs in 30d

Description

**I'm submitting a feature request**

* **Library Version:**
1.4.1

**Current behavior:**
`@children` and `@child` decorators do not support containerless custom elements and template controller custom attributes (which in fact are shortcuts to containerless custom elements)

**Expected/desired behavior:**
It is easy to track children of containerless elements. When `@containerless` element or `@templateController` custom attribute is created - it is attached to `` Comment DOM node . You can get this node before any children of containerless element are created by injecting `Element` to constructor. Later all children will be inserted before this `` node by Aurelia engine. So it is possible to insert another Comment node `` before `` node, and later all children will be added to DOM tree between those two Comment nodes. And here we can add `MutationObserver` to `` parent node and use `compareDocumentPosition()` DOM API to filter children of containerless element.

Here is a modified version of `ChildObserverBinder` that is used by `@children` decorator from `aurelia-templating/src/child-observation.ts` that implements the above logic:

child-observation.js

```js
import {DOM} from 'aurelia-pal';
import {metadata} from 'aurelia-metadata';
import {HtmlBehaviorResource} from 'aurelia-templating';
/*import { Controller } from './controller';
import { SlotMarkedNode } from './type-extension';
import { ShadowSlot } from './shadow-dom';*/

function createChildObserverDecorator(selectorOrConfig, all) {
return function (target, key, descriptor) {
let actualTarget = typeof key === 'string' ? target.constructor : target;
let r = metadata.getOrCreateOwn(metadata.resource, HtmlBehaviorResource, actualTarget);
if (typeof selectorOrConfig === 'string') {
selectorOrConfig = {
selector: selectorOrConfig,
name: key
};
}
if (descriptor) {
descriptor.writable = true;
descriptor.configurable = true;
}
selectorOrConfig.all = all;
r.addChildBinding(new ChildObserver(selectorOrConfig));
};
}
export function children(selectorOrConfig) {
return createChildObserverDecorator(selectorOrConfig, true);
}
export function child(selectorOrConfig) {
return createChildObserverDecorator(selectorOrConfig, false);
}

export function bindChildObserver(selector, viewModel, propName, element, all = true){
const observer = new ChildObserver({
selector,
name: propName,
all
});
const binder = observer.create(element, viewModel);
binder.bind(viewModel);
if (!Array.isArray(viewModel[propName]))
viewModel[propName] = [];

viewModel[propName].unbind = () => {
binder.unbind()
}
}

class ChildObserver {
constructor(config) {
this.name = config.name;
this.changeHandler = config.changeHandler || this.name + 'Changed';
this.selector = config.selector;
this.all = config.all;
}
create(viewHost, viewModel, controller) {
return new ChildObserverBinder(this.selector, viewHost, this.name, viewModel, controller, this.changeHandler, this.all);
}
}
const noMutations = [];
function trackMutation(groupedMutations, binder, record) {
let mutations = groupedMutations.get(binder);
if (!mutations) {
mutations = [];
groupedMutations.set(binder, mutations);
}
mutations.push(record);
}
function onChildChange(mutations, observer) {
let binders = observer.binders;
let bindersLength = binders.length;
let groupedMutations = new Map();
for (let i = 0, ii = mutations.length; i < ii; ++i) {
let record = mutations[i];
let added = record.addedNodes;
let removed = record.removedNodes;
for (let j = 0, jj = removed.length; j < jj; ++j) {
let node = removed[j];
if (node.nodeType === 1) {
for (let k = 0; k < bindersLength; ++k) {
let binder = binders[k];
if (binder.onRemove(node)) {
trackMutation(groupedMutations, binder, record);
}
}
}
}
for (let j = 0, jj = added.length; j < jj; ++j) {
let node = added[j];
if (node.nodeType === 1) {
for (let k = 0; k < bindersLength; ++k) {
let binder = binders[k];
if (binder.onAdd(node)) {
trackMutation(groupedMutations, binder, record);
}
}
}
}
}
groupedMutations.forEach((mutationRecords, binder) => {
if (binder.isBound && binder.changeHandler !== null) {
binder.viewModel[binder.changeHandler](mutationRecords);
}
});
}
class ChildObserverBinder {
constructor(selector, viewHost, property, viewModel, controller, changeHandler, all) {
this.selector = selector;
this.viewHost = viewHost;
this.property = property;
this.viewModel = viewModel;
this.controller = controller;
this.changeHandler = changeHandler in viewModel ? changeHandler : null;
this.all = all;
this.contentView = null;
if (controller) {
this.usesShadowDOM = controller.behavior.usesShadowDOM;

if (!this.usesShadowDOM && controller.view && controller.view.contentView) {
this.contentView = controller.view.contentView;
}
else {
this.contentView = null;
}
} else {
this.contentView = null;
}

this.source = null;
this.isBound = false;
}
matches(element) {
const viewHost = this.viewHost;
if (viewHost.__childrenStartNode__) { // Extra check for containerless children support
let start = viewHost.__childrenStartNode__;
if (!(element.isSameNode(start) || element.isSameNode(viewHost) ||
(element.compareDocumentPosition(start) & Node.DOCUMENT_POSITION_PRECEDING && element.compareDocumentPosition(viewHost) & Node.DOCUMENT_POSITION_FOLLOWING)))
return false;
}

if (element.matches(this.selector)) {
if (this.contentView === null) {
return true;
}
let contentView = this.contentView;
let assignedSlot = element.auAssignedSlot;
if (assignedSlot && assignedSlot.projectFromAnchors) {
let anchors = assignedSlot.projectFromAnchors;
for (let i = 0, ii = anchors.length; i < ii; ++i) {
if (anchors[i].auOwnerView === contentView) {
return true;
}
}
return false;
}
return element.auOwnerView === contentView;
}
return false;
}
bind(source) {
if (this.isBound) {
if (this.source === source) {
return;
}
this.source = source;
}
this.isBound = true;
let viewHost = this.viewHost;
let viewModel = this.viewModel;
let observer = viewHost.__childObserver__;
if (!observer) {
observer = viewHost.__childObserver__ = DOM.createMutationObserver(onChildChange);
let options = {
childList: true,
subtree: !this.usesShadowDOM
};
let observerHost; // support containerless
if (viewHost instanceof Element) // viewHost is container
observerHost = viewHost
else { // viewHost is Comment node - containerless view host
observerHost = viewHost.parentNode;
const childrenStartNode = DOM.createComment('@children');
viewHost.parentNode.insertBefore(childrenStartNode, viewHost);
viewHost.__childrenStartNode__ = childrenStartNode;
}
observer.observe(observerHost, options);
observer.binders = [];
}
observer.binders.push(this);
if (this.usesShadowDOM) {
let current = viewHost.firstElementChild;
if (this.all) {
let items = viewModel[this.property];
if (!items) {
items = viewModel[this.property] = [];
}
else {
items.splice(0);
}
while (current) {
if (this.matches(current)) {
items.push(current.au && current.au.controller ? current.au.controller.viewModel : current);
}
current = current.nextElementSibling;
}
if (this.changeHandler !== null) {
this.viewModel[this.changeHandler](noMutations);
}
}
else {
while (current) {
if (this.matches(current)) {
let value = current.au && current.au.controller ? current.au.controller.viewModel : current;
this.viewModel[this.property] = value;
if (this.changeHandler !== null) {
this.viewModel[this.changeHandler](value);
}
break;
}
current = current.nextElementSibling;
}
}
}
}
onRemove(element) {
if (this.matches(element)) {
let value = element.au && element.au.controller ? element.au.controller.viewModel : element;
if (this.all) {
let items = (this.viewModel[this.property] || (this.viewModel[this.property] = []));
let index = items.indexOf(value);
if (index !== -1) {
items.splice(index, 1);
}
return true;
}
const currentValue = this.viewModel[this.property];
if (currentValue === value) {
this.viewModel[this.property] = null;
if (this.isBound && this.changeHandler !== null) {
this.viewModel[this.changeHandler](value);
}
}
}
return false;
}
onAdd(element) {
if (this.matches(element)) {
let value = element.au && element.au.controller ? element.au.controller.viewModel : element;
if (this.all) {
let items = (this.viewModel[this.property] || (this.viewModel[this.property] = []));
if (this.selector === '*') {
items.push(value);
return true;
}
let index = 0;
let prev = element.previousElementSibling;
while (prev) {
if (this.matches(prev)) {
index++;
}
prev = prev.previousElementSibling;
}
items.splice(index, 0, value);
return true;
}
this.viewModel[this.property] = value;
if (this.isBound && this.changeHandler !== null) {
this.viewModel[this.changeHandler](value);
}
}
return false;
}
unbind() {
if (!this.isBound) {
return;
}
this.isBound = false;
this.source = null;
let childObserver = this.viewHost.__childObserver__;
if (childObserver) {
let binders = childObserver.binders;
if (binders && binders.length) {
let idx = binders.indexOf(this);
if (idx !== -1) {
binders.splice(idx, 1);
}
if (binders.length === 0) {
childObserver.disconnect();
this.viewHost.__childObserver__ = null;
}
}
if (this.usesShadowDOM) {
this.viewModel[this.property] = null;
}
}
}
}
```

Modified matcher:
```js
matches(element) {
const viewHost = this.viewHost;
if (viewHost.__childrenStartNode__) { // Extra check for containerless children support
let start = viewHost.__childrenStartNode__;
if (!(element.isSameNode(start) || element.isSameNode(viewHost) ||
(element.compareDocumentPosition(start) & Node.DOCUMENT_POSITION_PRECEDING && element.compareDocumentPosition(viewHost) & Node.DOCUMENT_POSITION_FOLLOWING)))
return false;
}
```
And modified `MutationObserver` creation:
```js
if (!observer) {
observer = viewHost.__childObserver__ = DOM.createMutationObserver(onChildChange);
let options = {
childList: true,
subtree: !this.usesShadowDOM
};
let observerHost; // support containerless
if (viewHost instanceof Element) // viewHost is container
observerHost = viewHost
else { // viewHost is Comment node - containerless view host
observerHost = viewHost.parentNode;
const childrenStartNode = DOM.createComment('@children');
viewHost.parentNode.insertBefore(childrenStartNode, viewHost);
viewHost.__childrenStartNode__ = childrenStartNode;
}
observer.observe(observerHost, options);
```

But the above modification does not work inside `@children` decorator, because `ChildObserver.create(viewHost, viewModel, controller)` method is never called when decorator is applied to `@containerless` element.
I added extra function to use this technic in constructor without decorator:
```js
export function bindChildObserver(selector, viewModel, propName, element, all = true){
const observer = new ChildObserver({
selector,
name: propName,
all
});
const binder = observer.create(element, viewModel);
binder.bind(viewModel);
if (!Array.isArray(viewModel[propName]))
viewModel[propName] = [];

viewModel[propName].unbind = () => {
binder.unbind()
}
}
```

Usage example:
```js
@containerless()
@inject(Element)
export class ContainerlessCustomElement {
children = [];

constructor(element){
this.element = element;
bindChildObserver('*', this, 'children', element);
}

childrenChanged(val){
console.log('Children changed:', this.children)
}
}

```

Contributor guide

Open the contributing guide

Research direction

Start in aurelia-templating/src/child-observation.ts, focusing on ChildObserverBinder and its matches and bind logic, then compare it with the modified child-observation.js shown in the issue. Done means @children and @child correctly track matching elements inside @containerless custom elements and @templateController attributes, including additions and removals.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.