feat(elements): add support for creating custom elements (#22413)

PR Close #22413
This commit is contained in:
Andrew Seguin
2018-02-28 09:45:11 -08:00
committed by Miško Hevery
parent cedc04c320
commit 22b96b9690
34 changed files with 1823 additions and 17 deletions

View File

@ -0,0 +1,262 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ApplicationRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {merge} from 'rxjs/observable/merge';
import {map} from 'rxjs/operator/map';
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy';
import {extractProjectableNodes} from './extract-projectable-nodes';
import {camelToKebabCase, isFunction, scheduler, strictEquals} from './utils';
/** Time in milliseconds to wait before destroying the component ref when disconnected. */
const DESTROY_DELAY = 10;
/**
* Creates an NgElementConfig based on the provided component factory and injector. By default,
* the observed attributes on the NgElement will be the kebab-case version of the component inputs.
*
* @experimental
*/
export function getConfigFromComponentFactory(
componentFactory: ComponentFactory<any>, injector: Injector) {
const attributeToPropertyInputs = new Map<string, string>();
componentFactory.inputs.forEach(({propName, templateName}) => {
const attr = camelToKebabCase(templateName);
attributeToPropertyInputs.set(attr, propName);
});
return {
strategyFactory: new ComponentFactoryNgElementStrategyFactory(componentFactory, injector),
propertyInputs: componentFactory.inputs.map(({propName}) => propName),
attributeToPropertyInputs,
};
}
/**
* Factory that creates new ComponentFactoryNgElementStrategy instances with the strategy factory's
* injector. A new strategy instance is created with the provided component factory which will
* create its components on connect.
*
* @experimental
*/
export class ComponentFactoryNgElementStrategyFactory implements NgElementStrategyFactory {
constructor(private componentFactory: ComponentFactory<any>, private injector: Injector) {}
create() { return new ComponentFactoryNgElementStrategy(this.componentFactory, this.injector); }
}
/**
* Creates and destroys a component ref using a component factory and handles change detection
* in response to input changes.
*
* @experimental
*/
export class ComponentFactoryNgElementStrategy implements NgElementStrategy {
/** Merged stream of the component's output events. */
events: Observable<NgElementStrategyEvent>;
/** Reference to the component that was created on connect. */
private componentRef: ComponentRef<any>;
/** Changes that have been made to the component ref since the last time onChanges was called. */
private inputChanges: SimpleChanges|null = null;
/** Whether the created component implements the onChanges function. */
private implementsOnChanges = false;
/** Whether a change detection has been scheduled to run on the component. */
private scheduledChangeDetectionFn: (() => void)|null = null;
/** Callback function that when called will cancel a scheduled destruction on the component. */
private scheduledDestroyFn: (() => void)|null = null;
/** Initial input values that were set before the component was created. */
private readonly initialInputValues = new Map<string, any>();
/** Set of inputs that were not initially set when the component was created. */
private readonly uninitializedInputs = new Set<string>();
constructor(private componentFactory: ComponentFactory<any>, private injector: Injector) {}
/**
* Initializes a new component if one has not yet been created and cancels any scheduled
* destruction.
*/
connect(element: HTMLElement) {
// If the element is marked to be destroyed, cancel the task since the component was reconnected
if (this.scheduledDestroyFn !== null) {
this.scheduledDestroyFn();
this.scheduledDestroyFn = null;
return;
}
if (!this.componentRef) {
this.initializeComponent(element);
}
}
/**
* Schedules the component to be destroyed after some small delay in case the element is just
* being moved across the DOM.
*/
disconnect() {
// Return if there is no componentRef or the component is already scheduled for destruction
if (!this.componentRef || this.scheduledDestroyFn !== null) {
return;
}
// Schedule the component to be destroyed after a small timeout in case it is being
// moved elsewhere in the DOM
this.scheduledDestroyFn =
scheduler.schedule(() => { this.componentRef !.destroy(); }, DESTROY_DELAY);
}
/**
* Returns the component property value. If the component has not yet been created, the value is
* retrieved from the cached initialization values.
*/
getPropertyValue(property: string): any {
if (!this.componentRef) {
return this.initialInputValues.get(property);
}
return (this.componentRef.instance as any)[property];
}
/**
* Sets the input value for the property. If the component has not yet been created, the value is
* cached and set when the component is created.
*/
setPropertyValue(property: string, value: any): void {
if (strictEquals(value, this.getPropertyValue(property))) {
return;
}
if (!this.componentRef) {
this.initialInputValues.set(property, value);
return;
}
this.recordInputChange(property, value);
(this.componentRef.instance as any)[property] = value;
this.scheduleDetectChanges();
}
/**
* Creates a new component through the component factory with the provided element host and
* sets up its initial inputs, listens for outputs changes, and runs an initial change detection.
*/
protected initializeComponent(element: HTMLElement) {
const childInjector = Injector.create({providers: [], parent: this.injector});
const projectableNodes =
extractProjectableNodes(element, this.componentFactory.ngContentSelectors);
this.componentRef = this.componentFactory.create(childInjector, projectableNodes, element);
this.implementsOnChanges =
isFunction((this.componentRef.instance as any as OnChanges).ngOnChanges);
this.initializeInputs();
this.initializeOutputs();
this.detectChanges();
const applicationRef = this.injector.get<ApplicationRef>(ApplicationRef);
applicationRef.attachView(this.componentRef.hostView);
}
/** Set any stored initial inputs on the component's properties. */
protected initializeInputs(): void {
this.componentFactory.inputs.forEach(({propName}) => {
const initialValue = this.initialInputValues.get(propName);
if (initialValue) {
this.setPropertyValue(propName, initialValue);
} else {
// Keep track of inputs that were not initialized in case we need to know this for
// calling ngOnChanges with SimpleChanges
this.uninitializedInputs.add(propName);
}
});
this.initialInputValues.clear();
}
/** Sets up listeners for the component's outputs so that the events stream emits the events. */
protected initializeOutputs(): void {
const eventEmitters = this.componentFactory.outputs.map(({propName, templateName}) => {
const emitter = (this.componentRef !.instance as any)[propName] as EventEmitter<any>;
return map.call(emitter, (value: any) => ({name: templateName, value}));
});
this.events = merge(...eventEmitters);
}
/** Calls ngOnChanges with all the inputs that have changed since the last call. */
protected callNgOnChanges(): void {
if (!this.implementsOnChanges || this.inputChanges === null) {
return;
}
(this.componentRef !.instance as any as OnChanges).ngOnChanges(this.inputChanges);
this.inputChanges = null;
}
/**
* Schedules change detection to run on the component.
* Ignores subsequent calls if already scheduled.
*/
protected scheduleDetectChanges(): void {
if (this.scheduledChangeDetectionFn) {
return;
}
this.scheduledChangeDetectionFn = scheduler.scheduleBeforeRender(() => {
this.detectChanges();
this.scheduledChangeDetectionFn = null;
});
}
/**
* Records input changes so that the component receives SimpleChanges in its onChanges function.
*/
protected recordInputChange(property: string, currentValue: any): void {
// Do not record the change if the component does not implement `OnChanges`.
if (!this.componentRef || !this.implementsOnChanges) {
return;
}
if (this.inputChanges === null) {
this.inputChanges = {};
}
// If there already is a change, modify the current value to match but leave the values for
// previousValue and isFirstChange.
const pendingChange = this.inputChanges[property];
if (pendingChange) {
pendingChange.currentValue = currentValue;
return;
}
const isFirstChange = this.uninitializedInputs.has(property);
this.uninitializedInputs.delete(property);
const previousValue = isFirstChange ? undefined : this.getPropertyValue(property);
this.inputChanges[property] = new SimpleChange(previousValue, currentValue, isFirstChange);
}
/** Runs change detection on the component. */
protected detectChanges(): void {
if (!this.componentRef) {
return;
}
this.callNgOnChanges();
this.componentRef !.changeDetectorRef.detectChanges();
}
}

View File

@ -0,0 +1,41 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ComponentFactory} from '@angular/core';
import {Observable} from 'rxjs/Observable';
/**
* Interface for the events emitted through the NgElementStrategy.
*
* @experimental
*/
export interface NgElementStrategyEvent {
name: string;
value: any;
}
/**
* Underlying strategy used by the NgElement to create/destroy the component and react to input
* changes.
*
* @experimental
*/
export interface NgElementStrategy {
events: Observable<NgElementStrategyEvent>;
connect(element: HTMLElement): void;
disconnect(): void;
getPropertyValue(propName: string): any;
setPropertyValue(propName: string, value: string): void;
}
/**
* Factory used to create new strategies for each NgElement instance.
*
* @experimental
*/
export interface NgElementStrategyFactory { create(): NgElementStrategy; }

