This PR was merged without API docs and general rollout plan. We can't release this as is in 5.1 without a plan for documentation, cli integration, etc.
This commit is contained in:

committed by
Victor Berchet

parent
200d92d030
commit
3997d97806
@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
/**
|
||||
* @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, Injector, NgZone} from '@angular/core';
|
||||
|
||||
export class NgElementApplicationContext {
|
||||
applicationRef = this.injector.get<ApplicationRef>(ApplicationRef);
|
||||
ngZone = this.injector.get<NgZone>(NgZone);
|
||||
|
||||
constructor(public injector: Injector) {}
|
||||
|
||||
runInNgZone<R>(cb: () => R): R { return this.ngZone.run(cb); }
|
||||
}
|
@ -1,141 +0,0 @@
|
||||
/**
|
||||
* @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, EventEmitter} from '@angular/core';
|
||||
|
||||
import {NgElementImpl, NgElementWithProps} from './ng-element';
|
||||
import {NgElementApplicationContext} from './ng-element-application-context';
|
||||
import {camelToKebabCase, throwError} from './utils';
|
||||
|
||||
/**
|
||||
* TODO(gkalpak): Add docs.
|
||||
* @experimental
|
||||
*/
|
||||
export interface NgElementConstructor<T, P> {
|
||||
readonly is: string;
|
||||
readonly observedAttributes: string[];
|
||||
|
||||
upgrade(host: HTMLElement): NgElementWithProps<T, P>;
|
||||
|
||||
new (): NgElementWithProps<T, P>;
|
||||
}
|
||||
|
||||
export interface NgElementConstructorInternal<T, P> extends NgElementConstructor<T, P> {
|
||||
readonly onConnected: EventEmitter<NgElementWithProps<T, P>>;
|
||||
readonly onDisconnected: EventEmitter<NgElementWithProps<T, P>>;
|
||||
upgrade(host: HTMLElement, ignoreUpgraded?: boolean): NgElementWithProps<T, P>;
|
||||
}
|
||||
|
||||
type WithProperties<P> = {
|
||||
[property in keyof P]: P[property]
|
||||
};
|
||||
|
||||
// For more info on `PotentialCustomElementName` rules see:
|
||||
// https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
|
||||
const PCEN_RE = createPcenRe();
|
||||
const PCEN_BLACKLIST = [
|
||||
'annotation-xml',
|
||||
'color-profile',
|
||||
'font-face',
|
||||
'font-face-src',
|
||||
'font-face-uri',
|
||||
'font-face-format',
|
||||
'font-face-name',
|
||||
'missing-glyph',
|
||||
];
|
||||
|
||||
export function createNgElementConstructor<T, P>(
|
||||
appContext: NgElementApplicationContext,
|
||||
componentFactory: ComponentFactory<T>): NgElementConstructorInternal<T, P> {
|
||||
const selector = componentFactory.selector;
|
||||
|
||||
if (!isPotentialCustomElementName(selector)) {
|
||||
throwError(
|
||||
`Using '${selector}' as a custom element name is not allowed. ` +
|
||||
'See https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name for more info.');
|
||||
}
|
||||
|
||||
const inputs = componentFactory.inputs.map(({propName, templateName}) => ({
|
||||
propName,
|
||||
attrName: camelToKebabCase(templateName),
|
||||
}));
|
||||
const outputs =
|
||||
componentFactory.outputs.map(({propName, templateName}) => ({
|
||||
propName,
|
||||
// TODO(gkalpak): Verify this is what we want and document.
|
||||
eventName: templateName,
|
||||
}));
|
||||
|
||||
// Note: According to the spec, this needs to be an ES2015 class
|
||||
// (i.e. not transpiled to an ES5 constructor function).
|
||||
// TODO(gkalpak): Document that if you are using ES5 sources you need to include a polyfill (e.g.
|
||||
// https://github.com/webcomponents/custom-elements/blob/32f043c3a/src/native-shim.js).
|
||||
class NgElementConstructorImpl extends NgElementImpl<T> {
|
||||
static readonly is = selector;
|
||||
static readonly observedAttributes = inputs.map(input => input.attrName);
|
||||
static readonly onConnected = new EventEmitter<NgElementWithProps<T, P>>();
|
||||
static readonly onDisconnected = new EventEmitter<NgElementWithProps<T, P>>();
|
||||
|
||||
static upgrade(host: HTMLElement, ignoreUpgraded = false): NgElementWithProps<T, P> {
|
||||
const ngElement = new NgElementConstructorImpl();
|
||||
|
||||
ngElement.setHost(host);
|
||||
ngElement.connectedCallback(ignoreUpgraded);
|
||||
|
||||
return ngElement as typeof ngElement & WithProperties<P>;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(appContext, componentFactory, inputs, outputs);
|
||||
|
||||
const ngElement = this as this & WithProperties<P>;
|
||||
this.onConnected.subscribe(() => NgElementConstructorImpl.onConnected.emit(ngElement));
|
||||
this.onDisconnected.subscribe(() => NgElementConstructorImpl.onDisconnected.emit(ngElement));
|
||||
}
|
||||
}
|
||||
|
||||
inputs.forEach(({propName}) => {
|
||||
Object.defineProperty(NgElementConstructorImpl.prototype, propName, {
|
||||
get: function(this: NgElementImpl<any>) { return this.getInputValue(propName); },
|
||||
set: function(this: NgElementImpl<any>, newValue: any) {
|
||||
this.setInputValue(propName, newValue);
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
});
|
||||
|
||||
return NgElementConstructorImpl as typeof NgElementConstructorImpl & {
|
||||
new (): NgElementConstructorImpl&WithProperties<P>;
|
||||
};
|
||||
}
|
||||
|
||||
function createPcenRe() {
|
||||
// According to [the
|
||||
// spec](https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name),
|
||||
// `pcenChar` is allowed to contain Unicode characters in the 10000-EFFFF range. But in order to
|
||||
// match this characters with a RegExp, we need the implementation to support the `u` flag.
|
||||
// On browsers that do not support it, valid PotentialCustomElementNames using characters in the
|
||||
// 10000-EFFFF range will still cause an error (but these characters are not expected to be used
|
||||
// in practice).
|
||||
let pcenChar = '-.0-9_a-z\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF' +
|
||||
'\\u200C-\\u200D\\u203F-\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF' +
|
||||
'\\uF900-\\uFDCF\\uFDF0-\\uFFFD';
|
||||
let flags = '';
|
||||
|
||||
if (RegExp.prototype.hasOwnProperty('unicode')) {
|
||||
pcenChar += '\\u{10000}-\\u{EFFFF}';
|
||||
flags += 'u';
|
||||
}
|
||||
|
||||
return RegExp(`^[a-z][${pcenChar}]*-[${pcenChar}]*$`, flags);
|
||||
}
|
||||
|
||||
function isPotentialCustomElementName(name: string): boolean {
|
||||
return PCEN_RE.test(name) && (PCEN_BLACKLIST.indexOf(name) === -1);
|
||||
}
|
@ -1,367 +0,0 @@
|
||||
/**
|
||||
* @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 {Subscription} from 'rxjs/Subscription';
|
||||
|
||||
import {extractProjectableNodes} from './extract-projectable-nodes';
|
||||
import {NgElementApplicationContext} from './ng-element-application-context';
|
||||
import {createCustomEvent, getComponentName, isFunction, scheduler, strictEquals, throwError} from './utils';
|
||||
|
||||
/**
|
||||
* TODO(gkalpak): Add docs.
|
||||
* @experimental
|
||||
*/
|
||||
export type NgElementWithProps<T, P> = NgElement<T>& {[property in keyof P]: P[property]};
|
||||
|
||||
/**
|
||||
* TODO(gkalpak): Add docs.
|
||||
* @experimental
|
||||
*/
|
||||
export interface NgElement<T> extends HTMLElement {
|
||||
ngElement: NgElement<T>|null;
|
||||
componentRef: ComponentRef<T>|null;
|
||||
|
||||
attributeChangedCallback(
|
||||
attrName: string, oldValue: string|null, newValue: string, namespace?: string): void;
|
||||
connectedCallback(): void;
|
||||
detach(): void;
|
||||
detectChanges(): void;
|
||||
disconnectedCallback(): void;
|
||||
getHost(): HTMLElement;
|
||||
markDirty(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an `NgElement` input.
|
||||
* Similar to a `ComponentFactory` input (`{propName: string, templateName: string}`),
|
||||
* except that `attrName` is derived by kebab-casing `templateName`.
|
||||
*/
|
||||
export interface NgElementInput {
|
||||
propName: string;
|
||||
attrName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an `NgElement` input.
|
||||
* Similar to a `ComponentFactory` output (`{propName: string, templateName: string}`),
|
||||
* except that `templateName` is renamed to `eventName`.
|
||||
*/
|
||||
export interface NgElementOutput {
|
||||
propName: string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An enum of possible lifecycle phases for `NgElement`s.
|
||||
*/
|
||||
const enum NgElementLifecyclePhase {
|
||||
// The element has been instantiated, but not connected.
|
||||
// (The associated component has not been created yet.)
|
||||
unconnected = 'unconnected',
|
||||
// The element has been instantiated and connected.
|
||||
// (The associated component has been created.)
|
||||
connected = 'connected',
|
||||
// The element has been instantiated, connected and then disconnected.
|
||||
// (The associated component has been created and then destroyed.)
|
||||
disconnected = 'disconnected',
|
||||
}
|
||||
|
||||
interface NgElementConnected<T> extends NgElementImpl<T> {
|
||||
ngElement: NgElementConnected<T>;
|
||||
componentRef: ComponentRef<T>;
|
||||
}
|
||||
|
||||
export abstract class NgElementImpl<T> extends HTMLElement implements NgElement<T> {
|
||||
private static DESTROY_DELAY = 10;
|
||||
ngElement: NgElement<T>|null = null;
|
||||
componentRef: ComponentRef<T>|null = null;
|
||||
onConnected = new EventEmitter<void>();
|
||||
onDisconnected = new EventEmitter<void>();
|
||||
|
||||
private host = this as HTMLElement;
|
||||
private readonly componentName = getComponentName(this.componentFactory.componentType);
|
||||
private readonly initialInputValues = new Map<string, any>();
|
||||
private readonly uninitializedInputs = new Set<string>();
|
||||
private readonly outputSubscriptions = new Map<string, Subscription>();
|
||||
private inputChanges: SimpleChanges|null = null;
|
||||
private implementsOnChanges = false;
|
||||
private changeDetectionScheduled = false;
|
||||
private lifecyclePhase: NgElementLifecyclePhase = NgElementLifecyclePhase.unconnected;
|
||||
private cancelDestruction: (() => void)|null = null;
|
||||
|
||||
constructor(
|
||||
private appContext: NgElementApplicationContext,
|
||||
private componentFactory: ComponentFactory<T>, private readonly inputs: NgElementInput[],
|
||||
private readonly outputs: NgElementOutput[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
attributeChangedCallback(
|
||||
attrName: string, oldValue: string|null, newValue: string, namespace?: string): void {
|
||||
const input = this.inputs.find(input => input.attrName === attrName) !;
|
||||
|
||||
if (input) {
|
||||
this.setInputValue(input.propName, newValue);
|
||||
} else {
|
||||
throwError(
|
||||
`Calling 'attributeChangedCallback()' with unknown attribute '${attrName}' ` +
|
||||
`on component '${this.componentName}' is not allowed.`);
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback(ignoreUpgraded = false): void {
|
||||
this.assertNotInPhase(NgElementLifecyclePhase.disconnected, 'connectedCallback');
|
||||
|
||||
if (this.cancelDestruction !== null) {
|
||||
this.cancelDestruction();
|
||||
this.cancelDestruction = null;
|
||||
}
|
||||
|
||||
if (this.lifecyclePhase === NgElementLifecyclePhase.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const host = this.host as NgElement<T>;
|
||||
|
||||
if (host.ngElement) {
|
||||
if (ignoreUpgraded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingNgElement = (host as NgElementConnected<T>).ngElement;
|
||||
const existingComponentName = getComponentName(existingNgElement.componentRef.componentType);
|
||||
|
||||
throwError(
|
||||
`Upgrading '${this.host.nodeName}' element to component '${this.componentName}' is not allowed, ` +
|
||||
`because the element is already upgraded to component '${existingComponentName}'.`);
|
||||
}
|
||||
|
||||
this.appContext.runInNgZone(() => {
|
||||
this.lifecyclePhase = NgElementLifecyclePhase.connected;
|
||||
const cThis = (this as any as NgElementConnected<T>);
|
||||
|
||||
const childInjector = Injector.create([], cThis.appContext.injector);
|
||||
const projectableNodes =
|
||||
extractProjectableNodes(cThis.host, cThis.componentFactory.ngContentSelectors);
|
||||
cThis.componentRef =
|
||||
cThis.componentFactory.create(childInjector, projectableNodes, cThis.host);
|
||||
cThis.implementsOnChanges =
|
||||
isFunction((cThis.componentRef.instance as any as OnChanges).ngOnChanges);
|
||||
|
||||
cThis.initializeInputs();
|
||||
cThis.initializeOutputs();
|
||||
cThis.detectChanges();
|
||||
|
||||
cThis.appContext.applicationRef.attachView(cThis.componentRef.hostView);
|
||||
|
||||
// Ensure `ngElement` is set on the host too (even for manually upgraded elements)
|
||||
// in order to be able to detect that the element has been been upgraded.
|
||||
cThis.ngElement = host.ngElement = cThis;
|
||||
|
||||
cThis.onConnected.emit();
|
||||
});
|
||||
}
|
||||
|
||||
detach(): void { this.disconnectedCallback(); }
|
||||
|
||||
detectChanges(): void {
|
||||
if (this.lifecyclePhase === NgElementLifecyclePhase.disconnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.assertNotInPhase(NgElementLifecyclePhase.unconnected, 'detectChanges');
|
||||
|
||||
this.appContext.runInNgZone(() => {
|
||||
const cThis = this as any as NgElementConnected<T>;
|
||||
|
||||
cThis.changeDetectionScheduled = false;
|
||||
|
||||
cThis.callNgOnChanges();
|
||||
cThis.componentRef.changeDetectorRef.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.lifecyclePhase === NgElementLifecyclePhase.disconnected ||
|
||||
this.cancelDestruction !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.assertNotInPhase(NgElementLifecyclePhase.unconnected, 'disconnectedCallback');
|
||||
|
||||
const doDestroy = () => this.appContext.runInNgZone(() => this.destroy());
|
||||
this.cancelDestruction = scheduler.schedule(doDestroy, NgElementImpl.DESTROY_DELAY);
|
||||
}
|
||||
|
||||
getHost(): HTMLElement { return this.host; }
|
||||
|
||||
getInputValue(propName: string): any {
|
||||
this.assertNotInPhase(NgElementLifecyclePhase.disconnected, 'getInputValue');
|
||||
|
||||
if (this.lifecyclePhase === NgElementLifecyclePhase.unconnected) {
|
||||
return this.initialInputValues.get(propName);
|
||||
}
|
||||
|
||||
const cThis = this as any as NgElementConnected<T>;
|
||||
|
||||
return (cThis.componentRef.instance as any)[propName];
|
||||
}
|
||||
|
||||
markDirty(): void {
|
||||
if (!this.changeDetectionScheduled) {
|
||||
this.changeDetectionScheduled = true;
|
||||
scheduler.scheduleBeforeRender(() => this.detectChanges());
|
||||
}
|
||||
}
|
||||
|
||||
setHost(host: HTMLElement): void {
|
||||
this.assertNotInPhase(NgElementLifecyclePhase.connected, 'setHost');
|
||||
this.assertNotInPhase(NgElementLifecyclePhase.disconnected, 'setHost');
|
||||
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
setInputValue(propName: string, newValue: any): void {
|
||||
this.assertNotInPhase(NgElementLifecyclePhase.disconnected, 'setInputValue');
|
||||
|
||||
if (this.lifecyclePhase === NgElementLifecyclePhase.unconnected) {
|
||||
this.initialInputValues.set(propName, newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const cThis = this as any as NgElementConnected<T>;
|
||||
|
||||
if (!strictEquals(newValue, cThis.getInputValue(propName))) {
|
||||
cThis.recordInputChange(propName, newValue);
|
||||
(cThis.componentRef.instance as any)[propName] = newValue;
|
||||
cThis.markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
private assertNotInPhase(phase: NgElementLifecyclePhase, caller: keyof this): void {
|
||||
if (this.lifecyclePhase === phase) {
|
||||
throwError(
|
||||
`Calling '${caller}()' on ${phase} component '${this.componentName}' is not allowed.`);
|
||||
}
|
||||
}
|
||||
|
||||
private callNgOnChanges(this: NgElementConnected<T>): void {
|
||||
if (this.implementsOnChanges && this.inputChanges !== null) {
|
||||
const inputChanges = this.inputChanges;
|
||||
this.inputChanges = null;
|
||||
(this.componentRef.instance as any as OnChanges).ngOnChanges(inputChanges);
|
||||
}
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
const cThis = this as any as NgElementConnected<T>;
|
||||
|
||||
cThis.componentRef.destroy();
|
||||
cThis.outputs.forEach(output => cThis.unsubscribeFromOutput(output));
|
||||
|
||||
this.ngElement = (this.host as NgElement<any>).ngElement = null;
|
||||
cThis.host.innerHTML = '';
|
||||
|
||||
cThis.lifecyclePhase = NgElementLifecyclePhase.disconnected;
|
||||
cThis.onDisconnected.emit();
|
||||
}
|
||||
|
||||
private dispatchCustomEvent(eventName: string, value: any): void {
|
||||
const event = createCustomEvent(this.host.ownerDocument, eventName, value);
|
||||
|
||||
this.dispatchEvent(event);
|
||||
|
||||
if (this.host !== this) {
|
||||
this.host.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeInputs(): void {
|
||||
this.inputs.forEach(({propName, attrName}) => {
|
||||
let initialValue;
|
||||
|
||||
if (this.initialInputValues.has(propName)) {
|
||||
// The property has already been set (prior to initialization).
|
||||
// Update the component instance.
|
||||
initialValue = this.initialInputValues.get(propName);
|
||||
} else if (this.host.hasAttribute(attrName)) {
|
||||
// A matching attribute exists.
|
||||
// Update the component instance.
|
||||
initialValue = this.host.getAttribute(attrName);
|
||||
} else {
|
||||
// The property does not have an initial value.
|
||||
this.uninitializedInputs.add(propName);
|
||||
}
|
||||
|
||||
if (!this.uninitializedInputs.has(propName)) {
|
||||
// The property does have an initial value.
|
||||
// Forward it to the component instance.
|
||||
this.setInputValue(propName, initialValue);
|
||||
}
|
||||
});
|
||||
|
||||
this.initialInputValues.clear();
|
||||
}
|
||||
|
||||
private initializeOutputs(this: NgElementConnected<T>): void {
|
||||
this.outputs.forEach(output => this.subscribeToOutput(output));
|
||||
}
|
||||
|
||||
private recordInputChange(propName: string, currentValue: any): void {
|
||||
if (!this.implementsOnChanges) {
|
||||
// The component does not implement `OnChanges`. Ignore the change.
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inputChanges === null) {
|
||||
this.inputChanges = {};
|
||||
}
|
||||
|
||||
const pendingChange = this.inputChanges[propName];
|
||||
|
||||
if (pendingChange) {
|
||||
pendingChange.currentValue = currentValue;
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstChange = this.uninitializedInputs.has(propName);
|
||||
const previousValue = isFirstChange ? undefined : this.getInputValue(propName);
|
||||
this.inputChanges[propName] = new SimpleChange(previousValue, currentValue, isFirstChange);
|
||||
|
||||
if (isFirstChange) {
|
||||
this.uninitializedInputs.delete(propName);
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToOutput(this: NgElementConnected<T>, output: NgElementOutput): void {
|
||||
const {propName, eventName} = output;
|
||||
const emitter = (this.componentRef.instance as any)[output.propName] as EventEmitter<any>;
|
||||
|
||||
if (!emitter) {
|
||||
throwError(`Missing emitter '${propName}' on component '${this.componentName}'.`);
|
||||
}
|
||||
|
||||
this.unsubscribeFromOutput(output);
|
||||
|
||||
const subscription =
|
||||
emitter.subscribe((value: any) => this.dispatchCustomEvent(eventName, value));
|
||||
this.outputSubscriptions.set(propName, subscription);
|
||||
}
|
||||
|
||||
private unsubscribeFromOutput({propName}: NgElementOutput): void {
|
||||
if (!this.outputSubscriptions.has(propName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = this.outputSubscriptions.get(propName) !;
|
||||
|
||||
this.outputSubscriptions.delete(propName);
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
}
|
@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @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 {ComponentFactoryResolver, NgModuleRef, Type} from '@angular/core';
|
||||
import {DOCUMENT} from '@angular/platform-browser';
|
||||
|
||||
import {NgElement} from './ng-element';
|
||||
import {NgElementApplicationContext} from './ng-element-application-context';
|
||||
import {NgElementConstructor, NgElementConstructorInternal, createNgElementConstructor} from './ng-element-constructor';
|
||||
import {scheduler, throwError} from './utils';
|
||||
|
||||
/**
|
||||
* TODO(gkalpak): Add docs.
|
||||
* @experimental
|
||||
*/
|
||||
export class NgElements<T> {
|
||||
private doc = this.moduleRef.injector.get<Document>(DOCUMENT);
|
||||
private definitions = new Map<string, NgElementConstructorInternal<any, any>>();
|
||||
private upgradedElements = new Set<NgElement<any>>();
|
||||
private appContext = new NgElementApplicationContext(this.moduleRef.injector);
|
||||
private changeDetectionScheduled = false;
|
||||
|
||||
constructor(public readonly moduleRef: NgModuleRef<T>, customElementComponents: Type<any>[]) {
|
||||
const resolver = moduleRef.componentFactoryResolver;
|
||||
customElementComponents.forEach(
|
||||
componentType => this.defineNgElement(this.appContext, resolver, componentType));
|
||||
}
|
||||
|
||||
detachAll(root: Element = this.doc.documentElement): void {
|
||||
const upgradedElements = Array.from(this.upgradedElements.values());
|
||||
const elementsToDetach: NgElement<any>[] = [];
|
||||
|
||||
this.traverseTree(root, (node: HTMLElement) => {
|
||||
upgradedElements.some(ngElement => {
|
||||
if (ngElement.getHost() === node) {
|
||||
elementsToDetach.push(ngElement);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
// Detach in reverse traversal order.
|
||||
this.appContext.runInNgZone(
|
||||
() => elementsToDetach.reverse().forEach(ngElement => ngElement.detach()));
|
||||
}
|
||||
|
||||
detectChanges(): void {
|
||||
this.changeDetectionScheduled = false;
|
||||
this.appContext.runInNgZone(
|
||||
() => this.upgradedElements.forEach(ngElement => ngElement.detectChanges()));
|
||||
}
|
||||
|
||||
forEach(
|
||||
cb:
|
||||
(def: NgElementConstructor<any, any>, selector: string,
|
||||
map: Map<string, NgElementConstructor<any, any>>) => void): void {
|
||||
return this.definitions.forEach(cb);
|
||||
}
|
||||
|
||||
get<C, P>(selector: string): NgElementConstructor<C, P>|undefined {
|
||||
return this.definitions.get(selector);
|
||||
}
|
||||
|
||||
markDirty(): void {
|
||||
if (!this.changeDetectionScheduled) {
|
||||
this.changeDetectionScheduled = true;
|
||||
scheduler.scheduleBeforeRender(() => this.detectChanges());
|
||||
}
|
||||
}
|
||||
|
||||
register(customElements?: CustomElementRegistry): void {
|
||||
if (!customElements && (typeof window !== 'undefined')) {
|
||||
customElements = window.customElements;
|
||||
}
|
||||
|
||||
if (!customElements) {
|
||||
throwError('Custom Elements are not supported in this environment.');
|
||||
}
|
||||
|
||||
this.definitions.forEach(def => customElements !.define(def.is, def));
|
||||
}
|
||||
|
||||
upgradeAll(root: Element = this.doc.documentElement): void {
|
||||
const definitions = Array.from(this.definitions.values());
|
||||
|
||||
this.appContext.runInNgZone(() => {
|
||||
this.traverseTree(root, (node: HTMLElement) => {
|
||||
const nodeName = node.nodeName.toLowerCase();
|
||||
definitions.some(def => {
|
||||
if (def.is === nodeName) {
|
||||
// TODO(gkalpak): What happens if `node` contains more custom elements
|
||||
// (as projectable content)?
|
||||
def.upgrade(node, true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private defineNgElement(
|
||||
appContext: NgElementApplicationContext, resolver: ComponentFactoryResolver,
|
||||
componentType: Type<any>): void {
|
||||
const componentFactory = resolver.resolveComponentFactory(componentType);
|
||||
const def = createNgElementConstructor<any, any>(appContext, componentFactory);
|
||||
const selector = def.is;
|
||||
|
||||
if (this.definitions.has(selector)) {
|
||||
throwError(
|
||||
`Defining an Angular custom element with selector '${selector}' is not allowed, ` +
|
||||
'because one is already defined.');
|
||||
}
|
||||
|
||||
def.onConnected.subscribe((ngElement: NgElement<T>) => this.upgradedElements.add(ngElement));
|
||||
def.onDisconnected.subscribe(
|
||||
(ngElement: NgElement<T>) => this.upgradedElements.delete(ngElement));
|
||||
|
||||
this.definitions.set(selector, def);
|
||||
}
|
||||
|
||||
// TODO(gkalpak): Add support for traversing through `shadowRoot`
|
||||
// (as should happen according to the spec).
|
||||
// TODO(gkalpak): Investigate security implications (e.g. as seen in
|
||||
// https://github.com/angular/angular.js/pull/15699).
|
||||
private traverseTree(root: Element, cb: (node: HTMLElement) => void): void {
|
||||
let currentNode: Element|null = root;
|
||||
|
||||
const getNextNonDescendant = (node: Element): Element | null => {
|
||||
let currNode: Element|null = node;
|
||||
let nextNode: Element|null = null;
|
||||
|
||||
while (!nextNode && currNode && (currNode !== root)) {
|
||||
nextNode = currNode.nextElementSibling;
|
||||
currNode = currNode.parentElement;
|
||||
}
|
||||
|
||||
return nextNode;
|
||||
};
|
||||
|
||||
while (currentNode) {
|
||||
if (currentNode instanceof HTMLElement) {
|
||||
cb(currentNode);
|
||||
}
|
||||
|
||||
currentNode = currentNode.firstElementChild || getNextNonDescendant(currentNode);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
/**
|
||||
* @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 {NgModuleFactory, NgModuleRef, PlatformRef, Type} from '@angular/core';
|
||||
|
||||
import {NgElements} from './ng-elements';
|
||||
import {isFunction} from './utils';
|
||||
|
||||
/**
|
||||
* TODO(gkalpak): Add docs.
|
||||
* @experimental
|
||||
*/
|
||||
export function registerAsCustomElements<T>(
|
||||
customElementComponents: Type<any>[], platformRef: PlatformRef,
|
||||
moduleFactory: NgModuleFactory<T>): Promise<NgModuleRef<T>>;
|
||||
export function registerAsCustomElements<T>(
|
||||
customElementComponents: Type<any>[],
|
||||
bootstrapFn: () => Promise<NgModuleRef<T>>): Promise<NgModuleRef<T>>;
|
||||
export function registerAsCustomElements<T>(
|
||||
customElementComponents: Type<any>[],
|
||||
platformRefOrBootstrapFn: PlatformRef | (() => Promise<NgModuleRef<T>>),
|
||||
moduleFactory?: NgModuleFactory<T>): Promise<NgModuleRef<T>> {
|
||||
const bootstrapFn = isFunction(platformRefOrBootstrapFn) ?
|
||||
platformRefOrBootstrapFn :
|
||||
() => platformRefOrBootstrapFn.bootstrapModuleFactory(moduleFactory !);
|
||||
|
||||
return bootstrapFn().then(moduleRef => {
|
||||
const ngElements = new NgElements(moduleRef, customElementComponents);
|
||||
ngElements.register();
|
||||
return moduleRef;
|
||||
});
|
||||
}
|
@ -1,112 +0,0 @@
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
schedule(cb: () => void, delay: number): () =>
|
||||
void{const id = window.setTimeout(cb, 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.)
|
||||
*/
|
||||
scheduleBeforeRender(cb: () => 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') {
|
||||
return scheduler.schedule(cb, 16);
|
||||
} const id = window.requestAnimationFrame(cb);
|
||||
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});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the name of the component or the first line of its stringified version.
|
||||
*/
|
||||
export function getComponentName(component: Type<any>): string {
|
||||
return (component as any).overriddenName || component.name ||
|
||||
component.toString().split('\n', 1)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an error with the specified message.
|
||||
* (It provides a centralized place where it is easy to apply some change/behavior to all errors.)
|
||||
*/
|
||||
export function throwError(message: string): void {
|
||||
throw Error(message);
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
/**
|
||||
* @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 {Version} from '@angular/core';
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export const VERSION = new Version('0.0.0-PLACEHOLDER');
|
Reference in New Issue
Block a user