From d98c81c0c6eda75325b726bc38112ba2471c145a Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Thu, 31 Mar 2022 23:34:40 -0400 Subject: [PATCH 1/8] refactor: new design for execution context --- .../fast-element/docs/api-report.md | 195 ++++++++++++------ .../fast-element/src/components/controller.ts | 44 ++-- .../fast-element/src/interfaces.ts | 10 + .../fast-element/src/observation/behavior.ts | 12 +- .../src/observation/observable.spec.ts | 77 ++++--- .../src/observation/observable.ts | 185 +++++++++++++---- .../fast-element/src/styles/styles.spec.ts | 6 +- .../src/templating/binding.spec.ts | 30 +-- .../fast-element/src/templating/binding.ts | 14 +- .../src/templating/children.spec.ts | 16 +- .../src/templating/compiler.spec.ts | 4 +- .../fast-element/src/templating/compiler.ts | 23 ++- .../src/templating/html-directive.ts | 6 +- .../src/templating/node-observation.ts | 12 +- .../src/templating/repeat.spec.ts | 30 +-- .../fast-element/src/templating/repeat.ts | 125 +++++++---- .../src/templating/slotted.spec.ts | 14 +- .../fast-element/src/templating/template.ts | 143 ++++++++++--- .../fast-element/src/templating/view.ts | 36 ++-- .../fast-element/src/templating/when.spec.ts | 8 +- 20 files changed, 666 insertions(+), 324 deletions(-) diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index 3150ac39d7c..39ca873f948 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -81,16 +81,16 @@ export class AttributeDefinition implements Accessor { export type AttributeMode = typeof reflectMode | typeof booleanMode | "fromView"; // @public -export interface Behavior { - bind(source: TSource, context: ExecutionContext): void; - unbind(source: TSource, context: ExecutionContext): void; +export interface Behavior = RootContext> { + bind(source: TSource, context: TContext): void; + unbind(source: TSource, context: TContext): void; } // @alpha (undocumented) export function bind(binding: Binding, config?: BindingConfig | DefaultBindingOptions): CaptureType; // @public -export type Binding = (source: TSource, context: ExecutionContext) => TReturn; +export type Binding = (source: TSource, context: TContext) => TReturn; // @alpha (undocumented) export type BindingBehaviorFactory = { @@ -111,7 +111,7 @@ export type BindingMode = Record; // @public export interface BindingObserver extends Notifier { disconnect(): void; - observe(source: TSource, context: ExecutionContext): TReturn; + observe(source: TSource, context: ExecutionContext): TReturn; records(): IterableIterator; } @@ -132,6 +132,16 @@ export type Callable = typeof Function.prototype.call | { export interface CaptureType { } +// @public +export const child: (strings: TemplateStringsArray, ...values: TemplateValue>[]) => ChildViewTemplate; + +// @public +export interface ChildContext extends RootContext { + createItemContext(index: number, length: number): ItemContext; + readonly parent: TParentSource; + readonly parentContext: ChildContext; +} + // @public export interface ChildListDirectiveOptions extends NodeBehaviorOptions, Omit { } @@ -152,6 +162,13 @@ export class ChildrenDirective extends NodeObservationDirective = ChildListDirectiveOptions | SubtreeDirectiveOptions; +// @public +export interface ChildViewTemplate { + create(): SyntheticView>; + // (undocumented) + type: 'child'; +} + // @public export type CompilationStrategy = ( html: string | HTMLTemplateElement, @@ -160,7 +177,7 @@ directives: readonly HTMLDirective[]) => HTMLTemplateCompilationResult; // @public export const Compiler: { setHTMLPolicy(policy: TrustedTypesPolicy): void; - compile(html: string | HTMLTemplateElement, directives: ReadonlyArray): HTMLTemplateCompilationResult; + compile = ExecutionContext>(html: string | HTMLTemplateElement, directives: ReadonlyArray): HTMLTemplateCompilationResult; setDefaultStrategy(strategy: CompilationStrategy): void; aggregate(parts: (string | HTMLDirective)[]): HTMLDirective; }; @@ -179,26 +196,26 @@ export type ConstructibleStyleStrategy = { }; // @public -export class Controller extends PropertyChangeNotifier { +export class Controller extends PropertyChangeNotifier { // @internal - constructor(element: HTMLElement, definition: FASTElementDefinition); - addBehaviors(behaviors: ReadonlyArray>): void; + constructor(element: TElement, definition: FASTElementDefinition); + addBehaviors(behaviors: ReadonlyArray>): void; addStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void; readonly definition: FASTElementDefinition; - readonly element: HTMLElement; + readonly element: TElement; emit(type: string, detail?: any, options?: Omit): void | boolean; static forCustomElement(element: HTMLElement): Controller; get isConnected(): boolean; onAttributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; onConnectedCallback(): void; onDisconnectedCallback(): void; - removeBehaviors(behaviors: ReadonlyArray>, force?: boolean): void; + removeBehaviors(behaviors: ReadonlyArray>, force?: boolean): void; removeStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void; get styles(): ElementStyles | null; set styles(value: ElementStyles | null); - get template(): ElementViewTemplate | null; - set template(value: ElementViewTemplate | null); - readonly view: ElementView | null; + get template(): ElementViewTemplate | null; + set template(value: ElementViewTemplate | null); + readonly view: ElementView | null; } // @public @@ -224,9 +241,6 @@ export type DefaultBindingOptions = { capture?: boolean; }; -// @public -export const defaultExecutionContext: ExecutionContext; - // @public export const DOM: Readonly<{ supportsAdoptedStyleSheets: boolean; @@ -265,14 +279,16 @@ export class ElementStyles { } // @public -export interface ElementView extends View { +export interface ElementView extends View { appendTo(node: Node): void; } // @public -export interface ElementViewTemplate { - create(hostBindingTarget: Element): ElementView; - render(source: TSource, host: Node, hostBindingTarget?: Element): HTMLView; +export interface ElementViewTemplate { + create(hostBindingTarget: Element): ElementView; + render(source: TSource, host: Node, hostBindingTarget?: Element): ElementView; + // (undocumented) + type: 'element'; } // Warning: (ae-internal-missing-underscore) The name "emptyArray" should be prefixed with an underscore because the declaration is marked as @internal @@ -284,20 +300,14 @@ export const emptyArray: readonly never[]; export function enableArrayObservation(): void; // @public -export class ExecutionContext { - get event(): Event; - index: number; - get isEven(): boolean; - get isFirst(): boolean; - get isInMiddle(): boolean; - get isLast(): boolean; - get isOdd(): boolean; - length: number; - parent: TParent; - parentContext: ExecutionContext; - // @internal - static setEvent(event: Event | null): void; -} +export const ExecutionContext: Readonly<{ + default: RootContext; + setEvent(event: Event | null): void; + create(): RootContext; +}>; + +// @public +export type ExecutionContext = RootContext | ChildContext | ItemContext; // Warning: (ae-internal-missing-underscore) The name "FAST" should be prefixed with an underscore because the declaration is marked as @internal // @@ -353,7 +363,7 @@ export interface FASTGlobal { } // @public -export function html(strings: TemplateStringsArray, ...values: TemplateValue[]): ViewTemplate; +export function html = ExecutionContext>(strings: TemplateStringsArray, ...values: TemplateValue[]): ViewTemplate; // @public export abstract class HTMLDirective implements ViewBehaviorFactory { @@ -364,16 +374,16 @@ export abstract class HTMLDirective implements ViewBehaviorFactory { } // @public -export interface HTMLTemplateCompilationResult { - createView(hostBindingTarget?: Element): HTMLView; +export interface HTMLTemplateCompilationResult = ExecutionContext> { + createView(hostBindingTarget?: Element): HTMLView; } // @public -export class HTMLView implements ElementView, SyntheticView { +export class HTMLView = ExecutionContext> implements ElementView, SyntheticView { constructor(fragment: DocumentFragment, factories: ReadonlyArray, targets: ViewBehaviorTargets); appendTo(node: Node): void; - bind(source: TSource, context: ExecutionContext): void; - context: ExecutionContext | null; + bind(source: TSource, context: TContext): void; + context: TContext | null; dispose(): void; static disposeContiguousBatch(views: SyntheticView[]): void; firstChild: Node; @@ -384,6 +394,28 @@ export class HTMLView implemen unbind(): void; } +// @public +export const item: (strings: TemplateStringsArray, ...values: TemplateValue>[]) => ItemViewTemplate; + +// @public +export interface ItemContext extends ChildContext { + readonly index: number; + readonly isEven: boolean; + readonly isFirst: boolean; + readonly isInMiddle: boolean; + readonly isLast: boolean; + readonly isOdd: boolean; + readonly length: number; + updatePosition(index: number, length: number): void; +} + +// @public +export interface ItemViewTemplate { + create(): SyntheticView>; + // (undocumented) + type: 'item'; +} + // @public export const Markup: Readonly<{ interpolation: (index: number) => string; @@ -408,12 +440,12 @@ export interface NodeBehaviorOptions { // // @internal export abstract class NodeObservationDirective extends StatelessAttachedAttributeDirective { - bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void; + bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void; protected computeNodes(target: any): Node[]; protected abstract disconnect(target: any): void; protected abstract getNodes(target: any): Node[]; protected abstract observe(target: any): void; - unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void; + unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void; protected updateTarget(source: any, value: ReadonlyArray): void; } @@ -437,8 +469,8 @@ export const Observable: Readonly<{ notify(source: unknown, args: any): void; defineProperty(target: {}, nameOrAccessor: string | Accessor): void; getAccessors: (target: {}) => Accessor[]; - binding(binding: Binding, initialSubscriber?: Subscriber | undefined, isVolatileBinding?: boolean): BindingObserver; - isVolatileBinding(binding: Binding): boolean; + binding(binding: Binding>, initialSubscriber?: Subscriber | undefined, isVolatileBinding?: boolean): BindingObserver; + isVolatileBinding(binding: Binding>): boolean; }>; // @public @@ -491,8 +523,44 @@ export class RefDirective extends StatelessAttachedAttributeDirective { unbind(): void; } +// Warning: (ae-forgotten-export) The symbol "ArrayItem" needs to be exported by the entry point index.d.ts +// // @public -export function repeat(itemsBinding: Binding, templateOrTemplateBinding: SyntheticViewTemplate | Binding, options?: RepeatOptions): CaptureType; +export function repeat = ReadonlyArray>(itemsBinding: Binding>, templateOrTemplateBinding: SyntheticViewTemplate, TSource, RootContext> | Binding, TSource, RootContext>>, options?: { + positioning: false; +} | { + recycle: true; +} | { + positioning: false; + recycle: false; +} | { + positioning: false; + recycle: true; +}): CaptureType; + +// @public +export function repeat = ReadonlyArray>(itemsBinding: Binding>, templateOrTemplateBinding: ChildViewTemplate, TSource> | Binding, TSource>>, options?: { + positioning: false; +} | { + recycle: true; +} | { + positioning: false; + recycle: false; +} | { + positioning: false; + recycle: true; +}): CaptureType; + +// @public +export function repeat = ReadonlyArray>(itemsBinding: Binding>, templateOrTemplateBinding: ItemViewTemplate, TSource> | Binding, TSource>>, options: { + positioning: true; +} | { + positioning: true; + recycle: true; +} | { + positioning: true; + recycle: false; +}): CaptureType; // @public export class RepeatBehavior implements Behavior, Subscriber { @@ -522,6 +590,12 @@ export interface RepeatOptions { recycle?: boolean; } +// @public +export interface RootContext { + createChildContext(source: TParentSource): ChildContext; + readonly event: Event; +} + // @public export function slotted(propertyOrOptions: (keyof T & string) | SlottedDirectiveOptions): CaptureType; @@ -600,7 +674,7 @@ export interface SubtreeDirectiveOptions extends Omit extends View { +export interface SyntheticView = ExecutionContext> extends View { dispose(): void; readonly firstChild: Node; insertBefore(node: Node): void; @@ -609,12 +683,14 @@ export interface SyntheticView } // @public -export interface SyntheticViewTemplate { - create(): SyntheticView; +export interface SyntheticViewTemplate = ExecutionContext> { + create(): SyntheticView; + // (undocumented) + type: 'synthetic'; } // @public -export type TemplateValue = Binding | HTMLDirective | CaptureType; +export type TemplateValue = ExecutionContext> = Binding | HTMLDirective | CaptureType; // @public export type TrustedTypes = { @@ -633,18 +709,18 @@ export interface ValueConverter { } // @public -export interface View { - bind(source: TSource, context: ExecutionContext): void; - readonly context: ExecutionContext | null; +export interface View = ExecutionContext> { + bind(source: TSource, context: TContext): void; + readonly context: TContext | null; dispose(): void; readonly source: TSource | null; unbind(): void; } // @public -export interface ViewBehavior { - bind(source: TSource, context: ExecutionContext, targets: ViewBehaviorTargets): void; - unbind(source: TSource, context: ExecutionContext, targets: ViewBehaviorTargets): void; +export interface ViewBehavior { + bind(source: TSource, context: ExecutionContext, targets: ViewBehaviorTargets): void; + unbind(source: TSource, context: ExecutionContext, targets: ViewBehaviorTargets): void; } // @public @@ -659,12 +735,13 @@ export type ViewBehaviorTargets = { }; // @public -export class ViewTemplate implements ElementViewTemplate, SyntheticViewTemplate { +export class ViewTemplate = ExecutionContext> implements ElementViewTemplate, SyntheticViewTemplate { constructor(html: string | HTMLTemplateElement, directives: ReadonlyArray); - create(hostBindingTarget?: Element): HTMLView; + create(hostBindingTarget?: Element): HTMLView; readonly directives: ReadonlyArray; readonly html: string | HTMLTemplateElement; - render(source: TSource, host: Node, hostBindingTarget?: Element): HTMLView; + render(source: TSource, host: Node, hostBindingTarget?: Element, context?: TContext): HTMLView; + type: any; } // @public diff --git a/packages/web-components/fast-element/src/components/controller.ts b/packages/web-components/fast-element/src/components/controller.ts index 7b6df838c59..aeb63d3a276 100644 --- a/packages/web-components/fast-element/src/components/controller.ts +++ b/packages/web-components/fast-element/src/components/controller.ts @@ -1,7 +1,7 @@ import { Message, Mutable, StyleTarget } from "../interfaces.js"; import type { Behavior } from "../observation/behavior.js"; import { PropertyChangeNotifier } from "../observation/notifier.js"; -import { defaultExecutionContext, Observable } from "../observation/observable.js"; +import { ExecutionContext, Observable } from "../observation/observable.js"; import { FAST } from "../platform.js"; import type { ElementStyles } from "../styles/element-styles.js"; import type { ElementViewTemplate } from "../templating/template.js"; @@ -25,12 +25,14 @@ const isConnectedPropertyName = "isConnected"; * Controls the lifecycle and rendering of a `FASTElement`. * @public */ -export class Controller extends PropertyChangeNotifier { +export class Controller< + TElement extends HTMLElement = HTMLElement +> extends PropertyChangeNotifier { private boundObservables: Record | null = null; - private behaviors: Map, number> | null = null; + private behaviors: Map, number> | null = null; private needsInitialization: boolean = true; private hasExistingShadowRoot = false; - private _template: ElementViewTemplate | null = null; + private _template: ElementViewTemplate | null = null; private _styles: ElementStyles | null = null; private _isConnected: boolean = false; @@ -47,7 +49,7 @@ export class Controller extends PropertyChangeNotifier { /** * The element being controlled by this controller. */ - public readonly element: HTMLElement; + public readonly element: TElement; /** * The element definition that instructs this controller @@ -60,7 +62,7 @@ export class Controller extends PropertyChangeNotifier { * @remarks * If `null` then the element is managing its own rendering. */ - public readonly view: ElementView | null = null; + public readonly view: ElementView | null = null; /** * Indicates whether or not the custom element has been @@ -81,7 +83,7 @@ export class Controller extends PropertyChangeNotifier { * @remarks * This value can only be accurately read after connect but can be set at any time. */ - public get template(): ElementViewTemplate | null { + public get template(): ElementViewTemplate | null { // 1. Template overrides take top precedence. if (this._template === null) { const definition = this.definition; @@ -98,7 +100,7 @@ export class Controller extends PropertyChangeNotifier { return this._template; } - public set template(value: ElementViewTemplate | null) { + public set template(value: ElementViewTemplate | null) { if (this._template === value) { return; } @@ -155,8 +157,9 @@ export class Controller extends PropertyChangeNotifier { * controller in how to handle rendering and other platform integrations. * @internal */ - public constructor(element: HTMLElement, definition: FASTElementDefinition) { + public constructor(element: TElement, definition: FASTElementDefinition) { super(element); + this.element = element; this.definition = definition; @@ -254,10 +257,10 @@ export class Controller extends PropertyChangeNotifier { * Adds behaviors to this element. * @param behaviors - The behaviors to add. */ - public addBehaviors(behaviors: ReadonlyArray>): void { + public addBehaviors(behaviors: ReadonlyArray>): void { const targetBehaviors = this.behaviors ?? (this.behaviors = new Map()); const length = behaviors.length; - const behaviorsToBind: Behavior[] = []; + const behaviorsToBind: Behavior[] = []; for (let i = 0; i < length; ++i) { const behavior = behaviors[i]; @@ -272,9 +275,10 @@ export class Controller extends PropertyChangeNotifier { if (this._isConnected) { const element = this.element; + const context = ExecutionContext.default; for (let i = 0; i < behaviorsToBind.length; ++i) { - behaviorsToBind[i].bind(element, defaultExecutionContext); + behaviorsToBind[i].bind(element, context); } } } @@ -285,7 +289,7 @@ export class Controller extends PropertyChangeNotifier { * @param force - Forces unbinding of behaviors. */ public removeBehaviors( - behaviors: ReadonlyArray>, + behaviors: ReadonlyArray>, force: boolean = false ): void { const targetBehaviors = this.behaviors; @@ -295,7 +299,7 @@ export class Controller extends PropertyChangeNotifier { } const length = behaviors.length; - const behaviorsToUnbind: Behavior[] = []; + const behaviorsToUnbind: Behavior[] = []; for (let i = 0; i < length; ++i) { const behavior = behaviors[i]; @@ -311,9 +315,10 @@ export class Controller extends PropertyChangeNotifier { if (this._isConnected) { const element = this.element; + const context = ExecutionContext.default; for (let i = 0; i < behaviorsToUnbind.length; ++i) { - behaviorsToUnbind[i].unbind(element, defaultExecutionContext); + behaviorsToUnbind[i].unbind(element, context); } } } @@ -327,18 +332,19 @@ export class Controller extends PropertyChangeNotifier { } const element = this.element; + const context = ExecutionContext.default; if (this.needsInitialization) { this.finishInitialization(); } else if (this.view !== null) { - this.view.bind(element, defaultExecutionContext); + this.view.bind(element, context); } const behaviors = this.behaviors; if (behaviors !== null) { for (const behavior of behaviors.keys()) { - behavior.bind(element, defaultExecutionContext); + behavior.bind(element, context); } } @@ -365,8 +371,10 @@ export class Controller extends PropertyChangeNotifier { if (behaviors !== null) { const element = this.element; + const context = ExecutionContext.default; + for (const behavior of behaviors.keys()) { - behavior.unbind(element, defaultExecutionContext); + behavior.unbind(element, context); } } } diff --git a/packages/web-components/fast-element/src/interfaces.ts b/packages/web-components/fast-element/src/interfaces.ts index 7f7179b6f8f..c12b72c44db 100644 --- a/packages/web-components/fast-element/src/interfaces.ts +++ b/packages/web-components/fast-element/src/interfaces.ts @@ -21,6 +21,16 @@ export type Mutable = { -readonly [P in keyof T]: T[P]; }; +/** + * Extracts the item type from an array. + * @public + */ +export type ArrayItem = T extends ReadonlyArray + ? TItem + : T extends Array + ? TItem + : any; + /** * A policy for use with the standard trustedTypes platform API. * @public diff --git a/packages/web-components/fast-element/src/observation/behavior.ts b/packages/web-components/fast-element/src/observation/behavior.ts index b1eea785632..bb8439a4ac4 100644 --- a/packages/web-components/fast-element/src/observation/behavior.ts +++ b/packages/web-components/fast-element/src/observation/behavior.ts @@ -1,21 +1,25 @@ -import type { ExecutionContext } from "./observable.js"; +import type { ExecutionContext, RootContext } from "./observable.js"; /** * Represents an object that can contribute behavior to a view or * element's bind/unbind operations. * @public */ -export interface Behavior { +export interface Behavior< + TSource = any, + TParent = any, + TContext extends ExecutionContext = RootContext +> { /** * Bind this behavior to the source. * @param source - The source to bind to. * @param context - The execution context that the binding is operating within. */ - bind(source: TSource, context: ExecutionContext): void; + bind(source: TSource, context: TContext): void; /** * Unbinds this behavior from the source. * @param source - The source to unbind from. */ - unbind(source: TSource, context: ExecutionContext): void; + unbind(source: TSource, context: TContext): void; } diff --git a/packages/web-components/fast-element/src/observation/observable.spec.ts b/packages/web-components/fast-element/src/observation/observable.spec.ts index c174b52ab0d..08909a23bf3 100644 --- a/packages/web-components/fast-element/src/observation/observable.spec.ts +++ b/packages/web-components/fast-element/src/observation/observable.spec.ts @@ -1,8 +1,7 @@ import { expect } from "chai"; import { DOM } from "../dom"; -import { enableArrayObservation } from "./array-observer"; import { PropertyChangeNotifier, SubscriberSet } from "./notifier"; -import { defaultExecutionContext, Observable, observable, volatile } from "./observable"; +import { ExecutionContext, Observable, observable, volatile } from "./observable"; describe("The Observable", () => { class Model { @@ -146,7 +145,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child); expect(wasNotified).to.be.false; @@ -156,7 +155,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child); }); @@ -170,7 +169,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value); expect(wasNotified).to.be.false; @@ -180,7 +179,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value); }); it("notifies on changes in a sub-property binding after disconnecting before notification has been processed", async () => { @@ -193,7 +192,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value); expect(called).to.be.false; @@ -204,7 +203,7 @@ describe("The Observable", () => { expect(called).to.be.false; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value); model.child.value = "another completely different thing"; @@ -225,7 +224,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value + model.child2.value); // change child.value @@ -236,7 +235,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value + model.child2.value); // change child2.value @@ -247,7 +246,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value + model.child2.value); // change child @@ -258,7 +257,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value + model.child2.value); // change child 2 @@ -269,7 +268,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.child.value + model.child2.value); }); @@ -284,7 +283,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); expect(wasNotified).to.be.false; @@ -294,7 +293,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); wasNotified = false; @@ -304,7 +303,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); }); @@ -319,7 +318,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); expect(wasNotified).to.be.false; @@ -329,7 +328,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); wasNotified = false; @@ -339,7 +338,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); }); @@ -360,7 +359,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); expect(wasNotified).to.be.false; @@ -370,7 +369,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); wasNotified = false; @@ -380,7 +379,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); }); @@ -395,7 +394,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); expect(wasNotified).to.be.false; @@ -405,7 +404,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); wasNotified = false; @@ -415,7 +414,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); }); @@ -430,7 +429,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); expect(wasNotified).to.be.false; @@ -440,7 +439,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); wasNotified = false; @@ -450,7 +449,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); }); @@ -465,7 +464,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); expect(wasNotified).to.be.false; @@ -475,7 +474,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); wasNotified = false; @@ -485,7 +484,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); }); @@ -502,7 +501,7 @@ describe("The Observable", () => { const model = new Model(); model.incrementTrigger(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); expect(wasNotified).to.be.false; @@ -512,7 +511,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); wasNotified = false; @@ -522,7 +521,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); }); @@ -544,7 +543,7 @@ describe("The Observable", () => { }); const model = new Model(); - let value = observer.observe(model, defaultExecutionContext); + let value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); expect(wasNotified).to.be.false; @@ -554,7 +553,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); wasNotified = false; @@ -564,7 +563,7 @@ describe("The Observable", () => { expect(wasNotified).to.be.true; - value = observer.observe(model, defaultExecutionContext); + value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(binding(model)); }); @@ -579,7 +578,7 @@ describe("The Observable", () => { const model = new Model(); - const value = observer.observe(model, defaultExecutionContext); + const value = observer.observe(model, ExecutionContext.default); expect(value).to.equal(model.value); expect(wasCalled).to.equal(false); @@ -604,7 +603,7 @@ describe("The Observable", () => { } const bindingObserver = Observable.binding(binding); - bindingObserver.observe({}, defaultExecutionContext); + bindingObserver.observe({}, ExecutionContext.default); let i = 0; for (const record of bindingObserver.records()) { diff --git a/packages/web-components/fast-element/src/observation/observable.ts b/packages/web-components/fast-element/src/observation/observable.ts index 0ae6883287a..93a0c930568 100644 --- a/packages/web-components/fast-element/src/observation/observable.ts +++ b/packages/web-components/fast-element/src/observation/observable.ts @@ -33,10 +33,11 @@ export interface Accessor { * as part of a template binding update. * @public */ -export type Binding = ( - source: TSource, - context: ExecutionContext -) => TReturn; +export type Binding< + TSource = any, + TReturn = any, + TContext extends ExecutionContext = ExecutionContext +> = (source: TSource, context: TContext) => TReturn; /** * A record of observable property access. @@ -63,7 +64,6 @@ interface SubscriptionRecord extends ObservationRecord { * Enables evaluation of and subscription to a binding. * @public */ -/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ export interface BindingObserver extends Notifier { /** @@ -72,7 +72,7 @@ export interface BindingObserver * @param context - The execution context to execute the binding within. * @returns The value of the binding. */ - observe(source: TSource, context: ExecutionContext): TReturn; + observe(source: TSource, context: ExecutionContext): TReturn; /** * Unsubscribe from all dependent observables of the binding. @@ -170,9 +170,9 @@ export const Observable = FAST.getById(KernelServiceId.observable, () => { } } - class BindingObserverImplementation + class BindingObserverImplementation extends SubscriberSet - implements BindingObserver { + implements BindingObserver { public needsRefresh: boolean = true; private needsQueue: boolean = true; @@ -184,7 +184,7 @@ export const Observable = FAST.getById(KernelServiceId.observable, () => { private next: SubscriptionRecord | undefined = void 0; constructor( - private binding: Binding, + private binding: Binding, initialSubscriber?: Subscriber, private isVolatileBinding: boolean = false ) { @@ -359,11 +359,11 @@ export const Observable = FAST.getById(KernelServiceId.observable, () => { * @param initialSubscriber - An initial subscriber to changes in the binding value. * @param isVolatileBinding - Indicates whether the binding's dependency list must be re-evaluated on every value evaluation. */ - binding( - binding: Binding, + binding( + binding: Binding, initialSubscriber?: Subscriber, isVolatileBinding: boolean = this.isVolatileBinding(binding) - ): BindingObserver { + ): BindingObserver { /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ return new BindingObserverImplementation( binding, @@ -377,8 +377,8 @@ export const Observable = FAST.getById(KernelServiceId.observable, () => { * on every evaluation of the value. * @param binding - The binding to inspect. */ - isVolatileBinding( - binding: Binding + isVolatileBinding( + binding: Binding ): boolean { return volatileRegex.test(binding.toString()); }, @@ -432,89 +432,184 @@ const contextEvent = FAST.getById(KernelServiceId.contextEvent, () => { * Provides additional contextual information available to behaviors and expressions. * @public */ -export class ExecutionContext { +export interface RootContext { /** - * The index of the current item within a repeat context. + * The current event within an event handler. */ - public index: number = 0; + readonly event: Event; /** - * The length of the current collection within a repeat context. + * Creates a new execution context descendent from the current context. + * @param source - The source for the context if different than the parent. + * @returns A child execution context. */ - public length: number = 0; + createChildContext(source: TParentSource): ChildContext; +} +/** + * Provides additional contextual information when inside a child template. + * @public + */ +export interface ChildContext extends RootContext { /** - * The parent data object within a repeat context. + * The parent data source within a nested context. */ - public parent: TParent = null as any; + readonly parent: TParentSource; /** * The parent execution context when in nested context scenarios. */ - public parentContext: ExecutionContext = null as any; + readonly parentContext: ChildContext; /** - * The current event within an event handler. + * Creates a new execution context descent suitable for use in list rendering. + * @param item - The list item to serve as the source. + * @param index - The index of the item in the list. + * @param length - The length of the list. */ - public get event(): Event { - return contextEvent.get()!; - } + createItemContext(index: number, length: number): ItemContext; +} + +/** + * Provides additional contextual information when inside a repeat item template.s + * @public + */ +export interface ItemContext extends ChildContext { + /** + * The index of the current item within a repeat context. + */ + readonly index: number; + + /** + * The length of the current collection within a repeat context. + */ + readonly length: number; /** * Indicates whether the current item within a repeat context * has an even index. */ - public get isEven(): boolean { - return this.index % 2 === 0; - } + readonly isEven: boolean; /** * Indicates whether the current item within a repeat context * has an odd index. */ - public get isOdd(): boolean { - return this.index % 2 !== 0; - } + readonly isOdd: boolean; /** * Indicates whether the current item within a repeat context * is the first item in the collection. */ - public get isFirst(): boolean { - return this.index === 0; - } + readonly isFirst: boolean; /** * Indicates whether the current item within a repeat context * is somewhere in the middle of the collection. */ - public get isInMiddle(): boolean { - return !this.isFirst && !this.isLast; - } + readonly isInMiddle: boolean; /** * Indicates whether the current item within a repeat context * is the last item in the collection. */ + readonly isLast: boolean; + + /** + * Updates the position/size on a context associated with a list item. + * @param index - The new index of the item. + * @param length - The new length of the list. + */ + updatePosition(index: number, length: number): void; +} + +class DefaultExecutionContext implements RootContext, ChildContext, ItemContext { + public index: number = 0; + public length: number = 0; + public readonly parent: any; + public readonly parentContext: ChildContext; + + constructor(parentSource: any = null, parentContext: ExecutionContext | null = null) { + this.parent = parentSource; + this.parentContext = parentContext as any; + } + + public get event(): Event { + return contextEvent.get()!; + } + + public get isEven(): boolean { + return this.index % 2 === 0; + } + + public get isOdd(): boolean { + return this.index % 2 !== 0; + } + + public get isFirst(): boolean { + return this.index === 0; + } + + public get isInMiddle(): boolean { + return !this.isFirst && !this.isLast; + } + public get isLast(): boolean { return this.index === this.length - 1; } + public updatePosition(index: number, length: number): void { + this.index = index; + this.length = length; + } + + public createChildContext( + parentSource: TParentSource + ): ChildContext { + return new DefaultExecutionContext(parentSource, this); + } + + public createItemContext(index: number, length: number): ItemContext { + const childContext = Object.create(this); + childContext.index = index; + childContext.length = length; + return childContext; + } +} + +Observable.defineProperty(DefaultExecutionContext.prototype, "index"); +Observable.defineProperty(DefaultExecutionContext.prototype, "length"); + +/** + * The common execution context APIs. + * @public + */ +export const ExecutionContext = Object.freeze({ + default: new DefaultExecutionContext() as RootContext, + /** * Sets the event for the current execution context. * @param event - The event to set. * @internal */ - public static setEvent(event: Event | null): void { + setEvent(event: Event | null): void { contextEvent.set(event); - } -} + }, -Observable.defineProperty(ExecutionContext.prototype, "index"); -Observable.defineProperty(ExecutionContext.prototype, "length"); + /** + * Creates a new root execution context. + * @returns A new execution context. + */ + create(): RootContext { + return new DefaultExecutionContext(); + }, +}); /** - * The default execution context used in binding expressions. + * Represents some sort of execution context. * @public */ -export const defaultExecutionContext = Object.seal(new ExecutionContext()); +export type ExecutionContext = + | RootContext + | ChildContext + | ItemContext; diff --git a/packages/web-components/fast-element/src/styles/styles.spec.ts b/packages/web-components/fast-element/src/styles/styles.spec.ts index dfe946c4cc3..634a166a247 100644 --- a/packages/web-components/fast-element/src/styles/styles.spec.ts +++ b/packages/web-components/fast-element/src/styles/styles.spec.ts @@ -7,10 +7,10 @@ import { DOM } from "../dom"; import { CSSDirective } from "./css-directive"; import { css, cssPartial } from "./css"; import type { Behavior } from "../observation/behavior"; -import { defaultExecutionContext } from "../observation/observable"; import type { FASTElement } from ".."; import { StyleElementStrategy } from "../polyfills"; import type { StyleTarget } from "../interfaces"; +import { ExecutionContext } from "../observation/observable"; if (DOM.supportsAdoptedStyleSheets) { describe("AdoptedStyleSheetsStrategy", () => { @@ -337,7 +337,7 @@ describe("cssPartial", () => { } } as FASTElement; - partial.createBehavior()?.bind(el, defaultExecutionContext) + partial.createBehavior()?.bind(el, ExecutionContext.default) }); it("should add any ElementStyles interpolated into the template function when bound to an element", () => { @@ -353,7 +353,7 @@ describe("cssPartial", () => { } } as FASTElement; - partial.createBehavior()?.bind(el, defaultExecutionContext) + partial.createBehavior()?.bind(el, ExecutionContext.default) expect(called).to.be.true; }) diff --git a/packages/web-components/fast-element/src/templating/binding.spec.ts b/packages/web-components/fast-element/src/templating/binding.spec.ts index ff29ac0c877..afebb8da469 100644 --- a/packages/web-components/fast-element/src/templating/binding.spec.ts +++ b/packages/web-components/fast-element/src/templating/binding.spec.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import { bind, HTMLBindingDirective } from "./binding"; -import { observable, defaultExecutionContext } from "../observation/observable"; +import { ExecutionContext, observable } from "../observation/observable"; import { DOM } from "../dom"; import { html, ViewTemplate } from "./template"; import { toHTML } from "../__test__/helpers"; @@ -46,7 +46,7 @@ describe("The HTML binding directive", () => { const { behavior, node, targets } = contentBinding(); const model = new Model("This is a test"); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(node.textContent).to.equal(model.value); }); @@ -55,7 +55,7 @@ describe("The HTML binding directive", () => { const { behavior, node, targets } = contentBinding(); const model = new Model("This is a test"); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(node.textContent).to.equal(model.value); @@ -73,7 +73,7 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(toHTML(parentNode)).to.equal(`This is a template. value`); }); @@ -83,7 +83,7 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(toHTML(parentNode)).to.equal(`This is a template. value`); @@ -99,7 +99,7 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(toHTML(parentNode)).to.equal(`This is a template. value`); @@ -115,7 +115,7 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(toHTML(parentNode)).to.equal(`This is a template. value`); @@ -131,7 +131,7 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(toHTML(parentNode)).to.equal(`This is a template. value`); @@ -148,7 +148,7 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); const view = (node as any).$fastView as SyntheticView; const capturedTemplate = (node as any).$fastTemplate as ViewTemplate; @@ -180,7 +180,7 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(toHTML(parentNode)).to.equal(`This is a template. value`); @@ -199,13 +199,13 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); const newView = (node as any).$fastView as SyntheticView; expect(newView.source).to.equal(model); expect(toHTML(parentNode)).to.equal(`This is a template. value`); - behavior.unbind(model, defaultExecutionContext, targets); + behavior.unbind(model, ExecutionContext.default, targets); expect(newView.source).to.equal(null); }); @@ -215,17 +215,17 @@ describe("The HTML binding directive", () => { const template = html`This is a template. ${x => x.knownValue}`; const model = new Model(template); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); const view = (node as any).$fastView as SyntheticView; expect(view.source).to.equal(model); expect(toHTML(parentNode)).to.equal(`This is a template. value`); - behavior.unbind(model, defaultExecutionContext, targets); + behavior.unbind(model, ExecutionContext.default, targets); expect(view.source).to.equal(null); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); const newView = (node as any).$fastView as SyntheticView; expect(newView.source).to.equal(model); diff --git a/packages/web-components/fast-element/src/templating/binding.ts b/packages/web-components/fast-element/src/templating/binding.ts index ef067c9c302..e1df2925b58 100644 --- a/packages/web-components/fast-element/src/templating/binding.ts +++ b/packages/web-components/fast-element/src/templating/binding.ts @@ -288,11 +288,7 @@ export function sendSignal(signal: string): void { } class OnSignalBinding extends TargetUpdateBinding { - bind( - source: any, - context: ExecutionContext, - targets: ViewBehaviorTargets - ): void { + bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { const directive = this.directive; const target = targets[directive.targetId]; const signal = this.getSignal(source, context); @@ -319,11 +315,7 @@ class OnSignalBinding extends TargetUpdateBinding { } } - unbind( - source: any, - context: ExecutionContext, - targets: ViewBehaviorTargets - ): void { + unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { const signal = this.getSignal(source, context); const found = signals[signal]; @@ -340,7 +332,7 @@ class OnSignalBinding extends TargetUpdateBinding { } } - private getSignal(source: any, context: ExecutionContext): string { + private getSignal(source: any, context: ExecutionContext): string { const options = this.directive.options; return isString(options) ? options : options(source, context); } diff --git a/packages/web-components/fast-element/src/templating/children.spec.ts b/packages/web-components/fast-element/src/templating/children.spec.ts index 0b68b60a665..390f2029825 100644 --- a/packages/web-components/fast-element/src/templating/children.spec.ts +++ b/packages/web-components/fast-element/src/templating/children.spec.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import { children, ChildrenDirective } from "./children"; -import { defaultExecutionContext, observable } from "../observation/observable"; +import { ExecutionContext, observable } from "../observation/observable"; import { elements } from "./node-observation"; import { DOM } from "../dom"; @@ -57,7 +57,7 @@ describe("The children", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children); }); @@ -71,7 +71,7 @@ describe("The children", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children.filter(elements("foo-bar"))); }); @@ -84,7 +84,7 @@ describe("The children", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children); @@ -104,7 +104,7 @@ describe("The children", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children); @@ -137,7 +137,7 @@ describe("The children", () => { const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(subtreeChildren); @@ -164,11 +164,11 @@ describe("The children", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children); - behavior.unbind(model, defaultExecutionContext, targets); + behavior.unbind(model, ExecutionContext.default, targets); expect(model.nodes).members([]); diff --git a/packages/web-components/fast-element/src/templating/compiler.spec.ts b/packages/web-components/fast-element/src/templating/compiler.spec.ts index e16ea181261..9ed9da516d1 100644 --- a/packages/web-components/fast-element/src/templating/compiler.spec.ts +++ b/packages/web-components/fast-element/src/templating/compiler.spec.ts @@ -2,7 +2,7 @@ import { expect } from "chai"; import { DOM } from "../dom"; import { customElement, FASTElement } from "../components/fast-element"; import { Markup } from './markup'; -import { defaultExecutionContext } from "../observation/observable"; +import { ExecutionContext } from "../observation/observable"; import { css } from "../styles/css"; import { toHTML, uniqueElementName } from "../__test__/helpers"; import { bind, HTMLBindingDirective } from "./binding"; @@ -333,7 +333,7 @@ describe("The template compiler", () => { expect( (factories[0] as HTMLBindingDirective).binding( scope, - defaultExecutionContext + ExecutionContext.default ) ).to.equal(x.result); } diff --git a/packages/web-components/fast-element/src/templating/compiler.ts b/packages/web-components/fast-element/src/templating/compiler.ts index 232d5ecfafd..c0eef87ec3a 100644 --- a/packages/web-components/fast-element/src/templating/compiler.ts +++ b/packages/web-components/fast-element/src/templating/compiler.ts @@ -26,7 +26,11 @@ const next: NextNode = { node: null as ChildNode | null, }; -class CompilationContext implements TemplateCompilationResult { +class CompilationContext< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +> implements TemplateCompilationResult { private proto: any = null; private targetIds = new Set(); private descriptors: PropertyDescriptorMap = {}; @@ -52,7 +56,7 @@ class CompilationContext implements TemplateCompilationResult { this.factories.push(factory); } - public freeze(): TemplateCompilationResult { + public freeze(): TemplateCompilationResult { this.proto = Object.create(null, this.descriptors); return this; } @@ -97,7 +101,7 @@ class CompilationContext implements TemplateCompilationResult { descriptors[targetId] = descriptor; } - public createView(hostBindingTarget?: Element): HTMLView { + public createView(hostBindingTarget?: Element): HTMLView { const fragment = this.fragment.cloneNode(true) as DocumentFragment; const targets = Object.create(this.proto); @@ -303,10 +307,14 @@ export const Compiler = { * it is recommended that you clone the original and pass the clone to this API. * @public */ - compile( + compile< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext + >( html: string | HTMLTemplateElement, directives: ReadonlyArray - ): TemplateCompilationResult { + ): TemplateCompilationResult { let template: HTMLTemplateElement; if (isString(html)) { @@ -324,7 +332,10 @@ export const Compiler = { // https://bugs.chromium.org/p/chromium/issues/detail?id=1111864 const fragment = document.adoptNode(template.content); - const context = new CompilationContext(fragment, directives); + const context = new CompilationContext( + fragment, + directives + ); compileAttributes(context, "", template, /* host */ "h", 0, true); if ( diff --git a/packages/web-components/fast-element/src/templating/html-directive.ts b/packages/web-components/fast-element/src/templating/html-directive.ts index 4dc51a78025..a79ee2f0482 100644 --- a/packages/web-components/fast-element/src/templating/html-directive.ts +++ b/packages/web-components/fast-element/src/templating/html-directive.ts @@ -14,7 +14,7 @@ export type ViewBehaviorTargets = { * Represents an object that can contribute behavior to a view. * @public */ -export interface ViewBehavior { +export interface ViewBehavior { /** * Bind this behavior to the source. * @param source - The source to bind to. @@ -23,7 +23,7 @@ export interface ViewBehavior */ bind( source: TSource, - context: ExecutionContext, + context: ExecutionContext, targets: ViewBehaviorTargets ): void; @@ -35,7 +35,7 @@ export interface ViewBehavior */ unbind( source: TSource, - context: ExecutionContext, + context: ExecutionContext, targets: ViewBehaviorTargets ): void; } diff --git a/packages/web-components/fast-element/src/templating/node-observation.ts b/packages/web-components/fast-element/src/templating/node-observation.ts index b87d1bb67d5..189e4f56107 100644 --- a/packages/web-components/fast-element/src/templating/node-observation.ts +++ b/packages/web-components/fast-element/src/templating/node-observation.ts @@ -57,11 +57,7 @@ export abstract class NodeObservationDirective< * @param context - The execution context that the binding is operating within. * @param targets - The targets that behaviors in a view can attach to. */ - bind( - source: any, - context: ExecutionContext, - targets: ViewBehaviorTargets - ): void { + bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { const target = targets[this.targetId] as any; target.$fastSource = source; this.updateTarget(source, this.computeNodes(target)); @@ -74,11 +70,7 @@ export abstract class NodeObservationDirective< * @param context - The execution context that the binding is operating within. * @param targets - The targets that behaviors in a view can attach to. */ - unbind( - source: any, - context: ExecutionContext, - targets: ViewBehaviorTargets - ): void { + unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { const target = targets[this.targetId] as any; this.updateTarget(source, emptyArray); this.disconnect(target); diff --git a/packages/web-components/fast-element/src/templating/repeat.spec.ts b/packages/web-components/fast-element/src/templating/repeat.spec.ts index f4094de7514..fd0a11afe60 100644 --- a/packages/web-components/fast-element/src/templating/repeat.spec.ts +++ b/packages/web-components/fast-element/src/templating/repeat.spec.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { repeat, RepeatDirective, RepeatBehavior } from "./repeat"; -import { html } from "./template"; -import { defaultExecutionContext, observable } from "../observation/observable"; +import { child, html } from "./template"; +import { ExecutionContext, observable } from "../observation/observable"; import { DOM } from "../dom"; import { toHTML } from "../__test__/helpers"; @@ -107,7 +107,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); expect(toHTML(parent)).to.equal(createOutput(size)); }); @@ -124,7 +124,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const data = new ViewModel(size); - behavior.bind(data, defaultExecutionContext); + behavior.bind(data, ExecutionContext.default); expect(toHTML(parent)).to.equal( createOutput(size, void 0, void 0, input => `
${input}
`) @@ -157,7 +157,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); vm.items.push({ name: "newitem" }); await DOM.nextUpdate(); @@ -177,7 +177,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); const index = size - 1; vm.items.splice(index, 1); @@ -201,7 +201,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); vm.items.splice(0, 1); @@ -222,7 +222,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); const index = size - 1; vm.items.splice(index, 1, { name: "newitem1" }, { name: "newitem2" }); @@ -246,7 +246,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); vm.items.splice(0, 1, { name: "newitem1" }, { name: "newitem2" }); @@ -269,7 +269,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); expect(toHTML(parent)).to.equal(createOutput(size)); @@ -286,7 +286,7 @@ describe("The repeat", () => { const deepItemTemplate = html` parent-${x => x.name}${repeat( x => x.items!, - html`child-${x => x.name}root-${(x, c) => c.parentContext.parent.name}` + child`child-${x => x.name}root-${(x, c) => c.parentContext.parent.name}` )} `; @@ -299,7 +299,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size, true); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); const text = toHTML(parent); @@ -321,7 +321,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); vm.items.shift(); vm.items.unshift({ name: "shift" }); @@ -345,7 +345,7 @@ describe("The repeat", () => { const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); await DOM.nextUpdate(); @@ -353,7 +353,7 @@ describe("The repeat", () => { await DOM.nextUpdate(); - behavior.bind(vm, defaultExecutionContext); + behavior.bind(vm, ExecutionContext.default); await DOM.nextUpdate(); diff --git a/packages/web-components/fast-element/src/templating/repeat.ts b/packages/web-components/fast-element/src/templating/repeat.ts index c669e1377b4..749cd6f22a4 100644 --- a/packages/web-components/fast-element/src/templating/repeat.ts +++ b/packages/web-components/fast-element/src/templating/repeat.ts @@ -1,4 +1,4 @@ -import { isFunction } from "../interfaces.js"; +import { ArrayItem, isFunction } from "../interfaces.js"; import type { Splice } from "../observation/array-change-records.js"; import { enableArrayObservation } from "../observation/array-observer.js"; import type { Behavior } from "../observation/behavior.js"; @@ -6,13 +6,21 @@ import type { Notifier, Subscriber } from "../observation/notifier.js"; import { Binding, BindingObserver, + ChildContext, ExecutionContext, + ItemContext, Observable, + RootContext, } from "../observation/observable.js"; import { emptyArray } from "../platform.js"; import { Markup } from "./markup.js"; import { HTMLDirective, ViewBehaviorTargets } from "./html-directive.js"; -import type { CaptureType, SyntheticViewTemplate } from "./template.js"; +import type { + CaptureType, + ChildViewTemplate, + ItemViewTemplate, + SyntheticViewTemplate, +} from "./template.js"; import { HTMLView, SyntheticView } from "./view.js"; /** @@ -40,7 +48,7 @@ function bindWithoutPositioning( view: SyntheticView, items: readonly any[], index: number, - context: ExecutionContext + context: ChildContext ): void { view.bind(items[index], context); } @@ -49,12 +57,9 @@ function bindWithPositioning( view: SyntheticView, items: readonly any[], index: number, - context: ExecutionContext + context: ChildContext ): void { - const childContext = Object.create(context); - childContext.index = index; - childContext.length = items.length; - view.bind(items[index], childContext); + view.bind(items[index], context.createItemContext(index, items.length)); } /** @@ -69,8 +74,8 @@ export class RepeatBehavior implements Behavior, Subscriber { private items: readonly any[] | null = null; private itemsObserver: Notifier | null = null; private itemsBindingObserver: BindingObserver; - private originalContext: ExecutionContext | undefined = void 0; - private childContext: ExecutionContext | undefined = void 0; + private context: ExecutionContext | undefined = void 0; + private childContext: ChildContext | undefined = void 0; private bindView: typeof bindWithoutPositioning = bindWithoutPositioning; /** @@ -113,16 +118,11 @@ export class RepeatBehavior implements Behavior, Subscriber { */ public bind(source: TSource, context: ExecutionContext): void { this.source = source; - this.originalContext = context; - this.childContext = Object.create(context); - this.childContext!.parent = source; - this.childContext!.parentContext = this.originalContext; - - this.items = this.itemsBindingObserver.observe(source, this.originalContext); - this.template = this.templateBindingObserver.observe( - source, - this.originalContext - ); + this.context = context; + this.childContext = context.createChildContext(source); + + this.items = this.itemsBindingObserver.observe(source, this.context); + this.template = this.templateBindingObserver.observe(source, this.context); this.observeItems(true); this.refreshAllViews(); } @@ -147,16 +147,13 @@ export class RepeatBehavior implements Behavior, Subscriber { /** @internal */ public handleChange(source: any, args: Splice[]): void { if (source === this.itemsBinding) { - this.items = this.itemsBindingObserver.observe( - this.source!, - this.originalContext! - ); + this.items = this.itemsBindingObserver.observe(this.source!, this.context!); this.observeItems(); this.refreshAllViews(); } else if (source === this.templateBinding) { this.template = this.templateBindingObserver.observe( this.source!, - this.originalContext! + this.context! ); this.refreshAllViews(true); } else { @@ -184,8 +181,8 @@ export class RepeatBehavior implements Behavior, Subscriber { } private updateViews(splices: Splice[]): void { - const childContext = this.childContext!; const views = this.views; + const childContext = this.childContext!; const totalRemoved: SyntheticView[] = []; const bindView = this.bindView; let removeDelta = 0; @@ -229,19 +226,17 @@ export class RepeatBehavior implements Behavior, Subscriber { if (this.options.positioning) { for (let i = 0, ii = views.length; i < ii; ++i) { - const currentContext = views[i].context!; - currentContext.length = ii; - currentContext.index = i; + (views[i].context! as ItemContext).updatePosition(i, ii); } } } private refreshAllViews(templateChanged: boolean = false): void { const items = this.items!; - const childContext = this.childContext!; const template = this.template; const location = this.location; const bindView = this.bindView; + const childContext = this.childContext!; let itemsLength = items.length; let views = this.views; let viewsLength = views.length; @@ -350,16 +345,74 @@ export class RepeatDirective extends HTMLDirective { * @param options - Options used to turn on special repeat features. * @public */ -export function repeat( - itemsBinding: Binding, +export function repeat< + TSource = any, + TArray extends ReadonlyArray = ReadonlyArray +>( + itemsBinding: Binding>, + templateOrTemplateBinding: + | SyntheticViewTemplate, TSource, RootContext> + | Binding< + TSource, + SyntheticViewTemplate, TSource, RootContext> + >, + options?: + | { positioning: false } + | { recycle: true } + | { positioning: false; recycle: false } + | { positioning: false; recycle: true } +): CaptureType; +/** + * A directive that enables list rendering. + * @param itemsBinding - The array to render. + * @param templateOrTemplateBinding - The template or a template binding used obtain a template + * to render for each item in the array. + * @param options - Options used to turn on special repeat features. + * @public + */ +export function repeat< + TSource = any, + TArray extends ReadonlyArray = ReadonlyArray +>( + itemsBinding: Binding>, + templateOrTemplateBinding: + | ChildViewTemplate, TSource> + | Binding, TSource>>, + options?: + | { positioning: false } + | { recycle: true } + | { positioning: false; recycle: false } + | { positioning: false; recycle: true } +): CaptureType; +/** + * A directive that enables list rendering. + * @param itemsBinding - The array to render. + * @param templateOrTemplateBinding - The template or a template binding used obtain a template + * to render for each item in the array. + * @param options - Options used to turn on special repeat features. + * @public + */ +export function repeat< + TSource = any, + TArray extends ReadonlyArray = ReadonlyArray +>( + itemsBinding: Binding>, templateOrTemplateBinding: - | SyntheticViewTemplate - | Binding, + | ItemViewTemplate, TSource> + | Binding, TSource>>, + options: + | { positioning: true } + | { positioning: true; recycle: true } + | { positioning: true; recycle: false } +): CaptureType; +export function repeat( + itemsBinding: any, + templateOrTemplateBinding: any, options: RepeatOptions = defaultRepeatOptions -): CaptureType { +): any { const templateBinding = isFunction(templateOrTemplateBinding) ? templateOrTemplateBinding : (): SyntheticViewTemplate => templateOrTemplateBinding; - return new RepeatDirective(itemsBinding, templateBinding, options); + return new RepeatDirective(itemsBinding, templateBinding, options) as any; } diff --git a/packages/web-components/fast-element/src/templating/slotted.spec.ts b/packages/web-components/fast-element/src/templating/slotted.spec.ts index 6b1969e23b0..89149e53c73 100644 --- a/packages/web-components/fast-element/src/templating/slotted.spec.ts +++ b/packages/web-components/fast-element/src/templating/slotted.spec.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import { slotted, SlottedDirective } from "./slotted"; -import { defaultExecutionContext, observable } from "../observation/observable"; +import { ExecutionContext, observable } from "../observation/observable"; import { elements } from "./node-observation"; import { DOM } from "../dom"; @@ -61,7 +61,7 @@ describe("The slotted", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children); }); @@ -75,7 +75,7 @@ describe("The slotted", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children.filter(elements("foo-bar"))); }); @@ -86,7 +86,7 @@ describe("The slotted", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children); @@ -106,7 +106,7 @@ describe("The slotted", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children); @@ -123,11 +123,11 @@ describe("The slotted", () => { behavior.targetId = targetId; const model = new Model(); - behavior.bind(model, defaultExecutionContext, targets); + behavior.bind(model, ExecutionContext.default, targets); expect(model.nodes).members(children); - behavior.unbind(model, defaultExecutionContext, targets); + behavior.unbind(model, ExecutionContext.default, targets); expect(model.nodes).members([]); diff --git a/packages/web-components/fast-element/src/templating/template.ts b/packages/web-components/fast-element/src/templating/template.ts index b2f8f367226..8c93c89e2a8 100644 --- a/packages/web-components/fast-element/src/templating/template.ts +++ b/packages/web-components/fast-element/src/templating/template.ts @@ -1,5 +1,11 @@ import { isFunction, isString } from "../interfaces.js"; -import { Binding, defaultExecutionContext } from "../observation/observable.js"; +import { + Binding, + ChildContext, + ExecutionContext, + ItemContext, + RootContext, +} from "../observation/observable.js"; import { bind, oneTime } from "./binding.js"; import { Compiler } from "./compiler.js"; import { AspectedHTMLDirective, HTMLDirective } from "./html-directive.js"; @@ -9,12 +15,13 @@ import type { ElementView, HTMLView, SyntheticView } from "./view.js"; * A template capable of creating views specifically for rendering custom elements. * @public */ -export interface ElementViewTemplate { +export interface ElementViewTemplate { + type: "element"; /** * Creates an ElementView instance based on this template definition. * @param hostBindingTarget - The element that host behaviors will be bound to. */ - create(hostBindingTarget: Element): ElementView; + create(hostBindingTarget: Element): ElementView; /** * Creates an HTMLView from this template, binds it to the source, and then appends it to the host. @@ -27,41 +34,90 @@ export interface ElementViewTemplate; + ): ElementView; } /** * A template capable of rendering views not specifically connected to custom elements. * @public */ -export interface SyntheticViewTemplate { +export interface SyntheticViewTemplate< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +> { + type: "synthetic"; /** * Creates a SyntheticView instance based on this template definition. */ - create(): SyntheticView; + create(): SyntheticView; +} + +/** + * A template capable of rendering child views not specifically connected to custom elements. + * @public + */ +export interface ChildViewTemplate { + type: "child"; + + /** + * Creates a SyntheticView instance based on this template definition. + */ + create(): SyntheticView>; +} + +/** + * A template capable of rendering item views not specifically connected to custom elements. + * @public + */ +export interface ItemViewTemplate { + type: "item"; + + /** + * Creates a SyntheticView instance based on this template definition. + */ + create(): SyntheticView>; } /** * The result of a template compilation operation. * @public */ -export interface HTMLTemplateCompilationResult { +export interface HTMLTemplateCompilationResult< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +> { /** * Creates a view instance. * @param hostBindingTarget - The host binding target for the view. */ - createView(hostBindingTarget?: Element): HTMLView; + createView(hostBindingTarget?: Element): HTMLView; } /** * A template capable of creating HTMLView instances or rendering directly to DOM. * @public */ -export class ViewTemplate +export class ViewTemplate< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +> implements - ElementViewTemplate, - SyntheticViewTemplate { - private result: HTMLTemplateCompilationResult | null = null; + ElementViewTemplate, + SyntheticViewTemplate { + private result: HTMLTemplateCompilationResult< + TSource, + TParent, + TContext + > | null = null; + + /** + * Used for TypeScript purposes only. + * Do not sure. + */ + type: any; /** * The html representing what this template will @@ -91,9 +147,12 @@ export class ViewTemplate * Creates an HTMLView instance based on this template definition. * @param hostBindingTarget - The element that host behaviors will be bound to. */ - public create(hostBindingTarget?: Element): HTMLView { + public create(hostBindingTarget?: Element): HTMLView { if (this.result === null) { - this.result = Compiler.compile(this.html, this.directives); + this.result = Compiler.compile( + this.html, + this.directives + ); } return this.result!.createView(hostBindingTarget); @@ -109,10 +168,11 @@ export class ViewTemplate public render( source: TSource, host: Node, - hostBindingTarget?: Element - ): HTMLView { + hostBindingTarget?: Element, + context?: TContext + ): HTMLView { const view = this.create(hostBindingTarget ?? (host as any)); - view.bind(source, defaultExecutionContext); + view.bind(source, context ?? (ExecutionContext.default as TContext)); view.appendTo(host); return view; } @@ -135,10 +195,11 @@ export interface CaptureType {} * Represents the types of values that can be interpolated into a template. * @public */ -export type TemplateValue = - | Binding - | HTMLDirective - | CaptureType; +export type TemplateValue< + TSource, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +> = Binding | HTMLDirective | CaptureType; /** * Transforms a template literal string into a ViewTemplate. @@ -149,10 +210,14 @@ export type TemplateValue = * other template instances, and Directive instances. * @public */ -export function html( +export function html< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +>( strings: TemplateStringsArray, - ...values: TemplateValue[] -): ViewTemplate { + ...values: TemplateValue[] +): ViewTemplate { const directives: HTMLDirective[] = []; let html = ""; @@ -188,5 +253,33 @@ export function html( html += strings[strings.length - 1]; - return new ViewTemplate(html, directives); + return new ViewTemplate(html, directives); } + +/** + * Transforms a template literal string into a ChildViewTemplate. + * @param strings - The string fragments that are interpolated with the values. + * @param values - The values that are interpolated with the string fragments. + * @remarks + * The html helper supports interpolation of strings, numbers, binding expressions, + * other template instances, and Directive instances. + * @public + */ +export const child: ( + strings: TemplateStringsArray, + ...values: TemplateValue>[] +) => ChildViewTemplate = html as any; + +/** + * Transforms a template literal string into an ItemViewTemplate. + * @param strings - The string fragments that are interpolated with the values. + * @param values - The values that are interpolated with the string fragments. + * @remarks + * The html helper supports interpolation of strings, numbers, binding expressions, + * other template instances, and Directive instances. + * @public + */ +export const item: ( + strings: TemplateStringsArray, + ...values: TemplateValue>[] +) => ItemViewTemplate = html as any; diff --git a/packages/web-components/fast-element/src/templating/view.ts b/packages/web-components/fast-element/src/templating/view.ts index 3af4e1e252c..ffbc8952351 100644 --- a/packages/web-components/fast-element/src/templating/view.ts +++ b/packages/web-components/fast-element/src/templating/view.ts @@ -1,5 +1,5 @@ import type { Behavior } from "../observation/behavior.js"; -import type { ExecutionContext } from "../observation/observable.js"; +import type { ExecutionContext, RootContext } from "../observation/observable.js"; import type { ViewBehavior, ViewBehaviorFactory, @@ -10,11 +10,15 @@ import type { * Represents a collection of DOM nodes which can be bound to a data source. * @public */ -export interface View { +export interface View< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +> { /** * The execution context the view is running within. */ - readonly context: ExecutionContext | null; + readonly context: TContext | null; /** * The data that the view is bound to. @@ -26,7 +30,7 @@ export interface View { * @param source - The binding source for the view's binding behaviors. * @param context - The execution context to run the view within. */ - bind(source: TSource, context: ExecutionContext): void; + bind(source: TSource, context: TContext): void; /** * Unbinds a view's behaviors from its binding source and context. @@ -44,8 +48,8 @@ export interface View { * A View representing DOM nodes specifically for rendering the view of a custom element. * @public */ -export interface ElementView - extends View { +export interface ElementView + extends View { /** * Appends the view's DOM nodes to the referenced node. * @param node - The parent node to append the view's DOM nodes to. @@ -57,8 +61,11 @@ export interface ElementView * A view representing a range of DOM nodes which can be added/removed ad hoc. * @public */ -export interface SyntheticView - extends View { +export interface SyntheticView< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +> extends View { /** * The first DOM node in the range of nodes that make up the view. */ @@ -106,10 +113,11 @@ function removeNodeSequence(firstNode: Node, lastNode: Node): void { * The standard View implementation, which also implements ElementView and SyntheticView. * @public */ -export class HTMLView - implements - ElementView, - SyntheticView { +export class HTMLView< + TSource = any, + TParent = any, + TContext extends ExecutionContext = ExecutionContext +> implements ElementView, SyntheticView { private behaviors: ViewBehavior[] | null = null; /** @@ -120,7 +128,7 @@ export class HTMLView /** * The execution context the view is running within. */ - public context: ExecutionContext | null = null; + public context: TContext | null = null; /** * The first DOM node in the range of nodes that make up the view. @@ -210,7 +218,7 @@ export class HTMLView * @param source - The binding source for the view's binding behaviors. * @param context - The execution context to run the behaviors within. */ - public bind(source: TSource, context: ExecutionContext): void { + public bind(source: TSource, context: TContext): void { let behaviors = this.behaviors; const oldSource = this.source; diff --git a/packages/web-components/fast-element/src/templating/when.spec.ts b/packages/web-components/fast-element/src/templating/when.spec.ts index 8e295d17530..724f22d2fa6 100644 --- a/packages/web-components/fast-element/src/templating/when.spec.ts +++ b/packages/web-components/fast-element/src/templating/when.spec.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { when } from "./when"; import { html } from "./template"; -import { Binding, defaultExecutionContext } from "../observation/observable"; +import { Binding, ExecutionContext } from "../observation/observable"; describe("The 'when' template function", () => { it("returns an expression", () => { @@ -15,13 +15,13 @@ describe("The 'when' template function", () => { it("returns a template when the condition is true", () => { const expression = when(() => true, template) as Binding; - const result = expression(scope, defaultExecutionContext); + const result = expression(scope, ExecutionContext.default); expect(result).to.equal(template); }); it("returns null when the condition is false", () => { const expression = when(() => false, template) as Binding; - const result = expression(scope, defaultExecutionContext); + const result = expression(scope, ExecutionContext.default); expect(result).to.equal(null); }); @@ -30,7 +30,7 @@ describe("The 'when' template function", () => { () => true, () => template ) as Binding; - const result = expression(scope, defaultExecutionContext); + const result = expression(scope, ExecutionContext.default); expect(result).to.equal(template); }); }); From 13fd06859cace52111142dbaaeaefbe252ca4fd8 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Fri, 1 Apr 2022 10:43:28 -0400 Subject: [PATCH 2/8] feat: add two new event helpers to the execution context and tests --- .../fast-element/docs/api-report.md | 10 +- .../src/observation/execution-context.spec.ts | 154 ++++++++++++++++++ .../src/observation/observable.ts | 36 +++- 3 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 packages/web-components/fast-element/src/observation/execution-context.spec.ts diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index 39ca873f948..f4cb660fce3 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -166,7 +166,7 @@ export type ChildrenDirectiveOptions = ChildListDirectiveOptions | S export interface ChildViewTemplate { create(): SyntheticView>; // (undocumented) - type: 'child'; + type: "child"; } // @public @@ -288,7 +288,7 @@ export interface ElementViewTemplate { create(hostBindingTarget: Element): ElementView; render(source: TSource, host: Node, hostBindingTarget?: Element): ElementView; // (undocumented) - type: 'element'; + type: "element"; } // Warning: (ae-internal-missing-underscore) The name "emptyArray" should be prefixed with an underscore because the declaration is marked as @internal @@ -413,7 +413,7 @@ export interface ItemContext extends ChildContext { create(): SyntheticView>; // (undocumented) - type: 'item'; + type: "item"; } // @public @@ -594,6 +594,8 @@ export interface RepeatOptions { export interface RootContext { createChildContext(source: TParentSource): ChildContext; readonly event: Event; + eventDetail(): TDetail; + eventTarget(): TTarget; } // @public @@ -686,7 +688,7 @@ export interface SyntheticView = ExecutionContext> { create(): SyntheticView; // (undocumented) - type: 'synthetic'; + type: "synthetic"; } // @public diff --git a/packages/web-components/fast-element/src/observation/execution-context.spec.ts b/packages/web-components/fast-element/src/observation/execution-context.spec.ts new file mode 100644 index 00000000000..73623adced6 --- /dev/null +++ b/packages/web-components/fast-element/src/observation/execution-context.spec.ts @@ -0,0 +1,154 @@ +import { expect } from "chai"; +import { ExecutionContext, ItemContext } from "./observable"; + +describe("The ExecutionContext", () => { + it("has a default", () => { + const defaultContext = ExecutionContext.default; + const newContext = ExecutionContext.create(); + + expect(defaultContext.constructor).equals(newContext.constructor); + }); + + function createEvent() { + const detail = { hello: "world" }; + const event = new CustomEvent('my-event', { detail }); + + return { event, detail }; + } + + it("can get the current event", () => { + const { event } = createEvent(); + + ExecutionContext.setEvent(event); + const context = ExecutionContext.create(); + + expect(context.event).equals(event); + + ExecutionContext.setEvent(null); + }); + + it("can get the current event detail", () => { + const { event, detail } = createEvent(); + + ExecutionContext.setEvent(event); + const context = ExecutionContext.create(); + + expect(context.eventDetail()).equals(detail); + expect(context.eventDetail().hello).equals(detail.hello); + + ExecutionContext.setEvent(null); + }); + + it("can create a child context for a parent source", () => { + const parentSource = {}; + const parentContext = ExecutionContext.create(); + const childContext = parentContext.createChildContext(parentSource); + + expect(childContext.parent).equals(parentSource); + expect(childContext.parentContext).equals(parentContext); + }); + + it("can create an item context from a child context", () => { + const parentSource = {}; + const parentContext = ExecutionContext.create(); + const childContext = parentContext.createChildContext(parentSource); + const itemContext = childContext.createItemContext(7, 42); + + expect(itemContext.parent).equals(parentSource); + expect(itemContext.parentContext).equals(parentContext); + expect(itemContext.index).equals(7); + expect(itemContext.length).equals(42); + }); + + context("item context", () => { + const scenarios = [ + { + name: "even is first", + index: 0, + length: 42, + isEven: true, + isOdd: false, + isFirst: true, + isMiddle: false, + isLast: false + }, + { + name: "odd in middle", + index: 7, + length: 42, + isEven: false, + isOdd: true, + isFirst: false, + isMiddle: true, + isLast: false + }, + { + name: "even in middle", + index: 8, + length: 42, + isEven: true, + isOdd: false, + isFirst: false, + isMiddle: true, + isLast: false + }, + { + name: "odd at end", + index: 41, + length: 42, + isEven: false, + isOdd: true, + isFirst: false, + isMiddle: false, + isLast: true + }, + { + name: "even at end", + index: 40, + length: 41, + isEven: true, + isOdd: false, + isFirst: false, + isMiddle: false, + isLast: true + } + ]; + + function assert(itemContext: ItemContext, scenario: typeof scenarios[0]) { + expect(itemContext.index).equals(scenario.index); + expect(itemContext.length).equals(scenario.length); + expect(itemContext.isEven).equals(scenario.isEven); + expect(itemContext.isOdd).equals(scenario.isOdd); + expect(itemContext.isFirst).equals(scenario.isFirst); + expect(itemContext.isInMiddle).equals(scenario.isMiddle); + expect(itemContext.isLast).equals(scenario.isLast); + } + + for (const scenario of scenarios) { + it(`has correct position when ${scenario.name}`, () => { + const parentSource = {}; + const parentContext = ExecutionContext.create(); + const childContext = parentContext.createChildContext(parentSource); + const itemContext = childContext.createItemContext(scenario.index, scenario.length); + + assert(itemContext, scenario); + }); + } + + it ("can update its index and length", () => { + const scenario1 = scenarios[0]; + const scenario2 = scenarios[1]; + + const parentSource = {}; + const parentContext = ExecutionContext.create(); + const childContext = parentContext.createChildContext(parentSource); + const itemContext = childContext.createItemContext(scenario1.index, scenario1.length); + + assert(itemContext, scenario1); + + itemContext.updatePosition(scenario2.index, scenario2.length); + + assert(itemContext, scenario2); + }); + }); +}); diff --git a/packages/web-components/fast-element/src/observation/observable.ts b/packages/web-components/fast-element/src/observation/observable.ts index 93a0c930568..ec9215536b5 100644 --- a/packages/web-components/fast-element/src/observation/observable.ts +++ b/packages/web-components/fast-element/src/observation/observable.ts @@ -438,6 +438,16 @@ export interface RootContext { */ readonly event: Event; + /** + * Returns the typed event detail of a custom event. + */ + eventDetail(): TDetail; + + /** + * Returns the typed event target of the event. + */ + eventTarget(): TTarget; + /** * Creates a new execution context descendent from the current context. * @param source - The source for the context if different than the parent. @@ -534,42 +544,50 @@ class DefaultExecutionContext implements RootContext, ChildContext, ItemContext this.parentContext = parentContext as any; } - public get event(): Event { + get event(): Event { return contextEvent.get()!; } - public get isEven(): boolean { + get isEven(): boolean { return this.index % 2 === 0; } - public get isOdd(): boolean { + get isOdd(): boolean { return this.index % 2 !== 0; } - public get isFirst(): boolean { + get isFirst(): boolean { return this.index === 0; } - public get isInMiddle(): boolean { + get isInMiddle(): boolean { return !this.isFirst && !this.isLast; } - public get isLast(): boolean { + get isLast(): boolean { return this.index === this.length - 1; } - public updatePosition(index: number, length: number): void { + eventDetail(): TDetail { + return (this.event as CustomEvent).detail; + } + + eventTarget(): TTarget { + return this.event.target! as TTarget; + } + + updatePosition(index: number, length: number): void { this.index = index; this.length = length; } - public createChildContext( + createChildContext( parentSource: TParentSource ): ChildContext { return new DefaultExecutionContext(parentSource, this); } - public createItemContext(index: number, length: number): ItemContext { + createItemContext(index: number, length: number): ItemContext { const childContext = Object.create(this); childContext.index = index; childContext.length = length; From 16a3bf79c2c7b10183f3aa0c33cdb28d24484d36 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Fri, 1 Apr 2022 20:20:32 -0400 Subject: [PATCH 3/8] fix: wip update types to match new context apis --- .../fast-foundation/docs/api-report.md | 16 +++++--- .../src/calendar/calendar.template.ts | 38 ++++++++++++------- .../fast-foundation/src/calendar/calendar.ts | 8 +++- .../src/data-grid/data-grid-row.template.ts | 26 +++++++++---- .../src/data-grid/data-grid.template.ts | 14 +++++-- .../src/design-token/design-token.ts | 4 +- .../src/picker/picker.template.ts | 23 ++++++----- .../src/test-utilities/fixture.ts | 3 +- .../web-components/fast-router/src/view.ts | 3 +- .../src/element-renderer/element-renderer.ts | 12 ++---- .../src/template-renderer/directives.ts | 15 ++++---- .../template-renderer.spec.ts | 12 +++--- .../template-renderer/template-renderer.ts | 5 +-- 13 files changed, 107 insertions(+), 72 deletions(-) diff --git a/packages/web-components/fast-foundation/docs/api-report.md b/packages/web-components/fast-foundation/docs/api-report.md index 5a3b82313f4..f53d3b75d40 100644 --- a/packages/web-components/fast-foundation/docs/api-report.md +++ b/packages/web-components/fast-foundation/docs/api-report.md @@ -13,6 +13,7 @@ import { Direction } from '@microsoft/fast-web-utilities'; import { ElementStyles } from '@microsoft/fast-element'; import { ElementViewTemplate } from '@microsoft/fast-element'; import { FASTElement } from '@microsoft/fast-element'; +import { ItemViewTemplate } from '@microsoft/fast-element'; import { Orientation } from '@microsoft/fast-web-utilities'; import { PartialFASTElementDefinition } from '@microsoft/fast-element'; import { SyntheticViewTemplate } from '@microsoft/fast-element'; @@ -312,10 +313,7 @@ export class Calendar extends FoundationElement { getDayClassNames(date: CalendarDateInfo, todayString?: string): string; getDays(info?: CalendarInfo, minWeeks?: number): CalendarDateInfo[][]; getMonthInfo(month?: number, year?: number): CalendarInfo; - getWeekdayText(): { - text: string; - abbr?: string; - }[]; + getWeekdayText(): WeekdayText[]; handleDateSelect(event: Event, day: CalendarDateInfo): void; handleKeydown(event: KeyboardEvent, date: CalendarDateInfo): boolean; locale: string; @@ -330,7 +328,7 @@ export class Calendar extends FoundationElement { } // @public -export const calendarCellTemplate: (context: ElementDefinitionContext, todayString: string) => ViewTemplate; +export const calendarCellTemplate: (context: ElementDefinitionContext, todayString: string) => ItemViewTemplate; // @public export type CalendarDateInfo = { @@ -362,7 +360,7 @@ export const calendarTemplate: FoundationElementTemplate, export const CalendarTitleTemplate: ViewTemplate; // @public -export const calendarWeekdayTemplate: (context: any) => ViewTemplate; +export const calendarWeekdayTemplate: (context: any) => ItemViewTemplate; // @public export class Card extends FoundationElement { @@ -2747,6 +2745,12 @@ export type VerticalPosition = "top" | "bottom" | "center" | "unset"; // @public export type WeekdayFormat = "long" | "narrow" | "short"; +// @public +export type WeekdayText = { + text: string; + abbr?: string; +}; + // @public export function whitespaceFilter(value: Node, index: number, array: Node[]): boolean; diff --git a/packages/web-components/fast-foundation/src/calendar/calendar.template.ts b/packages/web-components/fast-foundation/src/calendar/calendar.template.ts index be00b6e51b9..088d0244f03 100644 --- a/packages/web-components/fast-foundation/src/calendar/calendar.template.ts +++ b/packages/web-components/fast-foundation/src/calendar/calendar.template.ts @@ -1,10 +1,22 @@ -import { html, repeat, when } from "@microsoft/fast-element"; +import { + child, + html, + item, + ItemViewTemplate, + repeat, + when, +} from "@microsoft/fast-element"; import type { ViewTemplate } from "@microsoft/fast-element"; import { endTemplate, startTemplate } from "../patterns/start-end"; import { DataGrid, DataGridCell, DataGridRow } from "../data-grid"; import type { FoundationElementTemplate } from "../foundation-element"; import type { ElementDefinitionContext } from "../design-system"; -import type { Calendar, CalendarDateInfo, CalendarOptions } from "./calendar"; +import type { + Calendar, + CalendarDateInfo, + CalendarOptions, + WeekdayText, +} from "./calendar"; /** * A basic Calendar title template that includes the month and year @@ -33,9 +45,9 @@ export const CalendarTitleTemplate: ViewTemplate = html` * @returns - The weekday labels template * @public */ -export const calendarWeekdayTemplate: (context) => ViewTemplate = context => { +export const calendarWeekdayTemplate: (context) => ItemViewTemplate = context => { const cellTag = context.tagFor(DataGridCell); - return html` + return item` <${cellTag} class="week-day" part="week-day" @@ -58,12 +70,12 @@ export const calendarWeekdayTemplate: (context) => ViewTemplate = context => { export const calendarCellTemplate: ( context: ElementDefinitionContext, todayString: string -) => ViewTemplate = ( +) => ItemViewTemplate = ( context: ElementDefinitionContext, todayString: string ) => { const cellTag: string = context.tagFor(DataGridCell); - return html` + return item` <${cellTag} class="${(x, c) => c.parentContext.parent.getDayClassNames(x, todayString)}" part="day" @@ -132,7 +144,7 @@ export const interactiveCalendarGridTemplate: ( const gridTag: string = context.tagFor(DataGrid); const rowTag: string = context.tagFor(DataGridRow); - return html` + return html` <${gridTag} class="days interact" part="days" generate-header="none"> <${rowTag} class="week-days" @@ -160,12 +172,12 @@ export const interactiveCalendarGridTemplate: ( export const noninteractiveCalendarTemplate: (todayString: string) => ViewTemplate = ( todayString: string ) => { - return html` + return html`
${repeat( x => x.getWeekdayText(), - html` + html`
${x => x.text}
@@ -174,11 +186,11 @@ export const noninteractiveCalendarTemplate: (todayString: string) => ViewTempla
${repeat( x => x.getDays(), - html` + child`
${repeat( x => x, - html` + child`
x.month}-${x => x.day}-${x => - x.year}" + x.year}" >
` @@ -235,7 +247,7 @@ export const calendarTemplate: FoundationElementTemplate< const todayString: string = `${ today.getMonth() + 1 }-${today.getDate()}-${today.getFullYear()}`; - return html` + return html`