UNPKG

@clayui/shared

Version:

ClayShared component

158 lines (155 loc) 5.63 kB
/** * 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); }