Fix dropdown z index (#12442)

Fixes https://github.com/twentyhq/twenty/issues/11942

This PR creates two different dropdown z indexes, one for normal
dropdowns, and one for the dropdowns inside modals.
This commit is contained in:
Raphaël Bosi
2025-06-04 11:14:56 +02:00
committed by GitHub
parent f7e2c1c627
commit 7046965496
8 changed files with 124 additions and 5 deletions

View File

@ -137,6 +137,7 @@ export const MatchColumnToFieldSelect = ({
) )
} }
onClickOutside={handleClickOutside} onClickOutside={handleClickOutside}
isDropdownInModal
/> />
); );
}; };

View File

@ -69,6 +69,7 @@ export const SubMatchingSelectRowRightDropdown = <T extends string>({
onOptionSelected={handleSelect} onOptionSelected={handleSelect}
/> />
} }
isDropdownInModal
/> />
</StyledDropdownContainer> </StyledDropdownContainer>
); );

View File

@ -15,9 +15,10 @@
export enum RootStackingContextZIndices { export enum RootStackingContextZIndices {
CommandMenu = 21, CommandMenu = 21,
CommandMenuButton = 22, CommandMenuButton = 22,
DropdownPortalBelowModal = 38,
RootModalBackDrop = 39, RootModalBackDrop = 39,
RootModal = 40, RootModal = 40,
DropdownPortal = 50, DropdownPortalAboveModal = 50,
Dialog = 9999, Dialog = 9999,
SnackBar = 10002, SnackBar = 10002,
NotFound = 10001, NotFound = 10001,

View File

@ -58,6 +58,7 @@ export type DropdownProps = {
onClose?: () => void; onClose?: () => void;
onOpen?: () => void; onOpen?: () => void;
excludedClickOutsideIds?: string[]; excludedClickOutsideIds?: string[];
isDropdownInModal?: boolean;
}; };
export const Dropdown = ({ export const Dropdown = ({
@ -74,6 +75,7 @@ export const Dropdown = ({
onOpen, onOpen,
clickableComponentWidth = 'auto', clickableComponentWidth = 'auto',
excludedClickOutsideIds, excludedClickOutsideIds,
isDropdownInModal = false,
}: DropdownProps) => { }: DropdownProps) => {
const { isDropdownOpen, toggleDropdown } = useDropdown(dropdownId); const { isDropdownOpen, toggleDropdown } = useDropdown(dropdownId);
@ -193,6 +195,7 @@ export const Dropdown = ({
onClickOutside={onClickOutside} onClickOutside={onClickOutside}
onHotkeyTriggered={toggleDropdown} onHotkeyTriggered={toggleDropdown}
excludedClickOutsideIds={excludedClickOutsideIds} excludedClickOutsideIds={excludedClickOutsideIds}
isDropdownInModal={isDropdownInModal}
/> />
)} )}
<DropdownOnToggleEffect <DropdownOnToggleEffect

View File

@ -7,6 +7,14 @@ import { useState } from 'react';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem'; import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import { SetRecoilState } from 'recoil';
import { Avatar, IconChevronLeft } from 'twenty-ui/display'; import { Avatar, IconChevronLeft } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input'; import { Button } from 'twenty-ui/input';
import { import {
@ -15,6 +23,8 @@ import {
MenuItemSelectAvatar, MenuItemSelectAvatar,
} from 'twenty-ui/navigation'; } from 'twenty-ui/navigation';
import { ComponentDecorator } from 'twenty-ui/testing'; import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { Dropdown } from '../Dropdown'; import { Dropdown } from '../Dropdown';
import { DropdownMenuHeader } from '../DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '../DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuInput } from '../DropdownMenuInput'; import { DropdownMenuInput } from '../DropdownMenuInput';
@ -321,3 +331,96 @@ export const CheckableMenuItemWithAvatar: Story = {
}, },
play: playInteraction, play: playInteraction,
}; };
const modalId = 'dropdown-modal-test';
const ModalWithDropdown = () => {
return (
<>
<Modal modalId={modalId} size="medium" padding="medium" isClosable={true}>
<Modal.Header>Modal with Dropdown Test</Modal.Header>
<Modal.Content>
<p>
This modal contains a dropdown that should appear above the modal
(higher z-index).
</p>
<div style={{ marginTop: '20px' }}>
<Dropdown
clickableComponent={
<Button
dataTestId="dropdown-button"
title="Open Dropdown in Modal"
/>
}
dropdownHotkeyScope={{ scope: 'modal-dropdown' }}
dropdownOffset={{ x: 0, y: 8 }}
dropdownId="modal-dropdown-test"
isDropdownInModal={true}
dropdownComponents={
<DropdownMenuItemsContainer hasMaxHeight>
<div data-testid="dropdown-content">
<FakeSelectableMenuItemList hasAvatar />
</div>
</DropdownMenuItemsContainer>
}
/>
</div>
</Modal.Content>
</Modal>
</>
);
};
const initializeModalState = ({ set }: { set: SetRecoilState }) => {
set(
isModalOpenedComponentState.atomFamily({
instanceId: modalId,
}),
true,
);
set(currentHotkeyScopeState, {
scope: ModalHotkeyScope.ModalFocus,
customScopes: {
commandMenu: true,
goto: false,
keyboardShortcutMenu: false,
},
});
set(internalHotkeysEnabledScopesState, [ModalHotkeyScope.ModalFocus]);
set(focusStackState, [
{
focusId: modalId,
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: modalId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
},
]);
};
export const DropdownInsideModal: Story = {
decorators: [I18nFrontDecorator, RootDecorator, ComponentDecorator],
parameters: {
initializeState: initializeModalState,
disableHotkeyInitialization: true,
},
render: () => <ModalWithDropdown />,
play: async () => {
const canvas = within(document.body);
const dropdownButton = await canvas.findByTestId('dropdown-button');
await userEvent.click(dropdownButton);
const dropdownContent = await canvas.findByTestId('dropdown-content');
expect(dropdownContent).toBeVisible();
},
};

