Skip to content

Commit

Permalink
feat(synthetic-shadow): programmatic focus when delegating focus (#1722)
Browse files Browse the repository at this point in the history
* feat: focus method with shadow semantics

https://html.spec.whatwg.org/multipage/interaction.html#focus-processing-model
- The shadow host is never considered as a focusable area
- If the shadow host does not contain any possible focus delegates, then return
  • Loading branch information
ekashida authored Feb 18, 2020
1 parent 4640046 commit ba6794b
Show file tree
Hide file tree
Showing 37 changed files with 431 additions and 35 deletions.
2 changes: 2 additions & 0 deletions packages/@lwc/synthetic-shadow/src/env/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const {
getElementsByTagName,
getElementsByTagNameNS,
hasAttribute,
querySelector,
querySelectorAll,
removeAttribute,
removeEventListener,
Expand Down Expand Up @@ -110,6 +111,7 @@ export {
matches,
outerHTMLGetter,
outerHTMLSetter,
querySelector,
querySelectorAll,
removeAttribute,
removeEventListener,
Expand Down
97 changes: 74 additions & 23 deletions packages/@lwc/synthetic-shadow/src/faux-shadow/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ import {
import { windowAddEventListener, windowRemoveEventListener } from '../env/window';
import {
matches,
querySelector,
querySelectorAll,
getBoundingClientRect,
addEventListener,
removeEventListener,
tabIndexGetter,
tagNameGetter,
getAttribute,
hasAttribute,
} from '../env/element';
import {
compareDocumentPosition,
Expand All @@ -45,27 +48,37 @@ import {
import { isDelegatingFocus, isHostElement } from './shadow-root';
import { arrayFromCollection, getOwnerDocument, getOwnerWindow } from '../shared/utils';

// NOTE: Not sure what to do about elements with the hidden attribute. These
// elements should not be focusable but changing the value of the CSS display
// property overrides the behavior. For instance, elements styled display: flex
// will be displayed and will be focusable despite the hidden attribute's
// presence. If this ever becomes an issue, we could potentially add a check to
// verify that the element we focused on actually received focus, and move on
// to the next candidate otherwise.
const TabbableElementsQuery = `
a[href]:not([tabindex="-1"]),
area[href]:not([tabindex="-1"]),
button:not([tabindex="-1"]):not([disabled]),
[contenteditable]:not([tabindex="-1"]),
video[controls]:not([tabindex="-1"]),
audio[controls]:not([tabindex="-1"]),
iframe:not([tabindex="-1"]),
input:not([tabindex="-1"]):not([disabled]),
select:not([tabindex="-1"]):not([disabled]),
textarea:not([tabindex="-1"]):not([disabled]),
[tabindex="0"]
const FocusableSelector = `
[contenteditable],
[tabindex],
a[href],
area[href],
audio[controls],
button,
iframe,
input,
select,
textarea,
video[controls]
`;

const formElementTagNames = new Set(['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);

function filterSequentiallyFocusableElements(elements: Element[]): Element[] {
return elements.filter(element => {
if (hasAttribute.call(element, 'tabindex')) {
// Even though LWC only supports tabindex values of 0 or -1,
// passing through elements with tabindex="0" is a tighter criteria
// than filtering out elements based on tabindex="-1".
return getAttribute.call(element, 'tabindex') === '0';
}
if (formElementTagNames.has(tagNameGetter.call(element))) {
return !hasAttribute.call(element, 'disabled');
}
return true;
});
}

const DidAddMouseDownListener = createHiddenField<boolean>(
'DidAddMouseDownListener',
'synthetic-shadow'
Expand Down Expand Up @@ -93,7 +106,7 @@ function isTabbable(element: HTMLElement): boolean {
if (isHostElement(element) && isDelegatingFocus(element)) {
return false;
}
return matches.call(element, TabbableElementsQuery) && isVisible(element);
return matches.call(element, FocusableSelector) && isVisible(element);
}

interface QuerySegments {
Expand All @@ -102,11 +115,49 @@ interface QuerySegments {
next: HTMLElement[];
}

export function hostElementFocus(this: HTMLElement) {
const _rootNode = this.getRootNode();
if (_rootNode === this) {
// We invoke the focus() method even if the host is disconnected in order to eliminate
// observable differences for component authors between synthetic and native.
const focusable = querySelector.call(this, FocusableSelector) as HTMLElement;
if (!isNull(focusable)) {
// @ts-ignore type-mismatch
focusable.focus.apply(focusable, arguments);
}
return;
}

// If the root node is not the host element then it's either the document or a shadow root.
const rootNode = (_rootNode as unknown) as DocumentOrShadowRoot;
if (rootNode.activeElement === this) {
// The focused element should not change if the focus method is invoked
// on the shadow-including ancestor of the currently focused element.
return;
}

const focusables = arrayFromCollection(
querySelectorAll.call(this, FocusableSelector)
) as HTMLElement[];

let didFocus = false;
while (!didFocus && focusables.length !== 0) {
const focusable = focusables.shift() as HTMLElement;
// @ts-ignore type-mismatch
focusable.focus.apply(focusable, arguments);
// Get the root node of the current focusable in case it was slotted.
const currentRootNode = (focusable.getRootNode() as unknown) as DocumentOrShadowRoot;
didFocus = currentRootNode.activeElement === focusable;
}
}

function getTabbableSegments(host: HTMLElement): QuerySegments {
const doc = getOwnerDocument(host);
const all = arrayFromCollection(documentQuerySelectorAll.call(doc, TabbableElementsQuery));
const inner = arrayFromCollection(
querySelectorAll.call(host, TabbableElementsQuery)
const all = filterSequentiallyFocusableElements(
arrayFromCollection(documentQuerySelectorAll.call(doc, FocusableSelector))
);
const inner = filterSequentiallyFocusableElements(
arrayFromCollection(querySelectorAll.call(host, FocusableSelector))
) as HTMLElement[];
if (process.env.NODE_ENV !== 'production') {
assert.invariant(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getActiveElement,
handleFocus,
handleFocusIn,
hostElementFocus,
ignoreFocus,
ignoreFocusIn,
} from './focus';
Expand Down Expand Up @@ -115,10 +116,16 @@ function blurPatched(this: HTMLElement) {

function focusPatched(this: HTMLElement) {
disableKeyboardFocusNavigationRoutines();
// TODO [#1327]: Shadow DOM semantics for focus method

if (isHostElement(this) && isDelegatingFocus(this)) {
hostElementFocus.call(this);
return;
}

// Typescript does not like it when you treat the `arguments` object as an array
// @ts-ignore type-mismatch
focus.apply(this, arguments);

enableKeyboardFocusNavigationRoutines();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
const assert = require('assert');

describe('Delegate focus with tabindex 0, no tabbable elements, and no tabbable elements after', () => {
const URL = '/delegates-focus-tab-index-zero-no-focusable-elements-no-after-elements';

Expand All @@ -15,15 +17,10 @@ describe('Delegate focus with tabindex 0, no tabbable elements, and no tabbable
browser.keys(['Tab']);
browser.keys(['Tab']);

browser.waitUntil(
() => {
const active = browser.$(function() {
return document.activeElement;
});
return active.getTagName().toLowerCase() === 'body';
},
undefined,
'It should focus the body'
);
const tagName = browser.execute(function() {
return document.activeElement.tagName;
});

assert.strictEqual(tagName, 'BODY');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2018, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
const assert = require('assert');

const URL = '/backwards-compatible';

describe('when the component overrides the focus method', () => {
beforeEach(() => {
browser.url(URL);
});

it('should continue custom focus behavior', function() {
browser.execute(function() {
return document
.querySelector('integration-backwards-compatible')
.shadowRoot.querySelector('integration-child')
.focus();
});
const className = browser.execute(function() {
var active = document.activeElement;
while (active.shadowRoot) {
active = active.shadowRoot.activeElement;
}
return active.className;
});
assert.equal(className, 'internal-textarea');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<template>
<button onclick={handleClick}>focus</button>
<integration-child tabindex="0"></integration-child>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { LightningElement } from 'lwc';

export default class Container extends LightningElement {
handleClick() {
this.template.querySelector('integration-child').focus();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<template>
<input class="internal-input" placeholder="internal">
<textarea class="internal-textarea" placeholder="internal"></textarea>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { LightningElement, api } from 'lwc';

export default class Child extends LightningElement {
static delegatesFocus = true;

@api
focus() {
this.template.querySelector('textarea').focus();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2018, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
const assert = require('assert');

const URL = '/basic';

describe('basic invocation', () => {
beforeEach(() => {
browser.url(URL);
});

it('should focus on the first programmatically focusable element', function() {
const button = browser.$(function() {
return document.querySelector('integration-basic').shadowRoot.querySelector('button');
});
button.click();
const className = browser.execute(function() {
const container = document.activeElement;
const child = container.shadowRoot.activeElement;
return child.shadowRoot.activeElement.className;
});
assert.equal(className, 'internal-input');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<template>
<button onclick={handleClick}>focus</button>
<integration-child tabindex="0"></integration-child>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { LightningElement } from 'lwc';

export default class Container extends LightningElement {
handleClick() {
this.template.querySelector('integration-child').focus();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<template>
<input class="internal-input" placeholder="internal">
<textarea class="internal-textarea" placeholder="internal"></textarea>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LightningElement } from 'lwc';

export default class Child extends LightningElement {
static delegatesFocus = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<input class="child-input" placeholder="child-input">
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LightningElement } from 'lwc';

export default class Child extends LightningElement {
static delegatesFocus = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<template>
<button onclick={handleClick}>focus</button>
<integration-parent tabindex="0"></integration-parent>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { LightningElement } from 'lwc';

export default class Container extends LightningElement {
handleClick() {
this.template.querySelector('integration-parent').focus();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<integration-child tabindex="0"></integration-child>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LightningElement } from 'lwc';

export default class Parent extends LightningElement {
static delegatesFocus = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2018, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
const assert = require('assert');

const URL = '/nested';

describe('when the only focusable element is in a nested shadow', () => {
beforeEach(() => {
browser.url(URL);
});

it('should apply focus in the nested shadow', function() {
const button = browser.$(function() {
return document.querySelector('integration-nested').shadowRoot.querySelector('button');
});
button.click();
const className = browser.execute(function() {
var active = document.activeElement;
while (active.shadowRoot) {
active = active.shadowRoot.activeElement;
}
return active.className;
});
assert.equal(className, 'child-input');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<input class="child-input" placeholder="child-input">
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LightningElement } from 'lwc';

export default class Child extends LightningElement {
static delegatesFocus = true;
}
Loading

0 comments on commit ba6794b

Please sign in to comment.