UNPKG

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
/*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. */ @customAttribute('repeat') @templateController @inject(BoundViewFactory, TargetInstruction, ViewSlot, ViewResources, ObserverLocator, RepeatStrategyLocator) 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 */ @bindable items; /** * Local variable which gets assigned on each iteration. * * @property local */ @bindable local; /** * Key when iterating over Maps. * * @property key */ @bindable key; /** * Value when iterating over Maps. * * @property value */ @bindable 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; };