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:
@ -10,6 +10,7 @@ import { mockedUserJWT } from '../src/testing/mock-data/jwt';
|
||||
import 'react-loading-skeleton/dist/skeleton.css';
|
||||
import 'twenty-ui/style.css';
|
||||
import { THEME_DARK, THEME_LIGHT, ThemeContextProvider } from 'twenty-ui/theme';
|
||||
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
||||
|
||||
initialize({
|
||||
onUnhandledRequest: async (request: Request) => {
|
||||
@ -46,7 +47,11 @@ const preview: Preview = {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeContextProvider theme={theme}>
|
||||
<Story />
|
||||
<ClickOutsideListenerContext.Provider
|
||||
value={{ excludeClassName: undefined }}
|
||||
>
|
||||
<Story />
|
||||
</ClickOutsideListenerContext.Provider>
|
||||
</ThemeContextProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@ -5,6 +5,7 @@ import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
|
||||
import { AppRootErrorFallback } from '@/error-handler/components/AppRootErrorFallback';
|
||||
import { ExceptionHandlerProvider } from '@/error-handler/components/ExceptionHandlerProvider';
|
||||
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
|
||||
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
@ -28,7 +29,11 @@ export const App = () => {
|
||||
<IconsProvider>
|
||||
<ExceptionHandlerProvider>
|
||||
<HelmetProvider>
|
||||
<AppRouter />
|
||||
<ClickOutsideListenerContext.Provider
|
||||
value={{ excludeClassName: undefined }}
|
||||
>
|
||||
<AppRouter />
|
||||
</ClickOutsideListenerContext.Provider>
|
||||
</HelmetProvider>
|
||||
</ExceptionHandlerProvider>
|
||||
</IconsProvider>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
@ -35,10 +34,6 @@ type RecordDetailRelationSectionDropdownProps = {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
const StyledAddDropdown = styled(Dropdown)`
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
export const RecordDetailRelationSectionDropdown = ({
|
||||
loading,
|
||||
}: RecordDetailRelationSectionDropdownProps) => {
|
||||
@ -196,7 +191,7 @@ export const RecordDetailRelationSectionDropdown = ({
|
||||
|
||||
return (
|
||||
<DropdownScope dropdownScopeId={dropdownId}>
|
||||
<StyledAddDropdown
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="left-start"
|
||||
onClose={handleCloseRelationPickerDropdown}
|
||||
|
||||
@ -8,6 +8,7 @@ import { onToggleColumnSortComponentState } from '@/object-record/record-table/s
|
||||
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
|
||||
import { useToggleScrollWrapper } from '@/ui/utilities/scroll/hooks/useToggleScrollWrapper';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
@ -24,6 +25,10 @@ export type RecordTableColumnHeadDropdownMenuProps = {
|
||||
column: ColumnDefinition<FieldMetadata>;
|
||||
};
|
||||
|
||||
const StyledDropdownMenuItemsContainer = styled(DropdownMenuItemsContainer)`
|
||||
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
||||
`;
|
||||
|
||||
export const RecordTableColumnHeadDropdownMenu = ({
|
||||
column,
|
||||
}: RecordTableColumnHeadDropdownMenuProps) => {
|
||||
@ -102,7 +107,7 @@ export const RecordTableColumnHeadDropdownMenu = ({
|
||||
const canHide = column.isLabelIdentifier !== true;
|
||||
|
||||
return (
|
||||
<DropdownMenuItemsContainer>
|
||||
<StyledDropdownMenuItemsContainer>
|
||||
{isFilterable && (
|
||||
<MenuItem
|
||||
LeftIcon={IconFilter}
|
||||
@ -139,6 +144,6 @@ export const RecordTableColumnHeadDropdownMenu = ({
|
||||
text={t`Hide`}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</StyledDropdownMenuItemsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,7 +2,6 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'
|
||||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { useToggleScrollWrapper } from '@/ui/utilities/scroll/hooks/useToggleScrollWrapper';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback } from 'react';
|
||||
import { RecordTableColumnHead } from './RecordTableColumnHead';
|
||||
import { RecordTableColumnHeadDropdownMenu } from './RecordTableColumnHeadDropdownMenu';
|
||||
@ -11,12 +10,6 @@ type RecordTableColumnHeadWithDropdownProps = {
|
||||
column: ColumnDefinition<FieldMetadata>;
|
||||
};
|
||||
|
||||
const StyledDropdown = styled(Dropdown)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
||||
`;
|
||||
|
||||
export const RecordTableColumnHeadWithDropdown = ({
|
||||
column,
|
||||
}: RecordTableColumnHeadWithDropdownProps) => {
|
||||
@ -34,7 +27,7 @@ export const RecordTableColumnHeadWithDropdown = ({
|
||||
}, [toggleScrollXWrapper, toggleScrollYWrapper]);
|
||||
|
||||
return (
|
||||
<StyledDropdown
|
||||
<Dropdown
|
||||
onOpen={handleDropdownOpen}
|
||||
onClose={handleDropdownClose}
|
||||
dropdownId={column.fieldMetadataId + '-header'}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export const MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME =
|
||||
'modal-click-outside-listener-excluded';
|
||||
@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type ClickOutsideListenerContextType = {
|
||||
excludeClassName: string | undefined;
|
||||
};
|
||||
|
||||
export const ClickOutsideListenerContext =
|
||||
createContext<ClickOutsideListenerContextType>({
|
||||
excludeClassName: undefined,
|
||||
});
|
||||
@ -1,16 +1,11 @@
|
||||
import { fireEvent, renderHook } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import React, { act } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import {
|
||||
ClickOutsideMode,
|
||||
useListenClickOutside,
|
||||
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const containerRef = React.createRef<HTMLDivElement>();
|
||||
const nullRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot>
|
||||
@ -41,28 +36,6 @@ describe('useListenClickOutside', () => {
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger the callback when clicking outside the specified ref with pixel comparison', async () => {
|
||||
const callback = jest.fn();
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useListenClickOutside({
|
||||
refs: [nullRef],
|
||||
callback,
|
||||
mode: ClickOutsideMode.comparePixels,
|
||||
listenerId,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseDown(document);
|
||||
fireEvent.click(document);
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the callback when clicking inside the specified refs using default comparison', () => {
|
||||
const callback = jest.fn();
|
||||
|
||||
@ -85,28 +58,4 @@ describe('useListenClickOutside', () => {
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the callback when clicking inside the specified refs using pixel comparison', () => {
|
||||
const callback = jest.fn();
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
callback,
|
||||
mode: ClickOutsideMode.comparePixels,
|
||||
listenerId,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
if (isDefined(containerRef.current)) {
|
||||
fireEvent.mouseDown(containerRef.current);
|
||||
fireEvent.click(containerRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,16 +7,10 @@ import { useRecoilCallback } from 'recoil';
|
||||
|
||||
const CLICK_OUTSIDE_DEBUG_MODE = false;
|
||||
|
||||
export enum ClickOutsideMode {
|
||||
comparePixels = 'comparePixels',
|
||||
compareHTMLRef = 'compareHTMLRef',
|
||||
}
|
||||
|
||||
export type ClickOutsideListenerProps<T extends Element> = {
|
||||
refs: Array<React.RefObject<T>>;
|
||||
excludeClassNames?: string[];
|
||||
callback: (event: MouseEvent | TouchEvent) => void;
|
||||
mode?: ClickOutsideMode;
|
||||
listenerId: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
@ -25,7 +19,6 @@ export const useListenClickOutside = <T extends Element>({
|
||||
refs,
|
||||
excludeClassNames,
|
||||
callback,
|
||||
mode = ClickOutsideMode.compareHTMLRef,
|
||||
listenerId,
|
||||
enabled = true,
|
||||
}: ClickOutsideListenerProps<T>) => {
|
||||
@ -60,67 +53,16 @@ export const useListenClickOutside = <T extends Element>({
|
||||
return;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case ClickOutsideMode.compareHTMLRef: {
|
||||
const clickedOnAtLeastOneRef = refs
|
||||
.filter((ref) => !!ref.current)
|
||||
.some((ref) => ref.current?.contains(event.target as Node));
|
||||
const clickedOnAtLeastOneRef = refs
|
||||
.filter((ref) => !!ref.current)
|
||||
.some((ref) => ref.current?.contains(event.target as Node));
|
||||
|
||||
set(
|
||||
clickOutsideListenerIsMouseDownInsideState,
|
||||
clickedOnAtLeastOneRef,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case ClickOutsideMode.comparePixels: {
|
||||
const clickedOnAtLeastOneRef = refs
|
||||
.filter((ref) => !!ref.current)
|
||||
.some((ref) => {
|
||||
if (!ref.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { x, y, width, height } =
|
||||
ref.current.getBoundingClientRect();
|
||||
|
||||
const clientX =
|
||||
'clientX' in event
|
||||
? event.clientX
|
||||
: event.changedTouches[0].clientX;
|
||||
const clientY =
|
||||
'clientY' in event
|
||||
? event.clientY
|
||||
: event.changedTouches[0].clientY;
|
||||
|
||||
if (
|
||||
clientX < x ||
|
||||
clientX > x + width ||
|
||||
clientY < y ||
|
||||
clientY > y + height
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
set(
|
||||
clickOutsideListenerIsMouseDownInsideState,
|
||||
clickedOnAtLeastOneRef,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
set(clickOutsideListenerIsMouseDownInsideState, clickedOnAtLeastOneRef);
|
||||
},
|
||||
[
|
||||
clickOutsideListenerIsActivatedState,
|
||||
clickOutsideListenerMouseDownHappenedState,
|
||||
enabled,
|
||||
mode,
|
||||
refs,
|
||||
clickOutsideListenerIsMouseDownInsideState,
|
||||
],
|
||||
@ -162,94 +104,34 @@ export const useListenClickOutside = <T extends Element>({
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
if (mode === ClickOutsideMode.compareHTMLRef) {
|
||||
const clickedOnAtLeastOneRef = refs
|
||||
.filter((ref) => !!ref.current)
|
||||
.some((ref) => ref.current?.contains(event.target as Node));
|
||||
const clickedOnAtLeastOneRef = refs
|
||||
.filter((ref) => !!ref.current)
|
||||
.some((ref) => ref.current?.contains(event.target as Node));
|
||||
|
||||
const shouldTrigger =
|
||||
isListening &&
|
||||
hasMouseDownHappened &&
|
||||
!clickedOnAtLeastOneRef &&
|
||||
!isMouseDownInside &&
|
||||
!isClickedOnExcluded;
|
||||
const shouldTrigger =
|
||||
isListening &&
|
||||
hasMouseDownHappened &&
|
||||
!clickedOnAtLeastOneRef &&
|
||||
!isMouseDownInside &&
|
||||
!isClickedOnExcluded;
|
||||
|
||||
if (CLICK_OUTSIDE_DEBUG_MODE) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('click outside compare ref', {
|
||||
listenerId,
|
||||
shouldTrigger,
|
||||
clickedOnAtLeastOneRef,
|
||||
isMouseDownInside,
|
||||
isListening,
|
||||
hasMouseDownHappened,
|
||||
isClickedOnExcluded,
|
||||
enabled,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldTrigger) {
|
||||
callback(event);
|
||||
}
|
||||
if (CLICK_OUTSIDE_DEBUG_MODE) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('click outside compare ref', {
|
||||
listenerId,
|
||||
shouldTrigger,
|
||||
clickedOnAtLeastOneRef,
|
||||
isMouseDownInside,
|
||||
isListening,
|
||||
hasMouseDownHappened,
|
||||
isClickedOnExcluded,
|
||||
enabled,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === ClickOutsideMode.comparePixels) {
|
||||
const clickedOnAtLeastOneRef = refs
|
||||
.filter((ref) => !!ref.current)
|
||||
.some((ref) => {
|
||||
if (!ref.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { x, y, width, height } =
|
||||
ref.current.getBoundingClientRect();
|
||||
|
||||
const clientX =
|
||||
'clientX' in event
|
||||
? event.clientX
|
||||
: event.changedTouches[0].clientX;
|
||||
const clientY =
|
||||
'clientY' in event
|
||||
? event.clientY
|
||||
: event.changedTouches[0].clientY;
|
||||
|
||||
if (
|
||||
clientX < x ||
|
||||
clientX > x + width ||
|
||||
clientY < y ||
|
||||
clientY > y + height
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const shouldTrigger =
|
||||
!clickedOnAtLeastOneRef &&
|
||||
!isMouseDownInside &&
|
||||
isListening &&
|
||||
hasMouseDownHappened &&
|
||||
!isClickedOnExcluded;
|
||||
|
||||
if (CLICK_OUTSIDE_DEBUG_MODE) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('click outside compare pixel', {
|
||||
listenerId,
|
||||
shouldTrigger,
|
||||
clickedOnAtLeastOneRef,
|
||||
isMouseDownInside,
|
||||
isListening,
|
||||
hasMouseDownHappened,
|
||||
isClickedOnExcluded,
|
||||
enabled,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldTrigger) {
|
||||
callback(event);
|
||||
}
|
||||
if (shouldTrigger) {
|
||||
callback(event);
|
||||
}
|
||||
},
|
||||
[
|
||||
@ -257,7 +139,6 @@ export const useListenClickOutside = <T extends Element>({
|
||||
enabled,
|
||||
clickOutsideListenerIsMouseDownInsideState,
|
||||
clickOutsideListenerMouseDownHappenedState,
|
||||
mode,
|
||||
refs,
|
||||
excludeClassNames,
|
||||
callback,
|
||||
@ -291,5 +172,5 @@ export const useListenClickOutside = <T extends Element>({
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [refs, callback, mode, handleClickOutside, handleMouseDown]);
|
||||
}, [refs, callback, handleClickOutside, handleMouseDown]);
|
||||
};
|
||||
|
||||
@ -51,7 +51,6 @@ export const ViewBarFilterDropdown = ({
|
||||
dropdownHotkeyScope={hotkeyScope}
|
||||
dropdownOffset={{ y: 8 }}
|
||||
onClickOutside={handleDropdownClickOutside}
|
||||
dropdownWidth={280}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -79,7 +79,7 @@ export const ViewBarFilterDropdownFieldSelectMenu = () => {
|
||||
selectableItemIdArray={selectableFieldMetadataItemIds}
|
||||
selectableListInstanceId={FILTER_FIELD_LIST_ID}
|
||||
>
|
||||
<DropdownMenuItemsContainer width="auto">
|
||||
<DropdownMenuItemsContainer width={200}>
|
||||
{selectableVisibleFieldMetadataItems.map(
|
||||
(visibleFieldMetadataItem) => (
|
||||
<ViewBarFilterDropdownFieldSelectMenuItem
|
||||
|
||||
Reference in New Issue
Block a user