Last active
June 26, 2022 00:37
-
-
Save EisenbergEffect/281efd9ae96ad1439f2eb95ee745fd47 to your computer and use it in GitHub Desktop.
A FAST directive that enables rendering templates over any part of a model.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const addressTemplate = html<Address>` | |
<span>${x => x.country}</span> | |
`; | |
@renderWith(addressTemplate) | |
class Address { | |
@observable country = 'USA'; | |
} | |
class Person { | |
@observable address = new Address(); | |
} | |
const personTemplate = html<Person>` | |
${render(x => x.address)} | |
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const addressTemplate = html<Address>` | |
<span>${x => x.country}</span> | |
`; | |
const fancyAddressTemplate = html<Address>` | |
Fancy! <span>${x => x.country}</span> | |
`; | |
@renderWith(addressTemplate) | |
@renderWith(fancyAddressTemplate, 'fancy') | |
class Address { | |
@observable country = 'USA'; | |
} | |
class Person { | |
@observable address = new Address(); | |
} | |
const personTemplate = html<Person>` | |
${render(x => x.address, 'fancy')} | |
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Address { | |
@observable country = 'USA'; | |
} | |
class Person { | |
@observable address = new Address(); | |
} | |
const addressTemplate = html<Address>` | |
<span>${x => x.country}</span> | |
`; | |
const personTemplate = html<Person>` | |
${render(x => x.address, addressTemplate)} | |
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Behavior, | |
Binding, | |
BindingObserver, | |
CaptureType, | |
Constructable, | |
DOM, | |
ExecutionContext, | |
FASTElement, | |
FASTElementDefinition, | |
html, | |
HTMLDirective, | |
Observable, | |
Subscriber, | |
SyntheticViewTemplate, | |
TemplateValue, | |
ViewTemplate | |
} from "@microsoft/fast-element"; | |
const isFunction = (object: any): object is Function => typeof object === "function"; | |
const isString = (object: any): object is string => typeof object === "string"; | |
/** | |
* A simple View that can be interpolated into HTML content. | |
* @public | |
*/ | |
export interface ContentView { | |
/** | |
* Binds a view's behaviors to its binding source. | |
* @param source - The binding source for the view's binding behaviors. | |
* @param context - The execution context to run the view within. | |
*/ | |
bind(source: any, context: ExecutionContext): void; | |
/** | |
* Unbinds a view's behaviors from its binding source and context. | |
*/ | |
unbind(): void; | |
/** | |
* Inserts the view's DOM nodes before the referenced node. | |
* @param node - The node to insert the view's DOM before. | |
*/ | |
insertBefore(node: Node): void; | |
/** | |
* Removes the view's DOM nodes. | |
* The nodes are not disposed and the view can later be re-inserted. | |
*/ | |
remove(): void; | |
} | |
/** | |
* A simple template that can create ContentView instances. | |
* @public | |
*/ | |
export interface ContentTemplate { | |
/** | |
* Creates a simple content view instance. | |
*/ | |
create(): ContentView; | |
} | |
type ComposableView = ContentView & { | |
isComposed?: boolean; | |
needsBindOnly?: boolean; | |
$fastTemplate?: ContentTemplate; | |
}; | |
/** | |
* A Behavior that enables advanced rendering. | |
* @public | |
*/ | |
export class RenderBehavior<TSource = any, TParent = any> | |
implements Behavior, Subscriber { | |
private source: TSource | null = null; | |
private view: ComposableView | null = null; | |
private template!: ContentTemplate; | |
private templateBindingObserver: BindingObserver<TSource, ContentTemplate>; | |
private data: any | null = null; | |
private dataBindingObserver: BindingObserver<TSource, any[]>; | |
private originalContext: ExecutionContext | undefined = void 0; | |
private childContext: ExecutionContext | undefined = void 0; | |
/** | |
* Creates an instance of RenderBehavior. | |
* @param location - A Node representing the location where this behavior will render. | |
* @param dataBinding - A binding expression that returns the data to render. | |
* @param templateBinding - A binding expression that returns the template to use with the data. | |
*/ | |
public constructor( | |
private location: Node, | |
private dataBinding: Binding<TSource, any[]>, | |
private templateBinding: Binding<TSource, ContentTemplate> | |
) { | |
this.dataBindingObserver = Observable.binding(dataBinding, this, true); | |
this.templateBindingObserver = Observable.binding(templateBinding, this, true); | |
} | |
/** | |
* Bind this behavior to the source. | |
* @param source - The source to bind to. | |
* @param context - The execution context that the binding is operating within. | |
*/ | |
public bind(source: TSource, context: ExecutionContext): void { | |
this.source = source; | |
this.originalContext = context; | |
this.childContext = Object.create(context); | |
this.childContext!.parent = source; | |
(this.childContext! as any).parentContext = this.originalContext; | |
this.data = this.dataBindingObserver.observe(source, this.originalContext); | |
this.template = this.templateBindingObserver.observe( | |
source, | |
this.originalContext | |
); | |
this.refreshView(); | |
} | |
/** | |
* Unbinds this behavior from the source. | |
* @param source - The source to unbind from. | |
*/ | |
public unbind(): void { | |
this.source = null; | |
this.data = null; | |
const view = this.view; | |
if (view !== null && view.isComposed) { | |
view.unbind(); | |
view.needsBindOnly = true; | |
} | |
this.dataBindingObserver.disconnect(); | |
this.templateBindingObserver.disconnect(); | |
} | |
/** @internal */ | |
public handleChange(source: any): void { | |
if (source === this.dataBinding) { | |
this.data = this.dataBindingObserver.observe( | |
this.source!, | |
this.originalContext! | |
); | |
this.refreshView(); | |
} else if (source === this.templateBinding) { | |
this.template = this.templateBindingObserver.observe( | |
this.source!, | |
this.originalContext! | |
); | |
this.refreshView(); | |
} | |
} | |
private refreshView() { | |
let view = this.view; | |
const template = this.template; | |
if (view === null) { | |
this.view = view = template.create(); | |
} else { | |
// If there is a previous view, but it wasn't created | |
// from the same template as the new value, then we | |
// need to remove the old view if it's still in the DOM | |
// and create a new view from the template. | |
if (view.$fastTemplate !== template) { | |
if (view.isComposed) { | |
view.remove(); | |
view.unbind(); | |
} | |
this.view = view = template.create(); | |
} | |
} | |
// It's possible that the value is the same as the previous template | |
// and that there's actually no need to compose it. | |
if (!view.isComposed) { | |
view.isComposed = true; | |
view.bind(this.data, this.childContext!); | |
view.insertBefore(this.location); | |
view.$fastTemplate = template; | |
} else if (view.needsBindOnly) { | |
view.needsBindOnly = false; | |
view.bind(this.data, this.childContext!); | |
} | |
} | |
} | |
/** | |
* A Directive that enables use of the RenderBehavior. | |
* @public | |
*/ | |
export class RenderDirective<TSource = any> extends HTMLDirective { | |
/** | |
* Creates an instance of RenderDirective. | |
* @param dataBinding - A binding expression that returns the data to render. | |
* @param templateBinding - A binding expression that returns the template to use to render the data. | |
*/ | |
public constructor( | |
public readonly dataBinding: Binding, | |
public readonly templateBinding: Binding<TSource, ContentTemplate> | |
) { | |
super() | |
} | |
public createPlaceholder: (index: number) => string = DOM.createBlockPlaceholder; | |
/** | |
* Creates a behavior. | |
* @param targets - The targets available for behaviors to be attached to. | |
*/ | |
public createBehavior(target: Node): RenderBehavior<TSource> { | |
return new RenderBehavior<TSource>( | |
target, | |
this.dataBinding, | |
this.templateBinding | |
); | |
} | |
} | |
/** | |
* Provides instructions for how to render a type. | |
* @public | |
*/ | |
export interface RenderInstruction { | |
/** | |
* Identifies this as a RenderInstruction. | |
*/ | |
brand: symbol; | |
/** | |
* The type this instruction is associated with. | |
*/ | |
type: Constructable; | |
/** | |
* The template to use when rendering. | |
*/ | |
template: ContentTemplate; | |
/** | |
* A name that can be used to identify the instruction. | |
*/ | |
name: string; | |
} | |
/** | |
* Render options that are common to all configurations. | |
* @public | |
*/ | |
export type CommonRenderOptions = { | |
/** | |
* The type this instruction is associated with. | |
*/ | |
type: Constructable; | |
/** | |
* A name that can be used to identify the instruction. | |
*/ | |
name?: string; | |
}; | |
/** | |
* Render options used to specify a template. | |
* @public | |
*/ | |
export type TemplateRenderOptions = CommonRenderOptions & { | |
/** | |
* The template to use when rendering. | |
*/ | |
template: ContentTemplate; | |
}; | |
/** | |
* Render options that are common to all element render instructions. | |
* @public | |
*/ | |
export type BaseElementRenderOptions< | |
TSource = any, | |
TParent = any | |
> = CommonRenderOptions & { | |
/** | |
* Attributes to use when creating the element template. | |
*/ | |
attributes?: Record<string, string | TemplateValue<TSource, TParent>>; | |
/** | |
* Content to use when creating the element template. | |
*/ | |
content?: string | SyntheticViewTemplate; | |
}; | |
/** | |
* Render options used to specify an element. | |
* @public | |
*/ | |
export type ElementConstructorRenderOptions< | |
TSource = any, | |
TParent = any | |
> = BaseElementRenderOptions<TSource, TParent> & { | |
/** | |
* The element to use when rendering. | |
*/ | |
element: Constructable<FASTElement>; | |
}; | |
/** | |
* Render options use to specify an element by tag name. | |
* @public | |
*/ | |
export type TagNameRenderOptions<TSource = any, TParent = any> = BaseElementRenderOptions< | |
TSource, | |
TParent | |
> & { | |
/** | |
* The tag name to use when rendering. | |
*/ | |
tagName: string; | |
}; | |
type ElementRenderOptions<TSource = any, TParent = any> = | |
| TagNameRenderOptions<TSource, TParent> | |
| ElementConstructorRenderOptions<TSource, TParent>; | |
function isElementRenderOptions(object: any): object is ElementRenderOptions { | |
return !!object.element || !!object.tagName; | |
} | |
const typeToInstructionLookup = new Map< | |
Constructable, | |
Record<string, RenderInstruction> | |
>(); | |
/* eslint @typescript-eslint/naming-convention: "off"*/ | |
const defaultAttributes = { ":model": (x: any) => x }; | |
const brand = Symbol("RenderInstruction"); | |
const defaultViewName = "default-view"; | |
const nullTemplate = html` | |
| |
`; | |
function instructionToTemplate(def: RenderInstruction | undefined) { | |
if (def === void 0) { | |
return nullTemplate; | |
} | |
return def.template; | |
} | |
function createElementTemplate<TSource = any, TParent = any>( | |
tagName: string, | |
attributes?: Record<string, string | TemplateValue<TSource, TParent>>, | |
content?: string | ContentTemplate | |
): ViewTemplate<TSource, TParent> { | |
const markup = attributes ? [`<${tagName}`] : [`<${tagName}>`]; | |
const values: Array<TemplateValue<TSource, TParent>> = []; | |
if (attributes) { | |
const attrNames = Object.getOwnPropertyNames(attributes); | |
for (let i = 0, ii = attrNames.length; i < ii; ++i) { | |
const name = attrNames[i]; | |
if (i === 0) { | |
markup[0] = `${markup[0]} ${name}="`; | |
} else { | |
markup.push(`" ${name}="`); | |
} | |
values.push(attributes[name]); | |
} | |
if (content && isFunction((content as any).create)) { | |
markup.push(`">`); | |
values.push(content); | |
markup.push(`</${tagName}>`); | |
} else { | |
markup.push(`">${content ?? ''}</${tagName}>`); | |
} | |
} else if (content && isFunction((content as any).create)) { | |
values.push(content); | |
markup.push(`</${tagName}>`); | |
} else { | |
markup[0] = `${markup[0]}${content}</${tagName}>`; | |
} | |
return html(markup as any as TemplateStringsArray, ...values); | |
} | |
function create(options: TagNameRenderOptions): RenderInstruction; | |
function create(options: ElementConstructorRenderOptions): RenderInstruction; | |
function create(options: TemplateRenderOptions): RenderInstruction; | |
function create(options: any): RenderInstruction { | |
const name = options.name ?? defaultViewName; | |
let template: ContentTemplate; | |
if (isElementRenderOptions(options)) { | |
let tagName = (options as TagNameRenderOptions).tagName; | |
if (!tagName) { | |
const def = FASTElementDefinition.forType( | |
(options as ElementConstructorRenderOptions).element | |
); | |
if (def) { | |
tagName = def.name; | |
} else { | |
throw new Error("Invalid element for model rendering."); | |
} | |
} | |
template = createElementTemplate( | |
tagName, | |
options.attributes ?? defaultAttributes, | |
options.content | |
); | |
} else { | |
template = options.template; | |
} | |
return { | |
brand, | |
type: options.type, | |
name, | |
template, | |
}; | |
} | |
function instanceOf(object: any): object is RenderInstruction { | |
return object && object.brand === brand; | |
} | |
function register(options: TagNameRenderOptions): RenderInstruction; | |
function register(options: ElementConstructorRenderOptions): RenderInstruction; | |
function register(options: TemplateRenderOptions): RenderInstruction; | |
function register(instruction: RenderInstruction): RenderInstruction; | |
function register(optionsOrInstruction: any): RenderInstruction { | |
let lookup = typeToInstructionLookup.get(optionsOrInstruction.type); | |
if (lookup === void 0) { | |
typeToInstructionLookup.set( | |
optionsOrInstruction.type, | |
(lookup = Object.create(null) as {}) | |
); | |
} | |
const instruction = instanceOf(optionsOrInstruction) | |
? optionsOrInstruction | |
: create(optionsOrInstruction); | |
return (lookup[instruction.name] = instruction); | |
} | |
function getByType(type: Constructable, name?: string): RenderInstruction | undefined { | |
const entries = typeToInstructionLookup.get(type); | |
if (entries === void 0) { | |
return void 0; | |
} | |
return entries[name ?? defaultViewName]; | |
} | |
function getForInstance(object: any, name?: string): RenderInstruction | undefined { | |
if (object) { | |
return getByType(object.constructor, name); | |
} | |
return void 0; | |
} | |
/** | |
* Provides APIs for creating and interacting with render instructions. | |
* @public | |
*/ | |
export const RenderInstruction = Object.freeze({ | |
/** | |
* Checks whether the provided object is a RenderInstruction. | |
* @param object - The object to check. | |
* @returns true if the object is a RenderInstruction; false otherwise | |
*/ | |
instanceOf, | |
/** | |
* Creates a RenderInstruction for a set of options. | |
* @param options - The options to use when creating the RenderInstruction. | |
*/ | |
create, | |
/** | |
* Creates a template based on a tag name. | |
* @param tagName - The tag name to use when creating the template. | |
* @param attributes - The attributes to apply to the element. | |
* @param content - The content to insert into the element. | |
* @returns A template based on the provided specifications. | |
*/ | |
createElementTemplate, | |
/** | |
* Creates and registers an instruction. | |
* @param options The options to use when creating the RenderInstruction. | |
* @remarks | |
* A previously created RenderInstruction can also be registered. | |
*/ | |
register, | |
/** | |
* Finds a previously registered RenderInstruction by type and optionally by name. | |
* @param type - The type to retrieve the RenderInstruction for. | |
* @param name - An optional name used in differentiating between multiple registered instructions. | |
* @returns The located RenderInstruction that matches the criteria or undefined if none is found. | |
*/ | |
getByType, | |
/** | |
* Finds a previously registered RenderInstruction for the instance's type and optionally by name. | |
* @param object - The instance to retrieve the RenderInstruction for. | |
* @param name - An optional name used in differentiating between multiple registered instructions. | |
* @returns The located RenderInstruction that matches the criteria or undefined if none is found. | |
*/ | |
getForInstance, | |
}); | |
/** | |
* Decorates a type with render instruction metadata. | |
* @param options - The options used in creating the RenderInstruction. | |
* @public | |
*/ | |
export function renderWith(options: Omit<TagNameRenderOptions, "type">): ClassDecorator; | |
/** | |
* Decorates a type with render instruction metadata. | |
* @param options - The options used in creating the RenderInstruction. | |
* @public | |
*/ | |
export function renderWith( | |
options: Omit<ElementConstructorRenderOptions, "type"> | |
): ClassDecorator; | |
/** | |
* Decorates a type with render instruction metadata. | |
* @param options - The options used in creating the RenderInstruction. | |
* @public | |
*/ | |
export function renderWith(options: Omit<TemplateRenderOptions, "type">): ClassDecorator; | |
/** | |
* Decorates a type with render instruction metadata. | |
* @param element - The element to use to render the decorated class. | |
* @param name - An optional name to differentiate the render instruction. | |
* @public | |
*/ | |
export function renderWith( | |
element: Constructable<FASTElement>, | |
name?: string | |
): ClassDecorator; | |
/** | |
* Decorates a type with render instruction metadata. | |
* @param template - The template to use to render the decorated class. | |
* @param name - An optional name to differentiate the render instruction. | |
* @public | |
*/ | |
export function renderWith(template: ContentTemplate, name?: string): ClassDecorator; | |
export function renderWith(value: any, name?: string) { | |
return function (type: Constructable) { | |
if (isFunction(value)) { | |
register({ type, element: value, name }); | |
} else if (isFunction(value.create)) { | |
register({ type, template: value, name }); | |
} else { | |
register({ type, ...value }); | |
} | |
}; | |
} | |
/** | |
* @internal | |
*/ | |
export class NodeTemplate implements ContentTemplate, ContentView { | |
constructor(public readonly node: Node) { | |
(node as any).$fastTemplate = this; | |
} | |
bind(source: any, context: ExecutionContext<any>): void {} | |
unbind(): void {} | |
insertBefore(refNode: Node): void { | |
refNode.parentNode!.insertBefore(this.node, refNode); | |
} | |
remove(): void { | |
this.node.parentNode!.removeChild(this.node); | |
} | |
create(): ContentView { | |
return this; | |
} | |
} | |
/** | |
* Creates a RenderDirective for use in advanced rendering scenarios. | |
* @param binding - The binding expression that returns the data to be rendered. The expression | |
* can also return a Node to render directly. | |
* @param templateOrTemplateBindingOrViewName - A template to render the data with | |
* or a string to indicate which RenderInstruction to use when looking up a RenderInstruction. | |
* Expressions can also be provided to dynamically determine either the template or the name. | |
* @returns A RenderDirective suitable for use in a template. | |
* @remarks | |
* If no binding is provided, then a default binding that returns the source is created. | |
* If no template is provided, then a binding is created that will use registered | |
* RenderInstructions to determine the view. | |
* If the template binding returns a string, then it will be used to look up a | |
* RenderInstruction to determine the view. | |
* @public | |
*/ | |
export function render<TSource = any, TItem = any>( | |
binding?: Binding<TSource, TItem> | Node, | |
templateOrTemplateBindingOrViewName?: | |
| ContentTemplate | |
| string | |
| Binding<TSource, ContentTemplate | string | Node> | |
): CaptureType<TSource> { | |
let dataBinding: Binding<TSource>; | |
if (binding === void 0) { | |
dataBinding = (source: TSource) => source; | |
} else if (binding instanceof Node) { | |
dataBinding = () => binding; | |
} else { | |
dataBinding = binding; | |
} | |
let templateBinding; | |
if (templateOrTemplateBindingOrViewName === void 0) { | |
templateBinding = (s: any, c: ExecutionContext) => { | |
const data = dataBinding(s, c); | |
if (data instanceof Node) { | |
return (data as any).$fastTemplate ?? new NodeTemplate(data); | |
} | |
return instructionToTemplate(getForInstance(data)); | |
}; | |
} else if (isFunction(templateOrTemplateBindingOrViewName)) { | |
templateBinding = (s: any, c: ExecutionContext) => { | |
let result = templateOrTemplateBindingOrViewName(s, c); | |
if (isString(result)) { | |
result = instructionToTemplate(getForInstance(dataBinding(s, c), result)); | |
} else if (result instanceof Node) { | |
result = (result as any).$fastTemplate ?? new NodeTemplate(result); | |
} | |
return result; | |
}; | |
} else if (isString(templateOrTemplateBindingOrViewName)) { | |
templateBinding = (s: any, c: ExecutionContext) => { | |
const data = dataBinding(s, c); | |
if (data instanceof Node) { | |
return (data as any).$fastTemplate ?? new NodeTemplate(data); | |
} | |
return instructionToTemplate( | |
getForInstance(data, templateOrTemplateBindingOrViewName) | |
); | |
}; | |
} else { | |
templateBinding = (s: any, c: ExecutionContext) => | |
templateOrTemplateBindingOrViewName; | |
} | |
return new RenderDirective<TSource>(dataBinding, templateBinding); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment