Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TrapFocus] Fix trap to only focus on tabbable elements #23364

Merged
merged 19 commits into from
Dec 3, 2020
Merged
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9cc76e9
[TrapFocus] fix trapfocus to only focus on tabbable elements or root …
gregnb Nov 2, 2020
731e8ac
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus…
gregnb Nov 2, 2020
f4a955d
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus…
gregnb Nov 2, 2020
72ff097
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus…
gregnb Nov 2, 2020
44116b7
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus…
gregnb Nov 2, 2020
0b6f9aa
remove dead code
gregnb Nov 3, 2020
3f21e34
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus.js
gregnb Nov 2, 2020
17d3ca2
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus.js
gregnb Nov 2, 2020
53d0b94
Update test/utils/user-event/tab.js
gregnb Nov 2, 2020
d14e909
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus.js
gregnb Nov 2, 2020
00ebb41
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus.js
gregnb Nov 2, 2020
06dfa8f
convert focusSelectors to a getter
gregnb Nov 3, 2020
f65048b
set focus by calling onSentialFocus directly instead of focus() on el…
gregnb Nov 7, 2020
0c0e594
ran prettier
gregnb Nov 7, 2020
8063949
remove dead code
oliviertassinari Nov 7, 2020
e6139bd
improve the tabbable
oliviertassinari Nov 7, 2020
22d53e2
good to go
oliviertassinari Nov 7, 2020
4beecd0
less ambitious changes
oliviertassinari Nov 8, 2020
1b6990f
Update packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus.js
oliviertassinari Nov 25, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
[TrapFocus] fix trapfocus to only focus on tabbable elements or root …
…element
  • Loading branch information
gregnb authored and oliviertassinari committed Nov 25, 2020
commit 9cc76e9d1954791b0dccbe35c4b335110dd1d6b1
1 change: 1 addition & 0 deletions packages/material-ui/src/Menu/Menu.js
Original file line number Diff line number Diff line change
@@ -143,6 +143,7 @@ const Menu = React.forwardRef(function Menu(props, ref) {
},
}}
open={open}
disableAutoFocus={!autoFocus}
ref={ref}
transitionDuration={transitionDuration}
TransitionProps={{ onEntering: handleEntering, ...TransitionProps }}
Original file line number Diff line number Diff line change
@@ -44,6 +44,10 @@ export interface TrapFocusProps {
* @default false
*/
disableRestoreFocus?: boolean;
/**
* Array of selectors to add to the components focusable elements
gregnb marked this conversation as resolved.
Show resolved Hide resolved
*/
focusSelectors?: string[];
gregnb marked this conversation as resolved.
Show resolved Hide resolved
}

/**
165 changes: 157 additions & 8 deletions packages/material-ui/src/Unstable_TrapFocus/Unstable_TrapFocus.js
Original file line number Diff line number Diff line change
@@ -5,6 +5,20 @@ import { exactProp, elementAcceptingRef } from '@material-ui/utils';
import ownerDocument from '../utils/ownerDocument';
import useForkRef from '../utils/useForkRef';

const focusSelectorsRoot = [
'input',
'select',
'textarea',
'a[href]',
'button',
'[tabindex]',
'audio[controls]',
'video[controls]',
'[contenteditable]:not([contenteditable="false"])',
'details>summary:first-of-type',
'details'
];

/**
* Utility component that locks focus inside the component.
*/
@@ -16,26 +30,46 @@ function Unstable_TrapFocus(props) {
disableRestoreFocus = false,
getDoc,
isEnabled,
focusSelectors = [],
open,
} = props;
const ignoreNextEnforceFocus = React.useRef();
const lastEvent = React.useRef(null);
oliviertassinari marked this conversation as resolved.
Show resolved Hide resolved
const sentinelStart = React.useRef(null);
const sentinelEnd = React.useRef(null);
const nodeToRestore = React.useRef();
const reactFocusEventTarget = React.useRef(null);
// This variable is useful when disableAutoFocus is true.
// It waits for the active element to move into the component to activate.
const activated = React.useRef(false);

gregnb marked this conversation as resolved.
Show resolved Hide resolved
const rootRef = React.useRef(null);
const handleRef = useForkRef(children.ref, rootRef);

const prevOpenRef = React.useRef();

React.useEffect(() => {
prevOpenRef.current = open;
}, [open]);

if (!prevOpenRef.current && open && typeof window !== 'undefined' && !disableAutoFocus) {
const doc = ownerDocument(rootRef.current);

if (!prevOpenRef.current && open && rootRef.current &&
(
(rootRef.current.contains(doc.activeElement) && disableAutoFocus)
||
!disableAutoFocus
) && !ignoreNextEnforceFocus.current
) {

if (!nodeToRestore.current) {
nodeToRestore.current = doc.activeElement;
}

sentinelStart.current.focus();
}

prevOpenRef.current = open;
}, [disableAutoFocus, open]);

