@clayui/shared
Version:
ClayShared component
158 lines (155 loc) • 5.63 kB
JavaScript
/**
* SPDX-FileCopyrightText: © 2022 Liferay, Inc. <https://liferay.com>
* SPDX-License-Identifier: BSD-3-Clause
*/
import { hideOthers, supportsInert, suppressOthers } from 'aria-hidden';
import React, { useCallback, useEffect, useRef } from 'react';
import { Keys } from "./Keys.js";
import { ClayPortal } from "./Portal.js";
import { useInteractOutside } from "./useInteractOutside.js";
const overlayStack = [];
/**
* Overlay component is used for components like dialog and modal.
* For example, Autocomplete, DatePicker, ColorPicker, DropDown are components
* that can use and have the same consistent behavior.
*/
export function Overlay(_ref) {
let {
children,
inert,
isCloseOnInteractOutside = false,
isKeyboardDismiss = false,
isModal = false,
isOpen = false,
menuClassName,
menuRef,
onClose,
portalRef,
suppress,
triggerRef
} = _ref;
const unsuppressCallbackRef = useRef(null);
const isInert = (isModal || inert) && supportsInert();
const onHide = useCallback(action => {
if (overlayStack[overlayStack.length - 1]?.menu === menuRef) {
onClose(action);
}
}, [onClose]);
useEvent('focus', useCallback(event => {
if (portalRef && !portalRef.current?.contains(event.target) && triggerRef.current && !triggerRef.current.contains(event.target)) {
onHide('blur');
}
}, [onHide]), isOpen, true, [isOpen, onHide]);
useEvent('keydown', useCallback(event => {
if (event.key === Keys.Esc && overlayStack[overlayStack.length - 1]?.menu === menuRef) {
event.stopImmediatePropagation();
event.preventDefault();
if (triggerRef.current) {
// When inert is used to suppress user interaction with the rest of
// the document, to retrieve the focus in the trigger we need to
// first undo and then move the focus.
if (unsuppressCallbackRef.current) {
unsuppressCallbackRef.current();
unsuppressCallbackRef.current = null;
}
triggerRef.current.focus();
}
onClose('escape');
}
}, [onClose]), isOpen && isKeyboardDismiss, true, [isOpen, onClose]);
useInteractOutside({
isDisabled: isOpen ? !isCloseOnInteractOutside : true,
onInteract: event => {
// @ts-ignore
if (event.button === 0) {
onHide('blur');
}
},
onInteractStart: event => {
if (overlayStack[overlayStack.length - 1]?.menu === menuRef) {
if (unsuppressCallbackRef.current) {
unsuppressCallbackRef.current();
unsuppressCallbackRef.current = null;
}
if (isModal) {
event.stopPropagation();
event.preventDefault();
}
}
},
ref: portalRef ?? menuRef,
triggerRef
});
useEffect(() => {
if (isOpen) {
overlayStack.push({
inert: isInert,
menu: menuRef
});
}
return () => {
const index = overlayStack.findIndex(object => object.menu === menuRef);
if (index >= 0) {
overlayStack.splice(index, 1);
}
};
}, [isOpen, menuRef]);
useEffect(() => {
const currentMenuRef = menuRef?.current;
const previousOverlayStacks = overlayStack.slice(0, -1);
const previouslyHidden = previousOverlayStacks.findIndex(object => object.inert === undefined) >= 0;
const previouslyInert = previousOverlayStacks.findIndex(object => object.inert === true) >= 0;
if (currentMenuRef && isOpen) {
const elements = suppress ? suppress.map(ref => ref.current) : [currentMenuRef];
const hiddenElement = currentMenuRef.closest('[aria-hidden]') || currentMenuRef.closest('[inert]');
hiddenElement?.removeAttribute('data-suppressed');
hiddenElement?.removeAttribute('data-aria-hidden');
hiddenElement?.removeAttribute('aria-hidden');
hiddenElement?.removeAttribute('inert');
// Inert is a new native feature to better handle DOM arias that are not
// assertive to a SR or that should ignore any user interaction.
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert
if (isInert) {
unsuppressCallbackRef.current = suppressOthers(elements);
} else {
unsuppressCallbackRef.current = hideOthers(elements);
}
return () => {
if (unsuppressCallbackRef.current) {
unsuppressCallbackRef.current();
}
if (isInert && !previouslyInert) {
document.querySelectorAll('[inert]').forEach(element => {
element.removeAttribute('inert');
});
} else if (!isInert && !previouslyHidden) {
document.querySelectorAll('[aria-hidden]').forEach(element => {
element.removeAttribute('aria-hidden');
});
}
unsuppressCallbackRef.current = null;
};
}
}, [isModal, inert, isOpen]);
return /*#__PURE__*/React.createElement(ClayPortal, {
className: menuClassName,
subPortalRef: portalRef
}, isModal && /*#__PURE__*/React.createElement("span", {
"data-focus-scope-start": "true"
}), children, isModal && /*#__PURE__*/React.createElement("span", {
"data-focus-scope-end": "true"
}));
}
function useEvent(name, onEvent, conditional, capture) {
let deps = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : [];
useEffect(() => {
// This check should go away when the Overlay is shown using conditional
// instead of class CSS.
if (conditional) {
document.addEventListener(name, onEvent, capture);
return () => {
document.removeEventListener(name, onEvent, capture);
};
}
}, deps);
}