View File

@ -0,0 +1,54 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
// NOTE: This is a (slightly improved) version of what is used in ngUpgrade's
// `DowngradeComponentAdapter`.
// TODO(gkalpak): Investigate if it makes sense to share the code.
import {isElement, matchesSelector} from './utils';
export function extractProjectableNodes(host: HTMLElement, ngContentSelectors: string[]): Node[][] {
const nodes = host.childNodes;
const projectableNodes: Node[][] = ngContentSelectors.map(() => []);
let wildcardIndex = -1;
ngContentSelectors.some((selector, i) => {
if (selector === '*') {
wildcardIndex = i;
return true;
}
return false;
});
for (let i = 0, ii = nodes.length; i < ii; ++i) {
const node = nodes[i];
const ngContentIndex = findMatchingIndex(node, ngContentSelectors, wildcardIndex);
if (ngContentIndex !== -1) {
projectableNodes[ngContentIndex].push(node);
}
}
return projectableNodes;
}
function findMatchingIndex(node: Node, selectors: string[], defaultIndex: number): number {
let matchingIndex = defaultIndex;
if (isElement(node)) {
selectors.some((selector, i) => {
if ((selector !== '*') && matchesSelector(node, selector)) {
matchingIndex = i;
return true;
}
return false;
});
}
return matchingIndex;
}

