Skip to content

Commit 522324c

Browse files
hanslkara
authored andcommitted
feat(projection): Host Projection service (#1756)
1 parent aa472a0 commit 522324c

File tree

8 files changed

+237
-0
lines changed

8 files changed

+237
-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 {DomProjectionHost, DomProjection} from '@angular/material';
3+
4+
5+
@Component({
6+
selector: '[projection-test]',
7+
template: `
8+
<div class="demo-outer {{cssClass}}">
9+
Before
10+
<dom-projection-host><ng-content></ng-content></dom-projection-host>
11+
After
12+
</div>
13+
`,
14+
styles: [`
15+
.demo-outer {
16+
background-color: #663399;
17+
}
18+
`]
19+
})
20+
export class ProjectionTestComponent implements OnInit {
21+
@ViewChild(DomProjectionHost) _host: DomProjectionHost;
22+
@Input('class') cssClass: any;
23+
24+
constructor(private _projection: DomProjection, 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="demo-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+
.demo-inner {
44+
background-color: #DAA520;
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, DomProjection, DomProjectionHost} 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('dom-projection-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 `<dom-projection-host>` is not in the DOM anymore.
38+
expect(appEl.querySelector('dom-projection-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+
<dom-projection-host><ng-content></ng-content></dom-projection-host>
55+
</div>
56+
`,
57+
})
58+
class ProjectionTestComponent {
59+
@ViewChild(DomProjectionHost) _host: DomProjectionHost;
60+
61+
constructor(private _projection: DomProjection, 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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {Injectable, Directive, ModuleWithProviders, NgModule, ElementRef} from '@angular/core';
2+
3+
4+
// "Polyfill" for `Node.replaceWith()`.
5+
// cf. https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/replaceWith
6+
function _replaceWith(toReplaceEl: HTMLElement, otherEl: HTMLElement) {
7+
toReplaceEl.parentElement.replaceChild(otherEl, toReplaceEl);
8+
}
9+
10+
11+
@Directive({
12+
selector: 'dom-projection-host'
13+
})
14+
export class DomProjectionHost {
15+
constructor(public ref: ElementRef) {}
16+
}
17+
18+
19+
@Injectable()
20+
export class DomProjection {
21+
/**
22+
* Project an element into a host element.
23+
* Replace a host element by another element. This also replaces the children of the element
24+
* by the children of the host.
25+
*
26+
* It should be used like this:
27+
*
28+
* ```
29+
* @Component({
30+
* template: `<div>
31+
* <dom-projection-host>
32+
* <div>other</div>
33+
* <ng-content></ng-content>
34+
* </dom-projection-host>
35+
* </div>`
36+
* })
37+
* class Cmpt {
38+
* constructor(private _projector: DomProjection, private _el: ElementRef) {}
39+
* ngOnInit() { this._projector.project(this._el, this._projector); }
40+
* }
41+
* ```
42+
*
43+
* This component will move the content of the element it's applied to in the outer div. Because
44+
* `project()` also move the children of the host inside the projected element, the element will
45+
* contain the `<div>other</div>` HTML as well as its own children.
46+
*
47+
* Note: without `<ng-content></ng-content>` the projection will project an empty element.
48+
*/
49+
project(ref: ElementRef, host: DomProjectionHost): void {
50+
const projectedEl = ref.nativeElement;
51+
const hostEl = host.ref.nativeElement;
52+
const childNodes = projectedEl.childNodes;
53+
let child = childNodes[0];
54+
55+
// We hoist all of the projected element's children out into the projected elements position
56+
// because we *only* want to move the projected element and not its children.
57+
_replaceWith(projectedEl, child);
58+
let l = childNodes.length;
59+
while (l--) {
60+
child.parentNode.insertBefore(childNodes[0], child.nextSibling);
61+
child = child.nextSibling; // nextSibling is now the childNodes[0].
62+
}
63+
64+
// Insert all host children under the projectedEl, then replace host by component.
65+
l = hostEl.childNodes.length;
66+
while (l--) {
67+
projectedEl.appendChild(hostEl.childNodes[0]);
68+
}
69+
_replaceWith(hostEl, projectedEl);
70+
71+
// At this point the host is replaced by the component. Nothing else to be done.
72+
}
73+
}
74+
75+
76+
@NgModule({
77+
exports: [DomProjectionHost],
78+
declarations: [DomProjectionHost],
79+
})
80+
export class ProjectionModule {
81+
static forRoot(): ModuleWithProviders {
82+
return {
83+
ngModule: ProjectionModule,
84+
providers: [DomProjection]
85+
};
86+
}
87+
}

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)