Skip to content

Commit 1e2b7f2

Browse files
author
Michael Jordan
authored
fix(#1478) Button with display: none breaks keyboard navigation in <FocusScope contain> (#1493)
1 parent 0512c0b commit 1e2b7f2

File tree

7 files changed

+301
-93
lines changed

7 files changed

+301
-93
lines changed

NOTICE.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,16 @@ This codebase contains ResizeObserver.d.ts type declaration file which can be ob
114114
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
115115

116116
------------------------------------------------------------------------------
117+
118+
This codebase contains a portion of code that vuejs adapted from jest-dom which can be obtained at:
119+
* SOURCE:
120+
* https://github.com/vuejs/vue-test-utils-next/blob/master/src/utils/isElementVisible.ts
121+
* LICENSE:
122+
* https://github.com/vuejs/vue-test-utils-next/blob/master/LICENSE
123+
124+
* SOURCE:
125+
* https://github.com/testing-library/jest-dom/blob/main/src/to-be-visible.js
126+
* LICENSE:
127+
* https://github.com/testing-library/jest-dom/blob/main/LICENSE
128+
129+
------------------------------------------------------------------------------

packages/@react-aria/datepicker/src/useDateField.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function useDateField(props: DatePickerProps & DOMProps): DateFieldAria {
3535
let onMouseDown = (e: MouseEvent) => {
3636
e.preventDefault();
3737
e.stopPropagation();
38-
focusManager.focusPrevious({from: e.target as HTMLElement});
38+
focusManager.focusNext({from: e.target as HTMLElement});
3939
};
4040

4141
return {

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 48 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import {focusSafely} from './focusSafely';
14+
import {isElementVisible} from './isElementVisible';
1415
import React, {ReactNode, RefObject, useContext, useEffect, useRef} from 'react';
1516
import {useLayoutEffect} from '@react-aria/utils';
1617

@@ -119,30 +120,36 @@ export function useFocusManager(): FocusManager {
119120
function createFocusManager(scopeRef: React.RefObject<HTMLElement[]>): FocusManager {
120121
return {
121122
focusNext(opts: FocusManagerOptions = {}) {
122-
let node = opts.from || document.activeElement;
123-
let focusable = getFocusableElementsInScope(scopeRef.current, opts);
124-
let nextNode = focusable.find(n =>
125-
!!(node.compareDocumentPosition(n) & (Node.DOCUMENT_POSITION_FOLLOWING | Node.DOCUMENT_POSITION_CONTAINED_BY))
126-
);
127-
if (!nextNode && opts.wrap) {
128-
nextNode = focusable[0];
123+
let scope = scopeRef.current;
124+
let {from, tabbable, wrap} = opts;
125+
let node = from || document.activeElement;
126+
let sentinel = scope[0].previousElementSibling;
127+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
128+
walker.currentNode = isElementInScope(node, scope) ? node : sentinel;
129+
let nextNode = walker.nextNode() as HTMLElement;
130+
if (!nextNode && wrap) {
131+
walker.currentNode = sentinel;
132+
nextNode = walker.nextNode() as HTMLElement;
129133
}
130134
if (nextNode) {
131-
nextNode.focus();
135+
focusElement(nextNode, true);
132136
}
133137
return nextNode;
134138
},
135139
focusPrevious(opts: FocusManagerOptions = {}) {
136-
let node = opts.from || document.activeElement;
137-
let focusable = getFocusableElementsInScope(scopeRef.current, opts).reverse();
138-
let previousNode = focusable.find(n =>
139-
!!(node.compareDocumentPosition(n) & (Node.DOCUMENT_POSITION_PRECEDING | Node.DOCUMENT_POSITION_CONTAINED_BY))
140-
);
141-
if (!previousNode && opts.wrap) {
142-
previousNode = focusable[0];
140+
let scope = scopeRef.current;
141+
let {from, tabbable, wrap} = opts;
142+
let node = from || document.activeElement;
143+
let sentinel = scope[scope.length - 1].nextElementSibling;
144+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
145+
walker.currentNode = isElementInScope(node, scope) ? node : sentinel;
146+
let previousNode = walker.previousNode() as HTMLElement;
147+
if (!previousNode && wrap) {
148+
walker.currentNode = sentinel;
149+
previousNode = walker.previousNode() as HTMLElement;
143150
}
144151
if (previousNode) {
145-
previousNode.focus();
152+
focusElement(previousNode, true);
146153
}
147154
return previousNode;
148155
}
@@ -165,21 +172,13 @@ const focusableElements = [
165172
'[contenteditable]'
166173
];
167174

168-
const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(',') + ',[tabindex]';
175+
const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ',[tabindex]:not([hidden])';
169176

170177
focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])');
171-
const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([tabindex="-1"]),');
172-
173-
function getFocusableElementsInScope(scope: HTMLElement[], opts: FocusManagerOptions): HTMLElement[] {
174-
let res = [];
175-
let selector = opts.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
176-
for (let node of scope) {
177-
if (node.matches(selector)) {
178-
res.push(node);
179-
}
180-
res.push(...Array.from(node.querySelectorAll(selector)));
181-
}
182-
return res;
178+
const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),');
179+
180+
function getScopeRoot(scope: HTMLElement[]) {
181+
return scope[0].parentElement;
183182
}
184183

185184
function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolean) {
@@ -203,23 +202,12 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
203202
return;
204203
}
205204

206-
let elements = getFocusableElementsInScope(scope, {tabbable: true});
207-
let position = elements.indexOf(focusedElement);
208-
let lastPosition = elements.length - 1;
209-
let nextElement = null;
210-
211-
if (e.shiftKey) {
212-
if (position <= 0) {
213-
nextElement = elements[lastPosition];
214-
} else {
215-
nextElement = elements[position - 1];
216-
}
217-
} else {
218-
if (position === lastPosition) {
219-
nextElement = elements[0];
220-
} else {
221-
nextElement = elements[position + 1];
222-
}
205+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
206+
walker.currentNode = focusedElement;
207+
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
208+
if (!nextElement) {
209+
walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling : scope[0].previousElementSibling;
210+
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
223211
}
224212

225213
e.preventDefault();
@@ -306,8 +294,10 @@ function focusElement(element: HTMLElement | null, scroll = false) {
306294
}
307295

308296
function focusFirstInScope(scope: HTMLElement[]) {
309-
let elements = getFocusableElementsInScope(scope, {tabbable: true});
310-
focusElement(elements[0]);
297+
let sentinel = scope[0].previousElementSibling;
298+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
299+
walker.currentNode = sentinel;
300+
focusElement(walker.nextNode() as HTMLElement);
311301
}
312302

313303
function useAutoFocus(scopeRef: RefObject<HTMLElement[]>, autoFocus: boolean) {
@@ -348,6 +338,10 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
348338
walker.currentNode = focusedElement;
349339
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
350340

341+
if (!document.body.contains(nodeToRestore) || nodeToRestore === document.body) {
342+
nodeToRestore = null;
343+
}
344+
351345
// If there is no next element, or it is outside the current scope, move focus to the
352346
// next element after the node to restore to instead.
353347
if ((!nextElement || !isElementInScope(nextElement, scope)) && nodeToRestore) {
@@ -361,7 +355,7 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
361355
e.preventDefault();
362356
e.stopPropagation();
363357
if (nextElement) {
364-
nextElement.focus();
358+
focusElement(nextElement, true);
365359
} else {
366360
// If there is no next element, blur the focused element to move focus to the body.
367361
focusedElement.blur();
@@ -393,7 +387,7 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
393387
* Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker}
394388
* that matches all focusable/tabbable elements.
395389
*/
396-
export function getFocusableTreeWalker(root: HTMLElement, opts?: FocusManagerOptions) {
390+
export function getFocusableTreeWalker(root: HTMLElement, opts?: FocusManagerOptions, scope?: HTMLElement[]) {
397391
let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
398392
let walker = document.createTreeWalker(
399393
root,
@@ -405,14 +399,15 @@ export function getFocusableTreeWalker(root: HTMLElement, opts?: FocusManagerOpt
405399
return NodeFilter.FILTER_REJECT;
406400
}
407401

408-
if ((node as HTMLElement).matches(selector)) {
402+
if ((node as HTMLElement).matches(selector)
403+
&& isElementVisible(node as HTMLElement)
404+
&& (!scope || isElementInScope(node as HTMLElement, scope))) {
409405
return NodeFilter.FILTER_ACCEPT;
410406
}
411407

412408
return NodeFilter.FILTER_SKIP;
413409
}
414-
},
415-
false
410+
}
416411
);
417412

418413
if (opts?.from) {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
function isStyleVisible(element: Element) {
14+
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
15+
return false;
16+
}
17+
18+
let {display, visibility} = element.style;
19+
20+
let isVisible = (
21+
display !== 'none' &&
22+
visibility !== 'hidden' &&
23+
visibility !== 'collapse'
24+
);
25+
26+
if (isVisible) {
27+
const {getComputedStyle} = element.ownerDocument.defaultView;
28+
let {display: computedDisplay, visibility: computedVisibility} = getComputedStyle(element);
29+
30+
isVisible = (
31+
computedDisplay !== 'none' &&
32+
computedVisibility !== 'hidden' &&
33+
computedVisibility !== 'collapse'
34+
);
35+
}
36+
37+
return isVisible;
38+
}
39+
40+
function isAttributeVisible(element: Element, childElement?: Element) {
41+
return (
42+
!element.hasAttribute('hidden') &&
43+
(element.nodeName === 'DETAILS' &&
44+
childElement &&
45+
childElement.nodeName !== 'SUMMARY'
46+
? element.hasAttribute('open')
47+
: true)
48+
);
49+
}
50+
51+
/**
52+
* Adapted from https://github.com/testing-library/jest-dom and
53+
* https://github.com/vuejs/vue-test-utils-next/.
54+
* Licensed under the MIT License.
55+
* @param element - Element to evaluate for display or visibility.
56+
*/
57+
export function isElementVisible(element: Element, childElement?: Element) {
58+
return (
59+
element.nodeName !== '#comment' &&
60+
isStyleVisible(element) &&
61+
isAttributeVisible(element, childElement) &&
62+
(!element.parentElement || isElementVisible(element.parentElement, element))
63+
);
64+
}

0 commit comments

Comments
 (0)