Fix dropdown (#12126)

In this PR:
- deprecating listenClickOutside ComparePixel mode as this is not
accurate. We were using to avoid portal issue with CompareHtmlRef mode
but this is still an issue when portalled content overflows the
container.
- add ClickOutsideContext to specify excluded className so portal
children can use it easily (part of the tooling)
- fix stories
- remove avoidPortal from dropdown as this was not used
This commit is contained in:
Charles Bochet
2025-05-19 16:37:51 +02:00
committed by GitHub
parent bb4fed5a5e
commit cba36af1e8
16 changed files with 153 additions and 326 deletions

View File

@ -40,7 +40,6 @@ const StyledClickableComponent = styled.div<{
`;
export type DropdownProps = {
className?: string;
clickableComponent?: ReactNode;
clickableComponentWidth?: Width;
dropdownComponents: ReactNode;
@ -57,11 +56,9 @@ export type DropdownProps = {
onClickOutside?: () => void;
onClose?: () => void;
onOpen?: () => void;
avoidPortal?: boolean;
};
export const Dropdown = ({
className,
clickableComponent,
dropdownComponents,
dropdownWidth,
@ -74,7 +71,6 @@ export const Dropdown = ({
onClickOutside,
onClose,
onOpen,
avoidPortal,
clickableComponentWidth = 'auto',
}: DropdownProps) => {
const { isDropdownOpen, toggleDropdown } = useDropdown(dropdownId);
@ -174,7 +170,6 @@ export const Dropdown = ({
)}
{isDropdownOpen && (
<DropdownContent
className={className}
floatingStyles={floatingStyles}
dropdownWidth={dropdownWidth}
dropdownComponents={dropdownComponents}
@ -185,7 +180,6 @@ export const Dropdown = ({
hotkey={hotkey}
onClickOutside={onClickOutside}
onHotkeyTriggered={toggleDropdown}
avoidPortal={avoidPortal}
/>
)}
<DropdownOnToggleEffect

View File

@ -9,6 +9,7 @@ import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContaine
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
@ -17,7 +18,7 @@ import {
Placement,
UseFloatingReturn,
} from '@floating-ui/react';
import { useEffect } from 'react';
import { useContext, useEffect } from 'react';
import { Keys } from 'react-hotkeys-hook';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
@ -28,7 +29,6 @@ export const StyledDropdownContentContainer = styled.div`
`;
export type DropdownContentProps = {
className?: string;
dropdownId: string;
dropdownPlacement: Placement;
floatingUiRefs: UseFloatingReturn['refs'];
@ -43,11 +43,9 @@ export type DropdownContentProps = {
dropdownWidth?: `${string}px` | `${number}%` | 'auto' | number;
dropdownComponents: React.ReactNode;
parentDropdownId?: string;
avoidPortal?: boolean;
};
export const DropdownContent = ({
className,
dropdownId,
dropdownPlacement,
floatingUiRefs,
@ -58,7 +56,6 @@ export const DropdownContent = ({
onHotkeyTriggered,
dropdownWidth,
dropdownComponents,
avoidPortal,
}: DropdownContentProps) => {
const { isDropdownOpen, closeDropdown, setDropdownPlacement } =
useDropdown(dropdownId);
@ -121,30 +118,16 @@ export const DropdownContent = ({
maxWidth: dropdownMaxWidth,
};
const { excludeClassName } = useContext(ClickOutsideListenerContext);
return (
<>
{hotkey && onHotkeyTriggered && (
<HotkeyEffect hotkey={hotkey} onHotkeyTriggered={onHotkeyTriggered} />
)}
{avoidPortal ? (
<StyledDropdownContentContainer
ref={floatingUiRefs.setFloating}
style={dropdownMenuStyles}
role="listbox"
id={`${dropdownId}-options`}
>
<OverlayContainer>
<DropdownMenu
className={className}
width={dropdownWidth}
data-select-disable
>
{dropdownComponents}
</DropdownMenu>
</OverlayContainer>
</StyledDropdownContentContainer>
) : (
<FloatingPortal>
<FloatingPortal>
<div className={excludeClassName}>
<StyledDropdownContentContainer
ref={floatingUiRefs.setFloating}
style={dropdownMenuStyles}
@ -154,7 +137,6 @@ export const DropdownContent = ({
<OverlayContainer>
<DropdownMenu
id={dropdownId}
className={className}
width={dropdownWidth}
data-select-disable
>
@ -162,8 +144,8 @@ export const DropdownContent = ({
</DropdownMenu>
</OverlayContainer>
</StyledDropdownContentContainer>
</FloatingPortal>
)}
</div>
</FloatingPortal>
</>
);
};

View File

@ -4,7 +4,9 @@ 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 { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { css, useTheme } from '@emotion/react';
@ -225,36 +227,43 @@ export const Modal = ({
instanceId: modalId,
}}
>
<ModalHotkeysAndClickOutsideEffect
modalId={modalId}
modalRef={modalRef}
onEnter={onEnter}
isClosable={isClosable}
onClose={handleClose}
/>
<StyledBackDrop
data-testid="modal-backdrop"
className="modal-backdrop"
onMouseDown={stopEventPropagation}
modalVariant={modalVariant}
<ClickOutsideListenerContext.Provider
value={{
excludeClassName:
MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME,
}}
>
<StyledModalDiv
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
<ModalHotkeysAndClickOutsideEffect
modalId={modalId}
modalRef={modalRef}
onEnter={onEnter}
isClosable={isClosable}
onClose={handleClose}
/>
<StyledBackDrop
data-testid="modal-backdrop"
className="modal-backdrop"
onMouseDown={stopEventPropagation}
modalVariant={modalVariant}
variants={modalAnimation}
transition={{ duration: theme.animation.duration.normal }}
className={className}
isMobile={isMobile}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
<StyledModalDiv
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
modalVariant={modalVariant}
variants={modalAnimation}
transition={{ duration: theme.animation.duration.normal }}
className={className}
isMobile={isMobile}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
</ClickOutsideListenerContext.Provider>
</ModalComponentInstanceContext.Provider>
)}
</>

View File

@ -1,9 +1,7 @@
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { Key } from 'ts-key-enum';
type ModalHotkeysAndClickOutsideEffectProps = {
@ -41,13 +39,13 @@ export const ModalHotkeysAndClickOutsideEffect = ({
useListenClickOutside({
refs: [modalRef],
excludeClassNames: [MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME],
listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`,
callback: () => {
if (isClosable && onClose !== undefined) {
onClose();
}
},
mode: ClickOutsideMode.comparePixels,
});
return null;