if (!prevOpenRef.current && open && typeof window !== 'undefined' && !disableRestoreFocus) {
// WARNING: Potentially unsafe in concurrent mode.
// The way the read on `nodeToRestore` is setup could make this actually safe.
// Say we render `open={false}` -> `open={true}` but never commit.
@@ -44,7 +78,9 @@ function Unstable_TrapFocus(props) {
// that were committed on `open={true}`
// WARNING: Prevents the instance from being garbage collected. Should only
// hold a weak ref.

nodeToRestore.current = getDoc().activeElement;

gregnb marked this conversation as resolved.
Show resolved Hide resolved
}

React.useEffect(() => {
@@ -90,6 +126,7 @@ function Unstable_TrapFocus(props) {
// in nodeToRestore.current being null.
// Not all elements in IE11 have a focus method.
// Once IE11 support is dropped the focus() call can be unconditional.

gregnb marked this conversation as resolved.
Show resolved Hide resolved
if (nodeToRestore.current && nodeToRestore.current.focus) {
ignoreNextEnforceFocus.current = true;
nodeToRestore.current.focus();
@@ -103,6 +140,107 @@ function Unstable_TrapFocus(props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);

const onSentinelFocus = React.useCallback((position) => () => {

const isRadioTabble = (node) => {

if (!node.name) {
return true;
}

const radioScope = node.form || node.ownerDocument;
const radioSet = radioScope.querySelectorAll(`input[type="radio"][name="${node.name}"]`);

const getCheckedRadio = (nodes, form) => {
for (let i = 0; i < nodes.length; i += 1) {
if (nodes[i].checked && nodes[i].form === form) {
return nodes[i];
}
}
}

const checked = getCheckedRadio(radioSet, node.form);
return !checked || checked === node;

}

const getTabIndex = (node) => {

const tabindexAttr = parseInt(node.getAttribute('tabindex'), 10);

if (!Number.isNaN(tabindexAttr)) {
return tabindexAttr;
}

if (
node.contentEditable === 'true' ||
(node.nodeName === 'AUDIO' ||
node.nodeName === 'VIDEO' ||
node.nodeName === 'DETAILS') &&
node.getAttribute('tabindex') === null
) {
return 0;
}

return node.tabIndex;
};

const isFocusable = (node) => {

const isInput = nodeEl => nodeEl.tagName === 'INPUT';
if (node.disabled || (isInput(node) && node.type === 'hidden')
|| (isInput(node) && node.type === 'radio' && !isRadioTabble(node))) {
return false;
}
return true;
}

const selectors = [...focusSelectorsRoot, ...focusSelectors].filter(Boolean);
const isShiftTab = Boolean(lastEvent.current?.shiftKey && lastEvent.current?.key === 'Tab');
const regularTabNodes = [];
const orderedTabNodes = [];

Array.from(rootRef.current.querySelectorAll(selectors.join(', '))).forEach((node, i) => {

const nodeTabIndex = getTabIndex(node);

if (!isFocusable(node) || nodeTabIndex < 0) {
return;
}

if (nodeTabIndex === 0) {
regularTabNodes.push(node);
} else {
orderedTabNodes.push({
documentOrder: i,
tabIndex: nodeTabIndex,
node,
});
}

});

const focusChildren = orderedTabNodes
.sort((a, b) => a.tabIndex === b.tabIndex
? a.documentOrder - b.documentOrder
: a.tabIndex - b.tabIndex)
.map((a) => a.node)
.concat(regularTabNodes);

if (!focusChildren?.length) return rootRef.current.focus();
const focusStart = focusChildren[0];
const focusEnd = focusChildren[focusChildren.length - 1];

activated.current = true;

if (position === 'start' && isShiftTab) {
return focusEnd.focus();
}

return focusStart.focus();

}, [focusSelectors]);

React.useEffect(() => {
// We might render an empty child.
if (!open || !rootRef.current) {
@@ -115,6 +253,7 @@ function Unstable_TrapFocus(props) {
const { current: rootElement } = rootRef;
// Cleanup functions are executed lazily in React 17.
// Contain can be called between the component being unmounted and its cleanup function being run.

if (rootElement === null) {
return;
}
@@ -145,12 +284,15 @@ function Unstable_TrapFocus(props) {
}

rootElement.focus();

} else {
activated.current = true;
}
};

const loopFocus = (nativeEvent) => {
lastEvent.current = nativeEvent;

if (disableEnforceFocus || !isEnabled() || nativeEvent.key !== 'Tab') {
return;
}
@@ -168,6 +310,7 @@ function Unstable_TrapFocus(props) {
}
};


doc.addEventListener('focusin', contain);
doc.addEventListener('keydown', loopFocus, true);

@@ -192,9 +335,11 @@ function Unstable_TrapFocus(props) {
}, [disableAutoFocus, disableEnforceFocus, disableRestoreFocus, isEnabled, open]);

const onFocus = (event) => {
if (!activated.current) {

if (!activated.current && rootRef.current && event.relatedTarget && !rootRef.current.contains(event.relatedTarget) && event.relatedTarget !== sentinelStart.current && event.relatedTarget !== sentinelEnd.current) {
nodeToRestore.current = event.relatedTarget;
}

activated.current = true;
reactFocusEventTarget.current = event.target;

@@ -206,9 +351,9 @@ function Unstable_TrapFocus(props) {

return (
<React.Fragment>
<div tabIndex={0} ref={sentinelStart} data-test="sentinelStart" />
<div onFocus={onSentinelFocus('start')} tabIndex={0} ref={sentinelStart} data-test="sentinelStart" />
{React.cloneElement(children, { ref: handleRef, onFocus })}
<div tabIndex={0} ref={sentinelEnd} data-test="sentinelEnd" />
<div onFocus={onSentinelFocus('end')} tabIndex={0} ref={sentinelEnd} data-test="sentinelEnd" />
</React.Fragment>
);
}
@@ -246,6 +391,10 @@ Unstable_TrapFocus.propTypes = {
* @default false
*/
disableRestoreFocus: PropTypes.bool,
/**
* Array of selectors to add to the components focusable elements
*/
focusSelectors: PropTypes.arrayOf(PropTypes.string),
/**
* Return the document to consider.
* We use it to implement the restore focus between different browser documents.
Loading