View File

@ -22,9 +22,14 @@ import { Keys } from 'react-hotkeys-hook';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
export const StyledDropdownContentContainer = styled.div` export const StyledDropdownContentContainer = styled.div<{
isDropdownInModal?: boolean;
}>`
display: flex; display: flex;
z-index: ${RootStackingContextZIndices.DropdownPortal}; z-index: ${({ isDropdownInModal }) =>
isDropdownInModal
? RootStackingContextZIndices.DropdownPortalAboveModal
: RootStackingContextZIndices.DropdownPortalBelowModal};
`; `;
const StyledDropdownInsideContainer = styled.div` const StyledDropdownInsideContainer = styled.div`
@ -50,6 +55,7 @@ export type DropdownInternalContainerProps = {
dropdownComponents: React.ReactNode; dropdownComponents: React.ReactNode;
parentDropdownId?: string; parentDropdownId?: string;
excludedClickOutsideIds?: string[]; excludedClickOutsideIds?: string[];
isDropdownInModal?: boolean;
}; };
export const DropdownInternalContainer = ({ export const DropdownInternalContainer = ({
@ -63,6 +69,7 @@ export const DropdownInternalContainer = ({
onHotkeyTriggered, onHotkeyTriggered,
dropdownComponents, dropdownComponents,
excludedClickOutsideIds, excludedClickOutsideIds,
isDropdownInModal = false,
}: DropdownInternalContainerProps) => { }: DropdownInternalContainerProps) => {
const { isDropdownOpen, closeDropdown, setDropdownPlacement } = const { isDropdownOpen, closeDropdown, setDropdownPlacement } =
useDropdown(dropdownId); useDropdown(dropdownId);
@ -140,6 +147,7 @@ export const DropdownInternalContainer = ({
role="listbox" role="listbox"
id={`${dropdownId}-options`} id={`${dropdownId}-options`}
data-click-outside-id={excludedClickOutsideId} data-click-outside-id={excludedClickOutsideId}
isDropdownInModal={isDropdownInModal}
> >
<OverlayContainer> <OverlayContainer>
<StyledDropdownInsideContainer id={dropdownId} data-select-disable> <StyledDropdownInsideContainer id={dropdownId} data-select-disable>

View File

@ -6,6 +6,7 @@ import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState'; import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import { SetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui/testing'; import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { RootDecorator } from '~/testing/decorators/RootDecorator'; import { RootDecorator } from '~/testing/decorators/RootDecorator';
@ -13,7 +14,7 @@ import { sleep } from '~/utils/sleep';
import { isModalOpenedComponentState } from '../../states/isModalOpenedComponentState'; import { isModalOpenedComponentState } from '../../states/isModalOpenedComponentState';
import { ConfirmationModal } from '../ConfirmationModal'; import { ConfirmationModal } from '../ConfirmationModal';
const initializeState = ({ set }: { set: (atom: any, value: any) => void }) => { const initializeState = ({ set }: { set: SetRecoilState }) => {
set( set(
isModalOpenedComponentState.atomFamily({ isModalOpenedComponentState.atomFamily({
instanceId: 'confirmation-modal', instanceId: 'confirmation-modal',

View File

@ -6,6 +6,7 @@ import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState'; import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import { SetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui/testing'; import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { RootDecorator } from '~/testing/decorators/RootDecorator'; import { RootDecorator } from '~/testing/decorators/RootDecorator';
@ -13,7 +14,7 @@ import { sleep } from '~/utils/sleep';
import { isModalOpenedComponentState } from '../../states/isModalOpenedComponentState'; import { isModalOpenedComponentState } from '../../states/isModalOpenedComponentState';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
const initializeState = ({ set }: { set: (atom: any, value: any) => void }) => { const initializeState = ({ set }: { set: SetRecoilState }) => {
set( set(
isModalOpenedComponentState.atomFamily({ isModalOpenedComponentState.atomFamily({
instanceId: 'modal-id', instanceId: 'modal-id',