From 87ad6d0ffb6ff12737459956c02da729476a6fbb Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Tue, 12 Apr 2022 21:45:39 -0400 Subject: [PATCH] feat: new HTMLDirective design (#5826) * refactor: clean up array observer * refactor: modernize the splice code * chore: set up SSR package (#5589) * project files and starting to set up test infrastructure * incorporating ts project references and getting tests working * adding .npmignore to ingore tests and server * adding readmes * Update packages/web-components/fast-ssr/package.json Co-authored-by: Chris Holt Co-authored-by: nicholasrice Co-authored-by: Chris Holt * feat: enable multiple instances of fast-element on a page at once (#5718) * feat: enable multiple instances of fast-element on a page at once * Change files Co-authored-by: EisenbergEffect * refactor: extract polyfill and polyfill-like code to an optional module (#5752) * refactor: extract polyfill and polyfill-like code to an optional module * Change files * fix: correct build break in fast-foundation from removing $global * fix: update templates to use classList and fix classList bug * Change files * feat: require trusted types for bindings to innerHtml & move core policy * fix: bug that arises when there are real trusted types * refactor: minor internal cleanup in the polyfills * fix: remove polyfill external module dependency Co-authored-by: EisenbergEffect * feat: implement template renderer infrastructure (#5698) * implement factory fn to create TemplateRenderer and ElementRenderer * rename index to exports * adding default render info object * adding initial directive rendering * adds initial custom element rendering * add directive renderers * add renderInfo to DirectiveRenderer * use default directive renderers * adding attribute binding tests * ensure attributes don't get emitted twice for custom elements * Adding internal function to render op codes so that we can omit template elements when rendering custom element templates * emit template open and close codes from template parser * implement FAST parser changes and fix tests * working AspectedHTMLDirective changes into branch * naive implementation of custom element template rendering * adds rendering of template op code * progress on elmeent rendering * fixing custom element attribute rendering bug * Change files * adding boolean and property binding test * fix bug preventing template elements from being parsed * add failing template nesting test * update parsing of content nodes to correctly render interpolated templates * fix API report and formatting * adding repeater rendering * adding directive implementations for Ref, Slotted, and Children * clean up op types to reduce rendant information * remove commented code that is no longer needed * adding tests for ref, slotted, and children * re-use noop function for noop directives * cache length of arrays in for loops * adding style tests * implementing style renderer * remove 'data' from style id name * adding tests * renaming files to align with current conventions * update attachShadow signature * adding classList support * fixing tests and supporting string types * removing comment that is a non-concern * add playwright install step to ci * add browser path to ci script * change install script to install at the package level * update playwright * Change files * fixing test Co-authored-by: nicholasrice * feat: new execution context design (#5800) * refactor: new design for execution context * feat: add two new event helpers to the execution context and tests * fix: wip update types to match new context apis * fix: update foundation and components template types * Change files * fix: update template type in fast-website * fix: update site components for new template types * fix: add missing api updates Co-authored-by: EisenbergEffect * feat: new HTMLDirective API * feat: new directive registration/identification model * refactor: refine design/implementation of new directive aproach * refactor: clean up comments, interfaces, types for directives/registries * fix: update foundation to new APIs * fix: update router to new directive APIs. * fix: update ssr to new directive APIs * refactor: clean up ssr switch to new directive APIs * fix: update React wrapper lib to use latest APIs * Change files * fix: post rebase issues * fix: update reflectAttributes directive to new directive APIs * test: add more tests to capture new html/directive aspect scenarios * Change files * chore: run prettier on foundation * Update packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts Co-authored-by: Nicholas Rice <3213292+nicholasrice@users.noreply.github.com> * Update packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts Co-authored-by: Nicholas Rice <3213292+nicholasrice@users.noreply.github.com> * Update packages/web-components/fast-foundation/src/directives/reflect-attributes.ts Co-authored-by: Nicholas Rice <3213292+nicholasrice@users.noreply.github.com> * Update packages/web-components/fast-foundation/src/directives/reflect-attributes.ts Co-authored-by: Nicholas Rice <3213292+nicholasrice@users.noreply.github.com> * chore: cleanup tests after rebase Co-authored-by: Nicholas Rice <3213292+nicholasrice@users.noreply.github.com> Co-authored-by: nicholasrice Co-authored-by: Chris Holt Co-authored-by: EisenbergEffect --- ...-6effdb36-c7f1-45ae-9f15-7dac556840ab.json | 7 + ...-b9272117-48e8-48b0-8356-cc529d536cef.json | 7 + ...-3957121e-c6d9-47b4-8eeb-416d7b4807f1.json | 7 + ...-0c25947c-7fac-49d3-a9d0-06d39b0544e3.json | 7 + .../utilities/fast-react-wrapper/src/index.ts | 2 +- .../fast-element/docs/api-report.md | 129 ++++-- .../fast-element/src/components/controller.ts | 2 +- .../src/components/fast-definitions.spec.ts | 2 +- .../src/components/fast-definitions.ts | 40 +- .../src/components/fast-element.ts | 5 +- .../fast-element/src/platform.ts | 45 ++ .../src/templating/binding.spec.ts | 2 +- .../fast-element/src/templating/binding.ts | 124 +++--- .../src/templating/children.spec.ts | 30 +- .../fast-element/src/templating/children.ts | 8 +- .../src/templating/compiler.spec.ts | 21 +- .../fast-element/src/templating/compiler.ts | 56 ++- .../src/templating/html-directive.ts | 209 ++++++--- .../fast-element/src/templating/markup.ts | 24 +- .../src/templating/node-observation.ts | 4 +- .../fast-element/src/templating/ref.ts | 5 +- .../src/templating/repeat.spec.ts | 54 +-- .../fast-element/src/templating/repeat.ts | 29 +- .../src/templating/slotted.spec.ts | 32 +- .../fast-element/src/templating/slotted.ts | 3 + .../src/templating/template.spec.ts | 413 ++++++++++++------ .../fast-element/src/templating/template.ts | 104 +++-- .../fast-foundation/docs/api-report.md | 8 +- .../src/calendar/calendar.template.ts | 4 +- .../src/data-grid/data-grid-row.ts | 2 +- .../src/data-grid/data-grid.template.ts | 4 +- .../src/data-grid/data-grid.ts | 2 +- .../src/design-system/design-system.ts | 6 +- .../src/design-system/registration-context.ts | 6 +- .../src/directives/reflect-attributes.spec.ts | 120 ++--- .../src/directives/reflect-attributes.ts | 77 ++-- .../foundation-element/foundation-element.ts | 2 +- .../src/picker/picker.template.ts | 8 +- .../fast-foundation/src/picker/picker.ts | 4 +- .../src/test-utilities/fixture.ts | 4 +- .../fast-router/docs/api-report.md | 7 +- .../fast-router/src/commands.ts | 8 +- .../fast-router/src/contributors.ts | 22 +- .../src/element-renderer/element-renderer.ts | 12 +- .../web-components/fast-ssr/src/exports.ts | 15 +- .../fast-ssr/src/template-parser/op-codes.ts | 19 +- .../template-parser/template-parser.spec.ts | 28 +- .../src/template-parser/template-parser.ts | 37 +- .../src/template-renderer/directives.ts | 19 +- .../template-renderer/template-renderer.ts | 47 +- packages/web-components/fast-ssr/src/view.ts | 13 +- yarn.lock | 5 + 52 files changed, 1168 insertions(+), 682 deletions(-) create mode 100644 change/@microsoft-fast-element-6effdb36-c7f1-45ae-9f15-7dac556840ab.json create mode 100644 change/@microsoft-fast-foundation-b9272117-48e8-48b0-8356-cc529d536cef.json create mode 100644 change/@microsoft-fast-react-wrapper-3957121e-c6d9-47b4-8eeb-416d7b4807f1.json create mode 100644 change/@microsoft-fast-router-0c25947c-7fac-49d3-a9d0-06d39b0544e3.json diff --git a/change/@microsoft-fast-element-6effdb36-c7f1-45ae-9f15-7dac556840ab.json b/change/@microsoft-fast-element-6effdb36-c7f1-45ae-9f15-7dac556840ab.json new file mode 100644 index 00000000000..fb77ab8cf86 --- /dev/null +++ b/change/@microsoft-fast-element-6effdb36-c7f1-45ae-9f15-7dac556840ab.json @@ -0,0 +1,7 @@ +{ + "type": "major", + "comment": "feat: new directive registration/identification model", + "packageName": "@microsoft/fast-element", + "email": "roeisenb@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@microsoft-fast-foundation-b9272117-48e8-48b0-8356-cc529d536cef.json b/change/@microsoft-fast-foundation-b9272117-48e8-48b0-8356-cc529d536cef.json new file mode 100644 index 00000000000..60a5183d085 --- /dev/null +++ b/change/@microsoft-fast-foundation-b9272117-48e8-48b0-8356-cc529d536cef.json @@ -0,0 +1,7 @@ +{ + "type": "major", + "comment": "fix: update foundation to new APIs", + "packageName": "@microsoft/fast-foundation", + "email": "roeisenb@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@microsoft-fast-react-wrapper-3957121e-c6d9-47b4-8eeb-416d7b4807f1.json b/change/@microsoft-fast-react-wrapper-3957121e-c6d9-47b4-8eeb-416d7b4807f1.json new file mode 100644 index 00000000000..7e833996fc0 --- /dev/null +++ b/change/@microsoft-fast-react-wrapper-3957121e-c6d9-47b4-8eeb-416d7b4807f1.json @@ -0,0 +1,7 @@ +{ + "type": "major", + "comment": "fix: update React wrapper lib to use latest APIs", + "packageName": "@microsoft/fast-react-wrapper", + "email": "roeisenb@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@microsoft-fast-router-0c25947c-7fac-49d3-a9d0-06d39b0544e3.json b/change/@microsoft-fast-router-0c25947c-7fac-49d3-a9d0-06d39b0544e3.json new file mode 100644 index 00000000000..199cd29c31f --- /dev/null +++ b/change/@microsoft-fast-router-0c25947c-7fac-49d3-a9d0-06d39b0544e3.json @@ -0,0 +1,7 @@ +{ + "type": "major", + "comment": "fix: update router to new directive APIs.", + "packageName": "@microsoft/fast-router", + "email": "roeisenb@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/utilities/fast-react-wrapper/src/index.ts b/packages/utilities/fast-react-wrapper/src/index.ts index ebf41e0c3b0..19a698c3309 100644 --- a/packages/utilities/fast-react-wrapper/src/index.ts +++ b/packages/utilities/fast-react-wrapper/src/index.ts @@ -120,7 +120,7 @@ function getTagName( ) { if (!config.name) { /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - const definition = FASTElementDefinition.forType(type)!; + const definition = FASTElementDefinition.getByType(type)!; if (definition) { config.name = definition.name; diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index f4cb660fce3..d8ef1f1174a 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -11,6 +11,9 @@ export interface Accessor { setValue(source: any, value: any): void; } +// @public +export type AddViewBehaviorFactory = (factory: ViewBehaviorFactory) => string; + // Warning: (ae-internal-missing-underscore) The name "AdoptedStyleSheetsStrategy" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -25,23 +28,23 @@ export class AdoptedStyleSheetsStrategy implements StyleStrategy { } // @public -export enum Aspect { - attribute = 0, - booleanAttribute = 1, - content = 3, - event = 5, - property = 2, - tokenList = 4 -} +export const Aspect: Readonly<{ + none: number; + attribute: number; + booleanAttribute: number; + property: number; + content: number; + tokenList: number; + event: number; + assign(directive: Aspected, value: string): void; +}>; // @public -export abstract class AspectedHTMLDirective extends HTMLDirective { - abstract readonly aspect: Aspect; - abstract readonly binding?: Binding; - abstract captureSource(source: string): void; - createPlaceholder: (index: number) => string; - abstract readonly source: string; - abstract readonly target: string; +export interface Aspected { + aspectType: number; + binding?: Binding; + sourceAspect: string; + targetAspect: string; } // @public @@ -106,7 +109,7 @@ export interface BindingConfig { } // @alpha (undocumented) -export type BindingMode = Record; +export type BindingMode = Record; // @public export interface BindingObserver extends Notifier { @@ -172,14 +175,14 @@ export interface ChildViewTemplate { // @public export type CompilationStrategy = ( html: string | HTMLTemplateElement, -directives: readonly HTMLDirective[]) => HTMLTemplateCompilationResult; +factories: Record) => HTMLTemplateCompilationResult; // @public export const Compiler: { setHTMLPolicy(policy: TrustedTypesPolicy): void; - compile = ExecutionContext>(html: string | HTMLTemplateElement, directives: ReadonlyArray): HTMLTemplateCompilationResult; + compile = ExecutionContext>(html: string | HTMLTemplateElement, directives: Record): HTMLTemplateCompilationResult; setDefaultStrategy(strategy: CompilationStrategy): void; - aggregate(parts: (string | HTMLDirective)[]): HTMLDirective; + aggregate(parts: (string | ViewBehaviorFactory)[]): ViewBehaviorFactory; }; // @public @@ -218,6 +221,11 @@ export class Controller extends Prop readonly view: ElementView | null; } +// Warning: (ae-internal-missing-underscore) The name "createTypeRegistry" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export function createTypeRegistry(): TypeRegistry; + // @public export function css(strings: TemplateStringsArray, ...values: (ComposableStyles | CSSDirective)[]): ElementStyles; @@ -231,7 +239,7 @@ export class CSSDirective { export function cssPartial(strings: TemplateStringsArray, ...values: (ComposableStyles | CSSDirective)[]): CSSDirective; // @public -export function customElement(nameOrDef: string | PartialFASTElementDefinition): (type: Function) => void; +export function customElement(nameOrDef: string | PartialFASTElementDefinition): (type: Constructable) => void; // @public export type DecoratorAttributeConfiguration = Omit; @@ -329,17 +337,18 @@ export const FASTElement: (new () => HTMLElement & FASTElement) & { new (): HTMLElement; prototype: HTMLElement; }>(BaseType: TBase): new () => InstanceType & FASTElement; - define(type: TType, nameOrDef?: string | PartialFASTElementDefinition | undefined): TType; + define>(type: TType, nameOrDef?: string | PartialFASTElementDefinition | undefined): TType; }; // @public -export class FASTElementDefinition { +export class FASTElementDefinition = Constructable> { constructor(type: TType, nameOrConfig?: PartialFASTElementDefinition | string); readonly attributeLookup: Record; readonly attributes: ReadonlyArray; define(registry?: CustomElementRegistry): this; readonly elementOptions?: ElementDefinitionOptions; - static readonly forType: (key: TType_1) => FASTElementDefinition | undefined; + static readonly getByType: (key: Function) => FASTElementDefinition> | undefined; + static readonly getForInstance: (object: any) => FASTElementDefinition> | undefined; get isDefined(): boolean; readonly name: string; readonly propertyLookup: Record; @@ -366,11 +375,23 @@ export interface FASTGlobal { export function html = ExecutionContext>(strings: TemplateStringsArray, ...values: TemplateValue[]): ViewTemplate; // @public -export abstract class HTMLDirective implements ViewBehaviorFactory { - abstract createBehavior(targets: ViewBehaviorTargets): Behavior | ViewBehavior; - abstract createPlaceholder(index: number): string; - targetId: string; - readonly uniqueId: string; +export interface HTMLDirective { + createHTML(add: AddViewBehaviorFactory): string; +} + +// @public +export const HTMLDirective: Readonly<{ + getForInstance: (object: any) => HTMLDirectiveDefinition> | undefined; + getByType: (key: Function) => HTMLDirectiveDefinition> | undefined; + define>(type: TType, options?: PartialHTMLDirectiveDefinition | undefined): TType; +}>; + +// @public +export function htmlDirective(options?: PartialHTMLDirectiveDefinition): (type: Constructable) => void; + +// @public +export interface HTMLDirectiveDefinition = Constructable> extends Required { + readonly type: TType; } // @public @@ -418,9 +439,9 @@ export interface ItemViewTemplate { // @public export const Markup: Readonly<{ - interpolation: (index: number) => string; - attribute: (index: number) => string; - comment: (index: number) => string; + interpolation: (id: string) => string; + attribute: (id: string) => string; + comment: (id: string) => string; }>; // Warning: (ae-internal-missing-underscore) The name "Mutable" should be prefixed with an underscore because the declaration is marked as @internal @@ -492,7 +513,7 @@ export const oneTime: BindingConfig & BindingConfigResolv // @public export const Parser: Readonly<{ - parse(value: string, directives: readonly HTMLDirective[]): (string | HTMLDirective)[] | null; + parse(value: string, factories: Record): (string | ViewBehaviorFactory)[] | null; }>; // @public @@ -505,6 +526,11 @@ export interface PartialFASTElementDefinition { readonly template?: ElementViewTemplate; } +// @public +export interface PartialHTMLDirectiveDefinition { + aspected?: boolean; +} + // @public export class PropertyChangeNotifier implements Notifier { constructor(subject: any); @@ -572,12 +598,14 @@ export class RepeatBehavior implements Behavior, Subscriber { } // @public -export class RepeatDirective extends HTMLDirective { +export class RepeatDirective implements HTMLDirective, ViewBehaviorFactory { constructor(itemsBinding: Binding, templateBinding: Binding, options: RepeatOptions); createBehavior(targets: ViewBehaviorTargets): RepeatBehavior; - createPlaceholder: (index: number) => string; + createHTML(add: AddViewBehaviorFactory): string; + id: string; // (undocumented) readonly itemsBinding: Binding; + nodeId: string; // (undocumented) readonly options: RepeatOptions; // (undocumented) @@ -630,11 +658,13 @@ export class Splice { } // @public -export abstract class StatelessAttachedAttributeDirective extends HTMLDirective implements ViewBehavior { +export abstract class StatelessAttachedAttributeDirective implements HTMLDirective, ViewBehaviorFactory, ViewBehavior { constructor(options: T); abstract bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void; createBehavior(targets: ViewBehaviorTargets): ViewBehavior; - createPlaceholder: (index: number) => string; + createHTML(add: AddViewBehaviorFactory): string; + id: string; + nodeId: string; // (undocumented) protected options: T; abstract unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void; @@ -704,6 +734,26 @@ export type TrustedTypesPolicy = { createHTML(html: string): string; }; +// Warning: (ae-internal-missing-underscore) The name "TypeDefinition" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export interface TypeDefinition { + // (undocumented) + type: Function; +} + +// Warning: (ae-internal-missing-underscore) The name "TypeRegistry" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export interface TypeRegistry { + // (undocumented) + getByType(key: Function): TDefinition | undefined; + // (undocumented) + getForInstance(object: any): TDefinition | undefined; + // (undocumented) + register(definition: TDefinition): boolean; +} + // @public export interface ValueConverter { fromView(value: any): any; @@ -728,7 +778,8 @@ export interface ViewBehavior { // @public export interface ViewBehaviorFactory { createBehavior(targets: ViewBehaviorTargets): Behavior | ViewBehavior; - targetId: string; + id: string; + nodeId: string; } // @public @@ -738,9 +789,9 @@ export type ViewBehaviorTargets = { // @public export class ViewTemplate = ExecutionContext> implements ElementViewTemplate, SyntheticViewTemplate { - constructor(html: string | HTMLTemplateElement, directives: ReadonlyArray); + constructor(html: string | HTMLTemplateElement, factories: Record); create(hostBindingTarget?: Element): HTMLView; - readonly directives: ReadonlyArray; + readonly factories: Record; readonly html: string | HTMLTemplateElement; render(source: TSource, host: Node, hostBindingTarget?: Element, context?: TContext): HTMLView; type: any; diff --git a/packages/web-components/fast-element/src/components/controller.ts b/packages/web-components/fast-element/src/components/controller.ts index aeb63d3a276..80ba127329d 100644 --- a/packages/web-components/fast-element/src/components/controller.ts +++ b/packages/web-components/fast-element/src/components/controller.ts @@ -482,7 +482,7 @@ export class Controller< return controller; } - const definition = FASTElementDefinition.forType(element.constructor); + const definition = FASTElementDefinition.getForInstance(element); if (definition === void 0) { throw FAST.error(Message.missingElementDefinition); diff --git a/packages/web-components/fast-element/src/components/fast-definitions.spec.ts b/packages/web-components/fast-element/src/components/fast-definitions.spec.ts index 77754ee3682..f77df48fe27 100644 --- a/packages/web-components/fast-element/src/components/fast-definitions.spec.ts +++ b/packages/web-components/fast-element/src/components/fast-definitions.spec.ts @@ -4,7 +4,7 @@ import { FASTElementDefinition } from "./fast-definitions"; import { ElementStyles } from "../styles/element-styles"; describe("FASTElementDefinition", () => { - class MyElement {} + class MyElement extends HTMLElement {} context("styles", () => { it("can accept a string", () => { diff --git a/packages/web-components/fast-element/src/components/fast-definitions.ts b/packages/web-components/fast-element/src/components/fast-definitions.ts index 275dd93b7c6..622fe453a4f 100644 --- a/packages/web-components/fast-element/src/components/fast-definitions.ts +++ b/packages/web-components/fast-element/src/components/fast-definitions.ts @@ -1,29 +1,17 @@ -import { isString, KernelServiceId } from "../interfaces.js"; +import { Constructable, isString, KernelServiceId } from "../interfaces.js"; import { Observable } from "../observation/observable.js"; -import { FAST } from "../platform.js"; +import { createTypeRegistry, FAST, TypeRegistry } from "../platform.js"; import { ComposableStyles, ElementStyles } from "../styles/element-styles.js"; import type { ElementViewTemplate } from "../templating/template.js"; import { AttributeConfiguration, AttributeDefinition } from "./attributes.js"; const defaultShadowOptions: ShadowRootInit = { mode: "open" }; const defaultElementOptions: ElementDefinitionOptions = {}; -const fastRegistry = FAST.getById(KernelServiceId.elementRegistry, () => { - const typeToDefinition = new Map(); - return Object.freeze({ - register(definition: FASTElementDefinition): boolean { - if (typeToDefinition.has(definition.type)) { - return false; - } - - typeToDefinition.set(definition.type, definition); - return true; - }, - getByType(key: TType): FASTElementDefinition | undefined { - return typeToDefinition.get(key); - }, - }); -}); +const fastElementRegistry: TypeRegistry = FAST.getById( + KernelServiceId.elementRegistry, + () => createTypeRegistry() +); /** * Represents metadata configuration for a custom element. @@ -65,7 +53,9 @@ export interface PartialFASTElementDefinition { * Defines metadata for a FASTElement. * @public */ -export class FASTElementDefinition { +export class FASTElementDefinition< + TType extends Constructable = Constructable +> { private observedAttributes: string[]; /** @@ -77,7 +67,7 @@ export class FASTElementDefinition { * Indicates if this element has been defined in at least one registry. */ public get isDefined(): boolean { - return !!fastRegistry.getByType(this.type); + return !!fastElementRegistry.getByType(this.type); } /** @@ -184,7 +174,7 @@ export class FASTElementDefinition { public define(registry: CustomElementRegistry = customElements): this { const type = this.type; - if (fastRegistry.register(this)) { + if (fastElementRegistry.register(this)) { const attributes = this.attributes; const proto = type.prototype; @@ -209,5 +199,11 @@ export class FASTElementDefinition { * Gets the element definition associated with the specified type. * @param type - The custom element type to retrieve the definition for. */ - static readonly forType = fastRegistry.getByType; + static readonly getByType = fastElementRegistry.getByType; + + /** + * Gets the element definition associated with the instance. + * @param instance - The custom element instance to retrieve the definition for. + */ + static readonly getForInstance = fastElementRegistry.getForInstance; } diff --git a/packages/web-components/fast-element/src/components/fast-element.ts b/packages/web-components/fast-element/src/components/fast-element.ts index 0053801dee8..64ae3dee9fc 100644 --- a/packages/web-components/fast-element/src/components/fast-element.ts +++ b/packages/web-components/fast-element/src/components/fast-element.ts @@ -1,3 +1,4 @@ +import type { Constructable } from "../interfaces.js"; import { Controller } from "./controller.js"; import { FASTElementDefinition, @@ -121,7 +122,7 @@ export const FASTElement = Object.assign(createFASTElement(HTMLElement), { * @param nameOrDef - The name of the element to define or a definition object * that describes the element to define. */ - define( + define>( type: TType, nameOrDef?: string | PartialFASTElementDefinition ): TType { @@ -137,7 +138,7 @@ export const FASTElement = Object.assign(createFASTElement(HTMLElement), { */ export function customElement(nameOrDef: string | PartialFASTElementDefinition) { /* eslint-disable-next-line @typescript-eslint/explicit-function-return-type */ - return function (type: Function) { + return function (type: Constructable) { new FASTElementDefinition(type, nameOrDef).define(); }; } diff --git a/packages/web-components/fast-element/src/platform.ts b/packages/web-components/fast-element/src/platform.ts index f334c47c9b8..25e04791de4 100644 --- a/packages/web-components/fast-element/src/platform.ts +++ b/packages/web-components/fast-element/src/platform.ts @@ -55,3 +55,48 @@ if (FAST.error === void 0) { * @internal */ export const emptyArray = Object.freeze([]); + +/** + * Do not change. Part of shared kernel contract. + * @internal + */ +export interface TypeDefinition { + type: Function; +} + +/** + * Do not change. Part of shared kernel contract. + * @internal + */ +export interface TypeRegistry { + register(definition: TDefinition): boolean; + getByType(key: Function): TDefinition | undefined; + getForInstance(object: any): TDefinition | undefined; +} + +/** + * Do not change. Part of shared kernel contract. + * @internal + */ +export function createTypeRegistry(): TypeRegistry< + TDefinition +> { + const typeToDefinition = new Map(); + + return Object.freeze({ + register(definition: TDefinition): boolean { + if (typeToDefinition.has(definition.type)) { + return false; + } + + typeToDefinition.set(definition.type, definition); + return true; + }, + getByType(key: TType): TDefinition | undefined { + return typeToDefinition.get(key); + }, + getForInstance(object: any): TDefinition | undefined { + return typeToDefinition.get(object.constructor); + }, + }); +} 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 2c02a85e7bd..7bb45456916 100644 --- a/packages/web-components/fast-element/src/templating/binding.spec.ts +++ b/packages/web-components/fast-element/src/templating/binding.spec.ts @@ -28,7 +28,7 @@ describe("The HTML binding directive", () => { function contentBinding(propertyName: keyof Model = "value") { const directive = bind(x => x[propertyName]) as HTMLBindingDirective; - directive.targetId = 'r'; + directive.nodeId = 'r'; const node = document.createTextNode(" "); const targets = { r: node }; diff --git a/packages/web-components/fast-element/src/templating/binding.ts b/packages/web-components/fast-element/src/templating/binding.ts index e1df2925b58..55806b3e5de 100644 --- a/packages/web-components/fast-element/src/templating/binding.ts +++ b/packages/web-components/fast-element/src/templating/binding.ts @@ -1,5 +1,5 @@ import { DOM } from "../dom.js"; -import { isString, Message, Mutable } from "../interfaces.js"; +import { isString, Message } from "../interfaces.js"; import { Binding, BindingObserver, @@ -8,11 +8,15 @@ import { } from "../observation/observable.js"; import { FAST } from "../platform.js"; import { + AddViewBehaviorFactory, Aspect, - AspectedHTMLDirective, + Aspected, + HTMLDirective, ViewBehavior, + ViewBehaviorFactory, ViewBehaviorTargets, } from "./html-directive.js"; +import { Markup } from "./markup.js"; import type { CaptureType } from "./template.js"; import type { SyntheticView } from "./view.js"; @@ -40,7 +44,7 @@ export const notSupportedBindingType: BindingType = () => { /** * @alpha */ -export type BindingMode = Record; +export type BindingMode = Record; /** * @alpha @@ -85,7 +89,7 @@ function createContentBinding( ): void { super.unbind(source, context, targets); - const target = targets[this.directive.targetId] as ContentTarget; + const target = targets[this.directive.nodeId] as ContentTarget; const view = target.$fastView as ComposableView; if (view !== void 0 && view.isComposed) { @@ -177,7 +181,7 @@ function updateTokenListTarget( value: any ): void { const directive = this.directive; - const lookup = `${directive.uniqueId}-token-list`; + const lookup = `${directive.id}-token-list`; const state: TokenListState = target[lookup] ?? (target[lookup] = { c: 0, v: Object.create(null) }); const versions = state.v; @@ -264,10 +268,10 @@ class TargetUpdateBinding extends BindingBase { class OneTimeBinding extends TargetUpdateBinding { bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { const directive = this.directive; - const target = targets[directive.targetId]; + const target = targets[directive.nodeId]; this.updateTarget( target, - directive.target!, + directive.targetAspect!, directive.binding(source, context), source, context @@ -290,12 +294,12 @@ export function sendSignal(signal: string): void { class OnSignalBinding extends TargetUpdateBinding { bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { const directive = this.directive; - const target = targets[directive.targetId]; + const target = targets[directive.nodeId]; const signal = this.getSignal(source, context); - const handler = (target[directive.uniqueId] = () => { + const handler = (target[directive.id] = () => { this.updateTarget( target, - directive.target!, + directive.targetAspect!, directive.binding(source, context), source, context @@ -321,8 +325,8 @@ class OnSignalBinding extends TargetUpdateBinding { if (found && Array.isArray(found)) { const directive = this.directive; - const target = targets[directive.targetId]; - const handler = target[directive.uniqueId]; + const target = targets[directive.nodeId]; + const handler = target[directive.id]; const index = found.indexOf(handler); if (index !== -1) { found.splice(index, 1); @@ -348,10 +352,10 @@ class OnChangeBinding extends TargetUpdateBinding { bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { const directive = this.directive; - const target = targets[directive.targetId]; + const target = targets[directive.nodeId]; const observer: BindingObserver = - target[directive.uniqueId] ?? - (target[directive.uniqueId] = Observable.binding( + target[directive.id] ?? + (target[directive.id] = Observable.binding( directive.binding, this, this.isBindingVolatile @@ -363,7 +367,7 @@ class OnChangeBinding extends TargetUpdateBinding { this.updateTarget( target, - directive.target!, + directive.targetAspect!, observer.observe(source, context), source, context @@ -371,8 +375,8 @@ class OnChangeBinding extends TargetUpdateBinding { } unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { - const target = targets[this.directive.targetId]; - const observer = target[this.directive.uniqueId]; + const target = targets[this.directive.nodeId]; + const observer = target[this.directive.id]; observer.disconnect(); observer.target = null; observer.source = null; @@ -386,7 +390,7 @@ class OnChangeBinding extends TargetUpdateBinding { const context = (observer as any).context; this.updateTarget( target, - this.directive.target!, + this.directive.targetAspect!, observer.observe(source, context!), source, context @@ -412,20 +416,24 @@ type FASTEventSource = Node & { class EventListener extends BindingBase { bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { const directive = this.directive; - const target = targets[directive.targetId] as FASTEventSource; + const target = targets[directive.nodeId] as FASTEventSource; target.$fastSource = source; target.$fastContext = context; - target.addEventListener(directive.target!, this, directive.options); + target.addEventListener(directive.targetAspect!, this, directive.options); } unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { - this.removeEventListener(targets[this.directive.targetId] as FASTEventSource); + this.removeEventListener(targets[this.directive.nodeId] as FASTEventSource); } protected removeEventListener(target: FASTEventSource): void { target.$fastSource = null; target.$fastContext = null; - target.removeEventListener(this.directive.target!, this, this.directive.options); + target.removeEventListener( + this.directive.targetAspect!, + this, + this.directive.options + ); } handleEvent(event: Event): void { @@ -502,73 +510,39 @@ const createInnerHTMLBinding = globalThis.TrustedHTML /** * @internal */ -export class HTMLBindingDirective extends AspectedHTMLDirective { +export class HTMLBindingDirective + implements HTMLDirective, ViewBehaviorFactory, Aspected { private factory: BindingBehaviorFactory | null = null; - public readonly source: string = ""; - public readonly target: string = ""; - public readonly aspect: Aspect = Aspect.content; + id: string; + nodeId: string; + sourceAspect: string; + targetAspect: string; + aspectType: number; - public constructor( - public binding: Binding, - public mode: BindingMode, - public options: any - ) { - super(); + constructor(public binding: Binding, public mode: BindingMode, public options: any) { + this.aspectType = Aspect.content; } - public captureSource(value: string): void { - (this as Mutable).source = value; - - if (!value) { - return; - } - - switch (value[0]) { - case ":": - (this as Mutable).target = value.substring(1); - switch (this.target) { - case "innerHTML": - this.binding = createInnerHTMLBinding(this.binding); - (this as Mutable).aspect = Aspect.property; - break; - case "classList": - (this as Mutable).aspect = Aspect.tokenList; - break; - default: - (this as Mutable).aspect = Aspect.property; - break; - } - break; - case "?": - (this as Mutable).target = value.substring(1); - (this as Mutable).aspect = Aspect.booleanAttribute; - break; - case "@": - (this as Mutable).target = value.substring(1); - (this as Mutable).aspect = Aspect.event; - break; - default: - if (value === "class") { - (this as Mutable).target = "className"; - (this as Mutable).aspect = Aspect.property; - } else { - (this as Mutable).target = value; - (this as Mutable).aspect = Aspect.attribute; - } - break; - } + createHTML(add: AddViewBehaviorFactory): string { + return Markup.interpolation(add(this)); } createBehavior(targets: ViewBehaviorTargets): ViewBehavior { if (this.factory == null) { - this.factory = this.mode[this.aspect](this); + if (this.targetAspect === "innerHTML") { + this.binding = createInnerHTMLBinding(this.binding); + } + + this.factory = this.mode[this.aspectType](this); } return this.factory.createBehavior(targets); } } +HTMLDirective.define(HTMLBindingDirective, { aspected: true }); + /** * @alpha */ 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 390f2029825..a74dbbc8491 100644 --- a/packages/web-components/fast-element/src/templating/children.spec.ts +++ b/packages/web-components/fast-element/src/templating/children.spec.ts @@ -43,18 +43,18 @@ describe("The children", () => { function createDOM(elementName: string = "div") { const host = document.createElement("div"); const children = createAndAppendChildren(host, elementName); - const targetId = 'r'; - const targets = { [targetId]: host }; + const nodeId = 'r'; + const targets = { [nodeId]: host }; - return { host, children, targets, targetId }; + return { host, children, targets, nodeId }; } it("gathers child nodes", () => { - const { host, children, targets, targetId } = createDOM(); + const { host, children, targets, nodeId } = createDOM(); const behavior = new ChildrenDirective({ property: "nodes", }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); @@ -63,12 +63,12 @@ describe("The children", () => { }); it("gathers child nodes with a filter", () => { - const { host, children, targets, targetId } = createDOM("foo-bar"); + const { host, children, targets, nodeId } = createDOM("foo-bar"); const behavior = new ChildrenDirective({ property: "nodes", filter: elements("foo-bar"), }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); @@ -77,11 +77,11 @@ describe("The children", () => { }); it("updates child nodes when they change", async () => { - const { host, children, targets, targetId } = createDOM("foo-bar"); + const { host, children, targets, nodeId } = createDOM("foo-bar"); const behavior = new ChildrenDirective({ property: "nodes", }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); @@ -96,12 +96,12 @@ describe("The children", () => { }); it("updates child nodes when they change with a filter", async () => { - const { host, children, targets, targetId } = createDOM("foo-bar"); + const { host, children, targets, nodeId } = createDOM("foo-bar"); const behavior = new ChildrenDirective({ property: "nodes", filter: elements("foo-bar"), }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); @@ -116,7 +116,7 @@ describe("The children", () => { }); it("updates subtree nodes when they change with a selector", async () => { - const { host, children, targets, targetId } = createDOM("foo-bar"); + const { host, children, targets, nodeId } = createDOM("foo-bar"); const subtreeElement = "foo-bar-baz"; const subtreeChildren: HTMLElement[] = []; @@ -133,7 +133,7 @@ describe("The children", () => { subtree: true, selector: subtreeElement, }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); @@ -157,11 +157,11 @@ describe("The children", () => { }); it("clears and unwatches when unbound", async () => { - const { host, children, targets, targetId } = createDOM("foo-bar"); + const { host, children, targets, nodeId } = createDOM("foo-bar"); const behavior = new ChildrenDirective({ property: "nodes", }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); diff --git a/packages/web-components/fast-element/src/templating/children.ts b/packages/web-components/fast-element/src/templating/children.ts index ba6d113bbbd..ea5767224e0 100644 --- a/packages/web-components/fast-element/src/templating/children.ts +++ b/packages/web-components/fast-element/src/templating/children.ts @@ -1,4 +1,5 @@ import { isString } from "../interfaces.js"; +import { HTMLDirective } from "./html-directive.js"; import { NodeBehaviorOptions, NodeObservationDirective } from "./node-observation.js"; import type { CaptureType } from "./template.js"; @@ -59,8 +60,7 @@ export class ChildrenDirective extends NodeObservationDirective< */ observe(target: any): void { const observer = - target[this.uniqueId] ?? - (target[this.uniqueId] = new MutationObserver(this.handleEvent)); + target[this.id] ?? (target[this.id] = new MutationObserver(this.handleEvent)); observer.$fastTarget = target; observer.observe(target, this.options); } @@ -70,7 +70,7 @@ export class ChildrenDirective extends NodeObservationDirective< * @param target - The target to unobserve. */ disconnect(target: any): void { - const observer = target[this.uniqueId]; + const observer = target[this.id]; observer.$fastTarget = null; observer.disconnect(); } @@ -94,6 +94,8 @@ export class ChildrenDirective extends NodeObservationDirective< }; } +HTMLDirective.define(ChildrenDirective); + /** * A directive that observes the `childNodes` of an element and updates a property * whenever they change. 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 9ed9da516d1..ddc8dea5219 100644 --- a/packages/web-components/fast-element/src/templating/compiler.spec.ts +++ b/packages/web-components/fast-element/src/templating/compiler.spec.ts @@ -22,11 +22,24 @@ interface CompilationResultInternals { describe("The template compiler", () => { function compile(html: string, directives: HTMLDirective[]) { - return Compiler.compile(html, directives) as any as CompilationResultInternals; + const factories: Record = Object.create(null); + const ids: string[] = []; + let nextId = -1; + const add = (factory: ViewBehaviorFactory): string => { + const id = `${++nextId}`; + ids.push(id); + factory.id = id; + factories[id] = factory; + return id; + }; + + directives.forEach(x => x.createHTML(add)); + + return Compiler.compile(html, factories) as any as CompilationResultInternals; } function inline(index: number) { - return Markup.interpolation(index); + return Markup.interpolation(`${index}`); } function binding(result = "result") { @@ -176,7 +189,7 @@ describe("The template compiler", () => { expect(length).to.equal(x.targetIds.length); for (let i = 0; i < length; ++i) { - expect(factories[i].targetId).to.equal( + expect(factories[i].nodeId).to.equal( x.targetIds[i] ); } @@ -344,7 +357,7 @@ describe("The template compiler", () => { expect(length).to.equal(x.targetIds.length); for (let i = 0; i < length; ++i) { - expect(factories[i].targetId).to.equal( + expect(factories[i].nodeId).to.equal( x.targetIds[i] ); } diff --git a/packages/web-components/fast-element/src/templating/compiler.ts b/packages/web-components/fast-element/src/templating/compiler.ts index c0eef87ec3a..da7fc347e56 100644 --- a/packages/web-components/fast-element/src/templating/compiler.ts +++ b/packages/web-components/fast-element/src/templating/compiler.ts @@ -2,12 +2,8 @@ import { isString, Message, TrustedTypesPolicy } from "../interfaces.js"; import type { ExecutionContext } from "../observation/observable.js"; import { FAST } from "../platform.js"; import { Parser } from "./markup.js"; -import { bind, oneTime } from "./binding.js"; -import type { - AspectedHTMLDirective, - HTMLDirective, - ViewBehaviorFactory, -} from "./html-directive.js"; +import { bind, HTMLBindingDirective, oneTime } from "./binding.js"; +import { Aspect, Aspected, ViewBehaviorFactory } from "./html-directive.js"; import type { HTMLTemplateCompilationResult as TemplateCompilationResult } from "./template.js"; import { HTMLView } from "./view.js"; @@ -32,27 +28,27 @@ class CompilationContext< TContext extends ExecutionContext = ExecutionContext > implements TemplateCompilationResult { private proto: any = null; - private targetIds = new Set(); + private nodeIds = new Set(); private descriptors: PropertyDescriptorMap = {}; public readonly factories: ViewBehaviorFactory[] = []; constructor( public readonly fragment: DocumentFragment, - public readonly directives: ReadonlyArray + public readonly directives: Record ) {} public addFactory( factory: ViewBehaviorFactory, parentId: string, - targetId: string, + nodeId: string, targetIndex: number ): void { - if (!this.targetIds.has(targetId)) { - this.targetIds.add(targetId); - this.addTargetDescriptor(parentId, targetId, targetIndex); + if (!this.nodeIds.has(nodeId)) { + this.nodeIds.add(nodeId); + this.addTargetDescriptor(parentId, nodeId, targetIndex); } - factory.targetId = targetId; + factory.nodeId = nodeId; this.factories.push(factory); } @@ -108,7 +104,7 @@ class CompilationContext< targets.r = fragment; targets.h = hostBindingTarget ?? fragment; - for (const id of this.targetIds) { + for (const id of this.nodeIds) { targets[id]; // trigger locator } @@ -131,12 +127,12 @@ function compileAttributes( const attr = attributes[i]; const attrValue = attr.value; const parseResult = Parser.parse(attrValue, directives); - let result: HTMLDirective | null = null; + let result: ViewBehaviorFactory | null = null; if (parseResult === null) { if (includeBasicValues) { - result = bind(() => attrValue, oneTime) as AspectedHTMLDirective; - (result as AspectedHTMLDirective).captureSource(attr.name); + result = bind(() => attrValue, oneTime) as ViewBehaviorFactory; + Aspect.assign((result as any) as Aspected, attr.name); } } else { /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ @@ -246,7 +242,7 @@ function compileNode( return next; } -function isMarker(node: Node, directives: ReadonlyArray): boolean { +function isMarker(node: Node, directives: Record): boolean { return ( node && node.nodeType == 8 && @@ -265,9 +261,9 @@ export type CompilationStrategy = ( */ html: string | HTMLTemplateElement, /** - * The directives used within the html that is being compiled. + * The behavior factories used within the html that is being compiled. */ - directives: readonly HTMLDirective[] + factories: Record ) => TemplateCompilationResult; const templateTag = "TEMPLATE"; @@ -313,7 +309,7 @@ export const Compiler = { TContext extends ExecutionContext = ExecutionContext >( html: string | HTMLTemplateElement, - directives: ReadonlyArray + directives: Record ): TemplateCompilationResult { let template: HTMLTemplateElement; @@ -347,7 +343,7 @@ export const Compiler = { // Or if there is only one node and a directive, it means the template's content // is *only* the directive. In that case, HTMLView.dispose() misses any nodes inserted by // the directive. Inserting a new node ensures proper disposal of nodes added by the directive. - (fragment.childNodes.length === 1 && directives.length) + (fragment.childNodes.length === 1 && Object.keys(directives).length > 0) ) { fragment.insertBefore(document.createComment(""), fragment.firstChild); } @@ -372,20 +368,20 @@ export const Compiler = { * directives. * @returns A single inline directive that aggregates the behavior of all the parts. */ - aggregate(parts: (string | HTMLDirective)[]): HTMLDirective { + aggregate(parts: (string | ViewBehaviorFactory)[]): ViewBehaviorFactory { if (parts.length === 1) { - return parts[0] as HTMLDirective; + return parts[0] as ViewBehaviorFactory; } - let source: string | undefined; + let sourceAspect: string | undefined; const partCount = parts.length; - const finalParts = parts.map((x: string | AspectedHTMLDirective) => { + const finalParts = parts.map((x: string | ViewBehaviorFactory) => { if (isString(x)) { return (): string => x; } - source = x.source || source; - return x.binding!; + sourceAspect = ((x as any) as Aspected).sourceAspect || sourceAspect; + return ((x as any) as Aspected).binding!; }); const binding = (scope: unknown, context: ExecutionContext): string => { @@ -398,8 +394,8 @@ export const Compiler = { return output; }; - const directive = bind(binding) as AspectedHTMLDirective; - directive.captureSource(source!); + const directive = bind(binding) as HTMLBindingDirective; + Aspect.assign(directive, sourceAspect!); return directive; }, }; 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 a79ee2f0482..918eb7e1b17 100644 --- a/packages/web-components/fast-element/src/templating/html-directive.ts +++ b/packages/web-components/fast-element/src/templating/html-directive.ts @@ -1,6 +1,8 @@ +import type { Constructable, Mutable } from "../interfaces.js"; import type { Behavior } from "../observation/behavior.js"; import type { Binding, ExecutionContext } from "../observation/observable.js"; -import { Markup, nextId } from "./markup.js"; +import { createTypeRegistry } from "../platform.js"; +import { Markup } from "./markup.js"; /** * The target nodes available to a behavior. @@ -46,130 +48,233 @@ export interface ViewBehavior { * @public */ export interface ViewBehaviorFactory { + /** + * The unique id of the factory. + */ + id: string; + /** * The structural id of the DOM node to which the created behavior will apply. */ - targetId: string; + nodeId: string; /** * Creates a behavior. - * @param target - The targets available for behaviors to be attached to. + * @param targets - The targets available for behaviors to be attached to. */ createBehavior(targets: ViewBehaviorTargets): Behavior | ViewBehavior; } +/** + * Used to add behavior factories when constructing templates. + * @public + */ +export type AddViewBehaviorFactory = (factory: ViewBehaviorFactory) => string; + /** * Instructs the template engine to apply behavior to a node. * @public */ -export abstract class HTMLDirective implements ViewBehaviorFactory { +export interface HTMLDirective { /** - * The structural id of the directive based on the DOM node - * that it applies to. + * Creates HTML to be used within a template. + * @param add - Can be used to add behavior factories to a template. */ - public targetId: string = "h"; + createHTML(add: AddViewBehaviorFactory): string; +} +/** + * Represents metadata configuration for an HTMLDirective. + * @public + */ +export interface PartialHTMLDirectiveDefinition { /** - * The unique id of the directive instance. + * Indicates whether the directive needs access to template contextual information + * such as the sourceAspect, targetAspect, and aspectType. */ - public readonly uniqueId: string = nextId(); + aspected?: boolean; +} +/** + * Defines metadata for an HTMLDirective. + * @public + */ +export interface HTMLDirectiveDefinition< + TType extends Constructable = Constructable +> extends Required { /** - * Creates a placeholder string based on the directive's index within the template. - * @param index - The index of the directive within the template. + * The type that the definition provides metadata for. */ - public abstract createPlaceholder(index: number): string; + readonly type: TType; +} - /** - * Creates a behavior. - * @param targets - The targets available for behaviors to be attached to. - */ - public abstract createBehavior(targets: ViewBehaviorTargets): Behavior | ViewBehavior; +const registry = createTypeRegistry(); + +/** + * Instructs the template engine to apply behavior to a node. + * @public + */ +export const HTMLDirective = Object.freeze({ + getForInstance: registry.getForInstance, + getByType: registry.getByType, + define>( + type: TType, + options?: PartialHTMLDirectiveDefinition + ): TType { + options = options || {}; + (options as Mutable).type = type; + registry.register(options as HTMLDirectiveDefinition); + return type; + }, +}); + +/** + * Decorator: Defines an HTMLDirective. + * @param options - Provides options that specify the directives application. + * @public + */ +export function htmlDirective(options?: PartialHTMLDirectiveDefinition) { + /* eslint-disable-next-line @typescript-eslint/explicit-function-return-type */ + return function (type: Constructable) { + HTMLDirective.define(type, options); + }; } /** * The type of HTML aspect to target. * @public */ -export enum Aspect { +export const Aspect = Object.freeze({ + /** + * Not aspected. + */ + none: 0, + /** * An attribute. */ - attribute = 0, + attribute: 1, + /** * A boolean attribute. */ - booleanAttribute = 1, + booleanAttribute: 2, + /** * A property. */ - property = 2, + property: 3, + /** * Content */ - content = 3, + content: 4, + /** * A token list. */ - tokenList = 4, + tokenList: 5, + /** * An event. */ - event = 5, -} + event: 6, + + /** + * + * @param directive - The directive to assign the aspect to. + * @param value - The value to base the aspect determination on. + */ + assign(directive: Aspected, value: string): void { + directive.sourceAspect = value; + + if (!value) { + return; + } + + switch (value[0]) { + case ":": + directive.targetAspect = value.substring(1); + switch (directive.targetAspect) { + case "innerHTML": + directive.aspectType = Aspect.property; + break; + case "classList": + directive.aspectType = Aspect.tokenList; + break; + default: + directive.aspectType = Aspect.property; + break; + } + break; + case "?": + directive.targetAspect = value.substring(1); + directive.aspectType = Aspect.booleanAttribute; + break; + case "@": + directive.targetAspect = value.substring(1); + directive.aspectType = Aspect.event; + break; + default: + if (value === "class") { + directive.targetAspect = "className"; + directive.aspectType = Aspect.property; + } else { + directive.targetAspect = value; + directive.aspectType = Aspect.attribute; + } + break; + } + }, +}); /** - * A {@link HTMLDirective} that targets a particular aspect - * (attribute, property, event, etc.) of a node. + * Represents something that applies to a specific aspect of the DOM. * @public */ -export abstract class AspectedHTMLDirective extends HTMLDirective { +export interface Aspected { /** - * The original source aspect exactly as represented in the HTML. + * The original source aspect exactly as represented in markup. */ - abstract readonly source: string; + sourceAspect: string; /** * The evaluated target aspect, determined after processing the source. */ - abstract readonly target: string; + targetAspect: string; /** * The type of aspect to target. */ - abstract readonly aspect: Aspect; + aspectType: number; /** - * A binding to apply to the target, if applicable. + * A binding if one is associated with the aspect. */ - abstract readonly binding?: Binding; + binding?: Binding; +} +/** + * A base class used for attribute directives that don't need internal state. + * @public + */ +export abstract class StatelessAttachedAttributeDirective + implements HTMLDirective, ViewBehaviorFactory, ViewBehavior { /** - * Captures the original source aspect from HTML. - * @param source - The original source aspect. + * The unique id of the factory. */ - abstract captureSource(source: string): void; + id: string; /** - * Creates a placeholder string based on the directive's index within the template. - * @param index - The index of the directive within the template. + * The structural id of the DOM node to which the created behavior will apply. */ - public createPlaceholder: (index: number) => string = Markup.interpolation; -} + nodeId: string; -/** - * A base class used for attribute directives that don't need internal state. - * @public - */ -export abstract class StatelessAttachedAttributeDirective extends HTMLDirective - implements ViewBehavior { /** * Creates an instance of RefDirective. * @param options - The options to use in configuring the directive. */ - public constructor(protected options: T) { - super(); - } + public constructor(protected options: T) {} /** * Creates a behavior. @@ -185,7 +290,9 @@ export abstract class StatelessAttachedAttributeDirective extends HTMLDirecti * @remarks * Creates a custom attribute placeholder. */ - public createPlaceholder: (index: number) => string = Markup.attribute; + public createHTML(add: AddViewBehaviorFactory): string { + return Markup.attribute(add(this)); + } /** * Bind this behavior to the source. diff --git a/packages/web-components/fast-element/src/templating/markup.ts b/packages/web-components/fast-element/src/templating/markup.ts index 71a5b2148ac..34385a9c041 100644 --- a/packages/web-components/fast-element/src/templating/markup.ts +++ b/packages/web-components/fast-element/src/templating/markup.ts @@ -1,4 +1,4 @@ -import type { HTMLDirective } from "./html-directive.js"; +import type { ViewBehaviorFactory } from "./html-directive.js"; const marker = `fast-${Math.random().toString(36).substring(2, 8)}`; const interpolationStart = `${marker}{`; @@ -22,7 +22,7 @@ export const Markup = Object.freeze({ * @remarks * Used internally by binding directives. */ - interpolation: (index: number) => `${interpolationStart}${index}${interpolationEnd}`, + interpolation: (id: string) => `${interpolationStart}${id}${interpolationEnd}`, /** * Creates a placeholder that manifests itself as an attribute on an @@ -32,8 +32,8 @@ export const Markup = Object.freeze({ * @remarks * Used internally by attribute directives such as `ref`, `slotted`, and `children`. */ - attribute: (index: number) => - `${nextId()}="${interpolationStart}${index}${interpolationEnd}"`, + attribute: (id: string) => + `${nextId()}="${interpolationStart}${id}${interpolationEnd}"`, /** * Creates a placeholder that manifests itself as a marker within the DOM structure. @@ -41,7 +41,7 @@ export const Markup = Object.freeze({ * @remarks * Used internally by structural directives such as `repeat`. */ - comment: (index: number) => ``, + comment: (id: string) => ``, }); /** @@ -53,32 +53,32 @@ export const Parser = Object.freeze({ * Parses text content or HTML attribute content, separating out the static strings * from the directives. * @param value - The content or attribute string to parse. - * @param directives - A list of directives to search for in the string. + * @param factories - A list of directives to search for in the string. * @returns A heterogeneous array of static strings interspersed with * directives or null if no directives are found in the string. */ parse( value: string, - directives: readonly HTMLDirective[] - ): (string | HTMLDirective)[] | null { + factories: Record + ): (string | ViewBehaviorFactory)[] | null { const parts = value.split(interpolationStart); if (parts.length === 1) { return null; } - const result: (string | HTMLDirective)[] = []; + const result: (string | ViewBehaviorFactory)[] = []; for (let i = 0, ii = parts.length; i < ii; ++i) { const current = parts[i]; const index = current.indexOf(interpolationEnd); - let literal: string | HTMLDirective; + let literal: string | ViewBehaviorFactory; if (index === -1) { literal = current; } else { - const directiveIndex = parseInt(current.substring(0, index)); - result.push(directives[directiveIndex]); + const factoryId = current.substring(0, index); + result.push(factories[factoryId]); literal = current.substring(index + interpolationEndLength); } 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 189e4f56107..7951388f783 100644 --- a/packages/web-components/fast-element/src/templating/node-observation.ts +++ b/packages/web-components/fast-element/src/templating/node-observation.ts @@ -58,7 +58,7 @@ export abstract class NodeObservationDirective< * @param targets - The targets that behaviors in a view can attach to. */ bind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { - const target = targets[this.targetId] as any; + const target = targets[this.nodeId] as any; target.$fastSource = source; this.updateTarget(source, this.computeNodes(target)); this.observe(target); @@ -71,7 +71,7 @@ export abstract class NodeObservationDirective< * @param targets - The targets that behaviors in a view can attach to. */ unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { - const target = targets[this.targetId] as any; + const target = targets[this.nodeId] as any; this.updateTarget(source, emptyArray); this.disconnect(target); target.$fastSource = null; diff --git a/packages/web-components/fast-element/src/templating/ref.ts b/packages/web-components/fast-element/src/templating/ref.ts index af0b647077a..9bb6d5ccdc0 100644 --- a/packages/web-components/fast-element/src/templating/ref.ts +++ b/packages/web-components/fast-element/src/templating/ref.ts @@ -1,5 +1,6 @@ import type { ExecutionContext } from "../observation/observable.js"; import { + HTMLDirective, StatelessAttachedAttributeDirective, ViewBehaviorTargets, } from "./html-directive.js"; @@ -21,7 +22,7 @@ export class RefDirective extends StatelessAttachedAttributeDirective { context: ExecutionContext, targets: ViewBehaviorTargets ): void { - source[this.options] = targets[this.targetId]; + source[this.options] = targets[this.nodeId]; } /** @@ -32,6 +33,8 @@ export class RefDirective extends StatelessAttachedAttributeDirective { public unbind(): void {} } +HTMLDirective.define(RefDirective); + /** * A directive that observes the updates a property with a reference to the element. * @param propertyName - The name of the property to assign the reference to. 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 fd0a11afe60..653b33f930e 100644 --- a/packages/web-components/fast-element/src/templating/repeat.spec.ts +++ b/packages/web-components/fast-element/src/templating/repeat.spec.ts @@ -9,12 +9,12 @@ describe("The repeat", () => { function createLocation() { const parent = document.createElement("div"); const location = document.createComment(""); - const targetId = 'r'; - const targets = { [targetId]: location }; + const nodeId = 'r'; + const targets = { [nodeId]: location }; parent.appendChild(location); - return { parent, targets, targetId }; + return { parent, targets, nodeId }; } context("template function", () => { @@ -29,12 +29,12 @@ describe("The repeat", () => { context("directive", () => { it("creates a RepeatBehavior", () => { - const { parent, targets, targetId } = createLocation(); + const { targets, nodeId } = createLocation(); const directive = repeat( () => [], html`test` ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); @@ -97,12 +97,12 @@ describe("The repeat", () => { zeroThroughTen.forEach(size => { it(`renders a template for each item in array of size ${size}`, () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, itemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); @@ -115,12 +115,12 @@ describe("The repeat", () => { zeroThroughTen.forEach(size => { it(`renders empty when an array of size ${size} is replaced with an empty array`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, wrappedItemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const data = new ViewModel(size); @@ -148,12 +148,12 @@ describe("The repeat", () => { zeroThroughTen.forEach(size => { it(`updates rendered HTML when a new item is pushed into an array of size ${size}`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, itemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); @@ -168,12 +168,12 @@ describe("The repeat", () => { oneThroughTen.forEach(size => { it(`updates rendered HTML when a single item is spliced from the end of an array of size ${size}`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, itemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); @@ -192,12 +192,12 @@ describe("The repeat", () => { oneThroughTen.forEach(size => { it(`updates rendered HTML when a single item is spliced from the beginning of an array of size ${size}`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, itemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); @@ -213,12 +213,12 @@ describe("The repeat", () => { oneThroughTen.forEach(size => { it(`updates rendered HTML when a single item is replaced from the end of an array of size ${size}`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, itemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); @@ -237,12 +237,12 @@ describe("The repeat", () => { oneThroughTen.forEach(size => { it(`updates rendered HTML when a single item is replaced from the beginning of an array of size ${size}`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, itemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); @@ -260,12 +260,12 @@ describe("The repeat", () => { oneThroughTen.forEach(size => { it(`updates all when the template changes for an array of size ${size}`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, x => vm.template ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); @@ -290,12 +290,12 @@ describe("The repeat", () => { )} `; - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, deepItemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size, true); @@ -312,12 +312,12 @@ describe("The repeat", () => { oneThroughTen.forEach(size => { it(`handles back to back shift operations for arrays of size ${size}`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, itemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); @@ -336,12 +336,12 @@ describe("The repeat", () => { zeroThroughTen.forEach(size => { it(`updates rendered HTML when a new item is pushed into an array of size ${size} after it has been unbound and rebound`, async () => { - const { parent, targets, targetId } = createLocation(); + const { parent, targets, nodeId } = createLocation(); const directive = repeat( x => x.items, itemTemplate ) as RepeatDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const behavior = directive.createBehavior(targets); const vm = new ViewModel(size); diff --git a/packages/web-components/fast-element/src/templating/repeat.ts b/packages/web-components/fast-element/src/templating/repeat.ts index 749cd6f22a4..ab2bcb349ee 100644 --- a/packages/web-components/fast-element/src/templating/repeat.ts +++ b/packages/web-components/fast-element/src/templating/repeat.ts @@ -14,7 +14,12 @@ import { } from "../observation/observable.js"; import { emptyArray } from "../platform.js"; import { Markup } from "./markup.js"; -import { HTMLDirective, ViewBehaviorTargets } from "./html-directive.js"; +import { + AddViewBehaviorFactory, + HTMLDirective, + ViewBehaviorFactory, + ViewBehaviorTargets, +} from "./html-directive.js"; import type { CaptureType, ChildViewTemplate, @@ -294,15 +299,28 @@ export class RepeatBehavior implements Behavior, Subscriber { * A directive that configures list rendering. * @public */ -export class RepeatDirective extends HTMLDirective { +export class RepeatDirective + implements HTMLDirective, ViewBehaviorFactory { private isItemsBindingVolatile: boolean; private isTemplateBindingVolatile: boolean; + /** + * The unique id of the factory. + */ + id: string; + + /** + * The structural id of the DOM node to which the created behavior will apply. + */ + nodeId: string; + /** * Creates a placeholder string based on the directive's index within the template. * @param index - The index of the directive within the template. */ - public createPlaceholder: (index: number) => string = Markup.comment; + public createHTML(add: AddViewBehaviorFactory): string { + return Markup.comment(add(this)); + } /** * Creates an instance of RepeatDirective. @@ -315,7 +333,6 @@ export class RepeatDirective extends HTMLDirective { public readonly templateBinding: Binding, public readonly options: RepeatOptions ) { - super(); enableArrayObservation(); this.isItemsBindingVolatile = Observable.isVolatileBinding(itemsBinding); this.isTemplateBindingVolatile = Observable.isVolatileBinding(templateBinding); @@ -327,7 +344,7 @@ export class RepeatDirective extends HTMLDirective { */ public createBehavior(targets: ViewBehaviorTargets): RepeatBehavior { return new RepeatBehavior( - targets[this.targetId], + targets[this.nodeId], this.itemsBinding, this.isItemsBindingVolatile, this.templateBinding, @@ -337,6 +354,8 @@ export class RepeatDirective extends HTMLDirective { } } +HTMLDirective.define(RepeatDirective); + /** * A directive that enables list rendering. * @param itemsBinding - The array to render. 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 89149e53c73..ccdec09d260 100644 --- a/packages/web-components/fast-element/src/templating/slotted.spec.ts +++ b/packages/web-components/fast-element/src/templating/slotted.spec.ts @@ -14,11 +14,11 @@ describe("The slotted", () => { context("directive", () => { it("creates a behavior by returning itself", () => { - const targetId = 'r'; + const nodeId = 'r'; const directive = slotted("test") as SlottedDirective; - directive.targetId = targetId; + directive.nodeId = nodeId; const target = document.createElement("slot"); - const targets = { [targetId]: target } + const targets = { [nodeId]: target } const behavior = directive.createBehavior(targets); expect(behavior).to.equal(directive); @@ -47,18 +47,18 @@ describe("The slotted", () => { const slot = document.createElement("slot"); const shadowRoot = host.attachShadow({ mode: "open" }); const children = createAndAppendChildren(host, elementName); - const targetId = 'r'; - const targets = { [targetId]: slot }; + const nodeId = 'r'; + const targets = { [nodeId]: slot }; shadowRoot.appendChild(slot); - return { host, slot, children, targets, targetId }; + return { host, slot, children, targets, nodeId }; } it("gathers nodes from a slot", () => { - const { children, targets, targetId } = createDOM(); + const { children, targets, nodeId } = createDOM(); const behavior = new SlottedDirective({ property: "nodes" }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); @@ -67,12 +67,12 @@ describe("The slotted", () => { }); it("gathers nodes from a slot with a filter", () => { - const { targets, targetId, children } = createDOM("foo-bar"); + const { targets, nodeId, children } = createDOM("foo-bar"); const behavior = new SlottedDirective({ property: "nodes", filter: elements("foo-bar"), }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); @@ -81,9 +81,9 @@ describe("The slotted", () => { }); it("updates when slotted nodes change", async () => { - const { host, slot, children, targets, targetId } = createDOM("foo-bar"); + const { host, slot, children, targets, nodeId } = createDOM("foo-bar"); const behavior = new SlottedDirective({ property: "nodes" }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); @@ -98,12 +98,12 @@ describe("The slotted", () => { }); it("updates when slotted nodes change with a filter", async () => { - const { host, slot, children, targets, targetId } = createDOM("foo-bar"); + const { host, slot, children, targets, nodeId } = createDOM("foo-bar"); const behavior = new SlottedDirective({ property: "nodes", filter: elements("foo-bar"), }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); @@ -118,9 +118,9 @@ describe("The slotted", () => { }); it("clears and unwatches when unbound", async () => { - const { host, slot, children, targets, targetId } = createDOM("foo-bar"); + const { host, slot, children, targets, nodeId } = createDOM("foo-bar"); const behavior = new SlottedDirective({ property: "nodes" }); - behavior.targetId = targetId; + behavior.nodeId = nodeId; const model = new Model(); behavior.bind(model, ExecutionContext.default, targets); diff --git a/packages/web-components/fast-element/src/templating/slotted.ts b/packages/web-components/fast-element/src/templating/slotted.ts index 9c2a697d749..14436f36829 100644 --- a/packages/web-components/fast-element/src/templating/slotted.ts +++ b/packages/web-components/fast-element/src/templating/slotted.ts @@ -1,4 +1,5 @@ import { isString } from "../interfaces.js"; +import { HTMLDirective } from "./html-directive.js"; import { NodeBehaviorOptions, NodeObservationDirective } from "./node-observation.js"; import type { CaptureType } from "./template.js"; @@ -49,6 +50,8 @@ export class SlottedDirective extends NodeObservationDirective { it(`transforms a string into a ViewTemplate.`, () => { @@ -11,13 +13,17 @@ describe(`The html tag template helper`, () => { expect(template).instanceOf(ViewTemplate); }); - class TestDirective extends HTMLDirective { + @htmlDirective() + class TestDirective implements HTMLDirective, ViewBehaviorFactory { + id: string; + nodeId: string; + createBehavior() { return {} as any; } - createPlaceholder(index: number) { - return Markup.comment(index); + createHTML(add: AddViewBehaviorFactory) { + return Markup.comment(add(this)); } } @@ -26,6 +32,28 @@ describe(`The html tag template helper`, () => { doSomething() {} } + const FAKE = { + comment: Markup.comment("0"), + interpolation: Markup.interpolation("0") + }; + + function expectTemplateEquals(template: ViewTemplate, expectedHTML: string) { + expect(template).instanceOf(ViewTemplate); + + const parts = Parser.parse(template.html as string, {})!; + + if (parts !== null) { + const result = parts.reduce((a, b) => isString(b) + ? a + b + : a + Markup.interpolation("0") + , ""); + + expect(result).to.equal(expectedHTML); + } else { + expect(template.html).to.equal(expectedHTML); + } + } + const stringValue = "string value"; const numberValue = 42; const interpolationScenarios = [ @@ -53,40 +81,40 @@ describe(`The html tag template helper`, () => { type: "number", location: "at the beginning", template: html`${numberValue} end`, - result: `${Markup.interpolation(0)} end`, + result: `${FAKE.interpolation} end`, }, { type: "number", location: "in the middle", template: html`beginning ${numberValue} end`, - result: `beginning ${Markup.interpolation(0)} end`, + result: `beginning ${FAKE.interpolation} end`, }, { type: "number", location: "at the end", template: html`beginning ${numberValue}`, - result: `beginning ${Markup.interpolation(0)}`, + result: `beginning ${FAKE.interpolation}`, }, // expression interpolation { type: "expression", location: "at the beginning", template: html`${x => x.value} end`, - result: `${Markup.interpolation(0)} end`, + result: `${FAKE.interpolation} end`, expectDirectives: [HTMLBindingDirective], }, { type: "expression", location: "in the middle", template: html`beginning ${x => x.value} end`, - result: `beginning ${Markup.interpolation(0)} end`, + result: `beginning ${FAKE.interpolation} end`, expectDirectives: [HTMLBindingDirective], }, { type: "expression", location: "at the end", template: html`beginning ${x => x.value}`, - result: `beginning ${Markup.interpolation(0)}`, + result: `beginning ${FAKE.interpolation}`, expectDirectives: [HTMLBindingDirective], }, // directive interpolation @@ -94,21 +122,21 @@ describe(`The html tag template helper`, () => { type: "directive", location: "at the beginning", template: html`${new TestDirective()} end`, - result: `${Markup.comment(0)} end`, + result: `${FAKE.comment} end`, expectDirectives: [TestDirective], }, { type: "directive", location: "in the middle", template: html`beginning ${new TestDirective()} end`, - result: `beginning ${Markup.comment(0)} end`, + result: `beginning ${FAKE.comment} end`, expectDirectives: [TestDirective], }, { type: "directive", location: "at the end", template: html`beginning ${new TestDirective()}`, - result: `beginning ${Markup.comment(0)}`, + result: `beginning ${FAKE.comment}`, expectDirectives: [TestDirective], }, // template interpolation @@ -116,21 +144,21 @@ describe(`The html tag template helper`, () => { type: "template", location: "at the beginning", template: html`${html`sub-template`} end`, - result: `${Markup.interpolation(0)} end`, + result: `${FAKE.interpolation} end`, expectDirectives: [HTMLBindingDirective], }, { type: "template", location: "in the middle", template: html`beginning ${html`sub-template`} end`, - result: `beginning ${Markup.interpolation(0)} end`, + result: `beginning ${FAKE.interpolation} end`, expectDirectives: [HTMLBindingDirective], }, { type: "template", location: "at the end", template: html`beginning ${html`sub-template`}`, - result: `beginning ${Markup.interpolation(0)}`, + result: `beginning ${FAKE.interpolation}`, expectDirectives: [HTMLBindingDirective], }, // mixed interpolation @@ -138,33 +166,21 @@ describe(`The html tag template helper`, () => { type: "mixed, back-to-back string, number, expression, and directive", location: "at the beginning", template: html`${stringValue}${numberValue}${x => x.value}${new TestDirective()} end`, - result: `${stringValue}${Markup.interpolation( - 0 - )}${Markup.interpolation( - 1 - )}${Markup.comment(2)} end`, + result: `${stringValue}${FAKE.interpolation}${FAKE.interpolation}${FAKE.comment} end`, expectDirectives: [HTMLBindingDirective, HTMLBindingDirective, TestDirective], }, { type: "mixed, back-to-back string, number, expression, and directive", location: "in the middle", template: html`beginning ${stringValue}${numberValue}${x => x.value}${new TestDirective()} end`, - result: `beginning ${stringValue}${Markup.interpolation( - 0 - )}${Markup.interpolation( - 1 - )}${Markup.comment(2)} end`, + result: `beginning ${stringValue}${FAKE.interpolation}${FAKE.interpolation}${FAKE.comment} end`, expectDirectives: [HTMLBindingDirective, HTMLBindingDirective, TestDirective], }, { type: "mixed, back-to-back string, number, expression, and directive", location: "at the end", template: html`beginning ${stringValue}${numberValue}${x => x.value}${new TestDirective()}`, - result: `beginning ${stringValue}${Markup.interpolation( - 0 - )}${Markup.interpolation( - 1 - )}${Markup.comment(2)}`, + result: `beginning ${stringValue}${FAKE.interpolation}${FAKE.interpolation}${FAKE.comment}`, expectDirectives: [HTMLBindingDirective, HTMLBindingDirective, TestDirective], }, { @@ -172,11 +188,7 @@ describe(`The html tag template helper`, () => { location: "at the beginning", template: html`${stringValue}separator${numberValue}separator${x => x.value}separator${new TestDirective()} end`, - result: `${stringValue}separator${Markup.interpolation( - 0 - )}separator${Markup.interpolation( - 1 - )}separator${Markup.comment(2)} end`, + result: `${stringValue}separator${FAKE.interpolation}separator${FAKE.interpolation}separator${FAKE.comment} end`, expectDirectives: [HTMLBindingDirective, HTMLBindingDirective, TestDirective], }, { @@ -184,11 +196,7 @@ describe(`The html tag template helper`, () => { location: "in the middle", template: html`beginning ${stringValue}separator${numberValue}separator${x => x.value}separator${new TestDirective()} end`, - result: `beginning ${stringValue}separator${Markup.interpolation( - 0 - )}separator${Markup.interpolation( - 1 - )}separator${Markup.comment(2)} end`, + result: `beginning ${stringValue}separator${FAKE.interpolation}separator${FAKE.interpolation}separator${FAKE.comment} end`, expectDirectives: [HTMLBindingDirective, HTMLBindingDirective, TestDirective], }, { @@ -196,164 +204,319 @@ describe(`The html tag template helper`, () => { location: "at the end", template: html`beginning ${stringValue}separator${numberValue}separator${x => x.value}separator${new TestDirective()}`, - result: `beginning ${stringValue}separator${Markup.interpolation( - 0 - )}separator${Markup.interpolation( - 1 - )}separator${Markup.comment(2)}`, + result: `beginning ${stringValue}separator${FAKE.interpolation}separator${FAKE.interpolation}separator${FAKE.comment}`, expectDirectives: [HTMLBindingDirective, HTMLBindingDirective, TestDirective], }, ]; interpolationScenarios.forEach(x => { it(`inserts ${x.type} values ${x.location} of the html`, () => { - expect(x.template).instanceOf(ViewTemplate); - expect(x.template.html).to.equal(x.result); + expectTemplateEquals(x.template, x.result); if (x.expectDirectives) { - x.expectDirectives.forEach((type, index) => { - const directive = x.template.directives[index]; + x.expectDirectives.forEach(type => { + let found = false; - expect(directive).to.be.instanceOf(type); + for (const id in x.template.factories) { + const behaviorFactory = x.template.factories[id]; - if (directive instanceof HTMLBindingDirective) { - expect(directive.aspect).to.equal(Aspect.content); + if (behaviorFactory instanceof type) { + found = true; + + if (behaviorFactory instanceof HTMLBindingDirective) { + expect(behaviorFactory.aspectType).to.equal(Aspect.content); + } + } + + expect(behaviorFactory.id).equals(id); } + + expect(found).to.be.true; }); } }); }); + function getFactory>( + template: ViewTemplate, + type: T + ): InstanceType | null { + for (const id in template.factories) { + const potential = template.factories[id]; + + if (potential instanceof type) { + return potential as any as InstanceType; + } + } + + return null; + } + + function expectAspect>( + template: ViewTemplate, + type: T, + sourceAspect: string, + targetAspect: string, + aspectType: number + ) { + const factory = getFactory(template, type) as ViewBehaviorFactory & Aspected; + expect(factory!).to.be.instanceOf(type); + expect(factory!.sourceAspect).to.equal(sourceAspect); + expect(factory!.targetAspect).to.equal(targetAspect); + expect(factory!.aspectType).to.equal(aspectType); + } + it(`captures an attribute with an expression`, () => { const template = html` x.value}>`; - const placeholder = Markup.interpolation(0); - const directive = template.directives[0] as HTMLBindingDirective; - expect(template.html).to.equal( - `` + expectTemplateEquals( + template, + `` ); - expect(directive).to.be.instanceOf(HTMLBindingDirective); - expect(directive.source).to.equal("some-attribute"); - expect(directive.target).to.equal("some-attribute"); - expect(directive.aspect).to.equal(Aspect.attribute); + expectAspect( + template, + HTMLBindingDirective, + "some-attribute", + "some-attribute", + Aspect.attribute + ); }); it(`captures an attribute with a binding`, () => { const template = html` x.value)}>`; - const placeholder = Markup.interpolation(0); - const directive = template.directives[0] as HTMLBindingDirective; - expect(template.html).to.equal( - `` + expectTemplateEquals( + template, + `` + ); + + expectAspect( + template, + HTMLBindingDirective, + "some-attribute", + "some-attribute", + Aspect.attribute + ); + }); + + it(`captures an attribute with an interpolated string`, () => { + const template = html``; + + expectTemplateEquals( + template, + `` + ); + + expectAspect( + template, + HTMLBindingDirective, + "some-attribute", + "some-attribute", + Aspect.attribute + ); + + const factory = getFactory(template, HTMLBindingDirective); + expect(factory!.binding(null, ExecutionContext.default)).equals(stringValue); + }); + + it(`captures an attribute with an interpolated number`, () => { + const template = html``; + + expectTemplateEquals( + template, + `` + ); + + expectAspect( + template, + HTMLBindingDirective, + "some-attribute", + "some-attribute", + Aspect.attribute ); - expect(directive).to.be.instanceOf(HTMLBindingDirective); - expect(directive.source).to.equal("some-attribute"); - expect(directive.target).to.equal("some-attribute"); - expect(directive.aspect).to.equal(Aspect.attribute); + const factory = getFactory(template, HTMLBindingDirective); + expect(factory!.binding(null, ExecutionContext.default)).equals(numberValue); }); it(`captures a boolean attribute with an expression`, () => { const template = html` x.value}>`; - const placeholder = Markup.interpolation(0); - const directive = template.directives[0] as HTMLBindingDirective; - expect(template.html).to.equal( - `` + expectTemplateEquals( + template, + `` ); - expect(directive).to.be.instanceOf(HTMLBindingDirective); - expect(directive.source).to.equal("?some-attribute"); - expect(directive.target).to.equal("some-attribute"); - expect(directive.aspect).to.equal(Aspect.booleanAttribute); + expectAspect( + template, + HTMLBindingDirective, + "?some-attribute", + "some-attribute", + Aspect.booleanAttribute + ); }); it(`captures a boolean attribute with a binding`, () => { const template = html` x.value)}>`; - const placeholder = Markup.interpolation(0); - const directive = template.directives[0] as HTMLBindingDirective; - expect(template.html).to.equal( - `` + expectTemplateEquals( + template, + `` ); - expect(directive).to.be.instanceOf(HTMLBindingDirective); - expect(directive.source).to.equal("?some-attribute"); - expect(directive.target).to.equal("some-attribute"); - expect(directive.aspect).to.equal(Aspect.booleanAttribute); + expectAspect( + template, + HTMLBindingDirective, + "?some-attribute", + "some-attribute", + Aspect.booleanAttribute + ); + }); + + it(`captures a boolean attribute with an interpolated boolean`, () => { + const template = html``; + + expectTemplateEquals( + template, + `` + ); + + expectAspect( + template, + HTMLBindingDirective, + "?some-attribute", + "some-attribute", + Aspect.booleanAttribute + ); + + const factory = getFactory(template, HTMLBindingDirective); + expect(factory!.binding(null, ExecutionContext.default)).equals(true); }); it(`captures a case-sensitive property with an expression`, () => { const template = html` x.value}>`; - const placeholder = Markup.interpolation(0); - const directive = template.directives[0] as HTMLBindingDirective; - expect(template.html).to.equal( - `` + expectTemplateEquals( + template, + `` ); - expect(directive).to.be.instanceOf(HTMLBindingDirective); - expect(directive.source).to.equal(":someAttribute"); - expect(directive.target).to.equal("someAttribute"); - expect(directive.aspect).to.equal(Aspect.property); + expectAspect( + template, + HTMLBindingDirective, + ":someAttribute", + "someAttribute", + Aspect.property + ); }); it(`captures a case-sensitive property with a binding`, () => { const template = html` x.value)}>`; - const placeholder = Markup.interpolation(0); - const directive = template.directives[0] as HTMLBindingDirective; - expect(template.html).to.equal( - `` + expectTemplateEquals( + template, + `` ); - expect(directive).to.be.instanceOf(HTMLBindingDirective); - expect(directive.source).to.equal(":someAttribute"); - expect(directive.target).to.equal("someAttribute"); - expect(directive.aspect).to.equal(Aspect.property); + expectAspect( + template, + HTMLBindingDirective, + ":someAttribute", + "someAttribute", + Aspect.property + ); + }); + + it(`captures a case-sensitive property with an interpolated string`, () => { + const template = html``; + + expectTemplateEquals( + template, + `` + ); + + expectAspect( + template, + HTMLBindingDirective, + ":someAttribute", + "someAttribute", + Aspect.property + ); + + const factory = getFactory(template, HTMLBindingDirective); + expect(factory!.binding(null, ExecutionContext.default)).equals(stringValue); + }); + + it(`captures a case-sensitive property with an interpolated number`, () => { + const template = html``; + + expectTemplateEquals( + template, + `` + ); + + expectAspect( + template, + HTMLBindingDirective, + ":someAttribute", + "someAttribute", + Aspect.property + ); + + const factory = getFactory(template, HTMLBindingDirective); + expect(factory!.binding(null, ExecutionContext.default)).equals(numberValue); }); it(`captures a case-sensitive property with an inline directive`, () => { - class TestDirective extends AspectedHTMLDirective { - binding: Binding; - source: string; - target: string; - aspect = Aspect.property; - - captureSource(value) { - this.source = value; - } + @htmlDirective({ aspected: true }) + class TestDirective implements HTMLDirective, Aspected { + sourceAspect: string; + targetAspect: string; + aspectType = Aspect.property; + id: string; + nodeId: string; createBehavior(targets: ViewBehaviorTargets) { return { bind() {}, unbind() {} }; } + + public createHTML(add: AddViewBehaviorFactory): string { + return Markup.interpolation(add(this)); + } } const template = html``; - const placeholder = Markup.interpolation(0); - expect(template.html).to.equal( - `` + expectTemplateEquals( + template, + `` ); - expect((template.directives[0] as TestDirective).source).to.equal( - ":someAttribute" + + expectAspect( + template, + TestDirective, + ":someAttribute", + "someAttribute", + Aspect.property ); }); it(`captures a case-sensitive event when used with an expression`, () => { const template = html` x.doSomething()}>`; - const placeholder = Markup.interpolation(0); - const directive = template.directives[0] as HTMLBindingDirective; - expect(template.html).to.equal( - `` + expectTemplateEquals( + template, + `` ); - expect(directive).to.be.instanceOf(HTMLBindingDirective); - expect(directive.source).to.equal("@someEvent"); - expect(directive.target).to.equal("someEvent"); - expect(directive.aspect).to.equal(Aspect.event); + expectAspect( + template, + HTMLBindingDirective, + "@someEvent", + "someEvent", + Aspect.event + ); }); it("should dispose of embedded ViewTemplate when the rendering template contains *only* the embedded template", () => { diff --git a/packages/web-components/fast-element/src/templating/template.ts b/packages/web-components/fast-element/src/templating/template.ts index 8c93c89e2a8..1ee73c31511 100644 --- a/packages/web-components/fast-element/src/templating/template.ts +++ b/packages/web-components/fast-element/src/templating/template.ts @@ -4,11 +4,18 @@ import { ChildContext, ExecutionContext, ItemContext, - RootContext, } from "../observation/observable.js"; -import { bind, oneTime } from "./binding.js"; +import { bind, HTMLBindingDirective, oneTime } from "./binding.js"; import { Compiler } from "./compiler.js"; -import { AspectedHTMLDirective, HTMLDirective } from "./html-directive.js"; +import { + AddViewBehaviorFactory, + Aspect, + Aspected, + HTMLDirective, + HTMLDirectiveDefinition, + ViewBehaviorFactory, +} from "./html-directive.js"; +import { nextId } from "./markup.js"; import type { ElementView, HTMLView, SyntheticView } from "./view.js"; /** @@ -128,19 +135,19 @@ export class ViewTemplate< /** * The directives that will be connected to placeholders in the html. */ - public readonly directives: ReadonlyArray; + public readonly factories: Record; /** * Creates an instance of ViewTemplate. * @param html - The html representing what this template will instantiate, including placeholders for directives. - * @param directives - The directives that will be connected to placeholders in the html. + * @param factories - The directives that will be connected to placeholders in the html. */ public constructor( html: string | HTMLTemplateElement, - directives: ReadonlyArray + factories: Record ) { this.html = html; - this.directives = directives; + this.factories = factories; } /** @@ -151,7 +158,7 @@ export class ViewTemplate< if (this.result === null) { this.result = Compiler.compile( this.html, - this.directives + this.factories ); } @@ -201,6 +208,19 @@ export type TemplateValue< TContext extends ExecutionContext = ExecutionContext > = Binding | HTMLDirective | CaptureType; +function createAspectedHTML( + value: HTMLDirective & Aspected, + prevString: string, + add: AddViewBehaviorFactory +): string { + const match = lastAttributeNameRegex.exec(prevString); + if (match !== null) { + Aspect.assign(value as Aspected, match[2]); + } + + return value.createHTML(add); +} + /** * Transforms a template literal string into a ViewTemplate. * @param strings - The string fragments that are interpolated with the values. @@ -218,42 +238,62 @@ export function html< strings: TemplateStringsArray, ...values: TemplateValue[] ): ViewTemplate { - const directives: HTMLDirective[] = []; let html = ""; + const factories: Record = Object.create(null); + const add = (factory: ViewBehaviorFactory): string => { + const id = factory.id ?? (factory.id = nextId()); + factories[id] = factory; + return id; + }; for (let i = 0, ii = strings.length - 1; i < ii; ++i) { const currentString = strings[i]; - let currentValue = values[i]; + const currentValue = values[i]; + let definition: HTMLDirectiveDefinition | undefined; + html += currentString; if (isFunction(currentValue)) { - currentValue = bind(currentValue as Binding); - } else if (!isString(currentValue) && !(currentValue instanceof HTMLDirective)) { - const capturedValue = currentValue; - currentValue = bind(() => capturedValue, oneTime); - } - - if (currentValue instanceof HTMLDirective) { - if (currentValue instanceof AspectedHTMLDirective) { - const match = lastAttributeNameRegex.exec(currentString); - if (match !== null) { - currentValue.captureSource(match[2]); - } + html += createAspectedHTML( + bind(currentValue) as HTMLBindingDirective, + currentString, + add + ); + } else if (isString(currentValue)) { + const match = lastAttributeNameRegex.exec(currentString); + if (match !== null) { + const directive = bind( + () => currentValue, + oneTime + ) as HTMLBindingDirective; + Aspect.assign(directive, match[2]); + html += directive.createHTML(add); + } else { + html += currentValue; } - - // Since not all values are directives, we can't use i - // as the index for the placeholder. Instead, we need to - // use directives.length to get the next index. - html += currentValue.createPlaceholder(directives.length); - directives.push(currentValue); + } else if ((definition = HTMLDirective.getForInstance(currentValue)) === void 0) { + html += createAspectedHTML( + bind(() => currentValue, oneTime) as HTMLBindingDirective, + currentString, + add + ); } else { - html += currentValue; + if (definition.aspected) { + html += createAspectedHTML( + currentValue as HTMLDirective & Aspected, + currentString, + add + ); + } else { + html += (currentValue as HTMLDirective).createHTML(add); + } } } - html += strings[strings.length - 1]; - - return new ViewTemplate(html, directives); + return new ViewTemplate( + html + strings[strings.length - 1], + factories + ); } /** diff --git a/packages/web-components/fast-foundation/docs/api-report.md b/packages/web-components/fast-foundation/docs/api-report.md index 815c118d404..888cf706225 100644 --- a/packages/web-components/fast-foundation/docs/api-report.md +++ b/packages/web-components/fast-foundation/docs/api-report.md @@ -920,7 +920,7 @@ export const DesignSystem: Readonly<{ export interface DesignSystemRegistrationContext { readonly elementPrefix: string; // @deprecated - tryDefineElement(name: string, type: Constructable, callback: ElementDefinitionCallback): void; + tryDefineElement(name: string, type: Constructable, callback: ElementDefinitionCallback): void; tryDefineElement(params: ElementDefinitionParams): void; } @@ -1060,13 +1060,13 @@ export interface ElementDefinitionContext { readonly name: string; readonly shadowRootMode: ShadowRootMode | undefined; tagFor(type: Constructable): string; - readonly type: Constructable; + readonly type: Constructable; readonly willDefine: boolean; } // @public export interface ElementDefinitionParams extends Pick { - readonly baseClass?: Constructable; + readonly baseClass?: Constructable; callback: ElementDefinitionCallback; } @@ -1251,7 +1251,7 @@ export class FoundationElement extends FASTElement { // @public export interface FoundationElementDefinition { readonly attributes?: EagerOrLazyFoundationOption<(AttributeConfiguration | string)[], this>; - baseClass?: Constructable; + baseClass?: Constructable; baseName: string; readonly elementOptions?: EagerOrLazyFoundationOption; readonly shadowOptions?: EagerOrLazyFoundationOption | null, this>; 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 d5002cc49f6..d41976ab6d6 100644 --- a/packages/web-components/fast-foundation/src/calendar/calendar.template.ts +++ b/packages/web-components/fast-foundation/src/calendar/calendar.template.ts @@ -46,7 +46,9 @@ export const CalendarTitleTemplate: ViewTemplate = html` * @returns - The weekday labels template * @public */ -export const calendarWeekdayTemplate: (context: ElementDefinitionContext) => ItemViewTemplate = context => { +export const calendarWeekdayTemplate: ( + context: ElementDefinitionContext +) => ItemViewTemplate = context => { const cellTag = context.tagFor(DataGridCell); return item` <${cellTag} diff --git a/packages/web-components/fast-foundation/src/data-grid/data-grid-row.ts b/packages/web-components/fast-foundation/src/data-grid/data-grid-row.ts index 05dfc09f310..899fa366955 100644 --- a/packages/web-components/fast-foundation/src/data-grid/data-grid-row.ts +++ b/packages/web-components/fast-foundation/src/data-grid/data-grid-row.ts @@ -182,7 +182,7 @@ export class DataGridRow extends FoundationElement { { positioning: true } ); this.cellsRepeatBehavior = cellsRepeatDirective.createBehavior({ - [cellsRepeatDirective.targetId]: this.cellsPlaceholder, + [cellsRepeatDirective.nodeId]: this.cellsPlaceholder, }); /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ this.$fastController.addBehaviors([this.cellsRepeatBehavior!]); diff --git a/packages/web-components/fast-foundation/src/data-grid/data-grid.template.ts b/packages/web-components/fast-foundation/src/data-grid/data-grid.template.ts index 7f0489c433d..b55cd0cb1e9 100644 --- a/packages/web-components/fast-foundation/src/data-grid/data-grid.template.ts +++ b/packages/web-components/fast-foundation/src/data-grid/data-grid.template.ts @@ -11,7 +11,9 @@ import type { ElementDefinitionContext } from "../design-system/registration-con import type { DataGrid } from "./data-grid.js"; import { DataGridRow } from "./data-grid-row.js"; -function createRowItemTemplate(context: ElementDefinitionContext): ItemViewTemplate { +function createRowItemTemplate( + context: ElementDefinitionContext +): ItemViewTemplate { const rowTag = context.tagFor(DataGridRow); return item` <${rowTag} diff --git a/packages/web-components/fast-foundation/src/data-grid/data-grid.ts b/packages/web-components/fast-foundation/src/data-grid/data-grid.ts index a9cd6f57b59..d9836db30e9 100644 --- a/packages/web-components/fast-foundation/src/data-grid/data-grid.ts +++ b/packages/web-components/fast-foundation/src/data-grid/data-grid.ts @@ -347,7 +347,7 @@ export class DataGrid extends FoundationElement { { positioning: true } ); this.rowsRepeatBehavior = rowsRepeatDirective.createBehavior({ - [rowsRepeatDirective.targetId]: this.rowsPlaceholder, + [rowsRepeatDirective.nodeId]: this.rowsPlaceholder, }); /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ diff --git a/packages/web-components/fast-foundation/src/design-system/design-system.ts b/packages/web-components/fast-foundation/src/design-system/design-system.ts index a52e93f022e..989ff5c7ebf 100644 --- a/packages/web-components/fast-foundation/src/design-system/design-system.ts +++ b/packages/web-components/fast-foundation/src/design-system/design-system.ts @@ -184,7 +184,7 @@ export const DesignSystem = Object.freeze({ function extractTryDefineElementParams( params: string | ElementDefinitionParams, - elementDefinitionType?: Constructable, + elementDefinitionType?: Constructable, elementDefinitionCallback?: ElementDefinitionCallback ): ElementDefinitionParams { if (typeof params === "string") { @@ -243,7 +243,7 @@ class DefaultDesignSystem implements DesignSystem { elementPrefix: this.prefix, tryDefineElement( params: string | ElementDefinitionParams, - elementDefinitionType?: Constructable, + elementDefinitionType?: Constructable, elementDefinitionCallback?: ElementDefinitionCallback ) { const extractedParams = extractTryDefineElementParams( @@ -327,7 +327,7 @@ class ElementDefinitionEntry implements ElementDefinitionContext { constructor( public readonly container: Container, public readonly name: string, - public readonly type: Constructable, + public readonly type: Constructable, public shadowRootMode: ShadowRootMode | undefined, public readonly callback: ElementDefinitionCallback, public readonly willDefine: boolean diff --git a/packages/web-components/fast-foundation/src/design-system/registration-context.ts b/packages/web-components/fast-foundation/src/design-system/registration-context.ts index 3b6287d6270..6275d4aa3ae 100644 --- a/packages/web-components/fast-foundation/src/design-system/registration-context.ts +++ b/packages/web-components/fast-foundation/src/design-system/registration-context.ts @@ -26,7 +26,7 @@ export interface ElementDefinitionContext { * The type that will be defined. * @public */ - readonly type: Constructable; + readonly type: Constructable; /** * The dependency injection container associated with the design system. @@ -85,7 +85,7 @@ export interface ElementDefinitionParams * FAST actual base class instance. * @public */ - readonly baseClass?: Constructable; + readonly baseClass?: Constructable; /** * A callback to invoke if definition will happen. * @public @@ -115,7 +115,7 @@ export interface DesignSystemRegistrationContext { */ tryDefineElement( name: string, - type: Constructable, + type: Constructable, callback: ElementDefinitionCallback ): void; diff --git a/packages/web-components/fast-foundation/src/directives/reflect-attributes.spec.ts b/packages/web-components/fast-foundation/src/directives/reflect-attributes.spec.ts index e46ec24a50f..bd7e5175acd 100644 --- a/packages/web-components/fast-foundation/src/directives/reflect-attributes.spec.ts +++ b/packages/web-components/fast-foundation/src/directives/reflect-attributes.spec.ts @@ -1,10 +1,8 @@ -import { FoundationElement } from "../foundation-element"; -import { html, ref, customElement, DOM } from "@microsoft/fast-element"; -import { fixture } from "../test-utilities/fixture"; +import { html, ref, customElement, DOM, FASTElement } from "@microsoft/fast-element"; +import { fixture, uniqueElementName } from "../test-utilities/fixture"; import { reflectAttributes } from "./reflect-attributes"; import { expect } from "chai"; - const template = html`
` )} >
` -const name = "attr-reflection" +const name = uniqueElementName(); @customElement({ name, template }) -class AttributeReflectionTestElement extends FoundationElement { +class AttributeReflectionTestElement extends FASTElement { public a: HTMLElement; public b: HTMLElement; } +describe("reflectAttributes", () => { + it("should reflect configured attributes that exist on the host after connection", async () => { + const { element, connect, disconnect } = await fixture(name); -function create() { - return document.createElement(name) as AttributeReflectionTestElement; -} - -function connect(el: HTMLElement) { - document.body.append(el); + element.setAttribute("foo", "bar"); + await connect(); - return () => { - document.body.removeChild(el); - } -} -describe("reflectAttributes", () => { - it("should reflect configured attributes that exist on the host after connection", () => { - const el = create(); - el.setAttribute("foo", "bar"); - const disconnect = connect(el) - expect(el.a.getAttribute("foo")).to.equal("bar"); - disconnect(); + expect(element.a.getAttribute("foo")).to.equal("bar"); + await disconnect(); }); + it("should reflect a configured attribute when set on the host to the directive target", async () => { - const el = create(); - const disconnect = connect(el); - el.setAttribute("foo", "bar"); + const { element, connect, disconnect } = await fixture(name); + await connect(); + element.setAttribute("foo", "bar"); await DOM.nextUpdate(); - expect(el.a.getAttribute("foo")).to.equal("bar"); - disconnect(); + + expect(element.a.getAttribute("foo")).to.equal("bar"); + await disconnect(); }); + it("should reflect a configured attribute when set on the host to all directive targets", async () => { - const el = create(); - const disconnect = connect(el); - el.setAttribute("bar", "bat"); + const { element, connect, disconnect } = await fixture(name); + await connect(); + element.setAttribute("bar", "bat"); await DOM.nextUpdate(); - expect(el.a.getAttribute("bar")).to.equal("bat"); - expect(el.b.getAttribute("bar")).to.equal("bat"); - disconnect(); + + expect(element.a.getAttribute("bar")).to.equal("bat"); + expect(element.b.getAttribute("bar")).to.equal("bat"); + + await disconnect(); }); + it("should remove a configured attribute from the directive target when it is removed from the host", async () => { - const el = create(); - const disconnect = connect(el) - el.setAttribute("foo", "bar"); + const { element, connect, disconnect } = await fixture(name); + + await connect(); + element.setAttribute("foo", "bar"); await DOM.nextUpdate(); - el.removeAttribute("foo"); + element.removeAttribute("foo"); await DOM.nextUpdate(); - expect(el.a.hasAttribute("foo")).to.equal(false); - disconnect(); + expect(element.a.hasAttribute("foo")).to.equal(false); + + await disconnect(); }); + it("should remove a configured attribute from all directive targets when it is removed from the host", async () => { - const el = create(); - const disconnect = connect(el) - el.setAttribute("bar", "bat"); + const { element, connect, disconnect } = await fixture(name); + + await connect(); + element.setAttribute("bar", "bat"); await DOM.nextUpdate(); - el.removeAttribute("bar"); + element.removeAttribute("bar"); await DOM.nextUpdate(); - expect(el.a.hasAttribute("bar")).to.equal(false); - expect(el.b.hasAttribute("bar")).to.equal(false); - disconnect(); + expect(element.a.hasAttribute("bar")).to.equal(false); + expect(element.b.hasAttribute("bar")).to.equal(false); + + await disconnect(); }); it("should only reflect attributes in the directive configuration to the directive target", async () => { - const el = create(); - const disconnect = connect(el); - el.setAttribute("foo", "bar"); + const { element, connect, disconnect } = await fixture(name); + + await connect(); + element.setAttribute("foo", "bar"); await DOM.nextUpdate(); - expect(el.b.hasAttribute("foo")).to.equal(false); - disconnect(); + expect(element.b.hasAttribute("foo")).to.equal(false); + + await disconnect(); }); + it("should not reflect attributes thats are not in any directive configuration", async () => { - const el = create(); - const disconnect = connect(el); - el.setAttribute("bee", "bar"); + const { element, connect, disconnect } = await fixture(name); + await connect(); + element.setAttribute("bee", "bar"); await DOM.nextUpdate(); - expect(el.a.hasAttribute("bee")).to.equal(false); - expect(el.b.hasAttribute("bee")).to.equal(false); - disconnect(); + + expect(element.a.hasAttribute("bee")).to.equal(false); + expect(element.b.hasAttribute("bee")).to.equal(false); + + await disconnect(); }); }) diff --git a/packages/web-components/fast-foundation/src/directives/reflect-attributes.ts b/packages/web-components/fast-foundation/src/directives/reflect-attributes.ts index 6001f1eefa2..cf2e32d7739 100644 --- a/packages/web-components/fast-foundation/src/directives/reflect-attributes.ts +++ b/packages/web-components/fast-foundation/src/directives/reflect-attributes.ts @@ -1,6 +1,7 @@ import { DOM, ExecutionContext, + HTMLDirective, StatelessAttachedAttributeDirective, Subscriber, SubscriberSet, @@ -10,65 +11,73 @@ import type { CaptureType } from "@microsoft/fast-element"; const observer = new MutationObserver((mutations: MutationRecord[]) => { for (const mutation of mutations) { - AttributeReflectionSubscriptionSet.getOrCreateFor(mutation.target).notify( - mutation.attributeName - ); + AttributeReflectionSubscriptionSet.getOrCreateFor( + mutation.target as HTMLElement + ).notify(mutation.attributeName); } }); -class AttributeReflectionSubscriptionSet extends SubscriberSet { + +class AttributeReflectionSubscriptionSet { private static subscriberCache: WeakMap< any, AttributeReflectionSubscriptionSet > = new WeakMap(); + private watchedAttributes: Set> = new Set(); + private subscribers = new SubscriberSet(this); + + constructor(public element: HTMLElement) { + AttributeReflectionSubscriptionSet.subscriberCache.set(element, this); + } + + public notify(attr: string | null) { + this.subscribers.notify(attr); + } + + public subscribe(subscriber: Subscriber & ReflectAttributesDirective) { + this.subscribers.subscribe(subscriber); - public subscribe(subscriber: Subscriber & ReflectAttrBehavior) { - super.subscribe(subscriber); if (!this.watchedAttributes.has(subscriber.attributes)) { this.watchedAttributes.add(subscriber.attributes); this.observe(); } } - constructor(source: any) { - super(source); - - AttributeReflectionSubscriptionSet.subscriberCache.set(source, this); - } + public unsubscribe(subscriber: Subscriber & ReflectAttributesDirective) { + this.subscribers.unsubscribe(subscriber); - public unsubscribe(subscriber: Subscriber & ReflectAttrBehavior) { - super.unsubscribe(subscriber); if (this.watchedAttributes.has(subscriber.attributes)) { this.watchedAttributes.delete(subscriber.attributes); this.observe(); } } - public static getOrCreateFor(source: any) { - return ( - this.subscriberCache.get(source) || - new AttributeReflectionSubscriptionSet(source) - ); - } - private observe() { const attributeFilter: string[] = []; + for (const attributes of this.watchedAttributes.values()) { for (let i = 0; i < attributes.length; i++) { attributeFilter.push(attributes[i]); } } - observer.observe(this.subject, { attributeFilter }); + observer.observe(this.element, { attributeFilter }); + } + + public static getOrCreateFor(source: HTMLElement) { + return ( + this.subscriberCache.get(source) || + new AttributeReflectionSubscriptionSet(source) + ); } } -class ReflectAttrBehavior extends StatelessAttachedAttributeDirective { +class ReflectAttributesDirective extends StatelessAttachedAttributeDirective { /** * The attributes the behavior is reflecting */ public attributes: Readonly; - private target: HTMLElement; + constructor(attributes: string[]) { super(attributes); this.attributes = Object.freeze(attributes); @@ -79,14 +88,15 @@ class ReflectAttrBehavior extends StatelessAttachedAttributeDirective context: ExecutionContext, targets: ViewBehaviorTargets ): void { - this.target = targets[this.targetId] as HTMLElement; - AttributeReflectionSubscriptionSet.getOrCreateFor(source).subscribe(this); + const subscription = AttributeReflectionSubscriptionSet.getOrCreateFor(source); + subscription[this.id] = targets[this.nodeId]; + subscription.subscribe(this); // Reflect any existing attributes because MutationObserver will only // handle *changes* to attributes. if (source.hasAttributes()) { for (let i = 0; i < source.attributes.length; i++) { - this.handleChange(source, source.attributes[i].name); + this.handleChange(subscription, source.attributes[i].name); } } } @@ -95,17 +105,21 @@ class ReflectAttrBehavior extends StatelessAttachedAttributeDirective AttributeReflectionSubscriptionSet.getOrCreateFor(source).unsubscribe(this); } - public handleChange(source: HTMLElement, arg: string): void { + public handleChange(source: AttributeReflectionSubscriptionSet, arg: string): void { // In cases where two or more ReflectAttrBehavior instances are bound to the same element, // they will share a Subscriber implementation. In that case, this handle change can be invoked with // attributes an instances doesn't need to reflect. This guards against reflecting attrs // that shouldn't be reflected. if (this.attributes.includes(arg)) { - DOM.setAttribute(this.target, arg, source.getAttribute(arg)); + const element = source.element as HTMLElement; + const target = source[this.id] as HTMLElement; + DOM.setAttribute(target, arg, element.getAttribute(arg)); } } } +HTMLDirective.define(ReflectAttributesDirective); + /** * Reflects attributes from the host element to the target element of the directive. * @param attributes - The attributes to reflect @@ -123,10 +137,5 @@ class ReflectAttrBehavior extends StatelessAttachedAttributeDirective * ``` */ export function reflectAttributes(...attributes: string[]): CaptureType { - return new ReflectAttrBehavior(attributes); - // return new AttachedBehaviorHTMLDirective( - // "fast-reflect-attr", - // ReflectAttrBehavior, - // attributes - // ); + return new ReflectAttributesDirective(attributes); } diff --git a/packages/web-components/fast-foundation/src/foundation-element/foundation-element.ts b/packages/web-components/fast-foundation/src/foundation-element/foundation-element.ts index c647f9e6507..c539ec20b90 100644 --- a/packages/web-components/fast-foundation/src/foundation-element/foundation-element.ts +++ b/packages/web-components/fast-foundation/src/foundation-element/foundation-element.ts @@ -38,7 +38,7 @@ export interface FoundationElementDefinition { /** * The actual FAST base class of the component if different from the class used to compose. */ - baseClass?: Constructable; + baseClass?: Constructable; /** * The template to render for the custom element. diff --git a/packages/web-components/fast-foundation/src/picker/picker.template.ts b/packages/web-components/fast-foundation/src/picker/picker.template.ts index fad127a983b..15dab7cbbf7 100644 --- a/packages/web-components/fast-foundation/src/picker/picker.template.ts +++ b/packages/web-components/fast-foundation/src/picker/picker.template.ts @@ -15,7 +15,9 @@ import { PickerMenuOption } from "./picker-menu-option.js"; import { PickerList } from "./picker-list.js"; import { PickerListItem } from "./picker-list-item.js"; -function createDefaultListItemTemplate(context: ElementDefinitionContext): ChildViewTemplate { +function createDefaultListItemTemplate( + context: ElementDefinitionContext +): ChildViewTemplate { const pickerListItemTag: string = context.tagFor(PickerListItem); return child` <${pickerListItemTag} @@ -26,7 +28,9 @@ function createDefaultListItemTemplate(context: ElementDefinitionContext): Child `; } -function createDefaultMenuOptionTemplate(context: ElementDefinitionContext): ChildViewTemplate { +function createDefaultMenuOptionTemplate( + context: ElementDefinitionContext +): ChildViewTemplate { const pickerMenuOptionTag: string = context.tagFor(PickerMenuOption); return child` <${pickerMenuOptionTag} diff --git a/packages/web-components/fast-foundation/src/picker/picker.ts b/packages/web-components/fast-foundation/src/picker/picker.ts index db046885c2d..8733f8ffd0e 100644 --- a/packages/web-components/fast-foundation/src/picker/picker.ts +++ b/packages/web-components/fast-foundation/src/picker/picker.ts @@ -545,7 +545,7 @@ export class Picker extends FormAssociatedPicker { { positioning: true } ); this.itemsRepeatBehavior = itemsRepeatDirective.createBehavior({ - [itemsRepeatDirective.targetId]: this.itemsPlaceholderElement, + [itemsRepeatDirective.nodeId]: this.itemsPlaceholderElement, }); this.inputElement.addEventListener("input", this.handleTextInput); @@ -565,7 +565,7 @@ export class Picker extends FormAssociatedPicker { { positioning: true } ); this.optionsRepeatBehavior = optionsRepeatDirective.createBehavior({ - [optionsRepeatDirective.targetId]: this.optionsPlaceholder, + [optionsRepeatDirective.nodeId]: this.optionsPlaceholder, }); /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ diff --git a/packages/web-components/fast-foundation/src/test-utilities/fixture.ts b/packages/web-components/fast-foundation/src/test-utilities/fixture.ts index e6f3472f329..89eb8a0c3d3 100644 --- a/packages/web-components/fast-foundation/src/test-utilities/fixture.ts +++ b/packages/web-components/fast-foundation/src/test-utilities/fixture.ts @@ -146,7 +146,7 @@ export async function fixture( if (typeof templateNameOrRegistry === "string") { const html = `<${templateNameOrRegistry}>`; - templateNameOrRegistry = new ViewTemplate(html, []); + templateNameOrRegistry = new ViewTemplate(html, {}); } else if (isElementRegistry(templateNameOrRegistry)) { templateNameOrRegistry = [templateNameOrRegistry]; } @@ -164,7 +164,7 @@ export async function fixture( const elementName = `${prefix}-${first.definition.baseName}`; const html = `<${elementName}>`; - templateNameOrRegistry = new ViewTemplate(html, []); + templateNameOrRegistry = new ViewTemplate(html, {}); } const view = templateNameOrRegistry.create(); diff --git a/packages/web-components/fast-router/docs/api-report.md b/packages/web-components/fast-router/docs/api-report.md index 37bbe3b9a26..abcdf9fe6e1 100644 --- a/packages/web-components/fast-router/docs/api-report.md +++ b/packages/web-components/fast-router/docs/api-report.md @@ -4,11 +4,14 @@ ```ts +import { AddViewBehaviorFactory } from '@microsoft/fast-element'; +import { Behavior } from '@microsoft/fast-element'; import { ComposableStyles } from '@microsoft/fast-element'; import { Constructable } from '@microsoft/fast-element'; import { ExecutionContext } from '@microsoft/fast-element'; import { FASTElement } from '@microsoft/fast-element'; import { HTMLDirective } from '@microsoft/fast-element'; +import { ViewBehaviorTargets } from '@microsoft/fast-element'; import { ViewTemplate } from '@microsoft/fast-element'; // Warning: (ae-internal-missing-underscore) The name "childRouteParameter" should be prefixed with an underscore because the declaration is marked as @internal @@ -254,8 +257,10 @@ export type NavigationContributor = Partial; }; +// Warning: (ae-forgotten-export) The symbol "NavigationContributorDirective" needs to be exported by the entry point index.d.ts +// // @alpha (undocumented) -export function navigationContributor(options?: ContributorOptions): HTMLDirective; +export function navigationContributor(options?: ContributorOptions): NavigationContributorDirective; // @alpha (undocumented) export interface NavigationHandler { diff --git a/packages/web-components/fast-router/src/commands.ts b/packages/web-components/fast-router/src/commands.ts index 20603695d1c..e98ada6257e 100644 --- a/packages/web-components/fast-router/src/commands.ts +++ b/packages/web-components/fast-router/src/commands.ts @@ -91,10 +91,10 @@ function factoryFromElementInstance(element: HTMLElement): ViewFactory { fragment.appendChild(element); const factory = navigationContributor(); - factory.targetId = "h"; + factory.nodeId = "h"; const view = new HTMLView(fragment, [factory], { - [factory.targetId]: element, + [factory.nodeId]: element, }); return { @@ -196,7 +196,7 @@ export class Render implements RenderCommand { } else if (typeof element === "function") { // Do not cache it becase the function could return // a different value each time. - let def = FASTElementDefinition.forType(element); + let def = FASTElementDefinition.getByType(element); if (def) { factory = factoryFromElementName(def.name); @@ -208,7 +208,7 @@ export class Render implements RenderCommand { } else if (element instanceof HTMLElement) { factory = factoryFromElementInstance(element); } else { - def = FASTElementDefinition.forType(element as any); + def = FASTElementDefinition.getByType(element); if (def) { factory = factoryFromElementName(def.name); diff --git a/packages/web-components/fast-router/src/contributors.ts b/packages/web-components/fast-router/src/contributors.ts index f113acbc018..fba6656cf6b 100644 --- a/packages/web-components/fast-router/src/contributors.ts +++ b/packages/web-components/fast-router/src/contributors.ts @@ -1,4 +1,5 @@ import { + AddViewBehaviorFactory, Behavior, HTMLDirective, Markup, @@ -44,23 +45,26 @@ const defaultOptions: ContributorOptions = { parameters: true, }; -class NavigationContributorDirective extends HTMLDirective { - constructor(private options: Required) { - super(); - } +class NavigationContributorDirective implements HTMLDirective { + id: string; + nodeId: string; + + constructor(private options: Required) {} - createPlaceholder(index: number) { - return Markup.attribute(index); + createHTML(add: AddViewBehaviorFactory) { + return Markup.attribute(add(this)); } createBehavior(targets: ViewBehaviorTargets) { return new NavigationContributorBehavior( - targets[this.targetId] as HTMLElement & NavigationContributor, + targets[this.nodeId] as HTMLElement & NavigationContributor, this.options ); } } +HTMLDirective.define(NavigationContributorDirective); + class NavigationContributorBehavior implements Behavior { private router: Router | null = null; @@ -95,7 +99,9 @@ class NavigationContributorBehavior implements Behavior { /** * @alpha */ -export function navigationContributor(options?: ContributorOptions): HTMLDirective { +export function navigationContributor( + options?: ContributorOptions +): NavigationContributorDirective { return new NavigationContributorDirective( Object.assign({}, defaultOptions, options) as Required ); diff --git a/packages/web-components/fast-ssr/src/element-renderer/element-renderer.ts b/packages/web-components/fast-ssr/src/element-renderer/element-renderer.ts index e3035f62eaf..b31e8cc0ee4 100644 --- a/packages/web-components/fast-ssr/src/element-renderer/element-renderer.ts +++ b/packages/web-components/fast-ssr/src/element-renderer/element-renderer.ts @@ -1,15 +1,9 @@ import { ElementRenderer, RenderInfo } from "@lit-labs/ssr"; -import { Aspect, ExecutionContext, DOM, FASTElement } from "@microsoft/fast-element"; +import { Aspect, DOM, ExecutionContext, FASTElement } from "@microsoft/fast-element"; import { TemplateRenderer } from "../template-renderer/template-renderer.js"; import { SSRView } from "../view.js"; import { StyleRenderer } from "../styles/style-renderer.js"; -const prefix = "fast-style"; -let id = 0; -function nextId(): string { - return `${prefix}-${id++}`; -} - export abstract class FASTElementRenderer extends ElementRenderer { /** * The element instance represented by the {@link FASTElementRenderer}. @@ -90,10 +84,10 @@ export abstract class FASTElementRenderer extends ElementRenderer { constructor(tagName: string) { super(tagName); - const ctor: typeof FASTElement | null = customElements.get(this.tagName); + const ctor = customElements.get(this.tagName); if (ctor) { - this.element = new ctor(); + this.element = new ctor() as FASTElement; } else { throw new Error( `FASTElementRenderer was unable to find a constructor for a custom element with the tag name '${tagName}'.` diff --git a/packages/web-components/fast-ssr/src/exports.ts b/packages/web-components/fast-ssr/src/exports.ts index b4be9efe7ba..551163081fd 100644 --- a/packages/web-components/fast-ssr/src/exports.ts +++ b/packages/web-components/fast-ssr/src/exports.ts @@ -1,5 +1,5 @@ import { RenderInfo } from "@lit-labs/ssr"; -import { Compiler, ElementStyles, HTMLDirective } from "@microsoft/fast-element"; +import { Compiler, ElementStyles, ViewBehaviorFactory } from "@microsoft/fast-element"; import { FASTElementRenderer } from "./element-renderer/element-renderer.js"; import { FASTSSRStyleStrategy } from "./element-renderer/style-strategy.js"; import { @@ -7,7 +7,7 @@ import { StyleElementStyleRenderer, StyleRenderer, } from "./styles/style-renderer.js"; -import { defaultFASTDirectiveRenderers } from "./template-renderer/directives.js"; +import { defaultViewBehaviorFactoryRenderers } from "./template-renderer/directives.js"; import { TemplateRenderer, TemplateRendererConfiguration, @@ -16,14 +16,17 @@ import { SSRView } from "./view.js"; export type Configuration = TemplateRendererConfiguration; Compiler.setDefaultStrategy( - (html: string | HTMLTemplateElement, directives: ReadonlyArray) => { + ( + html: string | HTMLTemplateElement, + factories: Record + ) => { if (typeof html !== "string") { throw new Error( "SSR compiler does not support HTMLTemplateElement templates" ); } - return new SSRView(html, directives) as any; + return new SSRView(html, factories) as any; } ); @@ -58,7 +61,9 @@ export default function ( : new StyleElementStyleRenderer(); }; - templateRenderer.withDirectiveRenderer(...defaultFASTDirectiveRenderers); + templateRenderer.withViewBehaviorFactoryRenderers( + ...defaultViewBehaviorFactoryRenderers + ); return { templateRenderer, diff --git a/packages/web-components/fast-ssr/src/template-parser/op-codes.ts b/packages/web-components/fast-ssr/src/template-parser/op-codes.ts index a3b59f8afe1..53921e573fe 100644 --- a/packages/web-components/fast-ssr/src/template-parser/op-codes.ts +++ b/packages/web-components/fast-ssr/src/template-parser/op-codes.ts @@ -1,4 +1,4 @@ -import { Aspect, Binding, HTMLDirective } from "@microsoft/fast-element"; +import { Binding, ViewBehaviorFactory } from "@microsoft/fast-element"; /** * Allows fast identification of operation types @@ -9,7 +9,7 @@ export const enum OpType { customElementAttributes, customElementShadow, attributeBinding, - directive, + viewBehaviorFactory, templateElementOpen, templateElementClose, text, @@ -59,9 +59,9 @@ export type CustomElementShadowOp = { /** * Operation to emit static text */ -export type DirectiveOp = { - type: OpType.directive; - directive: HTMLDirective; +export type ViewBehaviorFactoryOp = { + type: OpType.viewBehaviorFactory; + factory: ViewBehaviorFactory; }; /** @@ -71,12 +71,7 @@ export type AttributeBindingOp = { type: OpType.attributeBinding; binding: Binding; target: string; - aspect: - | Aspect.attribute - | Aspect.booleanAttribute - | Aspect.event - | Aspect.property - | Aspect.tokenList; + aspect: number; useCustomElementInstance: boolean; }; @@ -109,7 +104,7 @@ export type Op = | AttributeBindingOp | CustomElementOpenOp | CustomElementCloseOp - | DirectiveOp + | ViewBehaviorFactoryOp | CustomElementAttributes | CustomElementShadowOp | TemplateElementOpenOp diff --git a/packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts b/packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts index 024455426de..323dc95aab0 100644 --- a/packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts +++ b/packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts @@ -1,22 +1,30 @@ import "../dom-shim.js"; -import { Aspect, customElement, FASTElement, html, ViewTemplate } from "@microsoft/fast-element"; +import { Aspect, customElement, FASTElement, html, ViewBehaviorFactory, ViewTemplate } from "@microsoft/fast-element"; import { expect, test } from "@playwright/test"; -import { AttributeBindingOp, CustomElementOpenOp, DirectiveOp, OpType, TemplateElementOpenOp, TextOp } from "./op-codes.js"; +import { AttributeBindingOp, CustomElementOpenOp, ViewBehaviorFactoryOp, OpType, TemplateElementOpenOp, TextOp } from "./op-codes.js"; import { parseTemplateToOpCodes } from "./template-parser.js"; @customElement("hello-world") class HelloWorld extends FASTElement {} +function firstFactory(factories: Record) { + for (const key in factories) { + return factories[key]; + } + + return null; +} + test.describe("parseTemplateToOpCodes", () => { test("should throw when invoked with a ViewTemplate with a HTMLTemplateElement template", () => { expect(() => { - parseTemplateToOpCodes(new ViewTemplate(document.createElement("template"), [])); + parseTemplateToOpCodes(new ViewTemplate(document.createElement("template"), {})); }).toThrow(); }); test("should not throw when invoked with a ViewTemplate with a string template", () => { expect(() => { - parseTemplateToOpCodes(new ViewTemplate("", [])); + parseTemplateToOpCodes(new ViewTemplate("", {})); }).not.toThrow(); }); @@ -26,24 +34,24 @@ test.describe("parseTemplateToOpCodes", () => { test("should emit doctype, html, head, and body elements as part of text op", () => { expect(parseTemplateToOpCodes(html``)).toEqual([{type: OpType.text, value: ""}]) }) - test("should emit a directive op from a binding", () => { + test("should emit a viewBehaviorFactory op from a binding", () => { const input = html`${() => "hello world"}`; - expect(parseTemplateToOpCodes(input)).toEqual([{ type: OpType.directive, directive: input.directives[0]}]) + expect(parseTemplateToOpCodes(input)).toEqual([{ type: OpType.viewBehaviorFactory, factory: firstFactory(input.factories)}]) }); test("should emit a directive op from a content binding", () => { const input = html`Hello ${() => "World"}.`; const codes = parseTemplateToOpCodes(input); - const code = codes[1] as DirectiveOp; + const code = codes[1] as ViewBehaviorFactoryOp; expect(codes.length).toBe(3); - expect(code.type).toBe(OpType.directive); + expect(code.type).toBe(OpType.viewBehaviorFactory); }); - test("should sandwich directive ops between text ops when binding native element content", () => { + test("should sandwich viewBehaviorFactory ops between text ops when binding native element content", () => { const input = html`

${() => "hello world"}

`; expect(parseTemplateToOpCodes(input)).toEqual([ { type: OpType.text, value: "

"}, - { type: OpType.directive, directive: input.directives[0]}, + { type: OpType.viewBehaviorFactory, factory: firstFactory(input.factories)}, { type: OpType.text, value: "

"}, ]) }); diff --git a/packages/web-components/fast-ssr/src/template-parser/template-parser.ts b/packages/web-components/fast-ssr/src/template-parser/template-parser.ts index c08ddc46ce8..7ddba5e90e2 100644 --- a/packages/web-components/fast-ssr/src/template-parser/template-parser.ts +++ b/packages/web-components/fast-ssr/src/template-parser/template-parser.ts @@ -4,10 +4,10 @@ */ import { Aspect, - AspectedHTMLDirective, + Aspected, Compiler, - HTMLDirective, Parser, + ViewBehaviorFactory, ViewTemplate, } from "@microsoft/fast-element"; import { @@ -111,14 +111,14 @@ export function parseTemplateToOpCodes(template: ViewTemplate): Op[] { */ const templateString = html; - const codes = parseStringToOpCodes(templateString, template.directives); + const codes = parseStringToOpCodes(templateString, template.factories); opCache.set(template, codes); return codes; } export function parseStringToOpCodes( templateString: string, - directives: ReadonlyArray + factories: Record ): Op[] { const nodeTree = parseFragment(templateString, { sourceCodeLocationInfo: true }); @@ -159,20 +159,17 @@ export function parseStringToOpCodes( dynamic: Map; } = node.attrs.reduce( (prev, current) => { - const parsed = Parser.parse(current.value, directives); + const parsed = Parser.parse(current.value, factories); if (parsed) { - const directive = Compiler.aggregate(parsed); + const factory = Compiler.aggregate(parsed) as ViewBehaviorFactory & + Aspected; // Guard against directives like children, ref, and slotted - if ( - directive instanceof AspectedHTMLDirective && - directive.binding && - directive.aspect !== Aspect.content - ) { + if (factory.binding && factory.aspectType !== Aspect.content) { prev.dynamic.set(current, { type: OpType.attributeBinding, - binding: directive.binding, - aspect: directive.aspect, - target: directive.target, + binding: factory.binding, + aspect: factory.aspectType, + target: factory.targetAspect, useCustomElementInstance: Boolean( node.isDefinedCustomElement ), @@ -227,12 +224,12 @@ export function parseStringToOpCodes( skipTo(location.endOffset); } else if (!attributes.static.has(attr.name)) { // Handle interpolated directives like children, ref, and slotted - const parsed = Parser.parse(attr.value, directives); + const parsed = Parser.parse(attr.value, factories); if (parsed) { const location = node.sourceCodeLocation!.attrs[attr.name]; - const directive = Compiler.aggregate(parsed); + const factory = Compiler.aggregate(parsed); flushTo(location.startOffset); - opCodes.push({ type: OpType.directive, directive }); + opCodes.push({ type: OpType.viewBehaviorFactory, factory }); skipTo(location.endOffset); } } else if (node.isDefinedCustomElement) { @@ -311,7 +308,7 @@ export function parseStringToOpCodes( const parsed = Parser.parse( (node as DefaultTreeCommentNode)?.data || (node as DefaultTreeTextNode).value, - directives + factories ); if (parsed) { @@ -321,8 +318,8 @@ export function parseStringToOpCodes( flush(part); } else { opCodes.push({ - type: OpType.directive, - directive: part, + type: OpType.viewBehaviorFactory, + factory: part, }); } } diff --git a/packages/web-components/fast-ssr/src/template-renderer/directives.ts b/packages/web-components/fast-ssr/src/template-renderer/directives.ts index 4cbf0ba538b..59ab38fccba 100644 --- a/packages/web-components/fast-ssr/src/template-renderer/directives.ts +++ b/packages/web-components/fast-ssr/src/template-renderer/directives.ts @@ -7,6 +7,7 @@ import { RefDirective, RepeatDirective, SlottedDirective, + ViewBehaviorFactory, ViewTemplate, } from "@microsoft/fast-element"; import { TemplateRenderer } from "./template-renderer.js"; @@ -14,22 +15,22 @@ import { TemplateRenderer } from "./template-renderer.js"; /** * Describes an implementation that can render a directive */ -export interface DirectiveRenderer { +export interface ViewBehaviorFactoryRenderer { render( - directive: InstanceType, + behavior: T, renderInfo: RenderInfo, source: any, renderer: TemplateRenderer, context: ExecutionContext ): IterableIterator; - matcher: T; + matcher: Constructable; } -export const RepeatDirectiveRenderer: DirectiveRenderer = Object.freeze( +export const RepeatDirectiveRenderer: ViewBehaviorFactoryRenderer = Object.freeze( { matcher: RepeatDirective, *render( - directive: InstanceType, + directive: RepeatDirective, renderInfo: RenderInfo, source: any, renderer: TemplateRenderer, @@ -69,27 +70,27 @@ export const RepeatDirectiveRenderer: DirectiveRenderer function* noop() { yield ""; } -export const ChildrenDirectiveRenderer: DirectiveRenderer = Object.freeze( +export const ChildrenDirectiveRenderer: ViewBehaviorFactoryRenderer = Object.freeze( { matcher: ChildrenDirective, render: noop, } ); -export const RefDirectiveRenderer: DirectiveRenderer = Object.freeze( +export const RefDirectiveRenderer: ViewBehaviorFactoryRenderer = Object.freeze( { matcher: RefDirective, render: noop, } ); -export const SlottedDirectiveRenderer: DirectiveRenderer = Object.freeze( +export const SlottedDirectiveRenderer: ViewBehaviorFactoryRenderer = Object.freeze( { matcher: SlottedDirective, render: noop, } ); -export const defaultFASTDirectiveRenderers: DirectiveRenderer[] = [ +export const defaultViewBehaviorFactoryRenderers: ViewBehaviorFactoryRenderer[] = [ RepeatDirectiveRenderer, ChildrenDirectiveRenderer, RefDirectiveRenderer, diff --git a/packages/web-components/fast-ssr/src/template-renderer/template-renderer.ts b/packages/web-components/fast-ssr/src/template-renderer/template-renderer.ts index e18c880985c..3d2c2663d95 100644 --- a/packages/web-components/fast-ssr/src/template-renderer/template-renderer.ts +++ b/packages/web-components/fast-ssr/src/template-renderer/template-renderer.ts @@ -2,8 +2,9 @@ import { RenderInfo } from "@lit-labs/ssr"; import { getElementRenderer } from "@lit-labs/ssr/lib/element-renderer.js"; import { Aspect, - AspectedHTMLDirective, + Aspected, ExecutionContext, + ViewBehaviorFactory, ViewTemplate, } from "@microsoft/fast-element"; import { Op, OpType } from "../template-parser/op-codes.js"; @@ -11,7 +12,7 @@ import { parseStringToOpCodes, parseTemplateToOpCodes, } from "../template-parser/template-parser.js"; -import { DirectiveRenderer } from "./directives.js"; +import { ViewBehaviorFactoryRenderer } from "./directives.js"; export type ComponentDOMEmissionMode = "shadow" | "light"; export interface TemplateRendererConfiguration { @@ -32,7 +33,10 @@ export interface TemplateRendererConfiguration { export class TemplateRenderer implements Readonly> { - private directiveRenderers: Map> = new Map(); + private viewBehaviorFactoryRenderers: Map< + any, + ViewBehaviorFactoryRenderer + > = new Map(); /** * {@inheritDoc TemplateRendererConfiguration.componentDOMEmissionMode} */ @@ -61,7 +65,7 @@ export class TemplateRenderer const codes = template instanceof ViewTemplate ? parseTemplateToOpCodes(template) - : parseStringToOpCodes(template, []); + : parseStringToOpCodes(template, {}); yield* this.renderOpCodes(codes, renderInfo, source, context); } @@ -84,18 +88,15 @@ export class TemplateRenderer case OpType.text: yield code.value; break; - case OpType.directive: { - const { directive } = code; - const ctor = directive.constructor; - if (this.directiveRenderers.has(ctor)) { - yield* this.directiveRenderers + case OpType.viewBehaviorFactory: { + const factory = code.factory as ViewBehaviorFactory & Aspected; + const ctor = factory.constructor; + if (this.viewBehaviorFactoryRenderers.has(ctor)) { + yield* this.viewBehaviorFactoryRenderers .get(ctor)! - .render(directive, renderInfo, source, this, context); - } else if ( - directive instanceof AspectedHTMLDirective && - directive.binding - ) { - const result = directive.binding(source, context); + .render(factory, renderInfo, source, this, context); + } else if (factory.aspectType && factory.binding) { + const result = factory.binding(source, context); // If the result is a template, render the template if (result instanceof ViewTemplate) { @@ -103,7 +104,7 @@ export class TemplateRenderer } else if (result === null || result === undefined) { // Don't yield anything if result is null break; - } else if (directive.aspect === Aspect.content) { + } else if (factory.aspectType === Aspect.content) { yield result; } else { // debugging error - we should handle all result cases @@ -113,7 +114,9 @@ export class TemplateRenderer } } else { // Throw if a SSR directive implementation cannot be found. - throw new Error(`Unable to process HTMLDirective: ${directive}`); + throw new Error( + `Unable to process view behavior factory: ${factory}` + ); } break; @@ -255,11 +258,13 @@ export class TemplateRenderer /** * Registers DirectiveRenderers to use when rendering templates. - * @param directives - The directive renderers to register + * @param renderers - The directive renderers to register */ - public withDirectiveRenderer(...directives: DirectiveRenderer[]): void { - for (const renderer of directives) { - this.directiveRenderers.set(renderer.matcher, renderer); + public withViewBehaviorFactoryRenderers( + ...renderers: ViewBehaviorFactoryRenderer[] + ): void { + for (const renderer of renderers) { + this.viewBehaviorFactoryRenderers.set(renderer.matcher, renderer); } } } diff --git a/packages/web-components/fast-ssr/src/view.ts b/packages/web-components/fast-ssr/src/view.ts index 95452d4991e..022aecf78fc 100644 --- a/packages/web-components/fast-ssr/src/view.ts +++ b/packages/web-components/fast-ssr/src/view.ts @@ -1,4 +1,7 @@ -import { HTMLDirective, HTMLTemplateCompilationResult } from "@microsoft/fast-element"; +import { + HTMLTemplateCompilationResult, + ViewBehaviorFactory, +} from "@microsoft/fast-element"; import { Op, OpType, TemplateElementOpenOp } from "./template-parser/op-codes.js"; import { parseStringToOpCodes } from "./template-parser/template-parser.js"; @@ -10,16 +13,16 @@ import { parseStringToOpCodes } from "./template-parser/template-parser.js"; */ export class SSRView { public readonly html: string; - public readonly directives: ReadonlyArray; + public readonly factories: Record; public result: HTMLTemplateCompilationResult | null = null; public codes: Op[]; public hostStaticAttributes?: TemplateElementOpenOp["staticAttributes"]; public hostDynamicAttributes?: TemplateElementOpenOp["dynamicAttributes"]; - constructor(html: string, directives: ReadonlyArray) { + constructor(html: string, factories: Record) { this.html = html; - this.directives = directives; - const codes = parseStringToOpCodes(html, directives); + this.factories = factories; + const codes = parseStringToOpCodes(html, factories); // Check to see if the root is a template element. We may need to // make this more sophisticated in the future because it doesn't quite diff --git a/yarn.lock b/yarn.lock index cd3c6101dde..2dab1ebebe0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24260,6 +24260,11 @@ tsscmp@1.0.6: resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"