175 lines
6.4 KiB
TypeScript
175 lines
6.4 KiB
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, 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;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// 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);
|
|
onBlur?.(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']});
|
|
}
|
|
}, [onBlur]);
|
|
}
|
|
|
|
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;
|
|
}
|