@zendeskgarden/container-focusvisible
Version:
Containers relating to the :focus-visible polyfill hook in the Garden Design System
207 lines (202 loc) • 7.19 kB
JavaScript
/**
* Copyright Zendesk, Inc.
*
* Use of this source code is governed under the Apache License, Version 2.0
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
const INPUT_TYPES_WHITE_LIST = {
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 useFocusVisible(_temp) {
let {
scope,
relativeDocument,
className = 'garden-focus-visible',
dataAttribute = 'data-garden-focus-visible'
} = _temp === void 0 ? {} : _temp;
if (!scope) {
throw new Error('Error: the useFocusVisible() hook requires a "scope" property');
}
const hadKeyboardEvent = useRef(false);
const hadFocusVisibleRecently = useRef(false);
const hadFocusVisibleRecentlyTimeout = useRef();
useEffect(() => {
let environment = relativeDocument;
if (!environment) {
environment = document;
}
const isValidFocusTarget = el => {
if (el && el !== scope.current && el.nodeName !== 'HTML' && el.nodeName !== 'BODY' && 'classList' in el && 'contains' in el.classList) {
return true;
}
return false;
};
const focusTriggersKeyboardModality = el => {
const type = el.type;
const tagName = el.tagName;
if (tagName === 'INPUT' && INPUT_TYPES_WHITE_LIST[type] && !el.readOnly) {
return true;
}
if (tagName === 'TEXTAREA' && !el.readOnly) {
return true;
}
if (el.isContentEditable) {
return true;
}
return false;
};
const isFocused = el => {
if (el && (el.classList.contains(className) || el.hasAttribute(dataAttribute))) {
return true;
}
return false;
};
const addFocusVisibleClass = el => {
if (isFocused(el)) {
return;
}
el && el.classList.add(className);
el && el.setAttribute(dataAttribute, 'true');
};
const removeFocusVisibleClass = el => {
el.classList.remove(className);
el.removeAttribute(dataAttribute);
};
const onKeyDown = e => {
if (e.metaKey || e.altKey || e.ctrlKey) {
return;
}
if (isValidFocusTarget(environment.activeElement)) {
addFocusVisibleClass(environment.activeElement);
}
hadKeyboardEvent.current = true;
};
const onPointerDown = () => {
hadKeyboardEvent.current = false;
};
const onFocus = e => {
if (!isValidFocusTarget(e.target)) {
return;
}
if (hadKeyboardEvent.current || focusTriggersKeyboardModality(e.target)) {
addFocusVisibleClass(e.target);
}
};
const onBlur = e => {
if (!isValidFocusTarget(e.target)) {
return;
}
if (isFocused(e.target)) {
hadFocusVisibleRecently.current = true;
clearTimeout(hadFocusVisibleRecentlyTimeout.current);
const timeoutId = setTimeout(() => {
hadFocusVisibleRecently.current = false;
clearTimeout(hadFocusVisibleRecentlyTimeout.current);
}, 100);
hadFocusVisibleRecentlyTimeout.current = Number(timeoutId);
removeFocusVisibleClass(e.target);
}
};
const onInitialPointerMove = e => {
const nodeName = e.target.nodeName;
if (nodeName && nodeName.toLowerCase() === 'html') {
return;
}
hadKeyboardEvent.current = false;
removeInitialPointerMoveListeners();
};
const addInitialPointerMoveListeners = () => {
environment.addEventListener('mousemove', onInitialPointerMove);
environment.addEventListener('mousedown', onInitialPointerMove);
environment.addEventListener('mouseup', onInitialPointerMove);
environment.addEventListener('pointermove', onInitialPointerMove);
environment.addEventListener('pointerdown', onInitialPointerMove);
environment.addEventListener('pointerup', onInitialPointerMove);
environment.addEventListener('touchmove', onInitialPointerMove);
environment.addEventListener('touchstart', onInitialPointerMove);
environment.addEventListener('touchend', onInitialPointerMove);
};
const removeInitialPointerMoveListeners = () => {
environment.removeEventListener('mousemove', onInitialPointerMove);
environment.removeEventListener('mousedown', onInitialPointerMove);
environment.removeEventListener('mouseup', onInitialPointerMove);
environment.removeEventListener('pointermove', onInitialPointerMove);
environment.removeEventListener('pointerdown', onInitialPointerMove);
environment.removeEventListener('pointerup', onInitialPointerMove);
environment.removeEventListener('touchmove', onInitialPointerMove);
environment.removeEventListener('touchstart', onInitialPointerMove);
environment.removeEventListener('touchend', onInitialPointerMove);
};
const onVisibilityChange = () => {
if (environment.visibilityState === 'hidden') {
if (hadFocusVisibleRecently.current) {
hadKeyboardEvent.current = true;
}
}
};
const currentScope = scope.current;
if (!environment || !currentScope) {
return;
}
environment.addEventListener('keydown', onKeyDown, true);
environment.addEventListener('mousedown', onPointerDown, true);
environment.addEventListener('pointerdown', onPointerDown, true);
environment.addEventListener('touchstart', onPointerDown, true);
environment.addEventListener('visibilitychange', onVisibilityChange, true);
addInitialPointerMoveListeners();
currentScope && currentScope.addEventListener('focus', onFocus, true);
currentScope && currentScope.addEventListener('blur', onBlur, true);
return () => {
environment.removeEventListener('keydown', onKeyDown);
environment.removeEventListener('mousedown', onPointerDown);
environment.removeEventListener('pointerdown', onPointerDown);
environment.removeEventListener('touchstart', onPointerDown);
environment.removeEventListener('visibilityChange', onVisibilityChange);
removeInitialPointerMoveListeners();
currentScope && currentScope.removeEventListener('focus', onFocus);
currentScope && currentScope.removeEventListener('blur', onBlur);
clearTimeout(hadFocusVisibleRecentlyTimeout.current);
};
}, [relativeDocument, scope, className, dataAttribute]);
}
const FocusVisibleContainer = _ref => {
let {
children,
render = children,
...options
} = _ref;
const scopeRef = useRef(null);
useFocusVisible({
scope: scopeRef,
...options
});
return React.createElement(React.Fragment, null, render({
ref: scopeRef
}));
};
FocusVisibleContainer.defaultProps = {
className: 'garden-focus-visible',
dataAttribute: 'data-garden-focus-visible'
};
FocusVisibleContainer.propTypes = {
children: PropTypes.func,
render: PropTypes.func,
relativeDocument: PropTypes.object,
className: PropTypes.string,
dataAttribute: PropTypes.string
};
export { FocusVisibleContainer, useFocusVisible };