@atlaskit/popup
Version:
A popup displays brief content in an overlay.
248 lines (246 loc) • 9.42 kB
JavaScript
// eslint-disable-next-line no-duplicate-imports
import { useEffect, useRef } from 'react';
import { bind, bindAll } from 'bind-event-listener';
import noop from '@atlaskit/ds-lib/noop';
import { useLayering } from '@atlaskit/layering';
import { fg } from '@atlaskit/platform-feature-flags';
import { isInteractiveElement } from './utils/is-element-interactive';
import { useAnimationFrame } from './utils/use-animation-frame';
export const useCloseManager = ({
isOpen,
onClose,
popupRef,
triggerRef,
autoFocus,
shouldDisableFocusTrap,
shouldUseCaptureOnOutsideClick: capture,
shouldCloseOnTab,
shouldRenderToParent
}) => {
const {
isLayerDisabled,
currentLevel
} = useLayering();
const {
requestFrame,
cancelAllFrames
} = useAnimationFrame();
const mouseDownTarget = useRef(null);
useEffect(() => {
if (!isOpen || !popupRef) {
return noop;
}
const closePopup = event => {
if (onClose) {
let currentLevel = null;
if (event.target instanceof HTMLElement) {
var _event$target$closest, _event$target$closest2;
currentLevel = (_event$target$closest = (_event$target$closest2 = event.target.closest(`[data-ds--level]`)) === null || _event$target$closest2 === void 0 ? void 0 : _event$target$closest2.getAttribute('data-ds--level')) !== null && _event$target$closest !== void 0 ? _event$target$closest : null;
}
currentLevel ? onClose(event, Number(currentLevel)) : onClose(event);
}
if (shouldDisableFocusTrap && fg('platform_dst_popup-disable-focuslock')) {
// Restoring the normal focus order for trigger.
requestFrame(() => {
triggerRef === null || triggerRef === void 0 ? void 0 : triggerRef.setAttribute('tabindex', '0');
if (popupRef && autoFocus) {
popupRef.setAttribute('tabindex', '0');
}
});
}
};
// This check is required for cases where components like
// Select or DDM are placed inside a Popup. A click
// on a MenuItem or Option would close the Popup, without registering
// a click on DDM/Select.
// Users would have to call `onClose` manually to close the Popup in these cases.
// You can see the bug in action here:
// https://codesandbox.io/s/atlaskitpopup-default-forked-2eb87?file=/example.tsx:0-1788
const onClick = event => {
const {
target
} = event;
const doesDomNodeExist = document.body.contains(target);
if (!doesDomNodeExist) {
return;
}
if (isLayerDisabled()) {
if (target instanceof HTMLElement) {
var _target$closest;
const layeredElement = (_target$closest = target.closest) === null || _target$closest === void 0 ? void 0 : _target$closest.call(target, `[data-ds--level]`);
if (layeredElement) {
const closeType = layeredElement.getAttribute('[data-ds--close--type]');
if (closeType === 'single') {
// if the close type is single, we won't close other disabled layers when clicking outside
return;
}
const levelOfClickedLayer = layeredElement.getAttribute('data-ds--level');
if (levelOfClickedLayer && Number(levelOfClickedLayer) > currentLevel) {
// won't trigger onClick event when we click in a higher layer.
return;
}
}
}
}
const isClickOnPopup = popupRef && popupRef.contains(target);
const isClickOnTrigger = triggerRef && triggerRef.contains(target);
const didClickStartInsidePopup = popupRef && mouseDownTarget.current instanceof Node && popupRef.contains(mouseDownTarget.current);
if (!isClickOnPopup && !isClickOnTrigger && !didClickStartInsidePopup) {
closePopup(event);
// If there was an outside click on a non-interactive element, the focus should be on the trigger.
if (document.activeElement && !isInteractiveElement(document.activeElement) && fg('platform_dst_popup-disable-focuslock')) {
triggerRef === null || triggerRef === void 0 ? void 0 : triggerRef.focus();
}
}
};
const onMouseDown = event => {
/**
* Tracking the target of the mouse down event.
* This is used to prevent the popup from closing when the user mouses down inside the popup, but then
* moves the mouse outside the popup before releasing the mouse button.
*
* This is a common user interaction - users may have mistakenly clicked on something, or changed their mind,
* so they try to cancel their click by moving their mouse away from what they had moused down on.
*
* Blanket uses the same technique.
*/
mouseDownTarget.current = event.target;
};
const onKeyDown = event => {
const key = event.key;
if ((key === 'Escape' || key === 'Esc') && fg('platform_dst_nested_escape')) {
var _eventTarget$closest;
const eventTarget = event.target instanceof HTMLElement ? event.target : null;
const layeredAncestor = eventTarget === null || eventTarget === void 0 ? void 0 : (_eventTarget$closest = eventTarget.closest) === null || _eventTarget$closest === void 0 ? void 0 : _eventTarget$closest.call(eventTarget, '[data-ds--level]');
const levelStr = layeredAncestor === null || layeredAncestor === void 0 ? void 0 : layeredAncestor.getAttribute('data-ds--level');
if (levelStr && Number(levelStr) > currentLevel) {
return;
}
}
if (fg('platform_dst_popup-disable-focuslock')) {
const {
shiftKey
} = event;
if (shiftKey && key === 'Tab' && !shouldRenderToParent) {
if (isLayerDisabled()) {
return;
}
// We need to move the focus to the popup trigger when the popup is displayed in React.Portal.
requestFrame(() => {
const isPopupFocusOut = popupRef && !popupRef.contains(document.activeElement);
if (isPopupFocusOut) {
closePopup(event);
if (currentLevel === 1) {
triggerRef === null || triggerRef === void 0 ? void 0 : triggerRef.focus();
}
}
});
return;
}
if (key === 'Tab') {
var _document$activeEleme;
// We have cases where we need to close the Popup on Tab press.
// Example: DropdownMenu
if (shouldCloseOnTab) {
if (isLayerDisabled()) {
return;
}
closePopup(event);
return;
}
if (isLayerDisabled() && (_document$activeEleme = document.activeElement) !== null && _document$activeEleme !== void 0 && _document$activeEleme.closest('[aria-modal]')) {
return;
}
if (shouldDisableFocusTrap) {
if (shouldRenderToParent) {
// We need to move the focus to the previous interactive element before popup trigger
requestFrame(() => {
const isPopupFocusOut = popupRef && !popupRef.contains(document.activeElement);
if (isPopupFocusOut) {
closePopup(event);
}
});
} else {
requestFrame(() => {
if (!document.hasFocus()) {
closePopup(event);
}
});
}
return;
}
}
if (isLayerDisabled()) {
return;
}
if (key === 'Escape' || key === 'Esc') {
if (triggerRef && autoFocus) {
triggerRef.focus();
}
closePopup(event);
}
} else {
if (isLayerDisabled()) {
return;
}
if (key === 'Escape' || key === 'Esc' || shouldCloseOnTab && key === 'Tab') {
closePopup(event);
}
}
};
let unbind = noop;
if (fg('popup-onclose-fix')) {
setTimeout(() => {
unbind = bindAll(window, [{
type: 'click',
listener: onClick,
options: {
capture
}
}, {
type: 'keydown',
listener: onKeyDown
}, {
type: 'mousedown',
listener: onMouseDown
}]);
}, 0);
} else {
unbind = bindAll(window, [{
type: 'click',
listener: onClick,
options: {
capture
}
}, {
type: 'keydown',
listener: onKeyDown
}, {
type: 'mousedown',
listener: onMouseDown
}]);
}
// bind onBlur event listener to fix popup not close when clicking on iframe outside
let unbindBlur = noop;
unbindBlur = bind(window, {
type: 'blur',
listener: function onBlur(e) {
if (isLayerDisabled() || !(document.activeElement instanceof HTMLIFrameElement)) {
return;
}
closePopup(e);
}
});
return () => {
if (fg('popup-onclose-fix')) {
setTimeout(() => {
unbind();
}, 0);
} else {
unbind();
}
cancelAllFrames();
unbindBlur();
};
}, [isOpen, onClose, popupRef, triggerRef, autoFocus, shouldDisableFocusTrap, capture, isLayerDisabled, shouldCloseOnTab, currentLevel, shouldRenderToParent, requestFrame, cancelAllFrames]);
};