diff --git a/.pullapprove.yml b/.pullapprove.yml
index a7d887edc8..7b2f26e9c7 100644
--- a/.pullapprove.yml
+++ b/.pullapprove.yml
@@ -7,6 +7,7 @@
#
# alexeagle - Alex Eagle
# alxhub - Alex Rickabaugh
+# andrewseguin - Andrew Seguin
# brocco - Mike Brocchi
# chuckjaz - Chuck Jazdzewski
# filipesilva - Filipe Silva
@@ -302,6 +303,16 @@ groups:
- IgorMinar #fallback
- mhevery #fallback
+ elements:
+ conditions:
+ files:
+ - "packages/elements/*"
+ users:
+ - andrewseguin #primary
+ - gkalpak
+ - IgorMinar #fallback
+ - mhevery #fallback
+
benchpress:
conditions:
files:
diff --git a/BUILD.bazel b/BUILD.bazel
index 12719d12e8..1b5a862bd1 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -40,6 +40,7 @@ filegroup(
"reflect-metadata",
"source-map-support",
"minimist",
+ "@webcomponents/webcomponentsjs",
"tslib",
] for ext in [
"*.js",
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 74cec311bc..1419eea496 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -212,6 +212,7 @@ The following is the list of supported scopes:
* **compiler**
* **compiler-cli**
* **core**
+* **elements**
* **forms**
* **http**
* **language-service**
diff --git a/aio/content/guide/elements.md b/aio/content/guide/elements.md
new file mode 100644
index 0000000000..f17ba92ef6
--- /dev/null
+++ b/aio/content/guide/elements.md
@@ -0,0 +1,67 @@
+# Elements
+
+## Release Status
+
+**Angular Labs Project** - experimental and unstable. **Breaking Changes Possible**
+
+Targeted to land in the [6.x release cycle](https://github.com/angular/angular/blob/master/docs/RELEASE_SCHEDULE.md) of Angular - subject to change
+
+## Overview
+
+Elements provides an API that allows developers to register Angular Components as Custom Elements
+("Web Components"), and bridges the built-in DOM API to Angular's component interface and change
+detection APIs.
+
+```ts
+//hello-world.ts
+import { Component, Input, NgModule } from '@angular/core';
+import { createNgElementConstructor, getConfigFromComponentFactory } from '@angular/elements';
+
+@Component({
+ selector: 'hello-world',
+ template: `
Hello {{name}}
`
+})
+export class HelloWorld {
+ @Input() name: string = 'World!';
+}
+
+@NgModule({
+ declarations: [ HelloWorld ],
+ entryComponents: [ HelloWorld ]
+})
+export class HelloWorldModule {}
+```
+
+```ts
+//app.component.ts
+import { Component, NgModuleRef } from '@angular/core';
+import { createNgElementConstructor } from '@angular/elements';
+
+import { HelloWorld } from './hello-world.ngfactory';
+
+@Component({
+ selector: 'app-root',
+ templateUrl: './app.component.html',
+ styleUrls: ['./app.component.css']
+})
+export class AppComponent {
+ constructor(ngModuleRef: NgModuleRef) {
+ const ngElementConfig = getConfigFromComponentFactory(HelloWorld, injector);
+ const NgElementConstructor = createNgElementConstructor(ngElementConfig);
+ customElements.register('hello-world', NgElementConstructor);
+ }
+}
+
+```
+Once registered, these components can be used just like built-in HTML elements, because they *are*
+HTML Elements!
+
+They can be used in any HTML page:
+
+```html
+
+
+```
+
+Custom Elements are "self-bootstrapping" - they are automatically started when they are added to the
+DOM, and automatically destroyed when removed from the DOM.
diff --git a/aio/content/navigation.json b/aio/content/navigation.json
index abd48b3142..938132a7ba 100644
--- a/aio/content/navigation.json
+++ b/aio/content/navigation.json
@@ -457,6 +457,11 @@
}
]
},
+ {
+ "url": "guide/elements",
+ "title": "Elements",
+ "tooltip": "Exporting Angular Components as Web Components"
+ },
{
"title": "Service Workers",
"tooltip": "Angular service workers: Controlling caching of application resources.",
diff --git a/aio/tools/transforms/angular-api-package/index.js b/aio/tools/transforms/angular-api-package/index.js
index 49bc40905a..043b3c50c9 100644
--- a/aio/tools/transforms/angular-api-package/index.js
+++ b/aio/tools/transforms/angular-api-package/index.js
@@ -73,6 +73,7 @@ module.exports = new Package('angular-api', [basePackage, typeScriptPackage])
'common/testing/index.ts',
'core/index.ts',
'core/testing/index.ts',
+ 'elements/index.ts',
'forms/index.ts',
'http/index.ts',
'http/testing/index.ts',
diff --git a/aio/tools/transforms/authors-package/api-package.js b/aio/tools/transforms/authors-package/api-package.js
index 541d1ac45e..5f07254b22 100644
--- a/aio/tools/transforms/authors-package/api-package.js
+++ b/aio/tools/transforms/authors-package/api-package.js
@@ -11,15 +11,16 @@ const { API_SOURCE_PATH } = require('../config');
const packageMap = {
animations: ['animations/index.ts', 'animations/browser/index.ts', 'animations/browser/testing/index.ts'],
- common: ['common/index.ts', 'common/testing/index.ts'],
+ common: ['common/index.ts', 'common/testing/index.ts', 'common/http/index.ts', 'common/http/testing/index.ts'],
core: ['core/index.ts', 'core/testing/index.ts'],
+ elements: ['elements/index.ts'],
forms: ['forms/index.ts'],
http: ['http/index.ts', 'http/testing/index.ts'],
'platform-browser': ['platform-browser/index.ts', 'platform-browser/animations/index.ts', 'platform-browser/testing/index.ts'],
'platform-browser-dynamic': ['platform-browser-dynamic/index.ts', 'platform-browser-dynamic/testing/index.ts'],
'platform-server': ['platform-server/index.ts', 'platform-server/testing/index.ts'],
'platform-webworker': ['platform-webworker/index.ts'],
- 'platform-webworker-dynamic': 'platform-webworker-dynamic/index.ts',
+ 'platform-webworker-dynamic': ['platform-webworker-dynamic/index.ts'],
router: ['router/index.ts', 'router/testing/index.ts', 'router/upgrade/index.ts'],
'service-worker': ['service-worker/index.ts'],
upgrade: ['upgrade/index.ts', 'upgrade/static/index.ts']
diff --git a/build.sh b/build.sh
index 45bf755443..cbd8ae7977 100755
--- a/build.sh
+++ b/build.sh
@@ -24,7 +24,8 @@ PACKAGES=(core
compiler-cli
language-service
benchpress
- service-worker)
+ service-worker
+ elements)
TSC_PACKAGES=(compiler-cli
language-service
diff --git a/karma-js.conf.js b/karma-js.conf.js
index f31417eda1..2015dd46ff 100644
--- a/karma-js.conf.js
+++ b/karma-js.conf.js
@@ -41,6 +41,15 @@ module.exports = function(config) {
'test-events.js',
'shims_for_IE.js',
'node_modules/systemjs/dist/system.src.js',
+
+ // Serve polyfills necessary for testing the `elements` package.
+ {
+ pattern: 'node_modules/@webcomponents/custom-elements/**/*.js',
+ included: false,
+ watched: false
+ },
+ {pattern: 'node_modules/mutation-observer/index.js', included: false, watched: false},
+
{pattern: 'node_modules/rxjs/**', included: false, watched: false, served: true},
'node_modules/reflect-metadata/Reflect.js',
'tools/build/file2modulename.js',
diff --git a/package.json b/package.json
index 788f0d7f6d..134bdd241b 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,8 @@
"@types/shelljs": "^0.7.8",
"@types/source-map": "^0.5.1",
"@types/systemjs": "0.19.32",
+ "@webcomponents/custom-elements": "^1.0.4",
+ "@webcomponents/webcomponentsjs": "^1.1.0",
"angular": "npm:angular@1.6",
"angular-1.5": "npm:angular@1.5",
"angular-mocks": "npm:angular-mocks@1.6",
@@ -87,6 +89,7 @@
"karma-sourcemap-loader": "0.3.6",
"madge": "0.5.0",
"minimist": "1.2.0",
+ "mutation-observer": "^1.0.3",
"node-uuid": "1.4.8",
"protractor": "5.1.2",
"rewire": "2.5.2",
diff --git a/packages/elements/BUILD.bazel b/packages/elements/BUILD.bazel
new file mode 100644
index 0000000000..e611e26cd3
--- /dev/null
+++ b/packages/elements/BUILD.bazel
@@ -0,0 +1,19 @@
+package(default_visibility = ["//visibility:public"])
+
+load("//tools:defaults.bzl", "ng_module")
+
+ng_module(
+ name = "elements",
+ srcs = glob(
+ [
+ "*.ts",
+ "src/**/*.ts",
+ ],
+ ),
+ module_name = "@angular/elements",
+ deps = [
+ "//packages/core",
+ "//packages/platform-browser",
+ "@rxjs",
+ ],
+)
diff --git a/packages/elements/index.ts b/packages/elements/index.ts
new file mode 100644
index 0000000000..e727e2e8a7
--- /dev/null
+++ b/packages/elements/index.ts
@@ -0,0 +1,14 @@
+/**
+ * @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
+ */
+
+// This file is not used to build this module. It is only used during editing
+// by the TypeScript language service and during build for verification. `ngc`
+// replaces this file with production index.ts when it rewrites private symbol
+// names.
+
+export * from './public_api';
diff --git a/packages/elements/package.json b/packages/elements/package.json
new file mode 100644
index 0000000000..a03d8d251c
--- /dev/null
+++ b/packages/elements/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@angular/elements",
+ "version": "0.0.0-PLACEHOLDER",
+ "description": "Angular - library for using Angular Components as Custom Elements",
+ "main": "./bundles/elements.umd.js",
+ "module": "./esm5/elements.js",
+ "es2015": "./esm2015/elements.js",
+ "typings": "./elements.d.ts",
+ "author": "angular",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^1.7.1"
+ },
+ "peerDependencies": {
+ "@angular/core": "0.0.0-PLACEHOLDER",
+ "@angular/platform-browser": "0.0.0-PLACEHOLDER"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/angular/angular.git"
+ }
+}
diff --git a/packages/elements/public_api.ts b/packages/elements/public_api.ts
new file mode 100644
index 0000000000..83a55578e7
--- /dev/null
+++ b/packages/elements/public_api.ts
@@ -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 `elements` package.
+ */
+export {ComponentFactoryNgElementStrategy, ComponentFactoryNgElementStrategyFactory, getConfigFromComponentFactory} from './src/component-factory-strategy';
+export {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './src/element-strategy';
+export {NgElement, NgElementConfig, NgElementConstructor, createNgElementConstructor} from './src/ng-element-constructor';
+export {VERSION} from './src/version';
+
+// This file only reexports content of the `src` folder. Keep it that way.
diff --git a/packages/elements/rollup.config.js b/packages/elements/rollup.config.js
new file mode 100644
index 0000000000..1aa8375c14
--- /dev/null
+++ b/packages/elements/rollup.config.js
@@ -0,0 +1,31 @@
+/**
+ * @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
+ */
+
+const resolve = require('rollup-plugin-node-resolve');
+const sourcemaps = require('rollup-plugin-sourcemaps');
+
+const globals = {
+ '@angular/core': 'ng.core',
+ '@angular/platform-browser': 'ng.platformBrowser',
+ 'rxjs/Subscription': 'Rx',
+ 'rxjs/Observable': 'Rx',
+ 'rxjs/observable/merge': 'Rx.Observable',
+ 'rxjs/operator/map': 'Rx.Observable.prototype'
+};
+
+module.exports = {
+ entry: '../../dist/packages-dist/elements/esm5/elements.js',
+ dest: '../../dist/packages-dist/elements/bundles/elements.umd.js',
+ format: 'umd',
+ exports: 'named',
+ amd: {id: '@angular/elements'},
+ moduleName: 'ng.elements',
+ plugins: [resolve(), sourcemaps()],
+ external: Object.keys(globals),
+ globals: globals
+};
diff --git a/packages/elements/src/component-factory-strategy.ts b/packages/elements/src/component-factory-strategy.ts
new file mode 100644
index 0000000000..51c785a71b
--- /dev/null
+++ b/packages/elements/src/component-factory-strategy.ts
@@ -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, injector: Injector) {
+ const attributeToPropertyInputs = new Map();
+ 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, 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;
+
+ /** Reference to the component that was created on connect. */
+ private componentRef: ComponentRef;
+
+ /** 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();
+
+ /** Set of inputs that were not initially set when the component was created. */
+ private readonly uninitializedInputs = new Set();
+
+ constructor(private componentFactory: ComponentFactory, 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.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;
+ 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();
+ }
+}
diff --git a/packages/elements/src/element-strategy.ts b/packages/elements/src/element-strategy.ts
new file mode 100644
index 0000000000..c5d8d4bc20
--- /dev/null
+++ b/packages/elements/src/element-strategy.ts
@@ -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;
+
+ 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; }
diff --git a/packages/elements/src/extract-projectable-nodes.ts b/packages/elements/src/extract-projectable-nodes.ts
new file mode 100644
index 0000000000..b6df05bcba
--- /dev/null
+++ b/packages/elements/src/extract-projectable-nodes.ts
@@ -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;
+}
diff --git a/packages/elements/src/ng-element-constructor.ts b/packages/elements/src/ng-element-constructor.ts
new file mode 100644
index 0000000000..025d7a68d1
--- /dev/null
+++ b/packages/elements/src/ng-element-constructor.ts
@@ -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
{
+ readonly observedAttributes: string[];
+
+ new (): NgElement&WithProperties
;
+}
+
+/**
+ * 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
= {
+ [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;
+}
+
+/**
+ * @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
(config: NgElementConfig): NgElementConstructor
{
+ 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
;
+}
diff --git a/packages/elements/src/utils.ts b/packages/elements/src/utils.ts
new file mode 100644
index 0000000000..e750e0af63
--- /dev/null
+++ b/packages/elements/src/utils.ts
@@ -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);
+}
diff --git a/packages/elements/src/version.ts b/packages/elements/src/version.ts
new file mode 100644
index 0000000000..ccdd01cba7
--- /dev/null
+++ b/packages/elements/src/version.ts
@@ -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');
diff --git a/packages/elements/test/BUILD.bazel b/packages/elements/test/BUILD.bazel
new file mode 100644
index 0000000000..e6a2893c27
--- /dev/null
+++ b/packages/elements/test/BUILD.bazel
@@ -0,0 +1,49 @@
+load("//tools:defaults.bzl", "ts_library")
+load("@build_bazel_rules_typescript//:defs.bzl", "ts_web_test")
+load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test")
+
+ts_library(
+ name = "test_lib",
+ testonly = 1,
+ srcs = glob(["**/*.ts"]),
+ deps = [
+ "//packages:types",
+ "//packages/compiler",
+ "//packages/core",
+ "//packages/core/testing",
+ "//packages/elements",
+ "//packages/elements/testing",
+ "//packages/platform-browser",
+ "//packages/platform-browser-dynamic",
+ "//packages/platform-browser-dynamic/testing",
+ "//packages/platform-browser/testing",
+ "@rxjs",
+ ],
+)
+
+filegroup(
+ name = "elements_test_bootstrap_scripts",
+ # do not sort
+ srcs = [
+ "//:node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js",
+ "//:node_modules/reflect-metadata/Reflect.js",
+ "//:node_modules/zone.js/dist/zone.js",
+ "//:node_modules/zone.js/dist/async-test.js",
+ "//:node_modules/zone.js/dist/sync-test.js",
+ "//:node_modules/zone.js/dist/fake-async-test.js",
+ "//:node_modules/zone.js/dist/proxy.js",
+ "//:node_modules/zone.js/dist/jasmine-patch.js",
+ ],
+)
+
+ts_web_test(
+ name = "test",
+ bootstrap = [
+ ":elements_test_bootstrap_scripts",
+ ],
+ # do not sort
+ deps = [
+ "//tools/testing:browser",
+ ":test_lib",
+ ],
+)
diff --git a/packages/elements/test/component-factory-strategy_spec.ts b/packages/elements/test/component-factory-strategy_spec.ts
new file mode 100644
index 0000000000..e0fc8a8a6d
--- /dev/null
+++ b/packages/elements/test/component-factory-strategy_spec.ts
@@ -0,0 +1,78 @@
+/**
+ * @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, ComponentRef, Injector, NgModuleRef, Type} from '@angular/core';
+import {Subject} from 'rxjs/Subject';
+
+import {ComponentFactoryNgElementStrategy} from '../src/component-factory-strategy';
+
+describe('ComponentFactoryNgElementStrategy', () => {
+ let factory: FakeComponentFactory;
+ let component: FakeComponent;
+ let strategy: ComponentFactoryNgElementStrategy;
+ let injector;
+
+ beforeEach(() => {
+ factory = new FakeComponentFactory();
+ component = factory.componentRef;
+
+ injector = jasmine.createSpyObj('injector', ['get']);
+ const applicationRef = jasmine.createSpyObj('applicationRef', ['attachView']);
+ injector.get.and.returnValue(applicationRef);
+
+ strategy = new ComponentFactoryNgElementStrategy(factory, injector);
+ });
+
+ describe('connect', () => {
+ beforeEach(() => {
+ const element = document.createElement('div');
+ strategy.connect(element);
+ });
+
+ // TODO(andrewseguin): Test everything
+ });
+});
+
+export class FakeComponent {
+ output1 = new Subject();
+ output2 = new Subject();
+}
+
+export class FakeComponentFactory extends ComponentFactory {
+ componentRef = jasmine.createSpyObj('componentRef', ['instance', 'changeDetectorRef']);
+
+ constructor() {
+ super();
+ this.componentRef.instance = new FakeComponent();
+ this.componentRef.changeDetectorRef =
+ jasmine.createSpyObj('changeDetectorRef', ['detectChanges']);
+ }
+
+ get selector(): string { return 'fake-component'; }
+ get componentType(): Type { return FakeComponent; }
+ get ngContentSelectors(): string[] { return ['content-1', 'content-2']; }
+ get inputs(): {propName: string; templateName: string}[] {
+ return [
+ {propName: 'input1', templateName: 'templateInput1'},
+ {propName: 'input1', templateName: 'templateInput2'},
+ ];
+ }
+
+ get outputs(): {propName: string; templateName: string}[] {
+ return [
+ {propName: 'output1', templateName: 'templateOutput1'},
+ {propName: 'output2', templateName: 'templateOutput2'},
+ ];
+ }
+
+ create(
+ injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string|any,
+ ngModule?: NgModuleRef): ComponentRef {
+ return this.componentRef;
+ }
+}
diff --git a/packages/elements/test/extract-projectable-nodes_spec.ts b/packages/elements/test/extract-projectable-nodes_spec.ts
new file mode 100644
index 0000000000..ea172e1254
--- /dev/null
+++ b/packages/elements/test/extract-projectable-nodes_spec.ts
@@ -0,0 +1,81 @@
+/**
+ * @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 {extractProjectableNodes} from '../src/extract-projectable-nodes';
+
+describe('extractProjectableNodes()', () => {
+ let elem: HTMLElement;
+ let childNodes: NodeList;
+
+ const expectProjectableNodes = (matches: {[selector: string]: number[]}) => {
+ const selectors = Object.keys(matches);
+ const expected = selectors.map(selector => {
+ const matchingIndices = matches[selector];
+ return matchingIndices.map(idx => childNodes[idx]);
+ });
+
+ expect(extractProjectableNodes(elem, selectors)).toEqual(expected);
+ };
+ const test = (matches: {[selector: string]: number[]}) => () => expectProjectableNodes(matches);
+
+ beforeEach(() => {
+ elem = document.createElement('div');
+ elem.innerHTML = '
' +
+ '' +
+ '
' +
+ '' +
+ '' +
+ 'Text' +
+ '' +
+ 'More text';
+ childNodes = Array.prototype.slice.call(elem.childNodes);
+ });
+
+ it('should match each node to the corresponding selector', test({
+ '[first]': [0],
+ '#bar': [1],
+ '#quux': [4],
+ }));
+
+ it('should ignore non-matching nodes', test({
+ '.zoo': [],
+ }));
+
+ it('should only match top-level child nodes', test({
+ 'span': [1],
+ '.bar': [],
+ }));
+
+ it('should support complex selectors', test({
+ '.foo:not(div)': [4],
+ 'div + #bar': [1],
+ }));
+
+ it('should match each node with the first matching selector', test({
+ 'div': [0],
+ '.foo': [4],
+ 'blink': [],
+ }));
+
+ describe('(with wildcard selector)', () => {
+ it('should match non-element nodes to `*` (but still ignore comments)', test({
+ 'div,span,blink': [0, 1, 4],
+ '*': [2, 3, 5],
+ }));
+
+ it('should match otherwise unmatched nodes to `*`', test({
+ 'div,blink': [0, 4],
+ '*': [1, 2, 3, 5],
+ }));
+
+ it('should give higher priority to `*` (eve if it appears first)', test({
+ '*': [2, 3, 5],
+ 'div,span,blink': [0, 1, 4],
+ }));
+ });
+});
diff --git a/packages/elements/test/ng-element-constructor_spec.ts b/packages/elements/test/ng-element-constructor_spec.ts
new file mode 100644
index 0000000000..a7e9114a96
--- /dev/null
+++ b/packages/elements/test/ng-element-constructor_spec.ts
@@ -0,0 +1,186 @@
+/**
+ * @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 {Component, ComponentFactory, EventEmitter, Input, NgModule, Output, destroyPlatform} from '@angular/core';
+import {BrowserModule} from '@angular/platform-browser';
+import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
+import {Subject} from 'rxjs/Subject';
+
+import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from '../src/element-strategy';
+import {NgElementConfig, NgElementConstructor, createNgElementConstructor} from '../src/ng-element-constructor';
+import {patchEnv, restoreEnv} from '../testing/index';
+
+type WithFooBar = {
+ fooFoo: string,
+ barBar: string
+};
+
+if (typeof customElements !== 'undefined') {
+ describe('createNgElementConstructor', () => {
+ let NgElementCtor: NgElementConstructor;
+ let factory: ComponentFactory;
+ let strategy: TestStrategy;
+ let strategyFactory: TestStrategyFactory;
+
+ beforeAll(() => patchEnv());
+ beforeAll(done => {
+ destroyPlatform();
+ platformBrowserDynamic()
+ .bootstrapModule(TestModule)
+ .then(ref => {
+ factory = ref.componentFactoryResolver.resolveComponentFactory(TestComponent);
+ strategyFactory = new TestStrategyFactory();
+ strategy = strategyFactory.testStrategy;
+
+ const config: NgElementConfig = {
+ strategyFactory,
+ propertyInputs: ['fooFoo', 'barBar'],
+ attributeToPropertyInputs:
+ new Map([['foo-foo', 'fooFoo'], ['barbar', 'barBar']])
+ };
+ NgElementCtor = createNgElementConstructor(config);
+
+ // The `@webcomponents/custom-elements/src/native-shim.js` polyfill allows us to create
+ // new instances of the NgElement which extends HTMLElement, as long as we define it.
+ customElements.define('test-element', NgElementCtor);
+ })
+ .then(done, done.fail);
+ });
+
+ afterAll(() => destroyPlatform());
+ afterAll(() => restoreEnv());
+
+ it('should use a default strategy for converting component inputs', () => {
+ expect(NgElementCtor.observedAttributes).toEqual(['foo-foo', 'barbar']);
+ });
+
+ it('should send input values from attributes when connected', () => {
+ const element = new NgElementCtor();
+ element.setAttribute('foo-foo', 'value-foo-foo');
+ element.setAttribute('barbar', 'value-barbar');
+ element.connectedCallback();
+ expect(strategy.connectedElement).toBe(element);
+
+ expect(strategy.getPropertyValue('fooFoo')).toBe('value-foo-foo');
+ expect(strategy.getPropertyValue('barBar')).toBe('value-barbar');
+ });
+
+ it('should listen to output events after connected', () => {
+ const element = new NgElementCtor();
+ element.connectedCallback();
+
+ let eventValue: any = null;
+ element.addEventListener('some-event', (e: CustomEvent) => eventValue = e.detail);
+ strategy.events.next({name: 'some-event', value: 'event-value'});
+
+ expect(eventValue).toEqual('event-value');
+ });
+
+ it('should not listen to output events after disconnected', () => {
+ const element = new NgElementCtor();
+ element.connectedCallback();
+ element.disconnectedCallback();
+ expect(strategy.disconnectCalled).toBe(true);
+
+ let eventValue: any = null;
+ element.addEventListener('some-event', (e: CustomEvent) => eventValue = e.detail);
+ strategy.events.next({name: 'some-event', value: 'event-value'});
+
+ expect(eventValue).toEqual(null);
+ });
+
+ it('should properly set getters/setters on the element', () => {
+ const element = new NgElementCtor();
+ element.fooFoo = 'foo-foo-value';
+ element.barBar = 'barBar-value';
+
+ expect(strategy.inputs.get('fooFoo')).toBe('foo-foo-value');
+ expect(strategy.inputs.get('barBar')).toBe('barBar-value');
+ });
+
+ describe('with different attribute strategy', () => {
+ let NgElementCtorWithChangedAttr: NgElementConstructor;
+ let element: HTMLElement;
+
+ beforeAll(() => {
+ strategyFactory = new TestStrategyFactory();
+ strategy = strategyFactory.testStrategy;
+ NgElementCtorWithChangedAttr = createNgElementConstructor({
+ strategyFactory: strategyFactory,
+ propertyInputs: ['prop1', 'prop2'],
+ attributeToPropertyInputs:
+ new Map([['attr-1', 'prop1'], ['attr-2', 'prop2']])
+ });
+
+ customElements.define('test-element-with-changed-attributes', NgElementCtorWithChangedAttr);
+ });
+
+ beforeEach(() => { element = new NgElementCtorWithChangedAttr(); });
+
+ it('should affect which attributes are watched', () => {
+ expect(NgElementCtorWithChangedAttr.observedAttributes).toEqual(['attr-1', 'attr-2']);
+ });
+
+ it('should send attribute values as inputs when connected', () => {
+ const element = new NgElementCtorWithChangedAttr();
+ element.setAttribute('attr-1', 'value-1');
+ element.setAttribute('attr-2', 'value-2');
+ element.setAttribute('attr-3', 'value-3'); // Made-up attribute
+ element.connectedCallback();
+
+ expect(strategy.getPropertyValue('prop1')).toBe('value-1');
+ expect(strategy.getPropertyValue('prop2')).toBe('value-2');
+ expect(strategy.getPropertyValue('prop3')).not.toBe('value-3');
+ });
+ });
+ });
+}
+
+// Helpers
+@Component({
+ selector: 'test-component',
+ template: 'TestComponent|foo({{ fooFoo }})|bar({{ barBar }})',
+})
+class TestComponent {
+ @Input() fooFoo: string = 'foo';
+ @Input('barbar') barBar: string;
+
+ @Output() bazBaz = new EventEmitter();
+ @Output('quxqux') quxQux = new EventEmitter