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:
Raphaël Bosi
2025-05-22 18:10:51 +02:00
committed by GitHub
parent 45c89a46d6
commit 051f0fc83f
28 changed files with 104 additions and 63 deletions

View File

@ -7,10 +7,10 @@ import { useDarkMode } from 'storybook-dark-mode';
import { RootDecorator } from '../src/testing/decorators/RootDecorator'; import { RootDecorator } from '../src/testing/decorators/RootDecorator';
import { mockedUserJWT } from '../src/testing/mock-data/jwt'; import { mockedUserJWT } from '../src/testing/mock-data/jwt';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import 'react-loading-skeleton/dist/skeleton.css'; import 'react-loading-skeleton/dist/skeleton.css';
import 'twenty-ui/style.css'; import 'twenty-ui/style.css';
import { THEME_DARK, THEME_LIGHT, ThemeContextProvider } from 'twenty-ui/theme'; import { THEME_DARK, THEME_LIGHT, ThemeContextProvider } from 'twenty-ui/theme';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
initialize({ initialize({
onUnhandledRequest: async (request: Request) => { onUnhandledRequest: async (request: Request) => {
@ -48,7 +48,7 @@ const preview: Preview = {
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<ThemeContextProvider theme={theme}> <ThemeContextProvider theme={theme}>
<ClickOutsideListenerContext.Provider <ClickOutsideListenerContext.Provider
value={{ excludeClassName: undefined }} value={{ excludedClickOutsideId: undefined }}
> >
<Story /> <Story />
</ClickOutsideListenerContext.Provider> </ClickOutsideListenerContext.Provider>

View File

@ -1,6 +1,7 @@
import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent'; import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent';
import { ActionScope } from '@/action-menu/actions/types/ActionScope'; import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType'; import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ACTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/action-menu/constants/ActionMenuDropdownClickOutsideId';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState'; import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
@ -81,7 +82,9 @@ export const RecordIndexActionMenuDropdown = () => {
y: actionMenuDropdownPosition.y ?? 0, y: actionMenuDropdownPosition.y ?? 0,
}} }}
dropdownComponents={ dropdownComponents={
<StyledDropdownMenuContainer className="action-menu-dropdown"> <StyledDropdownMenuContainer
data-click-outside-id={ACTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
<SelectableList <SelectableList
hotkeyScope={ActionMenuDropdownHotkeyScope.ActionMenuDropdown} hotkeyScope={ActionMenuDropdownHotkeyScope.ActionMenuDropdown}

View File

@ -0,0 +1 @@
export const ACTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID = 'action-menu-dropdown';

View File

@ -30,7 +30,7 @@ export const App = () => {
<ExceptionHandlerProvider> <ExceptionHandlerProvider>
<HelmetProvider> <HelmetProvider>
<ClickOutsideListenerContext.Provider <ClickOutsideListenerContext.Provider
value={{ excludeClassName: undefined }} value={{ excludedClickOutsideId: undefined }}
> >
<AppRouter /> <AppRouter />
</ClickOutsideListenerContext.Provider> </ClickOutsideListenerContext.Provider>

View File

@ -15,6 +15,7 @@ import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksO
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState'; import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath'; import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
@ -89,6 +90,12 @@ export const PageChangeEffect = () => {
const { executeTasksOnAnyLocationChange } = const { executeTasksOnAnyLocationChange } =
useExecuteTasksOnAnyLocationChange(); useExecuteTasksOnAnyLocationChange();
const { closeCommandMenu } = useCommandMenu();
useEffect(() => {
closeCommandMenu();
}, [location.pathname, closeCommandMenu]);
useEffect(() => { useEffect(() => {
if (!previousLocation || previousLocation !== location.pathname) { if (!previousLocation || previousLocation !== location.pathname) {
setPreviousLocation(location.pathname); setPreviousLocation(location.pathname);

View File

@ -1,8 +1,10 @@
import { COMMAND_MENU_ANIMATION_VARIANTS } from '@/command-menu/constants/CommandMenuAnimationVariants'; import { COMMAND_MENU_ANIMATION_VARIANTS } from '@/command-menu/constants/CommandMenuAnimationVariants';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant'; import { CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant';
import { CommandMenuHotkeyScope } from '@/command-menu/types/CommandMenuHotkeyScope'; import { CommandMenuHotkeyScope } from '@/command-menu/types/CommandMenuHotkeyScope';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderCommandMenuButtonClickOutsideId';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
@ -62,14 +64,14 @@ export const CommandMenuOpenContainer = ({
refs: [commandMenuRef], refs: [commandMenuRef],
callback: handleClickOutside, callback: handleClickOutside,
listenerId: 'COMMAND_MENU_LISTENER_ID', listenerId: 'COMMAND_MENU_LISTENER_ID',
excludeClassNames: ['page-header-command-menu-button'], excludedClickOutsideIds: [PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID],
}); });
return ( return (
<StyledCommandMenu <StyledCommandMenu
data-testid="command-menu" data-testid="command-menu"
data-click-outside-id={COMMAND_MENU_CLICK_OUTSIDE_ID}
ref={commandMenuRef} ref={commandMenuRef}
className="command-menu"
animate={targetVariantForAnimation} animate={targetVariantForAnimation}
initial="closed" initial="closed"
exit="closed" exit="closed"

View File

@ -0,0 +1 @@
export const COMMAND_MENU_CLICK_OUTSIDE_ID = 'command-menu';

View File

@ -3,7 +3,9 @@ import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atla
import { useContext, useRef } from 'react'; import { useContext, useRef } from 'react';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { ACTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/action-menu/constants/ActionMenuDropdownClickOutsideId';
import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader'; import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader';
import { RecordBoardScrollToFocusedCardEffect } from '@/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect'; import { RecordBoardScrollToFocusedCardEffect } from '@/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect';
import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/components/RecordBoardStickyHeaderEffect'; import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/components/RecordBoardStickyHeaderEffect';
@ -13,6 +15,7 @@ import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/use
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { RecordBoardDeactivateBoardCardEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect'; import { RecordBoardDeactivateBoardCardEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect';
import { RECORD_BOARD_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-board/record-board-card/constants/RecordBoardCardClickOutsideId';
import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn'; import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn';
import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope'; import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope';
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
@ -24,7 +27,9 @@ import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@ -104,13 +109,12 @@ export const RecordBoard = () => {
); );
useListenClickOutside({ useListenClickOutside({
excludeClassNames: [ excludedClickOutsideIds: [
'bottom-bar', ACTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
'action-menu-dropdown', COMMAND_MENU_CLICK_OUTSIDE_ID,
'command-menu', MODAL_BACKDROP_CLICK_OUTSIDE_ID,
'modal-backdrop', PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID,
'page-action-container', RECORD_BOARD_CARD_CLICK_OUTSIDE_ID,
'record-board-card',
], ],
listenerId: RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID, listenerId: RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID,
refs: [], refs: [],

View File

@ -13,6 +13,7 @@ import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDro
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu'; import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { RecordBoardCardBody } from '@/object-record/record-board/record-board-card/components/RecordBoardCardBody'; import { RecordBoardCardBody } from '@/object-record/record-board/record-board-card/components/RecordBoardCardBody';
import { RecordBoardCardHeader } from '@/object-record/record-board/record-board-card/components/RecordBoardCardHeader'; import { RecordBoardCardHeader } from '@/object-record/record-board/record-board-card/components/RecordBoardCardHeader';
import { RECORD_BOARD_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-board/record-board-card/constants/RecordBoardCardClickOutsideId';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState'; import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
@ -197,7 +198,7 @@ export const RecordBoardCard = () => {
return ( return (
<StyledBoardCardWrapper <StyledBoardCardWrapper
className="record-board-card" data-click-outside-id={RECORD_BOARD_CARD_CLICK_OUTSIDE_ID}
onContextMenu={handleActionMenuDropdown} onContextMenu={handleActionMenuDropdown}
> >
<InView> <InView>

View File

@ -50,7 +50,6 @@ export const RecordBoardCardDraggableContainer = ({
{...draggableProvided?.dragHandleProps} {...draggableProvided?.dragHandleProps}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.draggableProps} {...draggableProvided?.draggableProps}
className="record-board-card"
data-selectable-id={recordId} data-selectable-id={recordId}
data-select-disable data-select-disable
> >

View File

@ -0,0 +1 @@
export const RECORD_BOARD_CARD_CLICK_OUTSIDE_ID = 'record-board-card';

View File

@ -1,8 +1,12 @@
import { ACTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/action-menu/constants/ActionMenuDropdownClickOutsideId';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId'; import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/useLeaveTableFocus'; import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/useLeaveTableFocus';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@ -23,12 +27,11 @@ export const RecordTableBodyFocusClickOutsideEffect = ({
const setHotkeyScope = useSetHotkeyScope(); const setHotkeyScope = useSetHotkeyScope();
useListenClickOutside({ useListenClickOutside({
excludeClassNames: [ excludedClickOutsideIds: [
'bottom-bar', ACTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
'action-menu-dropdown', COMMAND_MENU_CLICK_OUTSIDE_ID,
'command-menu', PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID,
'modal-backdrop', MODAL_BACKDROP_CLICK_OUTSIDE_ID,
'page-action-container',
], ],
listenerId: RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID, listenerId: RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID,
refs: [tableBodyRef], refs: [tableBodyRef],

View File

@ -4,6 +4,7 @@ import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; 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 { DIALOG_LISTENER_ID } from '@/ui/feedback/dialog-manager/constants/DialogListenerId';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@ -138,6 +139,7 @@ export const Dialog = ({
animate="open" animate="open"
exit="closed" exit="closed"
className={className} className={className}
data-click-outside-id={DIALOG_CLICK_OUTSIDE_ID}
> >
<StyledDialogContainer <StyledDialogContainer
variants={containerVariants} variants={containerVariants}

View File

@ -15,7 +15,6 @@ export const DialogManager = ({ children }: React.PropsWithChildren) => {
{dialogInternal.queue.map(({ buttons, children, id, message, title }) => ( {dialogInternal.queue.map(({ buttons, children, id, message, title }) => (
<Dialog <Dialog
key={id} key={id}
className="dialog-manager-dialog"
{...{ title, message, buttons, id, children }} {...{ title, message, buttons, id, children }}
onClose={() => closeDialog(id)} onClose={() => closeDialog(id)}
/> />

View File

@ -0,0 +1 @@
export const DIALOG_CLICK_OUTSIDE_ID = 'dialog-manager-dialog';

View File

@ -203,6 +203,7 @@ export const SnackBar = ({
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
title={message || defaultAriaLabelByVariant[variant]} title={message || defaultAriaLabelByVariant[variant]}
{...{ className, id, role, variant }} {...{ className, id, role, variant }}
data-globally-prevent-click-outside
> >
<StyledProgressBar <StyledProgressBar
barColor={theme.snackBar[variant].backgroundColor} barColor={theme.snackBar[variant].backgroundColor}

View File

@ -79,7 +79,6 @@ export const DropdownContent = ({
useListenClickOutside({ useListenClickOutside({
refs: [floatingUiRefs.floating, floatingUiRefs.domReference], refs: [floatingUiRefs.floating, floatingUiRefs.domReference],
listenerId: dropdownId, listenerId: dropdownId,
excludeClassNames: ['confirmation-modal'],
callback: (event) => { callback: (event) => {
if (activeDropdownFocusId !== dropdownId) return; if (activeDropdownFocusId !== dropdownId) return;
@ -118,7 +117,7 @@ export const DropdownContent = ({
maxWidth: dropdownMaxWidth, maxWidth: dropdownMaxWidth,
}; };
const { excludeClassName } = useContext(ClickOutsideListenerContext); const { excludedClickOutsideId } = useContext(ClickOutsideListenerContext);
return ( return (
<> <>
@ -127,24 +126,23 @@ export const DropdownContent = ({
)} )}
<FloatingPortal> <FloatingPortal>
<div className={excludeClassName}> <StyledDropdownContentContainer
<StyledDropdownContentContainer ref={floatingUiRefs.setFloating}
ref={floatingUiRefs.setFloating} style={dropdownMenuStyles}
style={dropdownMenuStyles} role="listbox"
role="listbox" id={`${dropdownId}-options`}
id={`${dropdownId}-options`} data-click-outside-id={excludedClickOutsideId}
> >
<OverlayContainer> <OverlayContainer>
<DropdownMenu <DropdownMenu
id={dropdownId} id={dropdownId}
width={dropdownWidth} width={dropdownWidth}
data-select-disable data-select-disable
> >
{dropdownComponents} {dropdownComponents}
</DropdownMenu> </DropdownMenu>
</OverlayContainer> </OverlayContainer>
</StyledDropdownContentContainer> </StyledDropdownContentContainer>
</div>
</FloatingPortal> </FloatingPortal>
</> </>
); );

View File

@ -119,7 +119,7 @@ export const ConfirmationModal = ({
isClosable={true} isClosable={true}
padding="large" padding="large"
modalVariant={modalVariant} modalVariant={modalVariant}
className="confirmation-modal" data-globally-prevent-click-outside
> >
<StyledCenteredTitle> <StyledCenteredTitle>
<H1Title title={title} fontColor={H1TitleFontColor.Primary} /> <H1Title title={title} fontColor={H1TitleFontColor.Primary} />

View File

@ -4,7 +4,8 @@ import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkey
import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext'; import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; 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 { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext'; import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -229,8 +230,7 @@ export const Modal = ({
> >
<ClickOutsideListenerContext.Provider <ClickOutsideListenerContext.Provider
value={{ value={{
excludeClassName: excludedClickOutsideId: MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME,
}} }}
> >
<ModalHotkeysAndClickOutsideEffect <ModalHotkeysAndClickOutsideEffect
@ -242,7 +242,7 @@ export const Modal = ({
/> />
<StyledBackDrop <StyledBackDrop
data-testid="modal-backdrop" data-testid="modal-backdrop"
className="modal-backdrop" data-click-outside-id={MODAL_BACKDROP_CLICK_OUTSIDE_ID}
onMouseDown={stopEventPropagation} onMouseDown={stopEventPropagation}
modalVariant={modalVariant} modalVariant={modalVariant}
> >

View File

@ -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 { 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 { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
@ -45,9 +46,9 @@ export const ModalHotkeysAndClickOutsideEffect = ({
useListenClickOutside({ useListenClickOutside({
refs: [modalRef], refs: [modalRef],
excludeClassNames: [ excludedClickOutsideIds: [
MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME, MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
'dialog-manager-dialog', DIALOG_CLICK_OUTSIDE_ID,
], ],
listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`, listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`,
callback: () => { callback: () => {

View File

@ -0,0 +1 @@
export const MODAL_BACKDROP_CLICK_OUTSIDE_ID = 'modal-backdrop';

View File

@ -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'; 'modal-click-outside-listener-excluded';

View File

@ -1,6 +1,7 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; 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 { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
@ -124,7 +125,9 @@ export const PageHeaderToggleCommandMenuButton = () => {
animatedSvg={ animatedSvg={
<AnimatedIcon isCommandMenuOpened={isCommandMenuOpened} /> <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" dataTestId="page-header-command-menu-button"
size={isMobile ? 'medium' : 'small'} size={isMobile ? 'medium' : 'small'}
variant="secondary" variant="secondary"

View File

@ -0,0 +1,2 @@
export const PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID =
'page-header-command-menu-button';

View File

@ -5,6 +5,7 @@ import { useRecoilValue } from 'recoil';
import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerCollapseButton'; 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 { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { import {
@ -144,7 +145,9 @@ export const PageHeader = ({
</StyledTopBarIconStyledTitleContainer> </StyledTopBarIconStyledTitleContainer>
</StyledLeftContainer> </StyledLeftContainer>
<StyledPageActionContainer className="page-action-container"> <StyledPageActionContainer
data-click-outside-id={PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID}
>
{children} {children}
</StyledPageActionContainer> </StyledPageActionContainer>
</StyledTopBarContainer> </StyledTopBarContainer>

View File

@ -0,0 +1 @@
export const PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID = 'page-action-container';

View File

@ -1,10 +1,10 @@
import { createContext } from 'react'; import { createContext } from 'react';
type ClickOutsideListenerContextType = { type ClickOutsideListenerContextType = {
excludeClassName: string | undefined; excludedClickOutsideId: string | undefined;
}; };
export const ClickOutsideListenerContext = export const ClickOutsideListenerContext =
createContext<ClickOutsideListenerContextType>({ createContext<ClickOutsideListenerContextType>({
excludeClassName: undefined, excludedClickOutsideId: undefined,
}); });

View File

@ -4,12 +4,13 @@ import { clickOutsideListenerMouseDownHappenedComponentState } from '@/ui/utilit
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
const CLICK_OUTSIDE_DEBUG_MODE = false; const CLICK_OUTSIDE_DEBUG_MODE = false;
export type ClickOutsideListenerProps<T extends Element> = { export type ClickOutsideListenerProps<T extends Element> = {
refs: Array<React.RefObject<T>>; refs: Array<React.RefObject<T>>;
excludeClassNames?: string[]; excludedClickOutsideIds?: string[];
callback: (event: MouseEvent | TouchEvent) => void; callback: (event: MouseEvent | TouchEvent) => void;
listenerId: string; listenerId: string;
enabled?: boolean; enabled?: boolean;
@ -17,7 +18,7 @@ export type ClickOutsideListenerProps<T extends Element> = {
export const useListenClickOutside = <T extends Element>({ export const useListenClickOutside = <T extends Element>({
refs, refs,
excludeClassNames, excludedClickOutsideIds,
callback, callback,
listenerId, listenerId,
enabled = true, enabled = true,
@ -90,12 +91,18 @@ export const useListenClickOutside = <T extends Element>({
let currentElement: HTMLElement | null = clickedElement; let currentElement: HTMLElement | null = clickedElement;
while (currentElement) { while (currentElement) {
const currentClassList = currentElement.classList; const currentDataAttributes = currentElement.dataset;
const isGloballyExcluded =
currentDataAttributes?.globallyPreventClickOutside === 'true';
const clickOutsideId = currentDataAttributes?.clickOutsideId;
isClickedOnExcluded = isClickedOnExcluded =
excludeClassNames?.some((className) => isGloballyExcluded ||
currentClassList.contains(className), (isDefined(clickOutsideId) &&
) ?? false; isDefined(excludedClickOutsideIds) &&
excludedClickOutsideIds.includes(clickOutsideId));
if (isClickedOnExcluded) { if (isClickedOnExcluded) {
break; break;
@ -140,7 +147,7 @@ export const useListenClickOutside = <T extends Element>({
clickOutsideListenerIsMouseDownInsideState, clickOutsideListenerIsMouseDownInsideState,
clickOutsideListenerMouseDownHappenedState, clickOutsideListenerMouseDownHappenedState,
refs, refs,
excludeClassNames, excludedClickOutsideIds,
callback, callback,
listenerId, listenerId,
], ],