/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import useEvent from './useEvent';
const {useCallback, useEffect, useLayoutEffect, useRef} = React;
type FocusEvent = SyntheticEvent<EventTarget>;
type UseFocusOptions = {
disabled?: boolean,
onBlur?: ?(FocusEvent) => void,
onFocus?: ?(FocusEvent) => void,
onFocusChange?: ?(boolean) => void,
onFocusVisibleChange?: ?(boolean) => void,
};
type UseFocusWithinOptions = {
disabled?: boolean,
onAfterBlurWithin?: FocusEvent => void,
onBeforeBlurWithin?: FocusEvent => void,
onBlurWithin?: FocusEvent => void,
onFocusWithin?: FocusEvent => void,
onFocusWithinChange?: boolean => void,
onFocusWithinVisibleChange?: boolean => void,
};
const isMac =
typeof window !== 'undefined' && window.navigator != null
? /^Mac/.test(window.navigator.platform)
: false;
const hasPointerEvents =
typeof window !== 'undefined' && window.PointerEvent != null;
const globalFocusVisibleEvents = hasPointerEvents
? ['keydown', 'pointermove', 'pointerdown', 'pointerup']
: [
'keydown',
'mousedown',
'mousemove',
'mouseup',
'touchmove',
'touchstart',
'touchend',
];
// Global state for tracking focus visible and emulation of mouse
let isGlobalFocusVisible = true;
let hasTrackedGlobalFocusVisible = false;
function trackGlobalFocusVisible() {
globalFocusVisibleEvents.forEach(type => {
window.addEventListener(type, handleGlobalFocusVisibleEvent, true);
});
}
function isValidKey(nativeEvent: KeyboardEvent): boolean {
const {metaKey, altKey, ctrlKey} = nativeEvent;
return !(metaKey || (!isMac && altKey) || ctrlKey);
}
function isTextInput(nativeEvent: KeyboardEvent): boolean {
const {key, target} = nativeEvent;
if (key === 'Tab' || key === 'Escape') {
return false;
}
const {isContentEditable, tagName} = (target: any);
return tagName === 'INPUT' || tagName === 'TEXTAREA' || isContentEditable;
}
function handleGlobalFocusVisibleEvent(
nativeEvent: MouseEvent | TouchEvent | KeyboardEvent,
): void {
if (nativeEvent.type === 'keydown') {
if (isValidKey(((nativeEvent: any): KeyboardEvent))) {
isGlobalFocusVisible = true;
}
} else {
const nodeName = (nativeEvent.target: any).nodeName;
// Safari calls mousemove/pointermove events when you tab out of the active
// Safari frame.
if (nodeName === 'HTML') {
return;
}
// Handle all the other mouse/touch/pointer events
isGlobalFocusVisible = false;
}
}
function handleFocusVisibleTargetEvents(
event: SyntheticEvent<EventTarget>,
callback: boolean => void,
): void {
if (event.type === 'keydown') {
const {nativeEvent} = (event: any);
if (isValidKey(nativeEvent) && !isTextInput(nativeEvent)) {
callback(true);
}
} else {
callback(false);
}
}
function isRelatedTargetWithin(
focusWithinTarget: Object,
relatedTarget: null | EventTarget,
): boolean {
if (relatedTarget == null) {
return false;
}
// As the focusWithinTarget can be a Scope Instance (experimental API),
// we need to use the containsNode() method. Otherwise, focusWithinTarget
// must be a Node, which means we can use the contains() method.
return typeof focusWithinTarget.containsNode === 'function'
? focusWithinTarget.containsNode(relatedTarget)
: focusWithinTarget.contains(relatedTarget);
}
function setFocusVisibleListeners(
// $FlowFixMe[missing-local-annot]
focusVisibleHandles,
focusTarget: EventTarget,
callback: boolean => void,
) {
focusVisibleHandles.forEach(focusVisibleHandle => {
focusVisibleHandle.setListener(focusTarget, event =>
handleFocusVisibleTargetEvents(event, callback),
);
});
}
function useFocusVisibleInputHandles() {
return [
useEvent('mousedown'),
useEvent(hasPointerEvents ? 'pointerdown' : 'touchstart'),
useEvent('keydown'),
];
}
function useFocusLifecycles() {
useEffect(() => {
if (!hasTrackedGlobalFocusVisible) {
hasTrackedGlobalFocusVisible = true;
trackGlobalFocusVisible();
}
}, []);
}
export function useFocus(
focusTargetRef: {current: null | Node},
{
disabled,
onBlur,
onFocus,
onFocusChange,
onFocusVisibleChange,
}: UseFocusOptions,
): void {
// Setup controlled state for this useFocus hook
const stateRef = useRef<null | {
isFocused: boolean,
isFocusVisible: boolean,
}>({isFocused: false, isFocusVisible: false});
const focusHandle = useEvent('focusin');
const blurHandle = useEvent('focusout');
const focusVisibleHandles = useFocusVisibleInputHandles();
useLayoutEffect(() => {
const focusTarget = focusTargetRef.current;
const state = stateRef.current;
if (focusTarget !== null && state !== null && focusTarget.nodeType === 1) {
// Handle focus visible
setFocusVisibleListeners(
focusVisibleHandles,
focusTarget,
isFocusVisible => {
if (state.isFocused && state.isFocusVisible !== isFocusVisible) {
state.isFocusVisible = isFocusVisible;
if (onFocusVisibleChange) {
onFocusVisibleChange(isFocusVisible);
}
}
},
);
// Handle focus
focusHandle.setListener(focusTarget, (event: FocusEvent) => {
if (disabled === true) {
return;
}
if (!state.isFocused && focusTarget === event.target) {
state.isFocused = true;
state.isFocusVisible = isGlobalFocusVisible;
if (onFocus) {
onFocus(event);
}
if (onFocusChange) {
onFocusChange(true);
}
if (state.isFocusVisible && onFocusVisibleChange) {
onFocusVisibleChange(true);
}
}
});
// Handle blur
blurHandle.setListener(focusTarget, (event: FocusEvent) => {
if (disabled === true) {
return;
}
if (state.isFocused) {
state.isFocused = false;
state.isFocusVisible = isGlobalFocusVisible;
if (onBlur) {
onBlur(event);
}
if (onFocusChange) {
onFocusChange(false);
}
if (state.isFocusVisible && onFocusVisibleChange) {
onFocusVisibleChange(false);
}
}
});
}
}, [
blurHandle,
disabled,
focusHandle,
focusTargetRef,
focusVisibleHandles,
onBlur,
onFocus,
onFocusChange,
onFocusVisibleChange,
]);
// Mount/Unmount logic
useFocusLifecycles();
}
export function useFocusWithin<T>(
focusWithinTargetRef:
| {current: null | T}
| ((focusWithinTarget: null | T) => void),
{
disabled,
onAfterBlurWithin,
onBeforeBlurWithin,
onBlurWithin,
onFocusWithin,
onFocusWithinChange,
onFocusWithinVisibleChange,
}: UseFocusWithinOptions,
): (focusWithinTarget: null | T) => void {
// Setup controlled state for this useFocus hook
const stateRef = useRef<null | {
isFocused: boolean,
isFocusVisible: boolean,
}>({isFocused: false, isFocusVisible: false});
const focusHandle = useEvent('focusin');
const blurHandle = useEvent('focusout');
const afterBlurHandle = useEvent('afterblur');
const beforeBlurHandle = useEvent('beforeblur');
const focusVisibleHandles = useFocusVisibleInputHandles();
const useFocusWithinRef = useCallback(
(focusWithinTarget: null | T) => {
// Handle the incoming focusTargetRef. It can be either a function ref
// or an object ref.
if (typeof focusWithinTargetRef === 'function') {
focusWithinTargetRef(focusWithinTarget);
} else {
focusWithinTargetRef.current = focusWithinTarget;
}
const state = stateRef.current;
if (focusWithinTarget !== null && state !== null) {
// Handle focus visible
setFocusVisibleListeners(
focusVisibleHandles,
// $FlowFixMe[incompatible-call] focusWithinTarget is not null here
focusWithinTarget,
isFocusVisible => {
if (state.isFocused && state.isFocusVisible !== isFocusVisible) {
state.isFocusVisible = isFocusVisible;
if (onFocusWithinVisibleChange) {
onFocusWithinVisibleChange(isFocusVisible);
}
}
},
);
// Handle focus
// $FlowFixMe[incompatible-call] focusWithinTarget is not null here
focusHandle.setListener(focusWithinTarget, (event: FocusEvent) => {
if (disabled) {
return;
}
if (!state.isFocused) {
state.isFocused = true;
state.isFocusVisible = isGlobalFocusVisible;
if (onFocusWithinChange) {
onFocusWithinChange(true);
}
if (state.isFocusVisible && onFocusWithinVisibleChange) {
onFocusWithinVisibleChange(true);
}
}
if (!state.isFocusVisible && isGlobalFocusVisible) {
state.isFocusVisible = isGlobalFocusVisible;
if (onFocusWithinVisibleChange) {
onFocusWithinVisibleChange(true);
}
}
if (onFocusWithin) {
onFocusWithin(event);
}
});
// Handle blur
// $FlowFixMe[incompatible-call] focusWithinTarget is not null here
blurHandle.setListener(focusWithinTarget, (event: FocusEvent) => {
if (disabled) {
return;
}
const {relatedTarget} = (event.nativeEvent: any);
if (
state.isFocused &&
!isRelatedTargetWithin(focusWithinTarget, relatedTarget)
) {
state.isFocused = false;
if (onFocusWithinChange) {
onFocusWithinChange(false);
}
if (state.isFocusVisible && onFocusWithinVisibleChange) {
onFocusWithinVisibleChange(false);
}
if (onBlurWithin) {
onBlurWithin(event);
}
}
});
// Handle before blur. This is a special
// React provided event.
// $FlowFixMe[incompatible-call] focusWithinTarget is not null here
beforeBlurHandle.setListener(focusWithinTarget, (event: FocusEvent) => {
if (disabled) {
return;
}
if (onBeforeBlurWithin) {
onBeforeBlurWithin(event);
// Add an "afterblur" listener on document. This is a special
// React provided event.
afterBlurHandle.setListener(
document,
(afterBlurEvent: FocusEvent) => {
if (onAfterBlurWithin) {
onAfterBlurWithin(afterBlurEvent);
}
// Clear listener on document
afterBlurHandle.setListener(document, null);
},
);
}
});
}
},
[
afterBlurHandle,
beforeBlurHandle,
blurHandle,
disabled,
focusHandle,
focusWithinTargetRef,
onAfterBlurWithin,
onBeforeBlurWithin,
onBlurWithin,
onFocusWithin,
onFocusWithinChange,
onFocusWithinVisibleChange,
],
);
// Mount/Unmount logic
useFocusLifecycles();
return useFocusWithinRef;
}