View File

@ -130,51 +130,51 @@ export const ConfirmWithEnterKey: Story = {
},
};
// export const CancelButtonClick: Story = {
// args: {
// modalId: 'confirmation-modal',
// title: 'Cancel Button Test',
// subtitle: 'Clicking the cancel button should close the modal',
// confirmButtonText: 'Confirm',
// onClose: closeMock,
// },
// play: async ({ canvasElement }) => {
// const canvas = within(canvasElement);
export const CancelButtonClick: Story = {
args: {
modalId: 'confirmation-modal',
title: 'Cancel Button Test',
subtitle: 'Clicking the cancel button should close the modal',
confirmButtonText: 'Confirm',
onClose: closeMock,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// await canvas.findByText('Cancel Button Test');
await canvas.findByText('Cancel Button Test');
// const cancelButton = await canvas.findByRole('button', {
// name: /Cancel/,
// });
// await userEvent.click(cancelButton);
const cancelButton = await canvas.findByRole('button', {
name: /Cancel/,
});
await userEvent.click(cancelButton);
// await waitFor(() => {
// expect(closeMock).toHaveBeenCalledTimes(1);
// });
// },
// };
await waitFor(() => {
expect(closeMock).toHaveBeenCalledTimes(1);
});
},
};
// export const ConfirmButtonClick: Story = {
// args: {
// modalId: 'confirmation-modal',
// title: 'Confirm Button Test',
// subtitle: 'Clicking the confirm button should trigger the confirm action',
// confirmButtonText: 'Confirm',
// onConfirmClick: confirmMock,
// },
// play: async ({ canvasElement }) => {
// const canvas = within(canvasElement);
export const ConfirmButtonClick: Story = {
args: {
modalId: 'confirmation-modal',
title: 'Confirm Button Test',
subtitle: 'Clicking the confirm button should trigger the confirm action',
confirmButtonText: 'Confirm',
onConfirmClick: confirmMock,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// await canvas.findByText('Confirm Button Test');
await canvas.findByText('Confirm Button Test');
// const confirmButton = await canvas.findByRole('button', {
// name: /Confirm/,
// });
const confirmButton = await canvas.findByRole('button', {
name: /Confirm/,
});
// await userEvent.click(confirmButton);
await userEvent.click(confirmButton);
// await waitFor(() => {
// expect(confirmMock).toHaveBeenCalledTimes(1);
// });
// },
// };
await waitFor(() => {
expect(confirmMock).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -0,0 +1,2 @@
export const MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME =
'modal-click-outside-listener-excluded';