Use data attributes for click outside instead of classNames (#12228)
We previously used classnames to exclude elements from the click outside listener. With this PR we can now use `data-click-outside-id` instead of `classNames` to target the elements we want to exclude from the click outside listener. We can also add `data-globally-prevent-click-outside` to a component to globally prevent triggering click outside listeners for other components. This attribute is especially useful for confirmation modals and snackbar items. Fixes #11785: https://github.com/user-attachments/assets/318baa7e-0f82-4e3a-a447-bf981328462d
This commit is contained in:
@ -4,6 +4,7 @@ import { Key } from 'ts-key-enum';
|
||||
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
|
||||
import { DIALOG_CLICK_OUTSIDE_ID } from '@/ui/feedback/dialog-manager/constants/DialogClickOutsideId';
|
||||
import { DIALOG_LISTENER_ID } from '@/ui/feedback/dialog-manager/constants/DialogListenerId';
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
@ -138,6 +139,7 @@ export const Dialog = ({
|
||||
animate="open"
|
||||
exit="closed"
|
||||
className={className}
|
||||
data-click-outside-id={DIALOG_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<StyledDialogContainer
|
||||
variants={containerVariants}
|
||||
|
||||
@ -15,7 +15,6 @@ export const DialogManager = ({ children }: React.PropsWithChildren) => {
|
||||
{dialogInternal.queue.map(({ buttons, children, id, message, title }) => (
|
||||
<Dialog
|
||||
key={id}
|
||||
className="dialog-manager-dialog"
|
||||
{...{ title, message, buttons, id, children }}
|
||||
onClose={() => closeDialog(id)}
|
||||
/>
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const DIALOG_CLICK_OUTSIDE_ID = 'dialog-manager-dialog';
|
||||
@ -203,6 +203,7 @@ export const SnackBar = ({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={message || defaultAriaLabelByVariant[variant]}
|
||||
{...{ className, id, role, variant }}
|
||||
data-globally-prevent-click-outside
|
||||
>
|
||||
<StyledProgressBar
|
||||
barColor={theme.snackBar[variant].backgroundColor}
|
||||
|
||||
@ -79,7 +79,6 @@ export const DropdownContent = ({
|
||||
useListenClickOutside({
|
||||
refs: [floatingUiRefs.floating, floatingUiRefs.domReference],
|
||||
listenerId: dropdownId,
|
||||
excludeClassNames: ['confirmation-modal'],
|
||||
callback: (event) => {
|
||||
if (activeDropdownFocusId !== dropdownId) return;
|
||||
|
||||
@ -118,7 +117,7 @@ export const DropdownContent = ({
|
||||
maxWidth: dropdownMaxWidth,
|
||||
};
|
||||
|
||||
const { excludeClassName } = useContext(ClickOutsideListenerContext);
|
||||
const { excludedClickOutsideId } = useContext(ClickOutsideListenerContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -127,24 +126,23 @@ export const DropdownContent = ({
|
||||
)}
|
||||
|
||||
<FloatingPortal>
|
||||
<div className={excludeClassName}>
|
||||
<StyledDropdownContentContainer
|
||||
ref={floatingUiRefs.setFloating}
|
||||
style={dropdownMenuStyles}
|
||||
role="listbox"
|
||||
id={`${dropdownId}-options`}
|
||||
>
|
||||
<OverlayContainer>
|
||||
<DropdownMenu
|
||||
id={dropdownId}
|
||||
width={dropdownWidth}
|
||||
data-select-disable
|
||||
>
|
||||
{dropdownComponents}
|
||||
</DropdownMenu>
|
||||
</OverlayContainer>
|
||||
</StyledDropdownContentContainer>
|
||||
</div>
|
||||
<StyledDropdownContentContainer
|
||||
ref={floatingUiRefs.setFloating}
|
||||
style={dropdownMenuStyles}
|
||||
role="listbox"
|
||||
id={`${dropdownId}-options`}
|
||||
data-click-outside-id={excludedClickOutsideId}
|
||||
>
|
||||
<OverlayContainer>
|
||||
<DropdownMenu
|
||||
id={dropdownId}
|
||||
width={dropdownWidth}
|
||||
data-select-disable
|
||||
>
|
||||
{dropdownComponents}
|
||||
</DropdownMenu>
|
||||
</OverlayContainer>
|
||||
</StyledDropdownContentContainer>
|
||||
</FloatingPortal>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -119,7 +119,7 @@ export const ConfirmationModal = ({
|
||||
isClosable={true}
|
||||
padding="large"
|
||||
modalVariant={modalVariant}
|
||||
className="confirmation-modal"
|
||||
data-globally-prevent-click-outside
|
||||
>
|
||||
<StyledCenteredTitle>
|
||||
<H1Title title={title} fontColor={H1TitleFontColor.Primary} />
|
||||
|
||||
@ -4,7 +4,8 @@ import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkey
|
||||
import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext';
|
||||
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
|
||||
|
||||
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
|
||||
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
|
||||
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
@ -229,8 +230,7 @@ export const Modal = ({
|
||||
>
|
||||
<ClickOutsideListenerContext.Provider
|
||||
value={{
|
||||
excludeClassName:
|
||||
MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME,
|
||||
excludedClickOutsideId: MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
|
||||
}}
|
||||
>
|
||||
<ModalHotkeysAndClickOutsideEffect
|
||||
@ -242,7 +242,7 @@ export const Modal = ({
|
||||
/>
|
||||
<StyledBackDrop
|
||||
data-testid="modal-backdrop"
|
||||
className="modal-backdrop"
|
||||
data-click-outside-id={MODAL_BACKDROP_CLICK_OUTSIDE_ID}
|
||||
onMouseDown={stopEventPropagation}
|
||||
modalVariant={modalVariant}
|
||||
>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DIALOG_CLICK_OUTSIDE_ID } from '@/ui/feedback/dialog-manager/constants/DialogClickOutsideId';
|
||||
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
|
||||
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
|
||||
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { Key } from 'ts-key-enum';
|
||||
@ -45,9 +46,9 @@ export const ModalHotkeysAndClickOutsideEffect = ({
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [modalRef],
|
||||
excludeClassNames: [
|
||||
MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME,
|
||||
'dialog-manager-dialog',
|
||||
excludedClickOutsideIds: [
|
||||
MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
|
||||
DIALOG_CLICK_OUTSIDE_ID,
|
||||
],
|
||||
listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`,
|
||||
callback: () => {
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const MODAL_BACKDROP_CLICK_OUTSIDE_ID = 'modal-backdrop';
|
||||
@ -1,2 +1,2 @@
|
||||
export const MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME =
|
||||
export const MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID =
|
||||
'modal-click-outside-listener-excluded';
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderCommandMenuButtonClickOutsideId';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { i18n } from '@lingui/core';
|
||||
@ -124,7 +125,9 @@ export const PageHeaderToggleCommandMenuButton = () => {
|
||||
animatedSvg={
|
||||
<AnimatedIcon isCommandMenuOpened={isCommandMenuOpened} />
|
||||
}
|
||||
className="page-header-command-menu-button"
|
||||
data-click-outside-id={
|
||||
PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID
|
||||
}
|
||||
dataTestId="page-header-command-menu-button"
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
variant="secondary"
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export const PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID =
|
||||
'page-header-command-menu-button';
|
||||
@ -5,6 +5,7 @@ import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerCollapseButton';
|
||||
|
||||
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
|
||||
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import {
|
||||
@ -144,7 +145,9 @@ export const PageHeader = ({
|
||||
</StyledTopBarIconStyledTitleContainer>
|
||||
</StyledLeftContainer>
|
||||
|
||||
<StyledPageActionContainer className="page-action-container">
|
||||
<StyledPageActionContainer
|
||||
data-click-outside-id={PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
{children}
|
||||
</StyledPageActionContainer>
|
||||
</StyledTopBarContainer>
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID = 'page-action-container';
|
||||
@ -1,10 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type ClickOutsideListenerContextType = {
|
||||
excludeClassName: string | undefined;
|
||||
excludedClickOutsideId: string | undefined;
|
||||
};
|
||||
|
||||
export const ClickOutsideListenerContext =
|
||||
createContext<ClickOutsideListenerContextType>({
|
||||
excludeClassName: undefined,
|
||||
excludedClickOutsideId: undefined,
|
||||
});
|
||||
|
||||
@ -4,12 +4,13 @@ import { clickOutsideListenerMouseDownHappenedComponentState } from '@/ui/utilit
|
||||
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const CLICK_OUTSIDE_DEBUG_MODE = false;
|
||||
|
||||
export type ClickOutsideListenerProps<T extends Element> = {
|
||||
refs: Array<React.RefObject<T>>;
|
||||
excludeClassNames?: string[];
|
||||
excludedClickOutsideIds?: string[];
|
||||
callback: (event: MouseEvent | TouchEvent) => void;
|
||||
listenerId: string;
|
||||
enabled?: boolean;
|
||||
@ -17,7 +18,7 @@ export type ClickOutsideListenerProps<T extends Element> = {
|
||||
|
||||
export const useListenClickOutside = <T extends Element>({
|
||||
refs,
|
||||
excludeClassNames,
|
||||
excludedClickOutsideIds,
|
||||
callback,
|
||||
listenerId,
|
||||
enabled = true,
|
||||
@ -90,12 +91,18 @@ export const useListenClickOutside = <T extends Element>({
|
||||
let currentElement: HTMLElement | null = clickedElement;
|
||||
|
||||
while (currentElement) {
|
||||
const currentClassList = currentElement.classList;
|
||||
const currentDataAttributes = currentElement.dataset;
|
||||
|
||||
const isGloballyExcluded =
|
||||
currentDataAttributes?.globallyPreventClickOutside === 'true';
|
||||
|
||||
const clickOutsideId = currentDataAttributes?.clickOutsideId;
|
||||
|
||||
isClickedOnExcluded =
|
||||
excludeClassNames?.some((className) =>
|
||||
currentClassList.contains(className),
|
||||
) ?? false;
|
||||
isGloballyExcluded ||
|
||||
(isDefined(clickOutsideId) &&
|
||||
isDefined(excludedClickOutsideIds) &&
|
||||
excludedClickOutsideIds.includes(clickOutsideId));
|
||||
|
||||
if (isClickedOnExcluded) {
|
||||
break;
|
||||
@ -140,7 +147,7 @@ export const useListenClickOutside = <T extends Element>({
|
||||
clickOutsideListenerIsMouseDownInsideState,
|
||||
clickOutsideListenerMouseDownHappenedState,
|
||||
refs,
|
||||
excludeClassNames,
|
||||
excludedClickOutsideIds,
|
||||
callback,
|
||||
listenerId,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user