From d7dfa8c441e7c27266477d4704fe42d3663c1789 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Wed, 28 Feb 2018 09:45:11 -0800 Subject: [PATCH 01/18] feat(elements): add support for creating custom elements --- .pullapprove.yml | 11 + BUILD.bazel | 1 + CONTRIBUTING.md | 1 + aio/content/guide/elements.md | 67 +++++ aio/content/navigation.json | 5 + .../transforms/angular-api-package/index.js | 1 + .../transforms/authors-package/api-package.js | 5 +- build.sh | 3 +- karma-js.conf.js | 9 + package.json | 3 + packages/elements/BUILD.bazel | 19 ++ packages/elements/index.ts | 14 + packages/elements/package.json | 22 ++ packages/elements/public_api.ts | 19 ++ packages/elements/rollup.config.js | 31 +++ .../src/component-factory-strategy.ts | 262 ++++++++++++++++++ packages/elements/src/element-strategy.ts | 41 +++ .../elements/src/extract-projectable-nodes.ts | 54 ++++ .../elements/src/ng-element-constructor.ts | 135 +++++++++ packages/elements/src/utils.ts | 103 +++++++ packages/elements/src/version.ts | 19 ++ packages/elements/test/BUILD.bazel | 49 ++++ .../test/component-factory-strategy_spec.ts | 78 ++++++ .../test/extract-projectable-nodes_spec.ts | 81 ++++++ .../test/ng-element-constructor_spec.ts | 186 +++++++++++++ packages/elements/test/utils_spec.ts | 231 +++++++++++++++ packages/elements/testing/BUILD.bazel | 19 ++ packages/elements/testing/index.ts | 115 ++++++++ packages/elements/tsconfig-build.json | 27 ++ test-main.js | 138 ++++++++- tools/cjs-jasmine/index.ts | 1 + tools/public_api_guard/elements/elements.d.ts | 77 +++++ .../commit-message.json | 1 + yarn.lock | 12 + 34 files changed, 1823 insertions(+), 17 deletions(-) create mode 100644 aio/content/guide/elements.md create mode 100644 packages/elements/BUILD.bazel create mode 100644 packages/elements/index.ts create mode 100644 packages/elements/package.json create mode 100644 packages/elements/public_api.ts create mode 100644 packages/elements/rollup.config.js create mode 100644 packages/elements/src/component-factory-strategy.ts create mode 100644 packages/elements/src/element-strategy.ts create mode 100644 packages/elements/src/extract-projectable-nodes.ts create mode 100644 packages/elements/src/ng-element-constructor.ts create mode 100644 packages/elements/src/utils.ts create mode 100644 packages/elements/src/version.ts create mode 100644 packages/elements/test/BUILD.bazel create mode 100644 packages/elements/test/component-factory-strategy_spec.ts create mode 100644 packages/elements/test/extract-projectable-nodes_spec.ts create mode 100644 packages/elements/test/ng-element-constructor_spec.ts create mode 100644 packages/elements/test/utils_spec.ts create mode 100644 packages/elements/testing/BUILD.bazel create mode 100644 packages/elements/testing/index.ts create mode 100644 packages/elements/tsconfig-build.json create mode 100644 tools/public_api_guard/elements/elements.d.ts diff --git a/.pullapprove.yml b/.pullapprove.yml index a7d887edc8535..7b2f26e9c7ceb 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 12719d12e8eb4..1b5a862bd1b99 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 74cec311bc107..1419eea496864 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 0000000000000..f17ba92ef6b14 --- /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 abd48b31422b8..938132a7ba1cf 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 49bc40905ac5a..043b3c50c9d4b 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 541d1ac45e4a1..5f07254b22648 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 45bf755443f54..cbd8ae7977195 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 f31417eda1e41..2015dd46ff5bf 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 788f0d7f6d28f..134bdd241bd78 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 0000000000000..e611e26cd34bb --- /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 0000000000000..e727e2e8a7551 --- /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 0000000000000..a03d8d251c4d3 --- /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 0000000000000..83a55578e727f --- /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 0000000000000..1aa8375c145db --- /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 0000000000000..51c785a71b820 --- /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 0000000000000..c5d8d4bc20fd1 --- /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 0000000000000..b6df05bcba8cb --- /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 0000000000000..025d7a68d1343 --- /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 0000000000000..e750e0af631be --- /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 0000000000000..ccdd01cba7e04 --- /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 0000000000000..e6a2893c27df0 --- /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 0000000000000..e0fc8a8a6d11e --- /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 0000000000000..ea172e1254c0a --- /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 0000000000000..a7e9114a96f89 --- /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(); +} + +@NgModule({ + imports: [BrowserModule], + declarations: [TestComponent], + entryComponents: [TestComponent], +}) +class TestModule { + ngDoBootstrap() {} +} + +export class TestStrategy implements NgElementStrategy { + connectedElement: HTMLElement|null = null; + disconnectCalled = false; + inputs = new Map(); + + events = new Subject(); + + connect(element: HTMLElement): void { this.connectedElement = element; } + + disconnect(): void { this.disconnectCalled = true; } + + getPropertyValue(propName: string): any { return this.inputs.get(propName); } + + setPropertyValue(propName: string, value: string): void { this.inputs.set(propName, value); } +} + +export class TestStrategyFactory implements NgElementStrategyFactory { + testStrategy = new TestStrategy(); + + create(): NgElementStrategy { return this.testStrategy; } +} diff --git a/packages/elements/test/utils_spec.ts b/packages/elements/test/utils_spec.ts new file mode 100644 index 0000000000000..718b14029451b --- /dev/null +++ b/packages/elements/test/utils_spec.ts @@ -0,0 +1,231 @@ +/** + * @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 {camelToKebabCase, createCustomEvent, isElement, isFunction, kebabToCamelCase, matchesSelector, scheduler, strictEquals} from '../src/utils'; + +describe('utils', () => { + describe('scheduler', () => { + describe('schedule()', () => { + let setTimeoutSpy: jasmine.Spy; + let clearTimeoutSpy: jasmine.Spy; + + beforeEach(() => { + setTimeoutSpy = spyOn(window, 'setTimeout').and.returnValue(42); + clearTimeoutSpy = spyOn(window, 'clearTimeout'); + }); + + it('should delegate to `window.setTimeout()`', () => { + const cb = () => null; + const delay = 1337; + + scheduler.schedule(cb, delay); + + expect(setTimeoutSpy).toHaveBeenCalledWith(cb, delay); + }); + + it('should return a function for cancelling the scheduled job', () => { + const cancelFn = scheduler.schedule(() => null, 0); + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + + cancelFn(); + expect(clearTimeoutSpy).toHaveBeenCalledWith(42); + }); + }); + + describe('scheduleBeforeRender()', () => { + if (typeof window.requestAnimationFrame === 'undefined') { + const mockCancelFn = () => undefined; + let scheduleSpy: jasmine.Spy; + + beforeEach(() => scheduleSpy = spyOn(scheduler, 'schedule').and.returnValue(mockCancelFn)); + + it('should delegate to `scheduler.schedule()`', () => { + const cb = () => null; + expect(scheduler.scheduleBeforeRender(cb)).toBe(mockCancelFn); + expect(scheduleSpy).toHaveBeenCalledWith(cb, 16); + }); + } else { + let requestAnimationFrameSpy: jasmine.Spy; + let cancelAnimationFrameSpy: jasmine.Spy; + + beforeEach(() => { + requestAnimationFrameSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(42); + cancelAnimationFrameSpy = spyOn(window, 'cancelAnimationFrame'); + }); + + it('should delegate to `window.requestAnimationFrame()`', () => { + const cb = () => null; + scheduler.scheduleBeforeRender(cb); + expect(requestAnimationFrameSpy).toHaveBeenCalledWith(cb); + }); + + it('should return a function for cancelling the scheduled job', () => { + const cancelFn = scheduler.scheduleBeforeRender(() => null); + expect(cancelAnimationFrameSpy).not.toHaveBeenCalled(); + + cancelFn(); + expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42); + }); + } + }); + }); + + describe('camelToKebabCase()', () => { + it('should convert camel-case to kebab-case', () => { + expect(camelToKebabCase('fooBarBazQux')).toBe('foo-bar-baz-qux'); + expect(camelToKebabCase('foo1Bar2Baz3Qux4')).toBe('foo1-bar2-baz3-qux4'); + }); + + it('should keep existing dashes', + () => { expect(camelToKebabCase('fooBar-baz-Qux')).toBe('foo-bar-baz--qux'); }); + }); + + describe('createCustomEvent()', () => { + it('should create a custom event (with appropriate properties)', () => { + const value = {bar: 'baz'}; + const event = createCustomEvent(document, 'foo', value); + + expect(event).toEqual(jasmine.any(CustomEvent)); + expect(event).toEqual(jasmine.any(Event)); + expect(event.type).toBe('foo'); + expect(event.bubbles).toBe(false); + expect(event.cancelable).toBe(false); + expect(event.detail).toEqual(value); + }); + + }); + + describe('isElement()', () => { + it('should return true for Element nodes', () => { + const elems = [ + document.body, + document.createElement('div'), + document.createElement('option'), + document.documentElement, + ]; + + elems.forEach(n => expect(isElement(n)).toBe(true)); + }); + + it('should return false for non-Element nodes', () => { + const nonElems = [ + document, + document.createAttribute('foo'), + document.createDocumentFragment(), + document.createComment('bar'), + document.createTextNode('baz'), + ]; + + nonElems.forEach(n => expect(isElement(n)).toBe(false)); + }); + }); + + describe('isFunction()', () => { + it('should return true for functions', () => { + const obj = {foo: function() {}, bar: () => null, baz() {}}; + const fns = [ + function(){}, + () => null, + obj.foo, + obj.bar, + obj.baz, + Function, + Date, + ]; + + fns.forEach(v => expect(isFunction(v)).toBe(true)); + }); + + it('should return false for non-functions', () => { + const nonFns = [ + undefined, + null, + true, + 42, + {}, + ]; + + nonFns.forEach(v => expect(isFunction(v)).toBe(false)); + }); + }); + + describe('kebabToCamelCase()', () => { + it('should convert camel-case to kebab-case', () => { + expect(kebabToCamelCase('foo-bar-baz-qux')).toBe('fooBarBazQux'); + expect(kebabToCamelCase('foo1-bar2-baz3-qux4')).toBe('foo1Bar2Baz3Qux4'); + expect(kebabToCamelCase('foo-1-bar-2-baz-3-qux-4')).toBe('foo1Bar2Baz3Qux4'); + }); + + it('should keep uppercase letters', () => { + expect(kebabToCamelCase('foo-barBaz-Qux')).toBe('fooBarBaz-Qux'); + expect(kebabToCamelCase('foo-barBaz--qux')).toBe('fooBarBaz-Qux'); + }); + }); + + describe('matchesSelector()', () => { + let li: HTMLLIElement; + + beforeEach(() => { + const div = document.createElement('div'); + div.innerHTML = ` +
+ +
    +
  • +
+
+ `; + li = div.querySelector('li') !; + }); + + it('should return whether the element matches the selector', () => { + expect(matchesSelector(li, 'li')).toBe(true); + expect(matchesSelector(li, '.qux')).toBe(true); + expect(matchesSelector(li, '#quxLi')).toBe(true); + expect(matchesSelector(li, '.qux#quxLi:not(.quux)')).toBe(true); + expect(matchesSelector(li, '.bar > #bazUl > li')).toBe(true); + expect(matchesSelector(li, '.bar .baz ~ .baz li')).toBe(true); + + expect(matchesSelector(li, 'ol')).toBe(false); + expect(matchesSelector(li, '.quux')).toBe(false); + expect(matchesSelector(li, '#quuxOl')).toBe(false); + expect(matchesSelector(li, '.qux#quxLi:not(li)')).toBe(false); + expect(matchesSelector(li, '.bar > #bazUl > .quxLi')).toBe(false); + expect(matchesSelector(li, 'div span ul li')).toBe(false); + }); + }); + + describe('strictEquals()', () => { + it('should perform strict equality check', () => { + const values = [ + undefined, + null, + true, + false, + 42, + '42', + () => undefined, + () => undefined, + {}, + {}, + ]; + + values.forEach((v1, i) => { + values.forEach((v2, j) => { expect(strictEquals(v1, v2)).toBe(i === j); }); + }); + }); + + it('should consider two `NaN` values equals', () => { + expect(strictEquals(NaN, NaN)).toBe(true); + expect(strictEquals(NaN, 'foo')).toBe(false); + expect(strictEquals(NaN, 42)).toBe(false); + expect(strictEquals(NaN, null)).toBe(false); + expect(strictEquals(NaN, undefined)).toBe(false); + }); + }); +}); diff --git a/packages/elements/testing/BUILD.bazel b/packages/elements/testing/BUILD.bazel new file mode 100644 index 0000000000000..41d20c0e92b37 --- /dev/null +++ b/packages/elements/testing/BUILD.bazel @@ -0,0 +1,19 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "testing", + srcs = glob( + [ + "*.ts", + ], + ), + module_name = "@angular/elements/testing", + deps = [ + "//packages/core", + "//packages/elements", + "//packages/platform-browser", + "@rxjs", + ], +) diff --git a/packages/elements/testing/index.ts b/packages/elements/testing/index.ts new file mode 100644 index 0000000000000..e2d2e4802655e --- /dev/null +++ b/packages/elements/testing/index.ts @@ -0,0 +1,115 @@ +/** + * @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 {scheduler} from '../src/utils'; + +export interface MockScheduler { + schedule: (typeof scheduler)['schedule']; + scheduleBeforeRender: (typeof scheduler)['scheduleBeforeRender']; +} + +export class AsyncMockScheduler implements MockScheduler { + private uid = 0; + private pendingBeforeRenderCallbacks: ({id: number, cb: () => void})[] = []; + private pendingDelayedCallbacks: ({id: number, cb: () => void, delay: number})[] = []; + + flushBeforeRender(): void { + while (this.pendingBeforeRenderCallbacks.length) { + const cb = this.pendingBeforeRenderCallbacks.shift() !.cb; + cb(); + } + } + + reset(): void { + this.pendingBeforeRenderCallbacks.length = 0; + this.pendingDelayedCallbacks.length = 0; + } + + schedule(cb: () => void, delay: number): () => void { + const id = ++this.uid; + let idx = this.pendingDelayedCallbacks.length; + + for (let i = this.pendingDelayedCallbacks.length - 1; i >= 0; --i) { + if (this.pendingDelayedCallbacks[i].delay <= delay) { + idx = i + 1; + break; + } + } + this.pendingDelayedCallbacks.splice(idx, 0, {id, cb, delay}); + + return () => this.remove(id, this.pendingDelayedCallbacks); + } + + scheduleBeforeRender(cb: () => void): () => void { + const id = ++this.uid; + this.pendingBeforeRenderCallbacks.push({id, cb}); + return () => this.remove(id, this.pendingBeforeRenderCallbacks); + } + + tick(ms: number): void { + this.flushBeforeRender(); + + this.pendingDelayedCallbacks.forEach(item => item.delay -= ms); + this.pendingDelayedCallbacks = this.pendingDelayedCallbacks.filter(item => { + if (item.delay <= 0) { + const cb = item.cb; + cb(); + return false; + } + return true; + }); + } + + private remove(id: number, items: {id: number}[]): void { + for (let i = 0, ii = items.length; i < ii; ++i) { + if (items[i].id === id) { + items.splice(i, 1); + break; + } + } + } +} + +export class SyncMockScheduler implements MockScheduler { + schedule(cb: () => void, delay: number): () => void { + cb(); + return () => undefined; + } + + scheduleBeforeRender(cb: () => void): () => void { + cb(); + return () => undefined; + } +} + +export function installMockScheduler(isSync?: false): AsyncMockScheduler; +export function installMockScheduler(isSync: true): SyncMockScheduler; +export function installMockScheduler(isSync?: boolean): AsyncMockScheduler|SyncMockScheduler { + const mockScheduler = isSync ? new SyncMockScheduler() : new AsyncMockScheduler(); + + Object.keys(scheduler).forEach((method: keyof typeof scheduler) => { + spyOn(scheduler, method).and.callFake(mockScheduler[method].bind(mockScheduler)); + }); + + return mockScheduler; +} + +export function patchEnv() { + // This helper function is defined in `test-main.js`. See there for more details. + // (//window as any).$$patchInnerHtmlProp(); +} + +export function restoreEnv() { + // This helper function is defined in `test-main.js`. See there for more details. + //(window as any).$$restoreInnerHtmlProp(); +} + +export function supportsCustomElements() { + // The browser does not natively support custom elements and is not polyfillable. + return typeof customElements !== 'undefined'; +} diff --git a/packages/elements/tsconfig-build.json b/packages/elements/tsconfig-build.json new file mode 100644 index 0000000000000..47c15f8038e25 --- /dev/null +++ b/packages/elements/tsconfig-build.json @@ -0,0 +1,27 @@ +{ + "extends": "../tsconfig-build.json", + + "compilerOptions": { + "baseUrl": ".", + "rootDir": ".", + "paths": { + "@angular/core": ["../../dist/packages/core"], + "@angular/platform-browser": ["../../dist/packages/platform-browser"], + "rxjs/*": ["../../node_modules/rxjs/*"] + }, + "outDir": "../../dist/packages/elements" + }, + + "files": [ + "public_api.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ], + + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": false, + "skipTemplateCodegen": true, + "flatModuleOutFile": "elements.js", + "flatModuleId": "@angular/elements" + } +} diff --git a/test-main.js b/test-main.js index a38712d3fd0d3..436b775eb574b 100644 --- a/test-main.js +++ b/test-main.js @@ -65,25 +65,40 @@ System.config({ '@angular/platform-server': {main: 'index.js', defaultExtension: 'js'}, '@angular/platform-webworker': {main: 'index.js', defaultExtension: 'js'}, '@angular/platform-webworker-dynamic': {main: 'index.js', defaultExtension: 'js'}, + '@angular/elements': {main: 'index.js', defaultExtension: 'js'}, } }); -// Set up the test injector, then import all the specs, execute their `main()` -// method and kick off Karma (Jasmine). -System.import('@angular/core/testing') - .then(function(coreTesting) { - return Promise - .all([ - System.import('@angular/platform-browser-dynamic/testing'), - System.import('@angular/platform-browser/animations') - ]) - .then(function(mods) { - coreTesting.TestBed.initTestEnvironment( - [mods[0].BrowserDynamicTestingModule, mods[1].NoopAnimationsModule], - mods[0].platformBrowserDynamicTesting()); - }); +// Load browser-specific CustomElement polyfills, set up the test injector, import all the specs, +// execute their `main()` method and kick off Karma (Jasmine). +Promise + .resolve() + + // Load browser-specific polyfills for custom elements. + .then(function() { return loadCustomElementsPolyfills(); }) + + // Load necessary testing packages. + .then(function() { + return Promise.all([ + System.import('@angular/core/testing'), + System.import('@angular/platform-browser-dynamic/testing'), + System.import('@angular/platform-browser/animations') + ]); + }) + + // Set up the test injector. + .then(function(mods) { + var coreTesting = mods[0]; + var pbdTesting = mods[1]; + var pbAnimations = mods[2]; + + coreTesting.TestBed.initTestEnvironment( + [pbdTesting.BrowserDynamicTestingModule, pbAnimations.NoopAnimationsModule], + pbdTesting.platformBrowserDynamicTesting()); }) + + // Import all the specs and execute their `main()` method. .then(function() { return Promise.all(Object .keys(window.__karma__.files) // All files served by Karma. @@ -97,9 +112,104 @@ System.import('@angular/core/testing') }); })); }) + + // Kick off karma (Jasmine). .then(function() { __karma__.start(); }, function(error) { console.error(error); }); +function loadCustomElementsPolyfills() { + var loadedPromise = Promise.resolve(); + + // The custom elements polyfill relies on `MutationObserver`. + if (!window.MutationObserver) { + loadedPromise = + loadedPromise + .then(function() { return System.import('node_modules/mutation-observer/index.js'); }) + .then(function(MutationObserver) { window.MutationObserver = MutationObserver; }); + } + + // The custom elements polyfill relies on `Object.setPrototypeOf()`. + if (!Object.setPrototypeOf) { + var getDescriptor = function getDescriptor(obj, prop) { + var descriptor; + while (obj && !descriptor) { + descriptor = Object.getOwnPropertyDescriptor(obj, prop); + obj = Object.getPrototypeOf(obj); + } + return descriptor || {}; + }; + var setPrototypeOf = function setPrototypeOf(obj, proto) { + for (var prop in proto) { + if (!obj.hasOwnProperty(prop)) { + Object.defineProperty(obj, prop, getDescriptor(proto, prop)); + } + } + return obj; + }; + + Object.defineProperty(setPrototypeOf, '$$shimmed', {value: true}); + Object.setPrototypeOf = setPrototypeOf; + } + + // The custom elements polyfill will patch `(HTML)Element` properties, including `innerHTML`: + // https://github.com/webcomponents/custom-elements/blob/32f043c3a5e5fc3e035342c0ef10c6786fa416d7/src/Patch/Element.js#L28-L78 + // The patched `innerHTML` setter will try to traverse the DOM (via `nextSibling`), which leads to + // infinite loops when testing `HtmlSanitizer` with cloberred elements on browsers that do not + // support the `