diff --git a/src/compiler/app-core/build-conditionals.ts b/src/compiler/app-core/build-conditionals.ts index 7b1e159ba9a..c1eea7a25a7 100644 --- a/src/compiler/app-core/build-conditionals.ts +++ b/src/compiler/app-core/build-conditionals.ts @@ -3,6 +3,8 @@ import * as d from '../../declarations'; export function getBuildFeatures(cmps: d.ComponentCompilerMeta[]) { const slot = cmps.some(c => c.htmlTagNames.includes('slot')); + const shadowDom = cmps.some(c => c.encapsulation === 'shadow'); + const f: d.BuildFeatures = { allRenderFn: cmps.every(c => c.hasRenderFn), cmpDidLoad: cmps.some(c => c.hasComponentDidLoadFn), @@ -39,7 +41,8 @@ export function getBuildFeatures(cmps: d.ComponentCompilerMeta[]) { propMutable: cmps.some(c => c.hasPropMutable), reflect: cmps.some(c => c.hasReflect), scoped: cmps.some(c => c.encapsulation === 'scoped'), - shadowDom: cmps.some(c => c.encapsulation === 'shadow'), + shadowDom, + shadowDelegatesFocus: shadowDom && cmps.some(c => c.shadowDelegatesFocus), slot, slotRelocation: slot, // TODO: cmps.some(c => c.htmlTagNames.includes('slot') && c.encapsulation !== 'shadow'), state: cmps.some(c => c.hasState), diff --git a/src/compiler/app-core/format-component-runtime-meta.ts b/src/compiler/app-core/format-component-runtime-meta.ts index 197c9ea35ed..e1057a589fd 100644 --- a/src/compiler/app-core/format-component-runtime-meta.ts +++ b/src/compiler/app-core/format-component-runtime-meta.ts @@ -14,6 +14,9 @@ export const formatComponentRuntimeMeta = (compilerMeta: d.ComponentCompilerMeta let flags = 0; if (compilerMeta.encapsulation === 'shadow') { flags |= CMP_FLAGS.shadowDomEncapsulation; + if (compilerMeta.shadowDelegatesFocus) { + flags |= CMP_FLAGS.shadowDelegatesFocus; + } } else if (compilerMeta.encapsulation === 'scoped') { flags |= CMP_FLAGS.scopedCssEncapsulation; } diff --git a/src/compiler/browser/build-conditionals-client.ts b/src/compiler/browser/build-conditionals-client.ts index efb5b37a542..3053353dee2 100644 --- a/src/compiler/browser/build-conditionals-client.ts +++ b/src/compiler/browser/build-conditionals-client.ts @@ -36,6 +36,7 @@ export const BUILD: Required = { reflect: true, asyncLoading: true, scoped: true, + shadowDelegatesFocus: true, shadowDom: true, slot: true, state: true, diff --git a/src/compiler/transformers/collections/parse-collection-deprecated.ts b/src/compiler/transformers/collections/parse-collection-deprecated.ts index 9ce9f82da19..426e9eae243 100644 --- a/src/compiler/transformers/collections/parse-collection-deprecated.ts +++ b/src/compiler/transformers/collections/parse-collection-deprecated.ts @@ -49,6 +49,7 @@ function parseComponentDeprecated(config: d.Config, compilerCtx: d.CompilerCtx, elementRef: parseHostElementMember(cmpData), events: parseEvents(cmpData), encapsulation: parseEncapsulation(cmpData), + shadowDelegatesFocus: null, watchers: parseWatchers(cmpData), legacyConnect: parseConnectProps(cmpData), legacyContext: parseContextProps(cmpData), diff --git a/src/compiler/transformers/decorators-to-static/component-decorator.ts b/src/compiler/transformers/decorators-to-static/component-decorator.ts index f18e0369902..396e96fa79f 100644 --- a/src/compiler/transformers/decorators-to-static/component-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/component-decorator.ts @@ -23,6 +23,12 @@ export const componentDecoratorToStatic = (config: d.Config, typeChecker: ts.Typ if (componentOptions.shadow) { newMembers.push(createStaticGetter('encapsulation', convertValueToLiteral('shadow'))); + if (typeof componentOptions.shadow !== 'boolean') { + if (componentOptions.shadow.delegatesFocus === true) { + newMembers.push(createStaticGetter('delegatesFocus', convertValueToLiteral(true))); + } + } + } else if (componentOptions.scoped) { newMembers.push(createStaticGetter('encapsulation', convertValueToLiteral('scoped'))); } diff --git a/src/compiler/transformers/static-to-meta/component.ts b/src/compiler/transformers/static-to-meta/component.ts index dfd5947b7a0..2e73f4dddd8 100644 --- a/src/compiler/transformers/static-to-meta/component.ts +++ b/src/compiler/transformers/static-to-meta/component.ts @@ -6,7 +6,7 @@ import { parseStaticListeners } from './listeners'; import { setComponentBuildConditionals } from '../component-build-conditionals'; import { parseClassMethods } from './class-methods'; import { parseStaticElementRef } from './element-ref'; -import { parseStaticEncapsulation } from './encapsulation'; +import { parseStaticEncapsulation, parseStaticShadowDelegatesFocus } from './encapsulation'; import { parseStaticEvents } from './events'; import { getComponentTagName, getStaticValue, isInternal, isStaticGetter, serializeSymbol } from '../transform-utils'; import { parseStaticProps } from './props'; @@ -31,6 +31,7 @@ export const parseStaticComponentMeta = (config: d.Config, compilerCtx: d.Compil const symbol = typeChecker.getSymbolAtLocation(cmpNode.name); const docs = serializeSymbol(typeChecker, symbol); const isCollectionDependency = moduleFile.isCollectionDependency; + const encapsulation = parseStaticEncapsulation(staticMembers); const cmp: d.ComponentCompilerMeta = { isLegacy: false, @@ -39,7 +40,8 @@ export const parseStaticComponentMeta = (config: d.Config, compilerCtx: d.Compil isCollectionDependency, componentClassName: (cmpNode.name ? cmpNode.name.text : ''), elementRef: parseStaticElementRef(staticMembers), - encapsulation: parseStaticEncapsulation(staticMembers), + encapsulation, + shadowDelegatesFocus: parseStaticShadowDelegatesFocus(encapsulation, staticMembers), properties: parseStaticProps(staticMembers), virtualProperties: parseVirtualProps(docs), states: parseStaticStates(staticMembers), diff --git a/src/compiler/transformers/static-to-meta/encapsulation.ts b/src/compiler/transformers/static-to-meta/encapsulation.ts index 4c42e141d53..fe0a44d0ec5 100644 --- a/src/compiler/transformers/static-to-meta/encapsulation.ts +++ b/src/compiler/transformers/static-to-meta/encapsulation.ts @@ -14,3 +14,12 @@ export const parseStaticEncapsulation = (staticMembers: ts.ClassElement[]) => { return 'none'; }; + + +export const parseStaticShadowDelegatesFocus = (encapsulation: string, staticMembers: ts.ClassElement[]) => { + if (encapsulation === 'shadow') { + const delegatesFocus: boolean = getStaticValue(staticMembers, 'delegatesFocus'); + return !!delegatesFocus; + } + return null; +}; diff --git a/src/compiler/transformers/test/parse-encapsulation.spec.ts b/src/compiler/transformers/test/parse-encapsulation.spec.ts index 4d8877b949d..04425d532ee 100644 --- a/src/compiler/transformers/test/parse-encapsulation.spec.ts +++ b/src/compiler/transformers/test/parse-encapsulation.spec.ts @@ -13,7 +13,49 @@ describe('parse encapsulation', () => { `); expect(getStaticGetter(t.outputText, 'encapsulation')).toEqual('shadow'); + expect(getStaticGetter(t.outputText, 'delegatesFocus')).toEqual(undefined); + expect(getStaticGetter(t.outputText, 'mode')).toEqual(undefined); + + expect(t.cmp.encapsulation).toBe('shadow'); + expect(t.cmp.shadowDelegatesFocus).toBe(false); + }); + + it('delegatesFocus true', () => { + const t = transpileModule(` + @Component({ + tag: 'cmp-a', + shadow: { + delegatesFocus: true + } + }) + export class CmpA {} + `); + + expect(getStaticGetter(t.outputText, 'encapsulation')).toEqual('shadow'); + expect(getStaticGetter(t.outputText, 'delegatesFocus')).toEqual(true); + expect(getStaticGetter(t.outputText, 'mode')).toEqual(undefined); + + expect(t.cmp.encapsulation).toBe('shadow'); + expect(t.cmp.shadowDelegatesFocus).toBe(true); + }); + + it('delegatesFocus false', () => { + const t = transpileModule(` + @Component({ + tag: 'cmp-a', + shadow: { + delegatesFocus: false + } + }) + export class CmpA {} + `); + + expect(getStaticGetter(t.outputText, 'encapsulation')).toEqual('shadow'); + expect(getStaticGetter(t.outputText, 'delegatesFocus')).toEqual(undefined); + expect(getStaticGetter(t.outputText, 'mode')).toEqual(undefined); + expect(t.cmp.encapsulation).toBe('shadow'); + expect(t.cmp.shadowDelegatesFocus).toBe(false); }); it('scoped', () => { @@ -27,6 +69,7 @@ describe('parse encapsulation', () => { expect(getStaticGetter(t.outputText, 'encapsulation')).toEqual('scoped'); expect(t.cmp.encapsulation).toBe('scoped'); + expect(t.cmp.shadowDelegatesFocus).toBe(null); }); it('no encapsulation', () => { @@ -39,6 +82,7 @@ describe('parse encapsulation', () => { expect(t.outputText).not.toContain(`static get encapsulation()`); expect(t.cmp.encapsulation).toBe('none'); + expect(t.cmp.shadowDelegatesFocus).toBe(null); }); }); diff --git a/src/compiler/transformers/test/transpile.ts b/src/compiler/transformers/test/transpile.ts index 383dd6416d1..e39d7bf0c17 100644 --- a/src/compiler/transformers/test/transpile.ts +++ b/src/compiler/transformers/test/transpile.ts @@ -83,7 +83,7 @@ export function transpileModule(input: string, config?: d.Config, compilerCtx?: outputText = outputText.replace(/ /g, ' '); } - const moduleFile = compilerCtx.moduleMap.values().next().value; + const moduleFile: d.Module = compilerCtx.moduleMap.values().next().value; const cmps = moduleFile ? moduleFile.cmps : null; const cmp = Array.isArray(cmps) && cmps.length > 0 ? cmps[0] : null; const tagName = cmp ? cmp.tagName : null; diff --git a/src/declarations/build-conditionals.ts b/src/declarations/build-conditionals.ts index e8fd46f8d6c..a396d6eb0e8 100644 --- a/src/declarations/build-conditionals.ts +++ b/src/declarations/build-conditionals.ts @@ -6,6 +6,7 @@ export interface BuildFeatures { // dom shadowDom: boolean; + shadowDelegatesFocus: boolean; scoped: boolean; // render diff --git a/src/declarations/component-compiler-meta.ts b/src/declarations/component-compiler-meta.ts index 80c2b539e50..0c63fddeaa9 100644 --- a/src/declarations/component-compiler-meta.ts +++ b/src/declarations/component-compiler-meta.ts @@ -60,6 +60,7 @@ export interface ComponentCompilerMeta extends ComponentCompilerFeatures { componentClassName: string; elementRef: string; encapsulation: Encapsulation; + shadowDelegatesFocus: boolean; excludeFromCollection: boolean; isCollectionDependency: boolean; isLegacy: boolean; diff --git a/src/declarations/decorators.ts b/src/declarations/decorators.ts index 9a36525def8..78b204e522b 100644 --- a/src/declarations/decorators.ts +++ b/src/declarations/decorators.ts @@ -23,10 +23,11 @@ export interface ComponentOptions { scoped?: boolean; /** - * If `true`, the component will use native shadow-dom encapsulation, it will fallback to `scoped` if the browser - * does not support shadow-dom nativelly. Defaults to `false`. + * If `true`, the component will use native shadow-dom encapsulation, it will fallback to + * `scoped` if the browser does not support shadow-dom nativelly. Defaults to `false`. + * Additionally, `shadow` can also be given options when attaching the shadow root. */ - shadow?: boolean; + shadow?: boolean | ShadowRootOptions; /** * Relative URL to some external stylesheet file. It should be a `.css` file unless some @@ -58,6 +59,14 @@ export interface ComponentOptions { assetsDir?: string; } +export interface ShadowRootOptions { + /** + * When set to `true`, specifies behavior that mitigates custom element issues + * around focusability. When a non-focusable part of the shadow DOM is clicked, the first + * focusable part is given focus, and the shadow host is given any available `:focus` styling. + */ + delegatesFocus?: boolean; +} export interface PropDecorator { (opts?: PropOptions): PropertyDecorator; diff --git a/src/runtime/bootstrap-lazy.ts b/src/runtime/bootstrap-lazy.ts index a7cb2331a29..63d43d261cc 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -91,8 +91,19 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. // this component is using shadow dom // and this browser supports shadow dom // add the read-only property "shadowRoot" to the host element + // adding the shadow root build conditionals to minimize runtime if (supportsShadowDom) { - self.attachShadow({ 'mode': 'open' }); + + if (BUILD.shadowDelegatesFocus) { + self.attachShadow({ + mode: 'open', + delegatesFocus: !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDelegatesFocus), + }); + + } else { + self.attachShadow({ mode: 'open' }); + } + } else if (!BUILD.hydrateServerSide && !('shadowRoot' in self)) { (self as any).shadowRoot = self; } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a38c4ed6033..30a98949958 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -56,8 +56,8 @@ export const enum CMP_FLAGS { shadowDomEncapsulation = 1 << 0, scopedCssEncapsulation = 1 << 1, hasSlotRelocation = 1 << 2, - needsShadowDomShim = 1 << 3, + shadowDelegatesFocus = 1 << 4, needsScopedEncapsulation = scopedCssEncapsulation | needsShadowDomShim, } diff --git a/test/karma/test-app/components.d.ts b/test/karma/test-app/components.d.ts index 5799f96dfdb..b7bfb8f97b9 100644 --- a/test/karma/test-app/components.d.ts +++ b/test/karma/test-app/components.d.ts @@ -55,6 +55,7 @@ export namespace Components { interface CssVariablesNoEncapsulation {} interface CssVariablesShadowDom {} interface CustomEventRoot {} + interface DelegatesFocus {} interface DomReattach { 'didLoad': number; 'didUnload': number; @@ -118,6 +119,7 @@ export namespace Components { interface ListenJsx {} interface ListenJsxRoot {} interface ListenWindow {} + interface NoDelegatesFocus {} interface NodeGlobals {} interface NodeResolution {} interface ReflectToAttr { @@ -317,6 +319,12 @@ declare global { new (): HTMLCustomEventRootElement; }; + interface HTMLDelegatesFocusElement extends Components.DelegatesFocus, HTMLStencilElement {} + var HTMLDelegatesFocusElement: { + prototype: HTMLDelegatesFocusElement; + new (): HTMLDelegatesFocusElement; + }; + interface HTMLDomReattachElement extends Components.DomReattach, HTMLStencilElement {} var HTMLDomReattachElement: { prototype: HTMLDomReattachElement; @@ -533,6 +541,12 @@ declare global { new (): HTMLListenWindowElement; }; + interface HTMLNoDelegatesFocusElement extends Components.NoDelegatesFocus, HTMLStencilElement {} + var HTMLNoDelegatesFocusElement: { + prototype: HTMLNoDelegatesFocusElement; + new (): HTMLNoDelegatesFocusElement; + }; + interface HTMLNodeGlobalsElement extends Components.NodeGlobals, HTMLStencilElement {} var HTMLNodeGlobalsElement: { prototype: HTMLNodeGlobalsElement; @@ -845,6 +859,7 @@ declare global { 'css-variables-no-encapsulation': HTMLCssVariablesNoEncapsulationElement; 'css-variables-shadow-dom': HTMLCssVariablesShadowDomElement; 'custom-event-root': HTMLCustomEventRootElement; + 'delegates-focus': HTMLDelegatesFocusElement; 'dom-reattach': HTMLDomReattachElement; 'dom-reattach-clone': HTMLDomReattachCloneElement; 'dom-reattach-clone-deep-slot': HTMLDomReattachCloneDeepSlotElement; @@ -881,6 +896,7 @@ declare global { 'listen-jsx': HTMLListenJsxElement; 'listen-jsx-root': HTMLListenJsxRootElement; 'listen-window': HTMLListenWindowElement; + 'no-delegates-focus': HTMLNoDelegatesFocusElement; 'node-globals': HTMLNodeGlobalsElement; 'node-resolution': HTMLNodeResolutionElement; 'reflect-to-attr': HTMLReflectToAttrElement; @@ -974,6 +990,7 @@ declare namespace LocalJSX { interface CssVariablesNoEncapsulation {} interface CssVariablesShadowDom {} interface CustomEventRoot {} + interface DelegatesFocus {} interface DomReattach { 'didLoad'?: number; 'didUnload'?: number; @@ -1041,6 +1058,7 @@ declare namespace LocalJSX { interface ListenJsx {} interface ListenJsxRoot {} interface ListenWindow {} + interface NoDelegatesFocus {} interface NodeGlobals {} interface NodeResolution {} interface ReflectToAttr { @@ -1147,6 +1165,7 @@ declare namespace LocalJSX { 'css-variables-no-encapsulation': CssVariablesNoEncapsulation; 'css-variables-shadow-dom': CssVariablesShadowDom; 'custom-event-root': CustomEventRoot; + 'delegates-focus': DelegatesFocus; 'dom-reattach': DomReattach; 'dom-reattach-clone': DomReattachClone; 'dom-reattach-clone-deep-slot': DomReattachCloneDeepSlot; @@ -1183,6 +1202,7 @@ declare namespace LocalJSX { 'listen-jsx': ListenJsx; 'listen-jsx-root': ListenJsxRoot; 'listen-window': ListenWindow; + 'no-delegates-focus': NoDelegatesFocus; 'node-globals': NodeGlobals; 'node-resolution': NodeResolution; 'reflect-to-attr': ReflectToAttr; @@ -1259,6 +1279,7 @@ declare module "@stencil/core" { 'css-variables-no-encapsulation': LocalJSX.CssVariablesNoEncapsulation & JSXBase.HTMLAttributes; 'css-variables-shadow-dom': LocalJSX.CssVariablesShadowDom & JSXBase.HTMLAttributes; 'custom-event-root': LocalJSX.CustomEventRoot & JSXBase.HTMLAttributes; + 'delegates-focus': LocalJSX.DelegatesFocus & JSXBase.HTMLAttributes; 'dom-reattach': LocalJSX.DomReattach & JSXBase.HTMLAttributes; 'dom-reattach-clone': LocalJSX.DomReattachClone & JSXBase.HTMLAttributes; 'dom-reattach-clone-deep-slot': LocalJSX.DomReattachCloneDeepSlot & JSXBase.HTMLAttributes; @@ -1295,6 +1316,7 @@ declare module "@stencil/core" { 'listen-jsx': LocalJSX.ListenJsx & JSXBase.HTMLAttributes; 'listen-jsx-root': LocalJSX.ListenJsxRoot & JSXBase.HTMLAttributes; 'listen-window': LocalJSX.ListenWindow & JSXBase.HTMLAttributes; + 'no-delegates-focus': LocalJSX.NoDelegatesFocus & JSXBase.HTMLAttributes; 'node-globals': LocalJSX.NodeGlobals & JSXBase.HTMLAttributes; 'node-resolution': LocalJSX.NodeResolution & JSXBase.HTMLAttributes; 'reflect-to-attr': LocalJSX.ReflectToAttr & JSXBase.HTMLAttributes; diff --git a/test/karma/test-app/delegates-focus/delegates-focus.css b/test/karma/test-app/delegates-focus/delegates-focus.css new file mode 100644 index 00000000000..79697c8b45b --- /dev/null +++ b/test/karma/test-app/delegates-focus/delegates-focus.css @@ -0,0 +1,16 @@ + +:host { + display: block; + border: 5px solid red; + padding: 10px; + margin: 10px; +} + +input { + display: block; + width: 100%; +} + +:host(:focus) { + border: 5px solid blue; +} diff --git a/test/karma/test-app/delegates-focus/delegates-focus.tsx b/test/karma/test-app/delegates-focus/delegates-focus.tsx new file mode 100644 index 00000000000..4415cb87677 --- /dev/null +++ b/test/karma/test-app/delegates-focus/delegates-focus.tsx @@ -0,0 +1,19 @@ +import { Component, h, Host } from '@stencil/core'; + +@Component({ + tag: 'delegates-focus', + shadow: { + delegatesFocus: true + }, + styleUrl: 'delegates-focus.css' +}) +export class DelegatesFocus { + + render() { + return ( + + + + ) + } +} diff --git a/test/karma/test-app/delegates-focus/index.html b/test/karma/test-app/delegates-focus/index.html new file mode 100644 index 00000000000..2b0e9e553de --- /dev/null +++ b/test/karma/test-app/delegates-focus/index.html @@ -0,0 +1,27 @@ + + + + + + + + +
+ +
Delegate Focus Enabled:
+ + +
+ +
Delegate Focus Not Enabled:
+ + + + diff --git a/test/karma/test-app/delegates-focus/karma.spec.ts b/test/karma/test-app/delegates-focus/karma.spec.ts new file mode 100644 index 00000000000..ebf5556e278 --- /dev/null +++ b/test/karma/test-app/delegates-focus/karma.spec.ts @@ -0,0 +1,38 @@ +import { setupDomTests, waitForChanges } from '../util'; + + +describe('delegates-focus', function() { + if (navigator.userAgent.indexOf('Chrome/') === -1) { + return; + } + + const { setupDom, tearDownDom } = setupDomTests(document); + let app: HTMLElement; + + beforeEach(async () => { + app = await setupDom('/delegates-focus/index.html'); + }); + afterEach(tearDownDom); + + it('should delegate focus', async () => { + const button = app.querySelector('button'); + const delegateFocusElm = app.querySelector('delegates-focus'); + const noDelegateFocusElm = app.querySelector('no-delegates-focus'); + + const delegateFocusStyles1 = window.getComputedStyle(delegateFocusElm); + expect(delegateFocusStyles1.borderColor).toBe('rgb(255, 0, 0)'); + + const noDelegateFocusStyles1 = window.getComputedStyle(noDelegateFocusElm); + expect(noDelegateFocusStyles1.borderColor).toBe('rgb(255, 0, 0)'); + + button.click(); + await waitForChanges(); + + const delegateFocusStyles2 = window.getComputedStyle(delegateFocusElm); + expect(delegateFocusStyles2.borderColor).toBe('rgb(0, 0, 255)'); + + const noDelegateFocusStyles2 = window.getComputedStyle(noDelegateFocusElm); + expect(noDelegateFocusStyles2.borderColor).toBe('rgb(255, 0, 0)'); + }); + +}); diff --git a/test/karma/test-app/delegates-focus/no-delegates-focus.tsx b/test/karma/test-app/delegates-focus/no-delegates-focus.tsx new file mode 100644 index 00000000000..73f3b262cb4 --- /dev/null +++ b/test/karma/test-app/delegates-focus/no-delegates-focus.tsx @@ -0,0 +1,19 @@ +import { Component, h, Host } from '@stencil/core'; + +@Component({ + tag: 'no-delegates-focus', + shadow: { + delegatesFocus: false + }, + styleUrl: 'delegates-focus.css' +}) +export class DelegatesFocus { + + render() { + return ( + + + + ) + } +}