diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 048530e046fe..5f4a02dc5f68 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -33,6 +33,7 @@ import {SnackBarDemo} from './snack-bar/snack-bar-demo'; import {PortalDemo, ScienceJoke} from './portal/portal-demo'; import {MenuDemo} from './menu/menu-demo'; import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tabs/tabs-demo'; +import {ProjectionDemo, ProjectionTestComponent} from './projection/projection-demo'; @NgModule({ imports: [ @@ -66,6 +67,8 @@ import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tab PortalDemo, ProgressBarDemo, ProgressCircleDemo, + ProjectionDemo, + ProjectionTestComponent, RadioDemo, RippleDemo, RotiniPanel, diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 5a10843543af..ae2ab491bdf6 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -34,6 +34,7 @@ export class DemoApp { {name: 'Live Announcer', route: 'live-announcer'}, {name: 'Overlay', route: 'overlay'}, {name: 'Portal', route: 'portal'}, + {name: 'Projection', route: 'projection'}, {name: 'Progress Bar', route: 'progress-bar'}, {name: 'Progress Circle', route: 'progress-circle'}, {name: 'Radio', route: 'radio'}, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 68ec614443db..f2e1cf7b68e9 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -27,6 +27,7 @@ import {RippleDemo} from '../ripple/ripple-demo'; import {DialogDemo} from '../dialog/dialog-demo'; import {TooltipDemo} from '../tooltip/tooltip-demo'; import {SnackBarDemo} from '../snack-bar/snack-bar-demo'; +import {ProjectionDemo} from '../projection/projection-demo'; import {TABS_DEMO_ROUTES} from '../tabs/routes'; export const DEMO_APP_ROUTES: Routes = [ @@ -41,6 +42,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'progress-circle', component: ProgressCircleDemo}, {path: 'progress-bar', component: ProgressBarDemo}, {path: 'portal', component: PortalDemo}, + {path: 'projection', component: ProjectionDemo}, {path: 'overlay', component: OverlayDemo}, {path: 'checkbox', component: CheckboxDemo}, {path: 'input', component: InputDemo}, diff --git a/src/demo-app/projection/projection-demo.ts b/src/demo-app/projection/projection-demo.ts new file mode 100644 index 000000000000..1749090dee53 --- /dev/null +++ b/src/demo-app/projection/projection-demo.ts @@ -0,0 +1,50 @@ +import {Component, ViewChild, ElementRef, OnInit, Input} from '@angular/core'; +import {DomProjectionHost, DomProjection} from '@angular/material'; + + +@Component({ + selector: '[projection-test]', + template: ` +
+ Before + + After +
+ `, + styles: [` + .demo-outer { + background-color: #663399; + } + `] +}) +export class ProjectionTestComponent implements OnInit { + @ViewChild(DomProjectionHost) _host: DomProjectionHost; + @Input('class') cssClass: any; + + constructor(private _projection: DomProjection, private _ref: ElementRef) {} + + ngOnInit() { + this._projection.project(this._ref, this._host); + } +} + + +@Component({ + selector: 'projection-app', + template: ` +
+
Content: {{binding}}
+
+
+ + + `, + styles: [` + .demo-inner { + background-color: #DAA520; + } + `] +}) +export class ProjectionDemo { + binding: string = 'abc'; +} diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 5802e3b40284..7fce279561f2 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -26,6 +26,9 @@ export { } from './portal/portal-directives'; export {DomPortalHost} from './portal/dom-portal-host'; +// Projection +export * from './projection/projection'; + // Overlay export {Overlay, OVERLAY_PROVIDERS} from './overlay/overlay'; export {OverlayContainer} from './overlay/overlay-container'; diff --git a/src/lib/core/projection/projection.spec.ts b/src/lib/core/projection/projection.spec.ts new file mode 100644 index 000000000000..01cfd1acdb98 --- /dev/null +++ b/src/lib/core/projection/projection.spec.ts @@ -0,0 +1,88 @@ +import {TestBed, async} from '@angular/core/testing'; +import { + NgModule, + Component, + ViewChild, + ElementRef, +} from '@angular/core'; +import {ProjectionModule, DomProjection, DomProjectionHost} from './projection'; + + +describe('Projection', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ProjectionModule.forRoot(), ProjectionTestModule], + }); + + TestBed.compileComponents(); + })); + + it('should project properly', async(() => { + const fixture = TestBed.createComponent(ProjectionTestApp); + const appEl: HTMLDivElement = fixture.nativeElement; + const outerDivEl = appEl.querySelector('.outer'); + const innerDivEl = appEl.querySelector('.inner'); + + // Expect the reverse of the tests down there. + expect(appEl.querySelector('dom-projection-host')).not.toBeNull(); + expect(outerDivEl.querySelector('.inner')).not.toBe(innerDivEl); + + const innerHtml = appEl.innerHTML; + + // Trigger OnInit (and thus the projection). + fixture.detectChanges(); + + expect(appEl.innerHTML).not.toEqual(innerHtml); + + // Assert `` is not in the DOM anymore. + expect(appEl.querySelector('dom-projection-host')).toBeNull(); + + // Assert the outerDiv contains the innerDiv. + expect(outerDivEl.querySelector('.inner')).toBe(innerDivEl); + + // Assert the innerDiv contains the content. + expect(innerDivEl.querySelector('.content')).not.toBeNull(); + })); +}); + + +/** Test-bed component that contains a projection. */ +@Component({ + selector: '[projection-test]', + template: ` +
+ +
+ `, +}) +class ProjectionTestComponent { + @ViewChild(DomProjectionHost) _host: DomProjectionHost; + + constructor(private _projection: DomProjection, private _ref: ElementRef) {} + ngOnInit() { this._projection.project(this._ref, this._host); } +} + + +/** Test-bed component that contains a portal host and a couple of template portals. */ +@Component({ + selector: 'projection-app', + template: ` +
+
+
+ `, +}) +class ProjectionTestApp { +} + + + +const TEST_COMPONENTS = [ProjectionTestApp, ProjectionTestComponent]; +@NgModule({ + imports: [ProjectionModule], + exports: TEST_COMPONENTS, + declarations: TEST_COMPONENTS, + entryComponents: TEST_COMPONENTS, +}) +class ProjectionTestModule { } + diff --git a/src/lib/core/projection/projection.ts b/src/lib/core/projection/projection.ts new file mode 100644 index 000000000000..e6616d88be6c --- /dev/null +++ b/src/lib/core/projection/projection.ts @@ -0,0 +1,87 @@ +import {Injectable, Directive, ModuleWithProviders, NgModule, ElementRef} from '@angular/core'; + + +// "Polyfill" for `Node.replaceWith()`. +// cf. https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/replaceWith +function _replaceWith(toReplaceEl: HTMLElement, otherEl: HTMLElement) { + toReplaceEl.parentElement.replaceChild(otherEl, toReplaceEl); +} + + +@Directive({ + selector: 'dom-projection-host' +}) +export class DomProjectionHost { + constructor(public ref: ElementRef) {} +} + + +@Injectable() +export class DomProjection { + /** + * Project an element into a host element. + * Replace a host element by another element. This also replaces the children of the element + * by the children of the host. + * + * It should be used like this: + * + * ``` + * @Component({ + * template: `
+ * + *
other
+ * + *
+ *
` + * }) + * class Cmpt { + * constructor(private _projector: DomProjection, private _el: ElementRef) {} + * ngOnInit() { this._projector.project(this._el, this._projector); } + * } + * ``` + * + * This component will move the content of the element it's applied to in the outer div. Because + * `project()` also move the children of the host inside the projected element, the element will + * contain the `
other
` HTML as well as its own children. + * + * Note: without `` the projection will project an empty element. + */ + project(ref: ElementRef, host: DomProjectionHost): void { + const projectedEl = ref.nativeElement; + const hostEl = host.ref.nativeElement; + const childNodes = projectedEl.childNodes; + let child = childNodes[0]; + + // We hoist all of the projected element's children out into the projected elements position + // because we *only* want to move the projected element and not its children. + _replaceWith(projectedEl, child); + let l = childNodes.length; + while (l--) { + child.parentNode.insertBefore(childNodes[0], child.nextSibling); + child = child.nextSibling; // nextSibling is now the childNodes[0]. + } + + // Insert all host children under the projectedEl, then replace host by component. + l = hostEl.childNodes.length; + while (l--) { + projectedEl.appendChild(hostEl.childNodes[0]); + } + _replaceWith(hostEl, projectedEl); + + // At this point the host is replaced by the component. Nothing else to be done. + } +} + + +@NgModule({ + exports: [DomProjectionHost], + declarations: [DomProjectionHost], +}) +export class ProjectionModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: ProjectionModule, + providers: [DomProjection] + }; + } +} diff --git a/src/lib/module.ts b/src/lib/module.ts index 754226b7f0c6..31d18001e089 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -6,6 +6,7 @@ import { PortalModule, OverlayModule, A11yModule, + ProjectionModule, StyleCompatibilityModule, } from './core/index'; @@ -59,6 +60,7 @@ const MATERIAL_MODULES = [ PortalModule, RtlModule, A11yModule, + ProjectionModule, StyleCompatibilityModule, ]; @@ -78,6 +80,7 @@ const MATERIAL_MODULES = [ MdTabsModule.forRoot(), MdToolbarModule.forRoot(), PortalModule.forRoot(), + ProjectionModule.forRoot(), RtlModule.forRoot(), // These modules include providers.