diff --git a/packages/material-ui/src/ButtonBase/ButtonBase.js b/packages/material-ui/src/ButtonBase/ButtonBase.js
index 4977ab46a75d77..b1f29cab226a53 100644
--- a/packages/material-ui/src/ButtonBase/ButtonBase.js
+++ b/packages/material-ui/src/ButtonBase/ButtonBase.js
@@ -3,12 +3,11 @@ import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import clsx from 'clsx';
import { elementTypeAcceptingRef } from '@material-ui/utils';
-import ownerWindow from '../utils/ownerWindow';
import withForwardedRef from '../utils/withForwardedRef';
import { setRef } from '../utils/reactHelpers';
import withStyles from '../styles/withStyles';
import NoSsr from '../NoSsr';
-import { listenForFocusKeys, detectFocusVisible } from './focusVisible';
+import { handleBlurVisible, isFocusVisible, prepare as prepareFocusVisible } from './focusVisible';
import TouchRipple from './TouchRipple';
import createRippleHandler from './createRippleHandler';
@@ -60,18 +59,7 @@ class ButtonBase extends React.Component {
buttonRef = React.createRef();
- keyDown = false; // Used to help track keyboard activation keyDown
-
- focusVisibleCheckTime = 50;
-
- focusVisibleMaxCheckTimes = 5;
-
- handleMouseDown = createRippleHandler(this, 'MouseDown', 'start', () => {
- clearTimeout(this.focusVisibleTimeout);
- if (this.state.focusVisible) {
- this.setState({ focusVisible: false });
- }
- });
+ handleMouseDown = createRippleHandler(this, 'MouseDown', 'start');
handleMouseUp = createRippleHandler(this, 'MouseUp', 'stop');
@@ -89,16 +77,15 @@ class ButtonBase extends React.Component {
handleContextMenu = createRippleHandler(this, 'ContextMenu', 'stop');
- handleBlur = createRippleHandler(this, 'Blur', 'stop', () => {
- clearTimeout(this.focusVisibleTimeout);
+ handleBlur = createRippleHandler(this, 'Blur', 'stop', event => {
if (this.state.focusVisible) {
+ handleBlurVisible(event);
this.setState({ focusVisible: false });
}
});
componentDidMount() {
- listenForFocusKeys(ownerWindow(this.getButtonNode()));
-
+ prepareFocusVisible(this.getButtonNode().ownerDocument);
if (this.props.action) {
this.props.action({
focusVisible: () => {
@@ -120,10 +107,6 @@ class ButtonBase extends React.Component {
}
}
- componentWillUnmount() {
- clearTimeout(this.focusVisibleTimeout);
- }
-
getButtonNode() {
return ReactDOM.findDOMNode(this.buttonRef.current);
}
@@ -132,15 +115,6 @@ class ButtonBase extends React.Component {
this.ripple = node;
};
- onFocusVisibleHandler = event => {
- this.keyDown = false;
- this.setState({ focusVisible: true });
-
- if (this.props.onFocusVisible) {
- this.props.onFocusVisible(event);
- }
- };
-
static getDerivedStateFromProps(nextProps, prevState) {
if (typeof prevState.focusVisible === 'undefined') {
return {
@@ -224,10 +198,13 @@ class ButtonBase extends React.Component {
this.buttonRef.current = event.currentTarget;
}
- event.persist();
- detectFocusVisible(this, this.getButtonNode(), () => {
- this.onFocusVisibleHandler(event);
- });
+ if (isFocusVisible(event)) {
+ this.setState({ focusVisible: true });
+
+ if (this.props.onFocusVisible) {
+ this.props.onFocusVisible(event);
+ }
+ }
if (this.props.onFocus) {
this.props.onFocus(event);
diff --git a/packages/material-ui/src/ButtonBase/ButtonBase.test.js b/packages/material-ui/src/ButtonBase/ButtonBase.test.js
index df764f893f45e5..bb9b50e9847685 100644
--- a/packages/material-ui/src/ButtonBase/ButtonBase.test.js
+++ b/packages/material-ui/src/ButtonBase/ButtonBase.test.js
@@ -15,6 +15,17 @@ import ButtonBase from './ButtonBase';
const ButtonBaseNaked = unwrap(ButtonBase);
+function focusVisible(element) {
+ element.ownerDocument.dispatchEvent(new window.Event('keydown'));
+ element.focus();
+}
+
+function simulatePointerDevice() {
+ // first focus on a page triggers focus visible until a pointer event
+ // has been dispatched
+ document.dispatchEvent(new window.Event('pointerdown'));
+}
+
describe('', () => {
let mount;
let shallow;
@@ -270,55 +281,36 @@ describe('', () => {
return;
}
- let wrapper;
- let instance;
- let button;
- let clock;
let rootElement;
beforeEach(() => {
- clock = useFakeTimers();
rootElement = document.createElement('div');
rootElement.tabIndex = 0;
document.body.appendChild(rootElement);
rootElement.attachShadow({ mode: 'open' });
- wrapper = mount(Hello, {
- attachTo: rootElement.shadowRoot,
- });
- instance = wrapper.find('ButtonBase').instance();
- button = rootElement.shadowRoot.getElementById('test-button');
- if (!button) {
- throw new Error('missing button');
- }
-
- button.focus();
-
- if (document.activeElement !== rootElement) {
- // Mock activeElement value and simulate host-retargeting in shadow root for
- // jsdom@12.0.0 (https://github.com/jsdom/jsdom/issues/2343)
- rootElement.focus();
- rootElement.shadowRoot.activeElement = button;
- wrapper.simulate('focus');
- }
-
- const event = new window.Event('keyup');
- event.keyCode = 9; // Tab
- window.dispatchEvent(event);
});
afterEach(() => {
- clock.restore();
ReactDOM.unmountComponentAtNode(rootElement.shadowRoot);
document.body.removeChild(rootElement);
});
it('should set focus state for shadowRoot children', () => {
- assert.strictEqual(wrapper.find(`.${classes.focusVisible}`).exists(), false);
+ mount(Hello, {
+ attachTo: rootElement.shadowRoot,
+ });
+ simulatePointerDevice();
- clock.tick(instance.focusVisibleCheckTime * instance.focusVisibleMaxCheckTimes);
- wrapper.update();
+ const button = rootElement.shadowRoot.getElementById('test-button');
+ if (!button) {
+ throw new Error('missing button');
+ }
+
+ assert.strictEqual(button.classList.contains(classes.focusVisible), false);
+
+ focusVisible(button);
- assert.strictEqual(wrapper.find(`.${classes.focusVisible}`).exists(), true);
+ assert.strictEqual(button.classList.contains(classes.focusVisible), true);
});
});
@@ -338,17 +330,20 @@ describe('', () => {
beforeEach(() => {
clock = useFakeTimers();
- wrapper = mount(Hello);
+ wrapper = mount(
+ {
+ button = element;
+ }}
+ >
+ Hello
+ ,
+ );
+ simulatePointerDevice();
instance = wrapper.find('ButtonBase').instance();
- button = wrapper.find('button').getDOMNode();
if (!button) {
throw new Error('missing button');
}
- button.focus();
-
- const event = new window.Event('keyup');
- event.keyCode = 9; // Tab
- window.dispatchEvent(event);
});
afterEach(() => {
@@ -357,25 +352,9 @@ describe('', () => {
it('should detect the keyboard', () => {
assert.strictEqual(getState().focusVisible, false);
- clock.tick(instance.focusVisibleCheckTime * instance.focusVisibleMaxCheckTimes);
+ focusVisible(button);
assert.strictEqual(getState().focusVisible, true);
});
-
- it('should ignore the keyboard after 1s', () => {
- clock.tick(instance.focusVisibleCheckTime * instance.focusVisibleMaxCheckTimes);
- assert.strictEqual(getState().focusVisible, true);
- button.blur();
- assert.strictEqual(getState().focusVisible, false);
- button.focus();
- clock.tick(instance.focusVisibleCheckTime * instance.focusVisibleMaxCheckTimes);
- assert.strictEqual(getState().focusVisible, true);
- clock.tick(1e3);
- button.blur();
- assert.strictEqual(getState().focusVisible, false);
- button.focus();
- clock.tick(instance.focusVisibleCheckTime * instance.focusVisibleMaxCheckTimes);
- assert.strictEqual(getState().focusVisible, false);
- });
});
describe('prop: disabled', () => {
@@ -450,17 +429,19 @@ describe('', () => {
});
it('onFocusVisibleHandler() should propagate call to onFocusVisible prop', () => {
- const eventMock = 'woofButtonBase';
const onFocusVisibleSpy = spy();
- const wrapper = mount(
-
+ const buttonRef = React.createRef();
+ mount(
+
Hello
,
);
- const instance = wrapper.find('ButtonBase').instance();
- instance.onFocusVisibleHandler(eventMock);
+ simulatePointerDevice();
+
+ focusVisible(buttonRef.current);
+
assert.strictEqual(onFocusVisibleSpy.callCount, 1);
- assert.strictEqual(onFocusVisibleSpy.calledWith(eventMock), true);
+ assert.strictEqual(onFocusVisibleSpy.firstCall.args.length, 1);
});
it('should work with a functional component', () => {
diff --git a/packages/material-ui/src/ButtonBase/focusVisible.js b/packages/material-ui/src/ButtonBase/focusVisible.js
index 12fea08ef6214e..ed52aeb793c40d 100644
--- a/packages/material-ui/src/ButtonBase/focusVisible.js
+++ b/packages/material-ui/src/ButtonBase/focusVisible.js
@@ -1,75 +1,122 @@
-import warning from 'warning';
-import ownerDocument from '../utils/ownerDocument';
+// based on https://github.com/WICG/focus-visible/blob/v4.1.5/src/focus-visible.js
-const internal = {
- focusKeyPressed: false,
- keyUpEventTimeout: -1,
+let hadKeyboardEvent = true;
+let hadFocusVisibleRecently = false;
+let hadFocusVisibleRecentlyTimeout = null;
+
+const inputTypesWhitelist = {
+ text: true,
+ search: true,
+ url: true,
+ tel: true,
+ email: true,
+ password: true,
+ number: true,
+ date: true,
+ month: true,
+ week: true,
+ time: true,
+ datetime: true,
+ 'datetime-local': true,
};
-function findActiveElement(doc) {
- let activeElement = doc.activeElement;
- while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) {
- activeElement = activeElement.shadowRoot.activeElement;
+/**
+ * Computes whether the given element should automatically trigger the
+ * `focus-visible` class being added, i.e. whether it should always match
+ * `:focus-visible` when focused.
+ * @param {Element} node
+ * @return {boolean}
+ */
+function focusTriggersKeyboardModality(node) {
+ const { type, tagName } = node;
+
+ if (tagName === 'INPUT' && inputTypesWhitelist[type] && !node.readOnly) {
+ return true;
+ }
+
+ if (tagName === 'TEXTAREA' && !node.readOnly) {
+ return true;
}
- return activeElement;
+
+ if (node.isContentEditable) {
+ return true;
+ }
+
+ return false;
}
-export function detectFocusVisible(instance, element, callback, attempt = 1) {
- warning(instance.focusVisibleCheckTime, 'Material-UI: missing instance.focusVisibleCheckTime.');
- warning(
- instance.focusVisibleMaxCheckTimes,
- 'Material-UI: missing instance.focusVisibleMaxCheckTimes.',
- );
+function handleKeyDown() {
+ hadKeyboardEvent = true;
+}
- instance.focusVisibleTimeout = setTimeout(() => {
- const doc = ownerDocument(element);
- const activeElement = findActiveElement(doc);
+/**
+ * If at any point a user clicks with a pointing device, ensure that we change
+ * the modality away from keyboard.
+ * This avoids the situation where a user presses a key on an already focused
+ * element, and then clicks on a different element, focusing it with a
+ * pointing device, while we still think we're in keyboard modality.
+ * @param {Event} e
+ */
+function handlePointerDown() {
+ hadKeyboardEvent = false;
+}
- if (
- internal.focusKeyPressed &&
- (activeElement === element || element.contains(activeElement))
- ) {
- callback();
- } else if (attempt < instance.focusVisibleMaxCheckTimes) {
- detectFocusVisible(instance, element, callback, attempt + 1);
+function handleVisibilityChange() {
+ if (this.visibilityState === 'hidden') {
+ // If the tab becomes active again, the browser will handle calling focus
+ // on the element (Safari actually calls it twice).
+ // If this tab change caused a blur on an element with focus-visible,
+ // re-apply the class when the user switches back to the tab.
+ if (hadFocusVisibleRecently) {
+ hadKeyboardEvent = true;
}
- }, instance.focusVisibleCheckTime);
+ }
}
-// The keys that might change document.activeElement.
-const FOCUS_KEYS = [
- 9, // 'Tab',
- 13, // 'Enter',
- 27, // 'Escape',
- 32, // ' ',
- 36, // 'Home',
- 35, // 'End',
- 37, // 'ArrowLeft',
- 38, // 'ArrowUp',
- 39, // 'ArrowRight',
- 40, // 'ArrowDown',
-];
-
-function isFocusKey(event) {
- // Use event.keyCode to support IE 11
- return FOCUS_KEYS.indexOf(event.keyCode) > -1;
+export function prepare(ownerDocument) {
+ ownerDocument.addEventListener('keydown', handleKeyDown, true);
+ ownerDocument.addEventListener('mousedown', handlePointerDown, true);
+ ownerDocument.addEventListener('pointerdown', handlePointerDown, true);
+ ownerDocument.addEventListener('touchstart', handlePointerDown, true);
+ ownerDocument.addEventListener('visibilitychange', handleVisibilityChange, true);
}
-const handleKeyUpEvent = event => {
- if (isFocusKey(event)) {
- internal.focusKeyPressed = true;
+export function teardown(ownerDocument) {
+ ownerDocument.removeEventListener('keydown', handleKeyDown, true);
+ ownerDocument.removeEventListener('mousedown', handlePointerDown, true);
+ ownerDocument.removeEventListener('pointerdown', handlePointerDown, true);
+ ownerDocument.removeEventListener('touchstart', handlePointerDown, true);
+ ownerDocument.removeEventListener('visibilitychange', handleVisibilityChange, true);
+}
- // Let's consider that the user is using a keyboard during a window frame of 500ms.
- clearTimeout(internal.keyUpEventTimeout);
- internal.keyUpEventTimeout = setTimeout(() => {
- internal.focusKeyPressed = false;
- }, 500);
+export function isFocusVisible(event) {
+ const { target } = event;
+ try {
+ return target.matches(':focus-visible');
+ } catch (error) {
+ // browsers not implementing :focus-visible will throw a SyntaxError
+ // we use our own heuristic for those browsers
+ // rethrow might be better if it's not the expected error but do we really
+ // want to crash if focus-visible malfunctioned?
}
-};
-export function listenForFocusKeys(win) {
- // The event listener will only be added once per window.
- // Duplicate event listeners will be ignored by addEventListener.
- // Also, this logic is client side only, we don't need a teardown.
- win.addEventListener('keyup', handleKeyUpEvent);
+ // no need for validFocusTarget check. the user does that by attaching it to
+ // focusable events only
+ return hadKeyboardEvent || focusTriggersKeyboardModality(target);
+}
+
+/**
+ * Should be called if a blur event is fired on a focus-visible element
+ */
+export function handleBlurVisible() {
+ // To detect a tab/window switch, we look for a blur event followed
+ // rapidly by a visibility change.
+ // If we don't see a visibility change within 100ms, it's probably a
+ // regular focus change.
+ hadFocusVisibleRecently = true;
+ window.clearTimeout(hadFocusVisibleRecentlyTimeout);
+ hadFocusVisibleRecentlyTimeout = window.setTimeout(() => {
+ hadFocusVisibleRecently = false;
+ window.clearTimeout(hadFocusVisibleRecentlyTimeout);
+ }, 100);
}