@react-aria/interactions
Version:
Spectrum UI components in React
179 lines (152 loc) • 6.71 kB
text/typescript
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {FocusableElement} from '@react-types/shared';
import {focusWithoutScrolling, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils';
import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react';
// Turn a native event into a React synthetic event.
export function createSyntheticEvent<E extends SyntheticEvent>(nativeEvent: Event): E {
let event = nativeEvent as any as E;
event.nativeEvent = nativeEvent;
event.isDefaultPrevented = () => event.defaultPrevented;
// cancelBubble is technically deprecated in the spec, but still supported in all browsers.
event.isPropagationStopped = () => (event as any).cancelBubble;
event.persist = () => {};
return event;
}
export function setEventTarget(event: Event, target: Element): void {
Object.defineProperty(event, 'target', {value: target});
Object.defineProperty(event, 'currentTarget', {value: target});
}
export function useSyntheticBlurEvent<Target extends Element = Element>(onBlur: (e: ReactFocusEvent<Target>) => void): (e: ReactFocusEvent<Target>) => void {
let stateRef = useRef({
isFocused: false,
observer: null as MutationObserver | null
});
// Clean up MutationObserver on unmount. See below.
useLayoutEffect(() => {
const state = stateRef.current;
return () => {
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
};
}, []);
let dispatchBlur = useEffectEvent((e: ReactFocusEvent<Target>) => {
onBlur?.(e);
});
// This function is called during a React onFocus event.
return useCallback((e: ReactFocusEvent<Target>) => {
// React does not fire onBlur when an element is disabled. https://github.com/facebook/react/issues/9142
// Most browsers fire a native focusout event in this case, except for Firefox. In that case, we use a
// MutationObserver to watch for the disabled attribute, and dispatch these events ourselves.
// For browsers that do, focusout fires before the MutationObserver, so onBlur should not fire twice.
if (
e.target instanceof HTMLButtonElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
stateRef.current.isFocused = true;
let target = e.target;
let onBlurHandler: EventListenerOrEventListenerObject | null = (e) => {
stateRef.current.isFocused = false;
if (target.disabled) {
// For backward compatibility, dispatch a (fake) React synthetic event.
let event = createSyntheticEvent<ReactFocusEvent<Target>>(e);
dispatchBlur(event);
}
// We no longer need the MutationObserver once the target is blurred.
if (stateRef.current.observer) {
stateRef.current.observer.disconnect();
stateRef.current.observer = null;
}
};
target.addEventListener('focusout', onBlurHandler, {once: true});
stateRef.current.observer = new MutationObserver(() => {
if (stateRef.current.isFocused && target.disabled) {
stateRef.current.observer?.disconnect();
let relatedTargetEl = target === document.activeElement ? null : document.activeElement;
target.dispatchEvent(new FocusEvent('blur', {relatedTarget: relatedTargetEl}));
target.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: relatedTargetEl}));
}
});
stateRef.current.observer.observe(target, {attributes: true, attributeFilter: ['disabled']});
}
}, [dispatchBlur]);
}
export let ignoreFocusEvent = false;
/**
* This function prevents the next focus event fired on `target`, without using `event.preventDefault()`.
* It works by waiting for the series of focus events to occur, and reverts focus back to where it was before.
* It also makes these events mostly non-observable by using a capturing listener on the window and stopping propagation.
*/
export function preventFocus(target: FocusableElement | null): (() => void) | undefined {
// The browser will focus the nearest focusable ancestor of our target.
while (target && !isFocusable(target)) {
target = target.parentElement;
}
let window = getOwnerWindow(target);
let activeElement = window.document.activeElement as FocusableElement | null;
if (!activeElement || activeElement === target) {
return;
}
ignoreFocusEvent = true;
let isRefocusing = false;
let onBlur = (e: FocusEvent) => {
if (e.target === activeElement || isRefocusing) {
e.stopImmediatePropagation();
}
};
let onFocusOut = (e: FocusEvent) => {
if (e.target === activeElement || isRefocusing) {
e.stopImmediatePropagation();
// If there was no focusable ancestor, we don't expect a focus event.
// Re-focus the original active element here.
if (!target && !isRefocusing) {
isRefocusing = true;
focusWithoutScrolling(activeElement);
cleanup();
}
}
};
let onFocus = (e: FocusEvent) => {
if (e.target === target || isRefocusing) {
e.stopImmediatePropagation();
}
};
let onFocusIn = (e: FocusEvent) => {
if (e.target === target || isRefocusing) {
e.stopImmediatePropagation();
if (!isRefocusing) {
isRefocusing = true;
focusWithoutScrolling(activeElement);
cleanup();
}
}
};
window.addEventListener('blur', onBlur, true);
window.addEventListener('focusout', onFocusOut, true);
window.addEventListener('focusin', onFocusIn, true);
window.addEventListener('focus', onFocus, true);
let cleanup = () => {
cancelAnimationFrame(raf);
window.removeEventListener('blur', onBlur, true);
window.removeEventListener('focusout', onFocusOut, true);
window.removeEventListener('focusin', onFocusIn, true);
window.removeEventListener('focus', onFocus, true);
ignoreFocusEvent = false;
isRefocusing = false;
};
let raf = requestAnimationFrame(cleanup);
return cleanup;
}