aurelia-templating-resources
Version:
A standard set of behaviors, converters and other resources for use with the Aurelia templating library.
425 lines (390 loc) • 13.2 kB
text/typescript
/*eslint no-loop-func:0, no-unused-vars:0*/
import {inject} from 'aurelia-dependency-injection';
import {ObserverLocator, BindingExpression} from 'aurelia-binding';
import {
BoundViewFactory,
TargetInstruction,
ViewSlot,
ViewResources,
customAttribute,
bindable,
templateController,
View,
ViewFactory
} from 'aurelia-templating';
import {RepeatStrategyLocator} from './repeat-strategy-locator';
import {
getItemsSourceExpression,
unwrapExpression,
isOneTime,
updateOneTimeBinding
} from './repeat-utilities';
import {viewsRequireLifecycle} from './analyze-view-factory';
import {AbstractRepeater} from './abstract-repeater';
const matcherExtractionMarker = '__marker_extracted__';
/**
* Binding to iterate over iterable objects (Array, Map and Number) to genereate a template for each iteration.
*/
export class Repeat extends AbstractRepeater {
/**
* Setting this to `true` to enable legacy behavior, where a repeat would take first `matcher` binding
* any where inside its view if there's no `matcher` binding on the repeated element itself.
*
* Default value is true to avoid breaking change
* @default true
*/
static useInnerMatcher = true;
/**
* List of items to bind the repeater to.
*
* @property items
*/
items;
/**
* Local variable which gets assigned on each iteration.
*
* @property local
*/
local;
/**
* Key when iterating over Maps.
*
* @property key
*/
key;
/**
* Value when iterating over Maps.
*
* @property value
*/
value;
/**@internal*/
viewFactory: any;
/**@internal*/
instruction: any;
/**@internal*/
viewSlot: any;
/**@internal*/
lookupFunctions: any;
/**@internal*/
observerLocator: any;
/**@internal*/
strategyLocator: any;
/**@internal*/
ignoreMutation: boolean;
/**@internal*/
sourceExpression: any;
/**@internal*/
isOneTime: any;
/**@internal*/
viewsRequireLifecycle: any;
/**@internal*/
scope: { bindingContext: any; overrideContext: any; };
/**@internal*/
matcherBinding: any;
/**@internal*/
collectionObserver: any;
/**@internal*/
strategy: any;
/**@internal */
callContext: 'handleCollectionMutated' | 'handleInnerCollectionMutated';
/**
* Creates an instance of Repeat.
* @param viewFactory The factory generating the view
* @param instruction The instructions for how the element should be enhanced.
* @param viewResources Collection of resources used to compile the the views.
* @param viewSlot The slot the view is injected in to.
* @param observerLocator The observer locator instance.
* @param collectionStrategyLocator The strategy locator to locate best strategy to iterate the collection.
*/
constructor(viewFactory, instruction, viewSlot, viewResources, observerLocator, strategyLocator) {
super({
local: 'item',
viewsRequireLifecycle: viewsRequireLifecycle(viewFactory)
});
this.viewFactory = viewFactory;
this.instruction = instruction;
this.viewSlot = viewSlot;
this.lookupFunctions = viewResources.lookupFunctions;
this.observerLocator = observerLocator;
this.key = 'key';
this.value = 'value';
this.strategyLocator = strategyLocator;
this.ignoreMutation = false;
this.sourceExpression = getItemsSourceExpression(this.instruction, 'repeat.for');
this.isOneTime = isOneTime(this.sourceExpression);
this.viewsRequireLifecycle = viewsRequireLifecycle(viewFactory);
}
call(context, changes) {
this[context](this.items, changes);
}
/**
* Binds the repeat to the binding context and override context.
* @param bindingContext The binding context.
* @param overrideContext An override context for binding.
*/
bind(bindingContext, overrideContext) {
this.scope = { bindingContext, overrideContext };
const instruction = this.instruction;
if (!(matcherExtractionMarker in instruction)) {
instruction[matcherExtractionMarker] = this._captureAndRemoveMatcherBinding();
}
this.matcherBinding = instruction[matcherExtractionMarker];
this.itemsChanged();
}
/**
* Unbinds the repeat
*/
unbind() {
this.scope = null;
this.items = null;
this.matcherBinding = null;
this.viewSlot.removeAll(true, true);
this._unsubscribeCollection();
}
/**
* @internal
*/
_unsubscribeCollection() {
if (this.collectionObserver) {
this.collectionObserver.unsubscribe(this.callContext, this);
this.collectionObserver = null;
this.callContext = null;
}
}
/**
* Invoked everytime the item property changes.
*/
itemsChanged() {
this._unsubscribeCollection();
// still bound?
if (!this.scope) {
return;
}
let items = this.items;
this.strategy = this.strategyLocator.getStrategy(items);
if (!this.strategy) {
throw new Error(`Value for '${this.sourceExpression}' is non-repeatable`);
}
if (!this.isOneTime && !this._observeInnerCollection()) {
this._observeCollection();
}
this.ignoreMutation = true;
this.strategy.instanceChanged(this, items);
this.observerLocator.taskQueue.queueMicroTask(() => {
this.ignoreMutation = false;
});
}
/**
* @internal
*/
_getInnerCollection() {
let expression = unwrapExpression(this.sourceExpression);
if (!expression) {
return null;
}
return expression.evaluate(this.scope, null);
}
/**
* Invoked when the underlying collection changes.
*/
handleCollectionMutated(collection, changes) {
if (!this.collectionObserver) {
return;
}
if (this.ignoreMutation) {
return;
}
this.strategy.instanceMutated(this, collection, changes);
}
/**
* Invoked when the underlying inner collection changes.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handleInnerCollectionMutated(collection, changes) {
if (!this.collectionObserver) {
return;
}
// guard against source expressions that have observable side-effects that could
// cause an infinite loop- eg a value converter that mutates the source array.
if (this.ignoreMutation) {
return;
}
this.ignoreMutation = true;
let newItems = this.sourceExpression.evaluate(this.scope, this.lookupFunctions);
this.observerLocator.taskQueue.queueMicroTask(() => this.ignoreMutation = false);
// call itemsChanged...
if (newItems === this.items) {
// call itemsChanged directly.
this.itemsChanged();
} else {
// call itemsChanged indirectly by assigning the new collection value to
// the items property, which will trigger the self-subscriber to call itemsChanged.
this.items = newItems;
}
}
/**
* @internal
*/
_observeInnerCollection() {
let items = this._getInnerCollection();
let strategy = this.strategyLocator.getStrategy(items);
if (!strategy) {
return false;
}
this.collectionObserver = strategy.getCollectionObserver(this.observerLocator, items);
if (!this.collectionObserver) {
return false;
}
this.callContext = 'handleInnerCollectionMutated';
this.collectionObserver.subscribe(this.callContext, this);
return true;
}
/**
* @internal
*/
_observeCollection() {
let items = this.items;
this.collectionObserver = this.strategy.getCollectionObserver(this.observerLocator, items);
if (this.collectionObserver) {
this.callContext = 'handleCollectionMutated';
this.collectionObserver.subscribe(this.callContext, this);
}
}
/**
* Capture and remove matcher binding is a way to cache matcher binding + reduce redundant work
* caused by multiple unnecessary matcher bindings
* @internal
*/
_captureAndRemoveMatcherBinding() {
const viewFactory: ViewFactory = this.viewFactory.viewFactory;
if (viewFactory) {
const template = viewFactory.template;
const instructions = viewFactory.instructions as Record<string, TargetInstruction>;
// legacy behavior enabled when Repeat.useInnerMathcer === true
if (Repeat.useInnerMatcher) {
return extractMatcherBindingExpression(instructions);
}
// if the template has more than 1 immediate child element
// it's a repeat put on a <template/> element
// not valid for matcher binding
if (getChildrenCount(template) > 1) {
return undefined;
}
// if the root element does not have any instruction
// it means there's no matcher binding
// no need to do any further work
const repeatedElement = getFirstElementChild(template);
if (!repeatedElement.hasAttribute('au-target-id')) {
return undefined;
}
const repeatedElementTargetId = repeatedElement.getAttribute('au-target-id');
return extractMatcherBindingExpression(instructions, repeatedElementTargetId);
}
return undefined;
}
// @override AbstractRepeater
viewCount() { return this.viewSlot.children.length; }
views() { return this.viewSlot.children; }
view(index) { return this.viewSlot.children[index]; }
matcher() {
const matcherBinding = this.matcherBinding;
return matcherBinding
? matcherBinding.sourceExpression.evaluate(this.scope, matcherBinding.lookupFunctions)
: null;
}
addView(bindingContext, overrideContext) {
let view = this.viewFactory.create();
view.bind(bindingContext, overrideContext);
this.viewSlot.add(view);
}
insertView(index, bindingContext, overrideContext) {
let view = this.viewFactory.create();
view.bind(bindingContext, overrideContext);
this.viewSlot.insert(index, view);
}
moveView(sourceIndex, targetIndex) {
this.viewSlot.move(sourceIndex, targetIndex);
}
removeAllViews(returnToCache, skipAnimation) {
return this.viewSlot.removeAll(returnToCache, skipAnimation);
}
removeViews(viewsToRemove, returnToCache, skipAnimation) {
return this.viewSlot.removeMany(viewsToRemove, returnToCache, skipAnimation);
}
removeView(index, returnToCache, skipAnimation) {
return this.viewSlot.removeAt(index, returnToCache, skipAnimation);
}
updateBindings(view: View) {
const $view = view as View & { bindings: any[]; controllers: any[] };
let j = $view.bindings.length;
while (j--) {
updateOneTimeBinding($view.bindings[j]);
}
j = $view.controllers.length;
while (j--) {
let k = $view.controllers[j].boundProperties.length;
while (k--) {
let binding = $view.controllers[j].boundProperties[k].binding;
updateOneTimeBinding(binding);
}
}
}
}
/**
* Iterate a record of TargetInstruction and their expressions to find first binding expression that targets property named "matcher"
*/
const extractMatcherBindingExpression = (instructions: Record<string, TargetInstruction>, targetedElementId?: string): BindingExpression | undefined => {
const instructionIds = Object.keys(instructions);
for (let i = 0; i < instructionIds.length; i++) {
const instructionId = instructionIds[i];
// matcher binding can only works when root element is not a <template/>
// checking first el child
if (targetedElementId !== undefined && instructionId !== targetedElementId) {
continue;
}
const expressions = instructions[instructionId].expressions as BindingExpression[];
if (expressions) {
for (let ii = 0; ii < expressions.length; ii++) {
if (expressions[ii].targetProperty === 'matcher') {
const matcherBindingExpression = expressions[ii];
expressions.splice(ii, 1);
return matcherBindingExpression;
}
}
}
}
};
/**
* Calculate the number of child elements of an element
*
* Note: API .childElementCount/.children are not available in IE11
*/
const getChildrenCount = (el: Element | DocumentFragment) => {
const childNodes = el.childNodes;
let count = 0;
for (let i = 0, ii = childNodes.length; ii > i; ++i) {
if (childNodes[i].nodeType === /* element */1) {
++count;
}
}
return count;
};
/**
* Get the first child element of an element / doc fragment
*
* Note: API .firstElementChild is not available in IE11
*/
const getFirstElementChild = (el: Element | DocumentFragment) => {
let firstChild = el.firstChild as Element;
while (firstChild !== null) {
if (firstChild.nodeType === /* element */1) {
return firstChild;
}
firstChild = firstChild.nextSibling as Element;
}
return null;
};