-
-
Notifications
You must be signed in to change notification settings - Fork 102
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 <!--anchor--> 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 <!--anchor--> node by Aurelia engine. So it is possible to insert another Comment node <!--@children--> before <!--anchor--> node, and later all children will be added to DOM tree between those two Comment nodes. And here we can add MutationObserver to <!--anchor--> 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
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:
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:
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:
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:
@containerless()
@inject(Element)
export class ContainerlessCustomElement {
children = [];
constructor(element){
this.element = element;
bindChildObserver('*', this, 'children', element);
}
childrenChanged(val){
console.log('Children changed:', this.children)
}
}