Skip to content

feat(projection): Host Projection service #1756

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 16, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment needs some expansion; the key thing to communicate is that it's only the given element that is projected and not its content (children). Also worth mention when you might want to use this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

* 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