View File

@ -0,0 +1,135 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Subscription} from 'rxjs/Subscription';
import {NgElementStrategy, NgElementStrategyFactory} from './element-strategy';
import {createCustomEvent} from './utils';
/**
* Class constructor based on an Angular Component to be used for custom element registration.
*
* @experimental
*/
export interface NgElementConstructor<P> {
readonly observedAttributes: string[];
new (): NgElement&WithProperties<P>;
}
/**
* Class that extends HTMLElement and implements the functionality needed for a custom element.
*
* @experimental
*/
export abstract class NgElement extends HTMLElement {
protected ngElementStrategy: NgElementStrategy;
protected ngElementEventsSubscription: Subscription|null = null;
abstract attributeChangedCallback(
attrName: string, oldValue: string|null, newValue: string, namespace?: string): void;
abstract connectedCallback(): void;
abstract disconnectedCallback(): void;
}
/**
* Additional type information that can be added to the NgElement class for properties added based
* on the inputs and methods of the underlying component.
*/
export type WithProperties<P> = {
[property in keyof P]: P[property]
};
/**
* Initialization configuration for the NgElementConstructor. Provides the strategy factory
* that produces a strategy for each instantiated element. Additionally, provides a function
* that takes the component factory and provides a map of which attributes should be observed on
* the element and which property they are associated with.
*
* @experimental
*/
export interface NgElementConfig {
strategyFactory: NgElementStrategyFactory;
propertyInputs: string[];
attributeToPropertyInputs: Map<string, string>;
}
/**
* @whatItDoes Creates a custom element class based on an Angular Component. Takes a configuration
* that provides initialization information to the created class. E.g. the configuration's injector
* will be the initial injector set on the class which will be used for each created instance.
*
* @description Builds a class that encapsulates the functionality of the provided component and
* uses the config's information to provide more context to the class. Takes the component factory's
* inputs and outputs to convert them to the proper custom element API and add hooks to input
* changes. Passes the config's injector to each created instance (may be overriden with the
* static property to affect all newly created instances, or as a constructor argument for
* one-off creations).
*
* @experimental
*/
export function createNgElementConstructor<P>(config: NgElementConfig): NgElementConstructor<P> {
class NgElementImpl extends NgElement {
static readonly observedAttributes = Array.from(config.attributeToPropertyInputs.keys());
constructor(strategyFactoryOverride?: NgElementStrategyFactory) {
super();
// Use the constructor's strategy factory override if it is present, otherwise default to
// the config's factory.
const strategyFactory = strategyFactoryOverride || config.strategyFactory;
this.ngElementStrategy = strategyFactory.create();
}
attributeChangedCallback(
attrName: string, oldValue: string|null, newValue: string, namespace?: string): void {
const propName = config.attributeToPropertyInputs.get(attrName) !;
this.ngElementStrategy.setPropertyValue(propName, newValue);
}
connectedCallback(): void {
// Take element attribute inputs and set them as inputs on the strategy
config.attributeToPropertyInputs.forEach((propName, attrName) => {
const value = this.getAttribute(attrName);
if (value) {
this.ngElementStrategy.setPropertyValue(propName, value);
}
});
this.ngElementStrategy.connect(this);
// Listen for events from the strategy and dispatch them as custom events
this.ngElementEventsSubscription = this.ngElementStrategy.events.subscribe(e => {
const customEvent = createCustomEvent(this.ownerDocument, e.name, e.value);
this.dispatchEvent(customEvent);
});
}
disconnectedCallback(): void {
this.ngElementStrategy.disconnect();
if (this.ngElementEventsSubscription) {
this.ngElementEventsSubscription.unsubscribe();
this.ngElementEventsSubscription = null;
}
}
}
// Add getters and setters for each input defined on the Angular Component so that the input
// changes can be known.
config.propertyInputs.forEach(property => {
Object.defineProperty(NgElementImpl.prototype, property, {
get: function() { return this.ngElementStrategy.getPropertyValue(property); },
set: function(newValue: any) { this.ngElementStrategy.setPropertyValue(property, newValue); },
configurable: true,
enumerable: true,
});
});
return (NgElementImpl as any) as NgElementConstructor<P>;
}

