From 3553977bd75c8e168f1d4bfa42b7f09c677f7cbf Mon Sep 17 00:00:00 2001 From: Rob Wormald Date: Thu, 28 Jun 2018 12:42:04 -0700 Subject: [PATCH] feat(core): add support for ShadowDOM v1 (#24718) add a new ViewEncapsulation.ShadowDom option that uses the v1 Shadow DOM API to provide style encapsulation. PR Close #24718 --- aio/content/guide/component-styles.md | 8 +- packages/compiler/src/core.ts | 3 +- packages/core/src/metadata/view.ts | 18 ++- .../core/ts/metadata/encapsulation.ts | 35 +++++ .../platform-browser/src/dom/dom_renderer.ts | 7 +- .../test/dom/dom_renderer_spec.ts | 12 +- .../test/dom/shadow_dom_spec.ts | 122 ++++++++++++++++++ tools/public_api_guard/core/core.d.ts | 1 + 8 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 packages/examples/core/ts/metadata/encapsulation.ts create mode 100644 packages/platform-browser/test/dom/shadow_dom_spec.ts diff --git a/aio/content/guide/component-styles.md b/aio/content/guide/component-styles.md index aef31566b0..f7d564fca1 100644 --- a/aio/content/guide/component-styles.md +++ b/aio/content/guide/component-styles.md @@ -280,12 +280,14 @@ To control how this encapsulation happens on a *per component* basis, you can set the *view encapsulation mode* in the component metadata. Choose from the following modes: -* `Native` view encapsulation uses the browser's native shadow DOM implementation (see +* `ShadowDom` view encapsulation uses the browser's native shadow DOM implementation (see [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM) on the [MDN](https://developer.mozilla.org) site) to attach a shadow DOM to the component's host element, and then puts the component view inside that shadow DOM. The component's styles are included within the shadow DOM. +* `Native` view encapsulation uses a now deprecated version of the browser's native shadow DOM implementation - [learn about the changes](https://hayato.io/2016/shadowdomv1/). + * `Emulated` view encapsulation (the default) emulates the behavior of shadow DOM by preprocessing (and renaming) the CSS code to effectively scope the CSS to the component's view. For details, see [Appendix 1](guide/component-styles#inspect-generated-css). @@ -300,8 +302,8 @@ To set the components encapsulation mode, use the `encapsulation` property in th -`Native` view encapsulation only works on browsers that have native support -for shadow DOM (see [Shadow DOM v0](http://caniuse.com/#feat=shadowdom) on the +`ShadowDom` view encapsulation only works on browsers that have native support +for shadow DOM (see [Shadow DOM v1](https://caniuse.com/#feat=shadowdomv1) on the [Can I use](http://caniuse.com) site). The support is still limited, which is why `Emulated` view encapsulation is the default mode and recommended in most cases. diff --git a/packages/compiler/src/core.ts b/packages/compiler/src/core.ts index 1c704baf3f..001db134b9 100644 --- a/packages/compiler/src/core.ts +++ b/packages/compiler/src/core.ts @@ -75,7 +75,8 @@ export interface Component extends Directive { export enum ViewEncapsulation { Emulated = 0, Native = 1, - None = 2 + None = 2, + ShadowDom = 3 } export enum ChangeDetectionStrategy { diff --git a/packages/core/src/metadata/view.ts b/packages/core/src/metadata/view.ts index 1874257075..84efefcd5e 100644 --- a/packages/core/src/metadata/view.ts +++ b/packages/core/src/metadata/view.ts @@ -23,14 +23,28 @@ export enum ViewEncapsulation { */ Emulated = 0, /** + * @deprecated v6.1.0 - use {ViewEncapsulation.ShadowDom} instead. * Use the native encapsulation mechanism of the renderer. * - * For the DOM this means using [Shadow DOM](https://w3c.github.io/webcomponents/spec/shadow/) and + * For the DOM this means using the deprecated [Shadow DOM + * v0](https://w3c.github.io/webcomponents/spec/shadow/) and * creating a ShadowRoot for Component's Host Element. */ Native = 1, /** * Don't provide any template or style encapsulation. */ - None = 2 + None = 2, + + /** + * Use Shadow DOM to encapsulate styles. + * + * For the DOM this means using modern [Shadow + * DOM](https://w3c.github.io/webcomponents/spec/shadow/) and + * creating a ShadowRoot for Component's Host Element. + * + * ### Example + * {@example core/ts/metadata/encapsulation.ts region='longform'} + */ + ShadowDom = 3 } diff --git a/packages/examples/core/ts/metadata/encapsulation.ts b/packages/examples/core/ts/metadata/encapsulation.ts new file mode 100644 index 0000000000..04ac6928cc --- /dev/null +++ b/packages/examples/core/ts/metadata/encapsulation.ts @@ -0,0 +1,35 @@ +/** + * @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, ViewEncapsulation} from '@angular/core'; + +// #docregion longform +@Component({ + selector: 'my-app', + template: ` +

Hello World!

+ Shadow DOM Rocks! + `, + styles: [` + :host { + display: block; + border: 1px solid black; + } + h1 { + color: blue; + } + .red { + background-color: red; + } + + `], + encapsulation: ViewEncapsulation.ShadowDom +}) +class MyApp { +} +// #enddocregion diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index 6715d20513..9b583692ce 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -83,6 +83,7 @@ export class DomRendererFactory2 implements RendererFactory2 { return renderer; } case ViewEncapsulation.Native: + case ViewEncapsulation.ShadowDom: return new ShadowDomRenderer(this.eventManager, this.sharedStylesHost, element, type); default: { if (!this.rendererByCompId.has(type.id)) { @@ -256,7 +257,11 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { eventManager: EventManager, private sharedStylesHost: DomSharedStylesHost, private hostEl: any, private component: RendererType2) { super(eventManager); - this.shadowRoot = (hostEl as any).createShadowRoot(); + if (component.encapsulation === ViewEncapsulation.ShadowDom) { + this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'}); + } else { + this.shadowRoot = (hostEl as any).createShadowRoot(); + } this.sharedStylesHost.addHost(this.shadowRoot); const styles = flattenStyles(component.id, component.styles, []); for (let i = 0; i < styles.length; i++) { diff --git a/packages/platform-browser/test/dom/dom_renderer_spec.ts b/packages/platform-browser/test/dom/dom_renderer_spec.ts index 1386e8b617..9e69d3d5ad 100644 --- a/packages/platform-browser/test/dom/dom_renderer_spec.ts +++ b/packages/platform-browser/test/dom/dom_renderer_spec.ts @@ -20,7 +20,8 @@ import {NAMESPACE_URIS} from '../../src/dom/dom_renderer'; beforeEach(() => { TestBed.configureTestingModule({ declarations: [ - TestCmp, SomeApp, CmpEncapsulationEmulated, CmpEncapsulationNative, CmpEncapsulationNone + TestCmp, SomeApp, CmpEncapsulationEmulated, CmpEncapsulationNative, CmpEncapsulationNone, + CmpEncapsulationNative ] }); renderer = TestBed.createComponent(TestCmp).componentInstance.renderer; @@ -135,6 +136,15 @@ class CmpEncapsulationEmulated { class CmpEncapsulationNone { } +@Component({ + selector: 'cmp-shadow', + template: `
`, + styles: [`.native { color: red; }`], + encapsulation: ViewEncapsulation.ShadowDom +}) +class CmpEncapsulationShadow { +} + @Component({ selector: 'some-app', template: ` diff --git a/packages/platform-browser/test/dom/shadow_dom_spec.ts b/packages/platform-browser/test/dom/shadow_dom_spec.ts new file mode 100644 index 0000000000..b7a1b8f1e5 --- /dev/null +++ b/packages/platform-browser/test/dom/shadow_dom_spec.ts @@ -0,0 +1,122 @@ +/** + * @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, EventEmitter, Injector, Input, NgModule, Output, Renderer2, ViewEncapsulation, destroyPlatform} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {BrowserModule} from '@angular/platform-browser'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; + +function supportsShadowDOMV1() { + const testEl = document.createElement('div'); + return (typeof customElements !== 'undefined') && (typeof testEl.attachShadow !== 'undefined'); +} + +if (supportsShadowDOMV1()) { + describe('ShadowDOM Support', () => { + + let testContainer: HTMLDivElement; + + beforeEach(() => { TestBed.configureTestingModule({imports: [TestModule]}); }); + + it('should attach and use a shadowRoot when ViewEncapsulation.Native is set', () => { + const compEl = TestBed.createComponent(ShadowComponent).nativeElement; + expect(compEl.shadowRoot !.textContent).toEqual('Hello World'); + }); + + it('should use the shadow root to encapsulate styles', () => { + const compEl = TestBed.createComponent(StyledShadowComponent).nativeElement; + expect(window.getComputedStyle(compEl).border).toEqual('1px solid rgb(0, 0, 0)'); + const redDiv = compEl.shadowRoot.querySelector('div.red'); + expect(window.getComputedStyle(redDiv).border).toEqual('1px solid rgb(255, 0, 0)'); + }); + + it('should allow the usage of elements', () => { + const el = TestBed.createComponent(ShadowSlotComponent).nativeElement; + const projectedContent = document.createTextNode('Hello Slot!'); + el.appendChild(projectedContent); + const slot = el.shadowRoot !.querySelector('slot'); + + expect(slot !.assignedNodes().length).toBe(1); + expect(slot !.assignedNodes()[0].textContent).toBe('Hello Slot!'); + }); + + it('should allow the usage of named elements', () => { + const el = TestBed.createComponent(ShadowSlotsComponent).nativeElement; + + const headerContent = document.createElement('h1'); + headerContent.setAttribute('slot', 'header'); + headerContent.textContent = 'Header Text!'; + + const articleContent = document.createElement('span'); + articleContent.setAttribute('slot', 'article'); + articleContent.textContent = 'Article Text!'; + + const articleSubcontent = document.createElement('span'); + articleSubcontent.setAttribute('slot', 'article'); + articleSubcontent.textContent = 'Article Subtext!'; + + el.appendChild(headerContent); + el.appendChild(articleContent); + el.appendChild(articleSubcontent); + + const headerSlot = el.shadowRoot !.querySelector('slot[name=header]') as HTMLSlotElement; + const articleSlot = el.shadowRoot !.querySelector('slot[name=article]') as HTMLSlotElement; + + expect(headerSlot !.assignedNodes().length).toBe(1); + expect(headerSlot !.assignedNodes()[0].textContent).toBe('Header Text!'); + expect(headerContent.assignedSlot).toBe(headerSlot); + + expect(articleSlot !.assignedNodes().length).toBe(2); + expect(articleSlot !.assignedNodes()[0].textContent).toBe('Article Text!'); + expect(articleSlot !.assignedNodes()[1].textContent).toBe('Article Subtext!'); + expect(articleContent.assignedSlot).toBe(articleSlot); + expect(articleSubcontent.assignedSlot).toBe(articleSlot); + }); + }); +} + +@Component( + {selector: 'shadow-comp', template: 'Hello World', encapsulation: ViewEncapsulation.ShadowDom}) +class ShadowComponent { +} + +@Component({ + selector: 'styled-shadow-comp', + template: '
', + encapsulation: ViewEncapsulation.ShadowDom, + styles: [`:host { border: 1px solid black; } .red { border: 1px solid red; }`] +}) +class StyledShadowComponent { +} + +@Component({ + selector: 'shadow-slot-comp', + template: '', + encapsulation: ViewEncapsulation.ShadowDom +}) +class ShadowSlotComponent { +} + +@Component({ + selector: 'shadow-slots-comp', + template: + '
', + encapsulation: ViewEncapsulation.ShadowDom +}) +class ShadowSlotsComponent { +} + +@NgModule({ + imports: [BrowserModule], + declarations: [ShadowComponent, ShadowSlotComponent, ShadowSlotsComponent, StyledShadowComponent], + entryComponents: + [ShadowComponent, ShadowSlotComponent, ShadowSlotsComponent, StyledShadowComponent], +}) +class TestModule { + ngDoBootstrap() {} +} diff --git a/tools/public_api_guard/core/core.d.ts b/tools/public_api_guard/core/core.d.ts index a522d23f7f..a24304599d 100644 --- a/tools/public_api_guard/core/core.d.ts +++ b/tools/public_api_guard/core/core.d.ts @@ -938,6 +938,7 @@ export declare enum ViewEncapsulation { Emulated = 0, Native = 1, None = 2, + ShadowDom = 3, } export declare abstract class ViewRef extends ChangeDetectorRef {