Skip to content

Commit 07f8973

Browse files
committed
feat(projection): Host Projection service
1 parent bec1519 commit 07f8973

File tree

8 files changed

+206
-0
lines changed

8 files changed

+206
-0
lines changed

src/demo-app/demo-app-module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {SnackBarDemo} from './snack-bar/snack-bar-demo';
3333
import {PortalDemo, ScienceJoke} from './portal/portal-demo';
3434
import {MenuDemo} from './menu/menu-demo';
3535
import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tabs/tabs-demo';
36+
import {ProjectionDemo, ProjectionTestComponent} from './projection/projection-demo';
3637

3738
@NgModule({
3839
imports: [
@@ -66,6 +67,8 @@ import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tab
6667
PortalDemo,
6768
ProgressBarDemo,
6869
ProgressCircleDemo,
70+
ProjectionDemo,
71+
ProjectionTestComponent,
6972
RadioDemo,
7073
RippleDemo,
7174
RotiniPanel,

src/demo-app/demo-app/demo-app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class DemoApp {
3434
{name: 'Live Announcer', route: 'live-announcer'},
3535
{name: 'Overlay', route: 'overlay'},
3636
{name: 'Portal', route: 'portal'},
37+
{name: 'Projection', route: 'projection'},
3738
{name: 'Progress Bar', route: 'progress-bar'},
3839
{name: 'Progress Circle', route: 'progress-circle'},
3940
{name: 'Radio', route: 'radio'},

src/demo-app/demo-app/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {RippleDemo} from '../ripple/ripple-demo';
2727
import {DialogDemo} from '../dialog/dialog-demo';
2828
import {TooltipDemo} from '../tooltip/tooltip-demo';
2929
import {SnackBarDemo} from '../snack-bar/snack-bar-demo';
30+
import {ProjectionDemo} from '../projection/projection-demo';
3031
import {TABS_DEMO_ROUTES} from '../tabs/routes';
3132

3233
export const DEMO_APP_ROUTES: Routes = [
@@ -41,6 +42,7 @@ export const DEMO_APP_ROUTES: Routes = [
4142
{path: 'progress-circle', component: ProgressCircleDemo},
4243
{path: 'progress-bar', component: ProgressBarDemo},
4344
{path: 'portal', component: PortalDemo},
45+
{path: 'projection', component: ProjectionDemo},
4446
{path: 'overlay', component: OverlayDemo},
4547
{path: 'checkbox', component: CheckboxDemo},
4648
{path: 'input', component: InputDemo},
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {Component, ViewChild, ElementRef, OnInit, Input} from '@angular/core';
2+
import {MdProjectionHostDirective, MdProjectionService} from '@angular/material';
3+
4+
5+
@Component({
6+
selector: '[projection-test]',
7+
template: `
8+
<div class="outer {{cssClass}}">
9+
Before
10+
<md-host><ng-content></ng-content></md-host>
11+
After
12+
</div>
13+
`,
14+
styles: [`
15+
.outer {
16+
background-color: red;
17+
}
18+
`]
19+
})
20+
export class ProjectionTestComponent implements OnInit {
21+
@ViewChild(MdProjectionHostDirective) _host: MdProjectionHostDirective;
22+
@Input('class') cssClass: any;
23+
24+
constructor(private _projection: MdProjectionService, private _ref: ElementRef) {}
25+
26+
ngOnInit() {
27+
this._projection.project(this._ref, this._host);
28+
}
29+
}
30+
31+
32+
@Component({
33+
selector: 'projection-app',
34+
template: `
35+
<div projection-test class="inner">
36+
<div class="content">Content: {{binding}}</div>
37+
</div>
38+
<br/>
39+
<input projection-test [(ngModel)]="binding" [class]="binding" [ngClass]="{'blue': true}">
40+
<input [(ngModel)]="binding" class="my-class" [ngClass]="{'blue': true}">
41+
`,
42+
styles: [`
43+
.inner {
44+
background-color: blue;
45+
}
46+
`]
47+
})
48+
export class ProjectionDemo {
49+
binding: string = 'abc';
50+
}

src/lib/core/core.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export {
2626
} from './portal/portal-directives';
2727
export {DomPortalHost} from './portal/dom-portal-host';
2828

29+
// Projection
30+
export * from './projection/projection';
31+
2932
// Overlay
3033
export {Overlay, OVERLAY_PROVIDERS} from './overlay/overlay';
3134
export {OverlayContainer} from './overlay/overlay-container';
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {TestBed, async} from '@angular/core/testing';
2+
import {
3+
NgModule,
4+
Component,
5+
ViewChild,
6+
ElementRef,
7+
} from '@angular/core';
8+
import {ProjectionModule, MdProjectionService, MdProjectionHostDirective} from './projection';
9+
10+
11+
describe('Projection', () => {
12+
beforeEach(async(() => {
13+
TestBed.configureTestingModule({
14+
imports: [ProjectionModule.forRoot(), ProjectionTestModule],
15+
});
16+
17+
TestBed.compileComponents();
18+
}));
19+
20+
it('should project properly', async(() => {
21+
const fixture = TestBed.createComponent(ProjectionTestApp);
22+
const appEl: HTMLDivElement = fixture.nativeElement;
23+
const outerDivEl = appEl.querySelector('.outer');
24+
const innerDivEl = appEl.querySelector('.inner');
25+
26+
// Expect the reverse of the tests down there.
27+
expect(appEl.querySelector('md-host')).not.toBeNull();
28+
expect(outerDivEl.querySelector('.inner')).not.toBe(innerDivEl);
29+
30+
const innerHtml = appEl.innerHTML;
31+
32+
// Trigger OnInit (and thus the projection).
33+
fixture.detectChanges();
34+
35+
expect(appEl.innerHTML).not.toEqual(innerHtml);
36+
37+
// Assert `<md-host>` is not in the DOM anymore.
38+
expect(appEl.querySelector('md-host')).toBeNull();
39+
40+
// Assert the outerDiv contains the innerDiv.
41+
expect(outerDivEl.querySelector('.inner')).toBe(innerDivEl);
42+
43+
// Assert the innerDiv contains the content.
44+
expect(innerDivEl.querySelector('.content')).not.toBeNull();
45+
}));
46+
});
47+
48+
49+
/** Test-bed component that contains a projection. */
50+
@Component({
51+
selector: '[projection-test]',
52+
template: `
53+
<div class="outer">
54+
<md-host><ng-content></ng-content></md-host>
55+
</div>
56+
`,
57+
})
58+
class ProjectionTestComponent {
59+
@ViewChild(MdProjectionHostDirective) _host: MdProjectionHostDirective;
60+
61+
constructor(private _projection: MdProjectionService, private _ref: ElementRef) {}
62+
ngOnInit() { this._projection.project(this._ref, this._host); }
63+
}
64+
65+
66+
/** Test-bed component that contains a portal host and a couple of template portals. */
67+
@Component({
68+
selector: 'projection-app',
69+
template: `
70+
<div projection-test class="inner">
71+
<div class="content"></div>
72+
</div>
73+
`,
74+
})
75+
class ProjectionTestApp {
76+
}
77+
78+
79+
80+
const TEST_COMPONENTS = [ProjectionTestApp, ProjectionTestComponent];
81+
@NgModule({
82+
imports: [ProjectionModule],
83+
exports: TEST_COMPONENTS,
84+
declarations: TEST_COMPONENTS,
85+
entryComponents: TEST_COMPONENTS,
86+
})
87+
class ProjectionTestModule { }
88+

src/lib/core/projection/projection.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {Injectable, Directive, ModuleWithProviders, NgModule, ElementRef} from '@angular/core';
2+
3+
4+
@Directive({
5+
selector: 'md-host'
6+
})
7+
export class MdProjectionHostDirective {
8+
constructor(public ref: ElementRef) {}
9+
}
10+
11+
12+
@Injectable()
13+
export class MdProjectionService {
14+
private _replaceWith(toReplaceEl: HTMLElement, otherEl: HTMLElement) {
15+
toReplaceEl.parentElement.replaceChild(toReplaceEl, otherEl);
16+
}
17+
18+
/**
19+
* Project an element into a host element.
20+
*/
21+
project(ref: ElementRef, host: MdProjectionHostDirective): void {
22+
const componentEl = ref.nativeElement;
23+
const hostEl = host.ref.nativeElement;
24+
const childNodes = componentEl.childNodes;
25+
let child = childNodes[0];
26+
27+
// Replace ComponentEl by all its children, in place.
28+
this._replaceWith(componentEl, child);
29+
while (childNodes.length) {
30+
child.parentNode.insertBefore(childNodes[0], child.nextSibling);
31+
child = child.nextSibling; // nextSibling is now the childNodes[0].
32+
}
33+
34+
// Insert all host children under the componentEl, then replace host by component.
35+
while (hostEl.childNodes.length) {
36+
componentEl.appendChild(hostEl.childNodes[0]);
37+
}
38+
this._replaceWith(hostEl, componentEl);
39+
40+
// At this point the host is replaced by the component. Nothing else to be done.
41+
}
42+
}
43+
44+
45+
@NgModule({
46+
exports: [MdProjectionHostDirective],
47+
declarations: [MdProjectionHostDirective],
48+
})
49+
export class ProjectionModule {
50+
static forRoot(): ModuleWithProviders {
51+
return {
52+
ngModule: ProjectionModule,
53+
providers: [MdProjectionService]
54+
};
55+
}
56+
}

src/lib/module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
PortalModule,
77
OverlayModule,
88
A11yModule,
9+
ProjectionModule,
910
StyleCompatibilityModule,
1011
} from './core/index';
1112

@@ -59,6 +60,7 @@ const MATERIAL_MODULES = [
5960
PortalModule,
6061
RtlModule,
6162
A11yModule,
63+
ProjectionModule,
6264
StyleCompatibilityModule,
6365
];
6466

@@ -78,6 +80,7 @@ const MATERIAL_MODULES = [
7880
MdTabsModule.forRoot(),
7981
MdToolbarModule.forRoot(),
8082
PortalModule.forRoot(),
83+
ProjectionModule.forRoot(),
8184
RtlModule.forRoot(),
8285

8386
// These modules include providers.

0 commit comments

Comments
 (0)