Skip to content

Commit

Permalink
feat(delegatesFocus): ability to set delegatesFocus on shadow cmps
Browse files Browse the repository at this point in the history
Closes #1623

Co-Authored-By: Anthony Johnston <mrantix@users.noreply.github.com>
  • Loading branch information
adamdbradley and MrAntix committed Dec 18, 2019
1 parent d5591d3 commit f45c919
Show file tree
Hide file tree
Showing 19 changed files with 243 additions and 9 deletions.
3 changes: 3 additions & 0 deletions src/compiler/app-core/format-component-runtime-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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')));
}
Expand Down
6 changes: 4 additions & 2 deletions src/compiler/transformers/static-to-meta/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +40,7 @@ export const parseStaticComponentMeta = (
const symbol = typeChecker ? typeChecker.getSymbolAtLocation(cmpNode.name) : undefined;
const docs = serializeSymbol(typeChecker, symbol);
const isCollectionDependency = moduleFile.isCollectionDependency;
const encapsulation = parseStaticEncapsulation(staticMembers);

const cmp: d.ComponentCompilerMeta = {
isLegacy: false,
Expand All @@ -48,7 +49,8 @@ export const parseStaticComponentMeta = (
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),
Expand Down
9 changes: 9 additions & 0 deletions src/compiler/transformers/static-to-meta/encapsulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
44 changes: 44 additions & 0 deletions src/compiler/transformers/test/parse-encapsulation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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);
});

});
2 changes: 1 addition & 1 deletion src/compiler/transformers/test/transpile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/compiler_next/build/app-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export * from '../../app-data';

export function getBuildFeatures(cmps: ComponentCompilerMeta[]) {
const slot = cmps.some(c => c.htmlTagNames.includes('slot'));
const shadowDom = cmps.some(c => c.encapsulation === 'shadow');

const f: BuildFeatures = {
allRenderFn: cmps.every(c => c.hasRenderFn),
cmpDidLoad: cmps.some(c => c.hasComponentDidLoadFn),
Expand Down Expand Up @@ -42,7 +44,8 @@ export function getBuildFeatures(cmps: 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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export interface BuildFeatures {

// dom
shadowDom: boolean;
shadowDelegatesFocus: boolean;
scoped: boolean;

// render
Expand Down Expand Up @@ -878,6 +879,7 @@ export interface ComponentCompilerMeta extends ComponentCompilerFeatures {
componentClassName: string;
elementRef: string;
encapsulation: Encapsulation;
shadowDelegatesFocus: boolean;
excludeFromCollection: boolean;
isCollectionDependency: boolean;
isLegacy: boolean;
Expand Down
16 changes: 13 additions & 3 deletions src/declarations/stencil-public-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,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 natively. 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
Expand Down Expand Up @@ -55,6 +56,15 @@ 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 ModeStyles {
[modeName: string]: string | string[];
}
Expand Down
13 changes: 12 additions & 1 deletion src/runtime/bootstrap-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
22 changes: 22 additions & 0 deletions test/karma/test-app/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export namespace Components {
interface CssVariablesNoEncapsulation {}
interface CssVariablesShadowDom {}
interface CustomEventRoot {}
interface DelegatesFocus {}
interface DomReattach {
'didLoad': number;
'didUnload': number;
Expand Down Expand Up @@ -119,6 +120,7 @@ export namespace Components {
interface ListenJsx {}
interface ListenJsxRoot {}
interface ListenWindow {}
interface NoDelegatesFocus {}
interface NodeGlobals {}
interface NodeResolution {}
interface ReflectToAttr {
Expand Down Expand Up @@ -324,6 +326,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;
Expand Down Expand Up @@ -540,6 +548,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;
Expand Down Expand Up @@ -853,6 +867,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;
Expand Down Expand Up @@ -889,6 +904,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;
Expand Down Expand Up @@ -983,6 +999,7 @@ declare namespace LocalJSX {
interface CssVariablesNoEncapsulation {}
interface CssVariablesShadowDom {}
interface CustomEventRoot {}
interface DelegatesFocus {}
interface DomReattach {
'didLoad'?: number;
'didUnload'?: number;
Expand Down Expand Up @@ -1050,6 +1067,7 @@ declare namespace LocalJSX {
interface ListenJsx {}
interface ListenJsxRoot {}
interface ListenWindow {}
interface NoDelegatesFocus {}
interface NodeGlobals {}
interface NodeResolution {}
interface ReflectToAttr {
Expand Down Expand Up @@ -1157,6 +1175,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;
Expand Down Expand Up @@ -1193,6 +1212,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;
Expand Down Expand Up @@ -1270,6 +1290,7 @@ declare module "@stencil/core" {
'css-variables-no-encapsulation': LocalJSX.CssVariablesNoEncapsulation & JSXBase.HTMLAttributes<HTMLCssVariablesNoEncapsulationElement>;
'css-variables-shadow-dom': LocalJSX.CssVariablesShadowDom & JSXBase.HTMLAttributes<HTMLCssVariablesShadowDomElement>;
'custom-event-root': LocalJSX.CustomEventRoot & JSXBase.HTMLAttributes<HTMLCustomEventRootElement>;
'delegates-focus': LocalJSX.DelegatesFocus & JSXBase.HTMLAttributes<HTMLDelegatesFocusElement>;
'dom-reattach': LocalJSX.DomReattach & JSXBase.HTMLAttributes<HTMLDomReattachElement>;
'dom-reattach-clone': LocalJSX.DomReattachClone & JSXBase.HTMLAttributes<HTMLDomReattachCloneElement>;
'dom-reattach-clone-deep-slot': LocalJSX.DomReattachCloneDeepSlot & JSXBase.HTMLAttributes<HTMLDomReattachCloneDeepSlotElement>;
Expand Down Expand Up @@ -1306,6 +1327,7 @@ declare module "@stencil/core" {
'listen-jsx': LocalJSX.ListenJsx & JSXBase.HTMLAttributes<HTMLListenJsxElement>;
'listen-jsx-root': LocalJSX.ListenJsxRoot & JSXBase.HTMLAttributes<HTMLListenJsxRootElement>;
'listen-window': LocalJSX.ListenWindow & JSXBase.HTMLAttributes<HTMLListenWindowElement>;
'no-delegates-focus': LocalJSX.NoDelegatesFocus & JSXBase.HTMLAttributes<HTMLNoDelegatesFocusElement>;
'node-globals': LocalJSX.NodeGlobals & JSXBase.HTMLAttributes<HTMLNodeGlobalsElement>;
'node-resolution': LocalJSX.NodeResolution & JSXBase.HTMLAttributes<HTMLNodeResolutionElement>;
'reflect-to-attr': LocalJSX.ReflectToAttr & JSXBase.HTMLAttributes<HTMLReflectToAttrElement>;
Expand Down
15 changes: 15 additions & 0 deletions test/karma/test-app/delegates-focus/delegates-focus.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
:host {
display: block;
border: 5px solid red;
padding: 10px;
margin: 10px;
}

input {
display: block;
width: 100%;
}

:host(:focus) {
border: 5px solid blue;
}
19 changes: 19 additions & 0 deletions test/karma/test-app/delegates-focus/delegates-focus.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Host>
<input/>
</Host>
)
}
}

0 comments on commit f45c919

Please sign in to comment.