diff --git a/packages/core/src/render/api.ts b/packages/core/src/render/api.ts index ac80fd769c4fb..32b9b457724e7 100644 --- a/packages/core/src/render/api.ts +++ b/packages/core/src/render/api.ts @@ -164,7 +164,7 @@ export abstract class Renderer2 { abstract appendChild(parent: any, newChild: any): void; abstract insertBefore(parent: any, newChild: any, refChild: any): void; abstract removeChild(parent: any, oldChild: any): void; - abstract selectRootElement(selectorOrNode: string|any): any; + abstract selectRootElement(selectorOrNode: string|any, preserveContent?: boolean): any; /** * Attention: On WebWorkers, this will always return a value, * as we are asking for a result synchronously. I.e. diff --git a/packages/core/src/view/element.ts b/packages/core/src/view/element.ts index 925d71367c499..1cc299557cf95 100644 --- a/packages/core/src/view/element.ts +++ b/packages/core/src/view/element.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {ViewEncapsulation} from '../metadata/view'; import {RendererType2} from '../render/api'; import {SecurityContext} from '../sanitization/security'; @@ -163,7 +164,11 @@ export function createElement(view: ViewData, renderHost: any, def: NodeDef): El renderer.appendChild(parentEl, el); } } else { - el = renderer.selectRootElement(rootSelectorOrNode); + // when using native Shadow DOM, do not clear the root element contents to allow slot projection + const preserveContent = + (!!elDef.componentRendererType && + elDef.componentRendererType.encapsulation === ViewEncapsulation.ShadowDom); + el = renderer.selectRootElement(rootSelectorOrNode, preserveContent); } if (elDef.attrs) { for (let i = 0; i < elDef.attrs.length; i++) { diff --git a/packages/core/src/view/services.ts b/packages/core/src/view/services.ts index 6a58b759259cd..29d947a754ca6 100644 --- a/packages/core/src/view/services.ts +++ b/packages/core/src/view/services.ts @@ -752,8 +752,8 @@ class DebugRenderer2 implements Renderer2 { this.delegate.removeChild(parent, oldChild); } - selectRootElement(selectorOrNode: string|any): any { - const el = this.delegate.selectRootElement(selectorOrNode); + selectRootElement(selectorOrNode: string|any, preserveContent?: boolean): any { + const el = this.delegate.selectRootElement(selectorOrNode, preserveContent); const debugCtx = getCurrentDebugContext(); if (debugCtx) { indexDebugNode(new DebugElement(el, null, debugCtx)); diff --git a/packages/elements/test/slots_spec.ts b/packages/elements/test/slots_spec.ts new file mode 100644 index 0000000000000..f0e37020ac6df --- /dev/null +++ b/packages/elements/test/slots_spec.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ComponentFactoryResolver, EventEmitter, Injector, Input, NgModule, Output, ViewEncapsulation, destroyPlatform} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; +import {Subject} from 'rxjs'; + +import {NgElement, NgElementConstructor, createCustomElement} from '../src/create-custom-element'; +import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from '../src/element-strategy'; + +type WithFooBar = { + fooFoo: string, + barBar: string +}; + +if (typeof customElements !== 'undefined') { + describe('slots', () => { + let testContainer: HTMLDivElement; + + beforeAll(done => { + testContainer = document.createElement('div'); + document.body.appendChild(testContainer); + destroyPlatform(); + platformBrowserDynamic() + .bootstrapModule(TestModule) + .then(ref => { + const injector = ref.injector; + const cfr: ComponentFactoryResolver = injector.get(ComponentFactoryResolver); + + testElements.forEach(comp => { + const compFactory = cfr.resolveComponentFactory(comp); + customElements.define(compFactory.selector, createCustomElement(comp, {injector})); + }); + }) + .then(done, done.fail); + }); + + afterAll(() => { + destroyPlatform(); + testContainer.remove(); + (testContainer as any) = null; + }); + + it('should use slots to project content', () => { + const tpl = `` + testContainer.innerHTML = tpl; + const testEl = testContainer.querySelector('default-slot-el') !; + const content = testContainer.querySelector('span.projected') !; + const slot = testEl.shadowRoot !.querySelector('slot') !; + const assignedNodes = slot.assignedNodes(); + expect(assignedNodes[0]).toEqual(content); + }); + + it('should use a named slot to project content', () => { + const tpl = `` + testContainer.innerHTML = tpl; + const testEl = testContainer.querySelector('named-slot-el') !; + const content = testContainer.querySelector('span.projected') !; + const slot = testEl.shadowRoot !.querySelector('slot[name=header]') as HTMLSlotElement; + const assignedNodes = slot.assignedNodes(); + expect(assignedNodes[0]).toEqual(content); + }); + + it('should use named slots to project content', () => { + const tpl = ` + + + + ` + testContainer.innerHTML = tpl; + const testEl = testContainer.querySelector('named-slots-el') !; + const headerContent = testContainer.querySelector('span.projected-header') !; + const bodyContent = testContainer.querySelector('span.projected-body') !; + const headerSlot = testEl.shadowRoot !.querySelector('slot[name=header]') as HTMLSlotElement; + const bodySlot = testEl.shadowRoot !.querySelector('slot[name=body]') as HTMLSlotElement; + + expect(headerContent.assignedSlot).toEqual(headerSlot); + expect(bodyContent.assignedSlot).toEqual(bodySlot); + }); + + it('should listen to slotchange events', (done) => { + const templateEl = document.createElement('template'); + const tpl = ` + + Content + ` + templateEl.innerHTML = tpl; + const template = templateEl.content.cloneNode(true) as DocumentFragment; + const testEl = template.querySelector('slot-events-el') !as NgElement & SlotEventsComponent; + const content = template.querySelector('span.projected'); + testEl.addEventListener('slotEventsChange', e => { + expect(testEl.slotEvents.length).toEqual(1); + done(); + }); + testContainer.appendChild(template); + expect(testEl.slotEvents.length).toEqual(0); + }); + }); +} + +// Helpers +@Component({ + selector: 'default-slot-el', + template: '
', + encapsulation: ViewEncapsulation.ShadowDom +}) +class DefaultSlotComponent { + constructor() {} +} + +@Component({ + selector: 'named-slot-el', + template: '
', + encapsulation: ViewEncapsulation.ShadowDom +}) +class NamedSlotComponent { + constructor() {} +} + +@Component({ + selector: 'named-slots-el', + template: '
', + encapsulation: ViewEncapsulation.ShadowDom +}) +class NamedSlotsComponent { + constructor() {} +} + +@Component({ + selector: 'default-slots-el', + template: '
', + encapsulation: ViewEncapsulation.ShadowDom +}) +class DefaultSlotsComponent { + constructor() {} +} + +@Component({ + selector: 'slot-events-el', + template: '', + encapsulation: ViewEncapsulation.ShadowDom +}) +class SlotEventsComponent { + @Input() slotEvents: Event[] = []; + @Output() slotEventsChange = new EventEmitter(); + constructor() {} + onSlotChange(event: Event) { + this.slotEvents.push(event); + this.slotEventsChange.emit(event); + } +} + +const testElements = + [ + DefaultSlotComponent, NamedSlotComponent, NamedSlotsComponent, DefaultSlotsComponent, + SlotEventsComponent + ] + + @NgModule({imports: [BrowserModule], declarations: testElements, entryComponents: testElements}) + class TestModule { + ngDoBootstrap() {} +} diff --git a/packages/platform-browser/animations/src/animation_renderer.ts b/packages/platform-browser/animations/src/animation_renderer.ts index b7cf1cc2f0526..e2fd8fbe294cb 100644 --- a/packages/platform-browser/animations/src/animation_renderer.ts +++ b/packages/platform-browser/animations/src/animation_renderer.ts @@ -152,7 +152,9 @@ export class BaseAnimationRenderer implements Renderer2 { this.engine.onRemove(this.namespaceId, oldChild, this.delegate); } - selectRootElement(selectorOrNode: any) { return this.delegate.selectRootElement(selectorOrNode); } + selectRootElement(selectorOrNode: any, preserveContent?: boolean) { + return this.delegate.selectRootElement(selectorOrNode, preserveContent); + } parentNode(node: any) { return this.delegate.parentNode(node); } diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index 9b583692ce8ae..9d92b7950394e 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -135,13 +135,16 @@ class DefaultDomRenderer2 implements Renderer2 { } } - selectRootElement(selectorOrNode: string|any): any { + selectRootElement(selectorOrNode: string|any, preserveContent?: boolean): any { let el: any = typeof selectorOrNode === 'string' ? document.querySelector(selectorOrNode) : selectorOrNode; if (!el) { throw new Error(`The selector "${selectorOrNode}" did not match any elements`); } - el.textContent = ''; + if (preserveContent) { + return el; + } + el.textContent = '' return el; } @@ -271,6 +274,20 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { } } + selectRootElement(selectorOrNode: string|any): any { + let el: any = typeof selectorOrNode === 'string' ? document.querySelector(selectorOrNode) : + selectorOrNode; + if (!el) { + throw new Error(`The selector "${selectorOrNode}" did not match any elements`); + } + // do not clear the existing element content when using ShadowDOM, to allow elements to + // work. + if (this.component.encapsulation !== ViewEncapsulation.ShadowDom) { + el.textContent = ''; + } + return el; + } + private nodeOrShadowRoot(node: any): any { return node === this.hostEl ? this.shadowRoot : node; } destroy() { this.sharedStylesHost.removeHost(this.shadowRoot); }