View File

@ -0,0 +1,103 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Type} from '@angular/core';
const elProto = Element.prototype as any;
const matches = elProto.matches || elProto.matchesSelector || elProto.mozMatchesSelector ||
elProto.msMatchesSelector || elProto.oMatchesSelector || elProto.webkitMatchesSelector;
/**
* Provide methods for scheduling the execution of a callback.
*/
export const scheduler = {
/**
* Schedule a callback to be called after some delay.
*
* Returns a function that when executed will cancel the scheduled function.
*/
schedule(taskFn: () => void, delay: number): () =>
void{const id = window.setTimeout(taskFn, delay); return () => window.clearTimeout(id);},
/**
* Schedule a callback to be called before the next render.
* (If `window.requestAnimationFrame()` is not available, use `scheduler.schedule()` instead.)
*
* Returns a function that when executed will cancel the scheduled function.
*/
scheduleBeforeRender(taskFn: () => void): () => void{
// TODO(gkalpak): Implement a better way of accessing `requestAnimationFrame()`
// (e.g. accounting for vendor prefix, SSR-compatibility, etc).
if (typeof window.requestAnimationFrame === 'undefined') {
const frameMs = 16;
return scheduler.schedule(taskFn, frameMs);
}
const id = window.requestAnimationFrame(taskFn);
return () => window.cancelAnimationFrame(id);
},
};
/**
* Convert a camelCased string to kebab-cased.
*/
export function camelToKebabCase(input: string): string {
return input.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`);
}
/**
* Create a `CustomEvent` (even on browsers where `CustomEvent` is not a constructor).
*/
export function createCustomEvent(doc: Document, name: string, detail: any): CustomEvent {
const bubbles = false;
const cancelable = false;
// On IE9-11, `CustomEvent` is not a constructor.
if (typeof CustomEvent !== 'function') {
const event = doc.createEvent('CustomEvent');
event.initCustomEvent(name, bubbles, cancelable, detail);
return event;
}
return new CustomEvent(name, {bubbles, cancelable, detail});
}
/**
* Check whether the input is an `Element`.
*/
export function isElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}
/**
* Check whether the input is a function.
*/
export function isFunction(value: any): value is Function {
return typeof value === 'function';
}
/**
* Convert a kebab-cased string to camelCased.
*/
export function kebabToCamelCase(input: string): string {
return input.replace(/-([a-z\d])/g, (_, char) => char.toUpperCase());
}
/**
* Check whether an `Element` matches a CSS selector.
*/
export function matchesSelector(element: Element, selector: string): boolean {
return matches.call(element, selector);
}
/**
* Test two values for strict equality, accounting for the fact that `NaN !== NaN`.
*/
export function strictEquals(value1: any, value2: any): boolean {
return value1 === value2 || (value1 !== value1 && value2 !== value2);
}

View File

@ -0,0 +1,19 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/**
* @module
* @description
* Entry point for all public APIs of the common package.
*/
import {Version} from '@angular/core';
/**
* @experimental
*/
export const VERSION = new Version('0.0.0-PLACEHOLDER');