Skip to content

Commit

Permalink
feat(projection): Host Projection service (#1756)
Browse files Browse the repository at this point in the history
  • Loading branch information
hansl authored and kara committed Nov 16, 2016
1 parent aa472a0 commit 522324c
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/demo-app/demo-app-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -66,6 +67,8 @@ import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tab
PortalDemo,
ProgressBarDemo,
ProgressCircleDemo,
ProjectionDemo,
ProjectionTestComponent,
RadioDemo,
RippleDemo,
RotiniPanel,
Expand Down
1 change: 1 addition & 0 deletions src/demo-app/demo-app/demo-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down
2 changes: 2 additions & 0 deletions src/demo-app/demo-app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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},
Expand Down
50 changes: 50 additions & 0 deletions src/demo-app/projection/projection-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {Component, ViewChild, ElementRef, OnInit, Input} from '@angular/core';
import {DomProjectionHost, DomProjection} from '@angular/material';


@Component({
selector: '[projection-test]',
template: `
<div class="demo-outer {{cssClass}}">
Before
<dom-projection-host><ng-content></ng-content></dom-projection-host>
After
</div>
`,
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: `
<div projection-test class="demo-inner">
<div class="content">Content: {{binding}}</div>
</div>
<br/>
<input projection-test [(ngModel)]="binding" [class]="binding" [ngClass]="{'blue': true}">
<input [(ngModel)]="binding" class="my-class" [ngClass]="{'blue': true}">
`,
styles: [`
.demo-inner {
background-color: #DAA520;
}
`]
})
export class ProjectionDemo {
binding: string = 'abc';
}
3 changes: 3 additions & 0 deletions src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
88 changes: 88 additions & 0 deletions src/lib/core/projection/projection.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<dom-projection-host>` 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: `
<div class="outer">
<dom-projection-host><ng-content></ng-content></dom-projection-host>
</div>
`,
})
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: `
<div projection-test class="inner">
<div class="content"></div>
</div>
`,
})
class ProjectionTestApp {
}



const TEST_COMPONENTS = [ProjectionTestApp, ProjectionTestComponent];
@NgModule({
imports: [ProjectionModule],
exports: TEST_COMPONENTS,
declarations: TEST_COMPONENTS,
entryComponents: TEST_COMPONENTS,
})
class ProjectionTestModule { }

87 changes: 87 additions & 0 deletions src/lib/core/projection/projection.ts
Original file line number Diff line number Diff line change
@@ -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: `<div>
* <dom-projection-host>
* <div>other</div>
* <ng-content></ng-content>
* </dom-projection-host>
* </div>`
* })
* 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 `<div>other</div>` HTML as well as its own children.
*
* Note: without `<ng-content></ng-content>` 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]
};
}
}
3 changes: 3 additions & 0 deletions src/lib/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PortalModule,
OverlayModule,
A11yModule,
ProjectionModule,
StyleCompatibilityModule,
} from './core/index';

Expand Down Expand Up @@ -59,6 +60,7 @@ const MATERIAL_MODULES = [
PortalModule,
RtlModule,
A11yModule,
ProjectionModule,
StyleCompatibilityModule,
];

Expand All @@ -78,6 +80,7 @@ const MATERIAL_MODULES = [
MdTabsModule.forRoot(),
MdToolbarModule.forRoot(),
PortalModule.forRoot(),
ProjectionModule.forRoot(),
RtlModule.forRoot(),

// These modules include providers.
Expand Down

0 comments on commit 522324c

Please sign in to comment.