Refactor and fixes dropdown bugs (#8807)

Fixes https://github.com/twentyhq/twenty/issues/8788
Fixes https://github.com/twentyhq/twenty/issues/8793
Fixes https://github.com/twentyhq/twenty/issues/8791
Fixes https://github.com/twentyhq/twenty/issues/8890
Fixes https://github.com/twentyhq/twenty/issues/8893

- [x] Also : 

Icon buttons under dropdown are visible without blur : 

![Capture d’écran du 2024-11-29
15-09-53](https://github.com/user-attachments/assets/f563333d-4e43-4ded-acc7-62e116004ed9)

- [x] Also : 

<img width="237" alt="image"
src="https://github.com/user-attachments/assets/e4c70936-beff-4481-89cb-0a32a36e0ee2">

- [x] Also : 

<img width="335" alt="image"
src="https://github.com/user-attachments/assets/5be60395-6baf-49eb-8d40-197add049e20">

- [x] Also : 

<img width="287" alt="image"
src="https://github.com/user-attachments/assets/a317561f-7986-4d70-a1c0-deee4f4e268a">

- Button create new without padding
- Container is expanding

- [x] Also : 

<img width="303" alt="image"
src="https://github.com/user-attachments/assets/09f8a27f-91db-4191-acdc-aaaeedaf6da5">

- [x] Also : 

<img width="133" alt="image"
src="https://github.com/user-attachments/assets/fe17b32e-f7a4-46c4-8040-239eaf8198e8">

Font is cut at bottom ?

- [x] Also : 

<img width="385" alt="image"
src="https://github.com/user-attachments/assets/7bab2092-2936-4112-a2ee-d32d6737e304">

The component should flip and not resize in this situation

- [x] Also : 

<img width="244" alt="image"
src="https://github.com/user-attachments/assets/5384f49a-71f9-4638-a60c-158cc8c83f81">

- [x] Also : 


![image](https://github.com/user-attachments/assets/9cd1f43a-df59-401e-9a41-bdb8e93ebe58)
This commit is contained in:
Lucas Bordeau
2024-12-06 15:27:48 +01:00
committed by GitHub
parent 14b7bcf262
commit a9cb20f317
87 changed files with 1201 additions and 1192 deletions

View File

@ -8,10 +8,11 @@ import { CountrySelect } from '@/ui/input/components/internal/country/components
import { SELECT_COUNTRY_DROPDOWN_ID } from '@/ui/input/components/internal/country/constants/SelectCountryDropdownId';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilValue } from 'recoil';
import { isDefined, MOBILE_VIEWPORT } from 'twenty-ui';
const StyledAddressContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
@ -190,18 +191,22 @@ export const AddressInput = ({
[onEscape, internalValue],
);
const { useListenClickOutside } = useClickOutsideListener('addressInput');
const activeDropdownFocusId = useRecoilValue(activeDropdownFocusIdState);
useListenClickOutside({
refs: [wrapperRef],
callback: (event) => {
if (activeDropdownFocusId === SELECT_COUNTRY_DROPDOWN_ID) {
return;
}
event.stopImmediatePropagation();
closeCountryDropdown();
onClickOutside?.(event, internalValue);
},
enabled: isDefined(onClickOutside),
listenerId: 'address-input',
});
useEffect(() => {

View File

@ -9,7 +9,7 @@ import {
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useListenClickOutsideV2 } from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
const StyledCalendarContainer = styled.div`
background: ${({ theme }) => theme.background.transparent.secondary};
@ -72,7 +72,7 @@ export const DateInput = ({
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
);
useListenClickOutsideV2({
useListenClickOutside({
refs: [wrapperRef],
listenerId: 'DateInput',
callback: (event) => {

View File

@ -10,9 +10,9 @@ import { Key } from 'ts-key-enum';
import { FieldDoubleText } from '@/object-record/record-field/types/FieldDoubleText';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { splitFullName } from '~/utils/format/spiltFullName';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
import { StyledTextInput } from './TextInput';
@ -158,6 +158,7 @@ export const DoubleTextInput = ({
});
},
enabled: isDefined(onClickOutside),
listenerId: 'double-text-input',
});
const handleOnPaste = (event: ClipboardEvent<HTMLInputElement>) => {

View File

@ -2,13 +2,13 @@ import styled from '@emotion/styled';
import { OVERLAY_BACKGROUND } from 'twenty-ui';
const StyledFieldTextAreaOverlay = styled.div`
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
margin: -1px;
max-height: 420px;
position: absolute;
top: 0;
border-radius: ${({ theme }) => theme.border.radius.sm};
align-items: center;
display: flex;
max-height: 420px;
margin: -1px;
width: 100%;
${OVERLAY_BACKGROUND}
`;

View File

@ -1,14 +1,12 @@
import { SelectOption } from '@/spreadsheet-import/types';
import { SelectInput as SelectBaseInput } from '@/ui/input/components/SelectInput';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { ReferenceType } from '@floating-ui/react';
type SelectInputProps = {
selectableListId: string;
selectableItemIdArray: string[];
hotkeyScope: string;
onEnter: (itemId: string) => void;
selectWrapperRef?: ReferenceType | null | undefined;
onOptionSelected: (selectedOption: SelectOption) => void;
options: SelectOption[];
onCancel?: () => void;
@ -23,7 +21,6 @@ export const SelectInput = ({
selectableItemIdArray,
hotkeyScope,
onEnter,
selectWrapperRef,
onOptionSelected,
options,
onCancel,
@ -40,7 +37,6 @@ export const SelectInput = ({
onEnter={onEnter}
>
<SelectBaseInput
parentRef={selectWrapperRef}
onOptionSelected={onOptionSelected}
options={options}
onCancel={onCancel}

View File

@ -168,7 +168,7 @@ export const Select = <Value extends SelectValue>({
<DropdownMenuSeparator />
)}
{!!callToActionButton && (
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuItemsContainer hasMaxHeight withoutScrollWrapper>
<MenuItem
onClick={callToActionButton.onClick}
LeftIcon={callToActionButton.Icon}

View File

@ -1,5 +1,3 @@
import styled from '@emotion/styled';
import { SelectOption } from '@/spreadsheet-import/types';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
@ -8,32 +6,15 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useTheme } from '@emotion/react';
import {
ReferenceType,
autoUpdate,
flip,
offset,
size,
useFloating,
} from '@floating-ui/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { MenuItemSelectTag, TagColor, isDefined } from 'twenty-ui';
const StyledRelationPickerContainer = styled.div`
left: -1px;
position: absolute;
top: -1px;
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
interface SelectInputProps {
onOptionSelected: (selectedOption: SelectOption) => void;
options: SelectOption[];
onCancel?: () => void;
defaultOption?: SelectOption;
parentRef?: ReferenceType | null | undefined;
onFilterChange?: (filteredOptions: SelectOption[]) => void;
onClear?: () => void;
clearLabel?: string;
@ -47,13 +28,11 @@ export const SelectInput = ({
options,
onCancel,
defaultOption,
parentRef,
onFilterChange,
hotkeyScope,
}: SelectInputProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const [searchFilter, setSearchFilter] = useState('');
const [selectedOption, setSelectedOption] = useState<
SelectOption | undefined
@ -81,27 +60,12 @@ export const SelectInput = ({
onOptionSelected(option);
};
const { refs, floatingStyles } = useFloating({
elements: { reference: parentRef },
strategy: 'absolute',
middleware: [
offset(() => {
return parseInt(theme.spacing(2), 10);
}),
flip(),
size(),
],
whileElementsMounted: autoUpdate,
open: true,
placement: 'bottom-start',
});
useEffect(() => {
onFilterChange?.(optionsInDropDown);
}, [onFilterChange, optionsInDropDown]);
useListenClickOutside({
refs: [refs.floating],
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
@ -113,6 +77,7 @@ export const SelectInput = ({
onCancel();
}
},
listenerId: 'select-input',
});
useScopedHotkeys(
@ -130,44 +95,39 @@ export const SelectInput = ({
);
return (
<StyledRelationPickerContainer
ref={refs.setFloating}
style={floatingStyles}
>
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{onClear && clearLabel && (
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{onClear && clearLabel && (
<MenuItemSelectTag
key={`No ${clearLabel}`}
selected={false}
text={`No ${clearLabel}`}
color="transparent"
variant="outline"
onClick={() => {
setSelectedOption(undefined);
onClear();
}}
/>
)}
{optionsInDropDown.map((option) => {
return (
<MenuItemSelectTag
key={`No ${clearLabel}`}
selected={false}
text={`No ${clearLabel}`}
color="transparent"
variant="outline"
onClick={() => {
setSelectedOption(undefined);
onClear();
}}
key={option.value}
selected={selectedOption?.value === option.value}
text={option.label}
color={option.color as TagColor}
onClick={() => handleOptionChange(option)}
/>
)}
{optionsInDropDown.map((option) => {
return (
<MenuItemSelectTag
key={option.value}
selected={selectedOption?.value === option.value}
text={option.label}
color={option.color as TagColor}
onClick={() => handleOptionChange(option)}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
);
};

View File

@ -10,11 +10,7 @@ import { CurrencyPickerHotkeyScope } from '../types/CurrencyPickerHotkeyScope';
import { CurrencyPickerDropdownSelect } from './CurrencyPickerDropdownSelect';
type StyledDropdownButtonProps = {
isUnfolded: boolean;
};
const StyledDropdownButtonContainer = styled.div<StyledDropdownButtonProps>`
const StyledDropdownButtonContainer = styled.div`
align-items: center;
color: ${({ color }) => color ?? 'none'};
cursor: pointer;
@ -62,7 +58,7 @@ export const CurrencyPickerDropdownButton = ({
}) => {
const theme = useTheme();
const { isDropdownOpen, closeDropdown } = useDropdown(
const { closeDropdown } = useDropdown(
CurrencyPickerHotkeyScope.CurrencyPicker,
);
@ -77,11 +73,10 @@ export const CurrencyPickerDropdownButton = ({
return (
<Dropdown
dropdownMenuWidth={200}
dropdownId="currncy-picker-dropdown-id"
dropdownId="currency-picker-dropdown-id"
dropdownHotkeyScope={{ scope: CurrencyPickerHotkeyScope.CurrencyPicker }}
clickableComponent={
<StyledDropdownButtonContainer isUnfolded={isDropdownOpen}>
<StyledDropdownButtonContainer>
<StyledIconContainer>
{currencyCode}
<IconChevronDown size={theme.icon.size.sm} />

View File

@ -32,7 +32,7 @@ export const CurrencyPickerDropdownSelect = ({
);
return (
<DropdownMenu width="240px" disableBlur>
<DropdownMenu disableBlur>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.target.value)}

View File

@ -1,32 +1,26 @@
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
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 { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import {
autoUpdate,
flip,
FloatingPortal,
offset,
Placement,
size,
useFloating,
} from '@floating-ui/react';
import { MouseEvent, ReactNode, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import { MouseEvent, ReactNode } from 'react';
import { Keys } from 'react-hotkeys-hook';
import { Key } from 'ts-key-enum';
import { isDefined } from '~/utils/isDefined';
import { useDropdown } from '../hooks/useDropdown';
import { useInternalHotkeyScopeManagement } from '../hooks/useInternalHotkeyScopeManagement';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownUnmountEffect } from '@/ui/layout/dropdown/components/DropdownUnmountEffect';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponeInstanceContext';
import { dropdownMaxHeightComponentStateV2 } from '@/ui/layout/dropdown/states/dropdownMaxHeightComponentStateV2';
import { useListenClickOutsideV2 } from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { DropdownMenu } from './DropdownMenu';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { flushSync } from 'react-dom';
import { isDefined } from 'twenty-ui';
import { DropdownOnToggleEffect } from './DropdownOnToggleEffect';
type DropdownProps = {
@ -62,24 +56,15 @@ export const Dropdown = ({
dropdownStrategy = 'absolute',
dropdownOffset = { x: 0, y: 0 },
disableBlur = false,
usePortal = false,
onClickOutside,
onClose,
onOpen,
}: DropdownProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const {
isDropdownOpen,
toggleDropdown,
closeDropdown,
dropdownWidth,
setDropdownPlacement,
} = useDropdown(dropdownId);
const { isDropdownOpen, toggleDropdown } = useDropdown(dropdownId);
const offsetMiddlewares = [];
const [dropdownMaxHeight, setDropdownMaxHeight] = useRecoilComponentStateV2(
const setDropdownMaxHeight = useSetRecoilComponentStateV2(
dropdownMaxHeightComponentStateV2,
dropdownId,
);
@ -111,14 +96,6 @@ export const Dropdown = ({
strategy: dropdownStrategy,
});
useEffect(() => {
setDropdownPlacement(placement);
}, [placement, setDropdownPlacement]);
const handleHotkeyTriggered = () => {
toggleDropdown();
};
const handleClickableComponentClick = (event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
@ -127,88 +104,41 @@ export const Dropdown = ({
onClickOutside?.();
};
useListenClickOutsideV2({
refs: [refs.floating, refs.domReference],
listenerId: dropdownId,
callback: () => {
onClickOutside?.();
if (isDropdownOpen) {
closeDropdown();
}
},
});
useInternalHotkeyScopeManagement({
dropdownScopeId: getScopeIdFromComponentId(dropdownId),
dropdownHotkeyScopeFromParent: dropdownHotkeyScope,
});
useScopedHotkeys(
[Key.Escape],
() => {
if (isDropdownOpen) {
closeDropdown();
}
},
dropdownHotkeyScope.scope,
[closeDropdown, isDropdownOpen],
);
const dropdownMenuStyles = {
...floatingStyles,
maxHeight: dropdownMaxHeight,
};
return (
<DropdownComponentInstanceContext.Provider
value={{ instanceId: dropdownId }}
>
<DropdownScope dropdownScopeId={getScopeIdFromComponentId(dropdownId)}>
<div ref={containerRef} className={className}>
<>
{clickableComponent && (
<div
ref={refs.setReference}
onClick={handleClickableComponentClick}
className={className}
>
{clickableComponent}
</div>
)}
{hotkey && (
<HotkeyEffect
hotkey={hotkey}
onHotkeyTriggered={handleHotkeyTriggered}
/>
)}
{isDropdownOpen && usePortal && (
<FloatingPortal>
<DropdownMenu
disableBlur={disableBlur}
width={dropdownMenuWidth ?? dropdownWidth}
data-select-disable
ref={refs.setFloating}
style={dropdownMenuStyles}
>
{dropdownComponents}
</DropdownMenu>
</FloatingPortal>
)}
{isDropdownOpen && !usePortal && (
<DropdownMenu
{isDropdownOpen && (
<DropdownContent
className={className}
floatingStyles={floatingStyles}
disableBlur={disableBlur}
width={dropdownMenuWidth ?? dropdownWidth}
data-select-disable
ref={refs.setFloating}
style={dropdownMenuStyles}
>
{dropdownComponents}
</DropdownMenu>
dropdownMenuWidth={dropdownMenuWidth}
dropdownComponents={dropdownComponents}
dropdownId={dropdownId}
dropdownPlacement={placement ?? 'bottom-end'}
floatingUiRefs={refs}
hotkeyScope={dropdownHotkeyScope}
hotkey={hotkey}
onClickOutside={onClickOutside}
onHotkeyTriggered={toggleDropdown}
/>
)}
<DropdownOnToggleEffect
onDropdownClose={onClose}
onDropdownOpen={onOpen}
/>
</div>
</>
</DropdownScope>
<DropdownUnmountEffect dropdownId={dropdownId} />
</DropdownComponentInstanceContext.Provider>

View File

@ -0,0 +1,128 @@
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useInternalHotkeyScopeManagement } from '@/ui/layout/dropdown/hooks/useInternalHotkeyScopeManagement';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { dropdownMaxHeightComponentStateV2 } from '@/ui/layout/dropdown/states/dropdownMaxHeightComponentStateV2';
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 { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import {
FloatingPortal,
Placement,
UseFloatingReturn,
} from '@floating-ui/react';
import { useEffect } from 'react';
import { Keys } from 'react-hotkeys-hook';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
export type DropdownContentProps = {
className?: string;
dropdownId: string;
dropdownPlacement: Placement;
floatingUiRefs: UseFloatingReturn['refs'];
onClickOutside?: () => void;
hotkeyScope: HotkeyScope;
floatingStyles: UseFloatingReturn['floatingStyles'];
hotkey?: {
key: Keys;
scope: string;
};
onHotkeyTriggered?: () => void;
disableBlur?: boolean;
dropdownMenuWidth?: `${string}px` | `${number}%` | 'auto' | number;
dropdownComponents: React.ReactNode;
parentDropdownId?: string;
};
export const DropdownContent = ({
className,
dropdownId,
dropdownPlacement,
floatingUiRefs,
onClickOutside,
hotkeyScope,
floatingStyles,
hotkey,
onHotkeyTriggered,
disableBlur,
dropdownMenuWidth,
dropdownComponents,
}: DropdownContentProps) => {
const { isDropdownOpen, closeDropdown, dropdownWidth, setDropdownPlacement } =
useDropdown(dropdownId);
const activeDropdownFocusId = useRecoilValue(activeDropdownFocusIdState);
const [dropdownMaxHeight] = useRecoilComponentStateV2(
dropdownMaxHeightComponentStateV2,
dropdownId,
);
useEffect(() => {
setDropdownPlacement(dropdownPlacement);
}, [dropdownPlacement, setDropdownPlacement]);
useListenClickOutside({
refs: [floatingUiRefs.floating, floatingUiRefs.domReference],
listenerId: dropdownId,
callback: (event) => {
if (activeDropdownFocusId !== dropdownId) return;
if (isDropdownOpen) {
event.stopImmediatePropagation();
event.preventDefault();
closeDropdown();
}
onClickOutside?.();
},
});
useInternalHotkeyScopeManagement({
dropdownScopeId: getScopeIdFromComponentId(dropdownId),
dropdownHotkeyScopeFromParent: hotkeyScope,
});
useScopedHotkeys(
[Key.Escape],
() => {
if (activeDropdownFocusId !== dropdownId) return;
if (isDropdownOpen) {
closeDropdown();
}
},
hotkeyScope?.scope,
[closeDropdown, isDropdownOpen],
);
const dropdownMenuStyles = {
...floatingStyles,
maxHeight: dropdownMaxHeight,
};
return (
<>
{hotkey && onHotkeyTriggered && (
<HotkeyEffect hotkey={hotkey} onHotkeyTriggered={onHotkeyTriggered} />
)}
<FloatingPortal>
<DropdownMenu
className={className}
disableBlur={disableBlur}
width={dropdownMenuWidth ?? dropdownWidth}
data-select-disable
ref={floatingUiRefs.setFloating}
style={dropdownMenuStyles}
>
{dropdownComponents}
</DropdownMenu>
</FloatingPortal>
</>
);
};

View File

@ -18,7 +18,6 @@ const StyledHeader = styled.li`
padding: ${({ theme }) => theme.spacing(1)};
user-select: none;
width: inherit;
&:hover {
background: ${({ theme, onClick }) =>

View File

@ -44,7 +44,7 @@ const StyledInputContainer = styled.div`
const StyledRightContainer = styled.div`
position: absolute;
right: ${({ theme }) => theme.spacing(1)};
right: ${({ theme }) => theme.spacing(2)};
top: 50%;
transform: translateY(-50%);
`;

View File

@ -37,12 +37,14 @@ export const DropdownMenuItemsContainer = ({
children,
hasMaxHeight,
className,
withoutScrollWrapper,
}: {
children: React.ReactNode;
hasMaxHeight?: boolean;
className?: string;
withoutScrollWrapper?: boolean;
}) => {
return (
return withoutScrollWrapper === true ? (
<StyledDropdownMenuItemsExternalContainer
hasMaxHeight={hasMaxHeight}
className={className}
@ -59,5 +61,16 @@ export const DropdownMenuItemsContainer = ({
</StyledDropdownMenuItemsInternalContainer>
)}
</StyledDropdownMenuItemsExternalContainer>
) : (
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer">
<StyledDropdownMenuItemsExternalContainer
hasMaxHeight={hasMaxHeight}
className={className}
>
<StyledDropdownMenuItemsInternalContainer>
{children}
</StyledDropdownMenuItemsInternalContainer>
</StyledDropdownMenuItemsExternalContainer>
</ScrollWrapper>
);
};

View File

@ -1,6 +1,8 @@
import { useRecoilState } from 'recoil';
import { useDropdownStates } from '@/ui/layout/dropdown/hooks/internal/useDropdownStates';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
import { useCallback } from 'react';
@ -17,6 +19,12 @@ export const useDropdown = (dropdownId?: string) => {
dropdownScopeId: getScopeIdOrUndefinedFromComponentId(dropdownId),
});
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
@ -34,17 +42,28 @@ export const useDropdown = (dropdownId?: string) => {
useRecoilState(isDropdownOpenState);
const closeDropdown = useCallback(() => {
goBackToPreviousHotkeyScope();
setIsDropdownOpen(false);
}, [goBackToPreviousHotkeyScope, setIsDropdownOpen]);
if (isDropdownOpen) {
goBackToPreviousHotkeyScope();
setIsDropdownOpen(false);
goBackToPreviousDropdownFocusId();
}
}, [
isDropdownOpen,
goBackToPreviousHotkeyScope,
setIsDropdownOpen,
goBackToPreviousDropdownFocusId,
]);
const openDropdown = () => {
setIsDropdownOpen(true);
if (isDefined(dropdownHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope(
dropdownHotkeyScope.scope,
dropdownHotkeyScope.customScopes,
);
if (!isDropdownOpen) {
setIsDropdownOpen(true);
setActiveDropdownFocusIdAndMemorizePrevious(dropdownId ?? scopeId);
if (isDefined(dropdownHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope(
dropdownHotkeyScope.scope,
dropdownHotkeyScope.customScopes,
);
}
}
};

View File

@ -0,0 +1,23 @@
import { useRecoilCallback } from 'recoil';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
export const useGoBackToPreviousDropdownFocusId = () => {
const goBackToPreviousDropdownFocusId = useRecoilCallback(
({ snapshot, set }) =>
() => {
const previouslyFocusedDropdownId = snapshot
.getLoadable(previousDropdownFocusIdState)
.getValue();
set(activeDropdownFocusIdState, previouslyFocusedDropdownId);
set(previousDropdownFocusIdState, null);
},
[],
);
return {
goBackToPreviousDropdownFocusId,
};
};

View File

@ -0,0 +1,23 @@
import { useRecoilCallback } from 'recoil';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
export const useSetActiveDropdownFocusIdAndMemorizePrevious = () => {
const setActiveDropdownFocusIdAndMemorizePrevious = useRecoilCallback(
({ snapshot, set }) =>
(dropdownId: string) => {
const focusedDropdownId = snapshot
.getLoadable(activeDropdownFocusIdState)
.getValue();
set(previousDropdownFocusIdState, focusedDropdownId);
set(activeDropdownFocusIdState, dropdownId);
},
[],
);
return {
setActiveDropdownFocusIdAndMemorizePrevious,
};
};

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const activeDropdownFocusIdState = createState<string | null>({
key: 'activeDropdownFocusIdState',
defaultValue: null,
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const previousDropdownFocusIdState = createState<string | null>({
key: 'previousDropdownFocusIdState',
defaultValue: null,
});

View File

@ -4,7 +4,6 @@ import { AnimatedContainer, Chip, ChipVariant } from 'twenty-ui';
import { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown';
import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
const StyledContainer = styled.div`
@ -101,20 +100,18 @@ export const ExpandableList = ({
resetFirstHiddenChildIndex();
}, [isChipCountDisplayed, children.length, resetFirstHiddenChildIndex]);
useListenClickOutside({
refs: [containerRef],
callback: () => {
// Handle container resize
if (
childrenContainerElement?.clientWidth !== previousChildrenContainerWidth
) {
resetFirstHiddenChildIndex();
setPreviousChildrenContainerWidth(
childrenContainerElement?.clientWidth ?? 0,
);
}
},
});
const handleClickOutside = () => {
setIsListExpanded(false);
if (
childrenContainerElement?.clientWidth !== previousChildrenContainerWidth
) {
resetFirstHiddenChildIndex();
setPreviousChildrenContainerWidth(
childrenContainerElement?.clientWidth ?? 0,
);
}
};
return (
<StyledContainer
@ -163,10 +160,7 @@ export const ExpandableList = ({
{isListExpanded && (
<ExpandedListDropdown
anchorElement={containerRef.current ?? undefined}
onClickOutside={() => {
resetFirstHiddenChildIndex();
setIsListExpanded(false);
}}
onClickOutside={handleClickOutside}
withBorder={withExpandedListBorder}
>
{children}

View File

@ -46,8 +46,11 @@ export const ExpandedListDropdown = ({
});
useListenClickOutside({
refs: [refs.floating],
callback: onClickOutside ?? (() => {}),
refs: [refs.domReference],
callback: () => {
onClickOutside?.();
},
listenerId: 'expandable-list',
});
return (

View File

@ -3,8 +3,8 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutsideV2,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2';
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
@ -207,7 +207,7 @@ export const Modal = ({
hotkeyScope,
);
useListenClickOutsideV2({
useListenClickOutside({
refs: [modalRef],
listenerId: 'MODAL_CLICK_OUTSIDE_LISTENER_ID',
callback: () => {

View File

@ -1,26 +1,19 @@
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { isRightDrawerAnimationCompletedState } from '@/ui/layout/right-drawer/states/isRightDrawerAnimationCompletedState';
import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState';
import { rightDrawerCloseEventState } from '@/ui/layout/right-drawer/states/rightDrawerCloseEventsState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { ClickOutsideMode } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useRef } from 'react';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { useRightDrawer } from '../hooks/useRightDrawer';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerHotkeyScope } from '../types/RightDrawerHotkeyScope';
import { RIGHT_DRAWER_ANIMATION_VARIANTS } from '@/ui/layout/right-drawer/constants/RightDrawerAnimationVariants';
import { RightDrawerAnimationVariant } from '@/ui/layout/right-drawer/types/RightDrawerAnimationVariant';
import { RightDrawerRouter } from './RightDrawerRouter';
import { workflowReactFlowRefState } from '@/workflow/states/workflowReactFlowRefState';
const StyledContainer = styled(motion.div)<{ isRightDrawerMinimized: boolean }>`
background: ${({ theme }) => theme.background.primary};
@ -40,7 +33,7 @@ const StyledContainer = styled(motion.div)<{ isRightDrawerMinimized: boolean }>`
right: 0;
top: 0;
z-index: 100;
z-index: 30;
.modal-backdrop {
background: ${({ theme }) => theme.background.overlayTertiary};
@ -56,39 +49,6 @@ const StyledRightDrawer = styled.div`
export const RightDrawer = () => {
const theme = useTheme();
const animationVariants = {
fullScreen: {
x: '0%',
width: '100%',
height: '100%',
bottom: '0',
top: '0',
},
normal: {
x: '0%',
width: theme.rightDrawerWidth,
height: '100%',
bottom: '0',
top: '0',
},
closed: {
x: '100%',
width: '0',
height: '100%',
bottom: '0',
top: 'auto',
},
minimized: {
x: '0%',
width: 220,
height: 41,
bottom: '0',
top: 'auto',
},
};
type RightDrawerAnimationVariant = keyof typeof animationVariants;
const isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState);
const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState);
@ -99,52 +59,6 @@ export const RightDrawer = () => {
const rightDrawerPage = useRecoilValue(rightDrawerPageState);
const { closeRightDrawer } = useRightDrawer();
const rightDrawerRef = useRef<HTMLDivElement>(null);
const workflowReactFlowRef = useRecoilValue(workflowReactFlowRefState);
const { useListenClickOutside } = useClickOutsideListener(
RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID,
);
useListenClickOutside({
refs: [
rightDrawerRef,
...(workflowReactFlowRef ? [workflowReactFlowRef] : []),
],
callback: useRecoilCallback(
({ snapshot, set }) =>
(event) => {
const isRightDrawerOpen = snapshot
.getLoadable(isRightDrawerOpenState)
.getValue();
const isRightDrawerMinimized = snapshot
.getLoadable(isRightDrawerMinimizedState)
.getValue();
if (isRightDrawerOpen && !isRightDrawerMinimized) {
set(rightDrawerCloseEventState, event);
closeRightDrawer();
}
},
[closeRightDrawer],
),
mode: ClickOutsideMode.comparePixels,
});
useScopedHotkeys(
[Key.Escape],
() => {
if (isRightDrawerOpen && !isRightDrawerMinimized) {
closeRightDrawer();
}
},
RightDrawerHotkeyScope.RightDrawer,
[isRightDrawerOpen, isRightDrawerMinimized],
);
const isMobile = useIsMobile();
const targetVariantForAnimation: RightDrawerAnimationVariant =
@ -168,13 +82,13 @@ export const RightDrawer = () => {
<StyledContainer
isRightDrawerMinimized={isRightDrawerMinimized}
animate={targetVariantForAnimation}
variants={animationVariants}
variants={RIGHT_DRAWER_ANIMATION_VARIANTS}
transition={{
duration: theme.animation.duration.normal,
}}
onAnimationComplete={handleAnimationComplete}
>
<StyledRightDrawer ref={rightDrawerRef}>
<StyledRightDrawer>
{isRightDrawerOpen && <RightDrawerRouter />}
</StyledRightDrawer>
</StyledContainer>

View File

@ -0,0 +1,83 @@
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState';
import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState';
import { rightDrawerCloseEventState } from '@/ui/layout/right-drawer/states/rightDrawerCloseEventsState';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { workflowReactFlowRefState } from '@/workflow/states/workflowReactFlowRefState';
import styled from '@emotion/styled';
import { useRef } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
const StyledRightDrawerPage = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`;
export const RightDrawerContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
const rightDrawerRef = useRef<HTMLDivElement>(null);
const isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState);
const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState);
const { closeRightDrawer } = useRightDrawer();
const workflowReactFlowRef = useRecoilValue(workflowReactFlowRefState);
useListenClickOutside({
refs: [
rightDrawerRef,
...(workflowReactFlowRef ? [workflowReactFlowRef] : []),
],
listenerId: RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID,
callback: useRecoilCallback(
({ snapshot, set }) =>
(event) => {
const isRightDrawerOpen = snapshot
.getLoadable(isRightDrawerOpenState)
.getValue();
const isRightDrawerMinimized = snapshot
.getLoadable(isRightDrawerMinimizedState)
.getValue();
if (isRightDrawerOpen && !isRightDrawerMinimized) {
set(rightDrawerCloseEventState, event);
closeRightDrawer();
}
},
[closeRightDrawer],
),
mode: ClickOutsideMode.comparePixels,
});
useScopedHotkeys(
[Key.Escape],
() => {
if (isRightDrawerOpen && !isRightDrawerMinimized) {
closeRightDrawer();
}
},
RightDrawerHotkeyScope.RightDrawer,
[isRightDrawerOpen, isRightDrawerMinimized],
);
return (
<StyledRightDrawerPage ref={rightDrawerRef}>
{children}
</StyledRightDrawerPage>
);
};

View File

@ -7,22 +7,16 @@ import { RightDrawerEmailThread } from '@/activities/emails/right-drawer/compone
import { RightDrawerRecord } from '@/object-record/record-right-drawer/components/RightDrawerRecord';
import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState';
import { RightDrawerContainer } from '@/ui/layout/right-drawer/components/RightDrawerContainer';
import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage';
import { RightDrawerWorkflowEditStep } from '@/workflow/components/RightDrawerWorkflowEditStep';
import { RightDrawerWorkflowSelectAction } from '@/workflow/components/RightDrawerWorkflowSelectAction';
import { RightDrawerWorkflowSelectTriggerType } from '@/workflow/components/RightDrawerWorkflowSelectTriggerType';
import { RightDrawerWorkflowViewStep } from '@/workflow/components/RightDrawerWorkflowViewStep';
import { isDefined } from 'twenty-ui';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
import { RightDrawerWorkflowSelectTriggerType } from '@/workflow/components/RightDrawerWorkflowSelectTriggerType';
const StyledRightDrawerPage = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`;
const StyledRightDrawerBody = styled.div`
display: flex;
@ -61,13 +55,13 @@ export const RightDrawerRouter = () => {
const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState);
return (
<StyledRightDrawerPage>
<RightDrawerContainer>
<RightDrawerTopBar />
{!isRightDrawerMinimized && (
<StyledRightDrawerBody>
{rightDrawerPageComponent}
</StyledRightDrawerBody>
)}
</StyledRightDrawerPage>
</RightDrawerContainer>
);
};

View File

@ -0,0 +1,32 @@
import { THEME_COMMON } from 'twenty-ui';
export const RIGHT_DRAWER_ANIMATION_VARIANTS = {
fullScreen: {
x: '0%',
width: '100%',
height: '100%',
bottom: '0',
top: '0',
},
normal: {
x: '0%',
width: THEME_COMMON.rightDrawerWidth,
height: '100%',
bottom: '0',
top: '0',
},
closed: {
x: '100%',
width: '0',
height: '100%',
bottom: '0',
top: 'auto',
},
minimized: {
x: '0%',
width: 220,
height: 41,
bottom: '0',
top: 'auto',
},
};

View File

@ -0,0 +1,4 @@
import { RIGHT_DRAWER_ANIMATION_VARIANTS } from '@/ui/layout/right-drawer/constants/RightDrawerAnimationVariants';
export type RightDrawerAnimationVariant =
keyof typeof RIGHT_DRAWER_ANIMATION_VARIANTS;

View File

@ -96,6 +96,7 @@ export const NavigationDrawerInput = ({
event.stopImmediatePropagation();
onClickOutside(event, value);
},
listenerId: 'navigation-drawer-input',
});
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {

View File

@ -1,12 +1,12 @@
import { fireEvent, renderHook } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { fireEvent, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import {
ClickOutsideMode,
useListenClickOutsideV2,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2';
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
const containerRef = React.createRef<HTMLDivElement>();
@ -19,13 +19,13 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
);
const listenerId = 'listenerId';
describe('useListenClickOutsideV2', () => {
describe('useListenClickOutside', () => {
it('should trigger the callback when clicking outside the specified refs', () => {
const callback = jest.fn();
renderHook(
() =>
useListenClickOutsideV2({
useListenClickOutside({
refs: [containerRef],
callback,
listenerId,
@ -46,7 +46,7 @@ describe('useListenClickOutsideV2', () => {
renderHook(
() =>
useListenClickOutsideV2({
useListenClickOutside({
refs: [nullRef],
callback,
mode: ClickOutsideMode.comparePixels,
@ -68,7 +68,7 @@ describe('useListenClickOutsideV2', () => {
renderHook(
() =>
useListenClickOutsideV2({
useListenClickOutside({
refs: [containerRef],
callback,
listenerId,
@ -91,7 +91,7 @@ describe('useListenClickOutsideV2', () => {
renderHook(
() =>
useListenClickOutsideV2({
useListenClickOutside({
refs: [containerRef],
callback,
mode: ClickOutsideMode.comparePixels,

View File

@ -1,78 +0,0 @@
import { fireEvent, renderHook } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { isDefined } from '~/utils/isDefined';
import {
ClickOutsideMode,
useListenClickOutside,
} from '../useListenClickOutside';
const containerRef = React.createRef<HTMLDivElement>();
const nullRef = React.createRef<HTMLDivElement>();
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<div ref={containerRef}>{children}</div>
);
describe('useListenClickOutside', () => {
it('should trigger the callback when clicking outside the specified refs', () => {
const callback = jest.fn();
renderHook(
() => useListenClickOutside({ refs: [containerRef], callback }),
{ 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 pixel comparison', () => {
const callback = jest.fn();
renderHook(
() =>
useListenClickOutside({
refs: [containerRef, nullRef],
callback,
mode: ClickOutsideMode.comparePixels,
}),
{ wrapper: Wrapper },
);
act(() => {
if (isDefined(containerRef.current)) {
fireEvent.mouseDown(containerRef.current);
fireEvent.click(containerRef.current);
}
});
expect(callback).not.toHaveBeenCalled();
});
it('should call the callback when clicking outside the specified refs using pixel comparison', () => {
const callback = jest.fn();
renderHook(() =>
useListenClickOutside({
refs: [containerRef, nullRef],
callback,
mode: ClickOutsideMode.comparePixels,
}),
);
act(() => {
// Simulate a click outside the specified refs
fireEvent.mouseDown(document.body);
fireEvent.click(document.body);
});
expect(callback).toHaveBeenCalled();
});
});

View File

@ -2,10 +2,7 @@ import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates';
import {
ClickOutsideListenerProps,
useListenClickOutsideV2,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2';
import { ClickOutsideListenerCallback } from '@/ui/utilities/pointer-event/types/ClickOutsideListenerCallback';
import { toSpliced } from '~/utils/array/toSpliced';
import { isDefined } from '~/utils/isDefined';
@ -17,35 +14,6 @@ export const useClickOutsideListener = (componentId: string) => {
getClickOutsideListenerMouseDownHappenedState,
} = useClickOustideListenerStates(componentId);
const useListenClickOutside = <T extends Element>({
callback,
refs,
enabled,
mode,
}: Omit<ClickOutsideListenerProps<T>, 'listenerId'>) => {
return useListenClickOutsideV2({
listenerId: componentId,
refs,
callback: useRecoilCallback(
({ snapshot }) =>
(event) => {
callback(event);
const additionalCallbacks = snapshot
.getLoadable(getClickOutsideListenerCallbacksState)
.getValue();
additionalCallbacks.forEach((additionalCallback) => {
additionalCallback.callbackFunction(event);
});
},
[callback],
),
enabled,
mode,
});
};
const toggleClickOutsideListener = useRecoilCallback(
({ set }) =>
(activated: boolean) => {
@ -152,7 +120,6 @@ export const useClickOutsideListener = (componentId: string) => {
};
return {
useListenClickOutside,
toggleClickOutsideListener,
useRegisterClickOutsideListenerCallback,
};

View File

@ -1,140 +1,266 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates';
export enum ClickOutsideMode {
comparePixels = 'comparePixels',
compareHTMLRef = 'compareHTMLRef',
}
export const useListenClickOutside = <T extends Element>({
refs,
callback,
mode = ClickOutsideMode.compareHTMLRef,
enabled = true,
}: {
export type ClickOutsideListenerProps<T extends Element> = {
refs: Array<React.RefObject<T>>;
excludeClassNames?: string[];
callback: (event: MouseEvent | TouchEvent) => void;
mode?: ClickOutsideMode;
listenerId: string;
hotkeyScope?: string;
enabled?: boolean;
}) => {
const [isMouseDownInside, setIsMouseDownInside] = useState(false);
};
export const useListenClickOutside = <T extends Element>({
refs,
excludeClassNames,
callback,
mode = ClickOutsideMode.compareHTMLRef,
listenerId,
hotkeyScope,
enabled = true,
}: ClickOutsideListenerProps<T>) => {
const {
getClickOutsideListenerIsMouseDownInsideState,
getClickOutsideListenerIsActivatedState,
getClickOutsideListenerMouseDownHappenedState,
} = useClickOustideListenerStates(listenerId);
const handleMouseDown = useRecoilCallback(
({ snapshot, set }) =>
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = snapshot
.getLoadable(getClickOutsideListenerIsActivatedState)
.getValue();
set(getClickOutsideListenerMouseDownHappenedState, true);
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
const isListeningBasedOnHotkeyScope = hotkeyScope
? currentHotkeyScopes.includes(hotkeyScope)
: true;
const isListening =
clickOutsideListenerIsActivated &&
enabled &&
isListeningBasedOnHotkeyScope;
if (!isListening) {
return;
}
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
set(
getClickOutsideListenerIsMouseDownInsideState,
clickedOnAtLeastOneRef,
);
}
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;
});
set(
getClickOutsideListenerIsMouseDownInsideState,
clickedOnAtLeastOneRef,
);
}
},
[
getClickOutsideListenerIsActivatedState,
getClickOutsideListenerMouseDownHappenedState,
hotkeyScope,
enabled,
mode,
refs,
getClickOutsideListenerIsMouseDownInsideState,
],
);
const handleClickOutside = useRecoilCallback(
({ snapshot }) =>
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = snapshot
.getLoadable(getClickOutsideListenerIsActivatedState)
.getValue();
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
const isListeningBasedOnHotkeyScope = hotkeyScope
? currentHotkeyScopes.includes(hotkeyScope)
: true;
const isListening =
clickOutsideListenerIsActivated &&
enabled &&
isListeningBasedOnHotkeyScope;
const isMouseDownInside = snapshot
.getLoadable(getClickOutsideListenerIsMouseDownInsideState)
.getValue();
const hasMouseDownHappened = snapshot
.getLoadable(getClickOutsideListenerMouseDownHappenedState)
.getValue();
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedElement = event.target as HTMLElement;
let isClickedOnExcluded = false;
let currentElement: HTMLElement | null = clickedElement;
while (currentElement) {
const currentClassList = currentElement.classList;
isClickedOnExcluded =
excludeClassNames?.some((className) =>
currentClassList.contains(className),
) ?? false;
if (isClickedOnExcluded) {
break;
}
currentElement = currentElement.parentElement;
}
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (
isListening &&
hasMouseDownHappened &&
!clickedOnAtLeastOneRef &&
!isMouseDownInside &&
!isClickedOnExcluded
) {
callback(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;
});
if (
!clickedOnAtLeastOneRef &&
!isMouseDownInside &&
isListening &&
hasMouseDownHappened
) {
callback(event);
}
}
},
[
getClickOutsideListenerIsActivatedState,
hotkeyScope,
enabled,
getClickOutsideListenerIsMouseDownInsideState,
getClickOutsideListenerMouseDownHappenedState,
mode,
refs,
excludeClassNames,
callback,
],
);
useEffect(() => {
const handleMouseDown = (event: MouseEvent | TouchEvent) => {
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
document.addEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.addEventListener('click', handleClickOutside, { capture: true });
document.addEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.addEventListener('touchend', handleClickOutside, {
capture: true,
});
setIsMouseDownInside(clickedOnAtLeastOneRef);
}
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;
});
setIsMouseDownInside(clickedOnAtLeastOneRef);
}
return () => {
document.removeEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.removeEventListener('click', handleClickOutside, {
capture: true,
});
document.removeEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.removeEventListener('touchend', handleClickOutside, {
capture: true,
});
};
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (!clickedOnAtLeastOneRef && !isMouseDownInside) {
callback(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;
});
if (!clickedOnAtLeastOneRef && !isMouseDownInside) {
callback(event);
}
}
};
if (enabled) {
document.addEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.addEventListener('click', handleClickOutside, { capture: true });
document.addEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.addEventListener('touchend', handleClickOutside, {
capture: true,
});
return () => {
document.removeEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.removeEventListener('click', handleClickOutside, {
capture: true,
});
document.removeEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.removeEventListener('touchend', handleClickOutside, {
capture: true,
});
};
}
}, [refs, callback, mode, enabled, isMouseDownInside]);
}, [refs, callback, mode, handleClickOutside, handleMouseDown]);
};

View File

@ -1,266 +0,0 @@
import React, { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates';
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;
hotkeyScope?: string;
enabled?: boolean;
};
export const useListenClickOutsideV2 = <T extends Element>({
refs,
excludeClassNames,
callback,
mode = ClickOutsideMode.compareHTMLRef,
listenerId,
hotkeyScope,
enabled = true,
}: ClickOutsideListenerProps<T>) => {
const {
getClickOutsideListenerIsMouseDownInsideState,
getClickOutsideListenerIsActivatedState,
getClickOutsideListenerMouseDownHappenedState,
} = useClickOustideListenerStates(listenerId);
const handleMouseDown = useRecoilCallback(
({ snapshot, set }) =>
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = snapshot
.getLoadable(getClickOutsideListenerIsActivatedState)
.getValue();
set(getClickOutsideListenerMouseDownHappenedState, true);
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
const isListeningBasedOnHotkeyScope = hotkeyScope
? currentHotkeyScopes.includes(hotkeyScope)
: true;
const isListening =
clickOutsideListenerIsActivated &&
enabled &&
isListeningBasedOnHotkeyScope;
if (!isListening) {
return;
}
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
set(
getClickOutsideListenerIsMouseDownInsideState,
clickedOnAtLeastOneRef,
);
}
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;
});
set(
getClickOutsideListenerIsMouseDownInsideState,
clickedOnAtLeastOneRef,
);
}
},
[
getClickOutsideListenerIsActivatedState,
getClickOutsideListenerMouseDownHappenedState,
hotkeyScope,
enabled,
mode,
refs,
getClickOutsideListenerIsMouseDownInsideState,
],
);
const handleClickOutside = useRecoilCallback(
({ snapshot }) =>
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = snapshot
.getLoadable(getClickOutsideListenerIsActivatedState)
.getValue();
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
const isListeningBasedOnHotkeyScope = hotkeyScope
? currentHotkeyScopes.includes(hotkeyScope)
: true;
const isListening =
clickOutsideListenerIsActivated &&
enabled &&
isListeningBasedOnHotkeyScope;
const isMouseDownInside = snapshot
.getLoadable(getClickOutsideListenerIsMouseDownInsideState)
.getValue();
const hasMouseDownHappened = snapshot
.getLoadable(getClickOutsideListenerMouseDownHappenedState)
.getValue();
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedElement = event.target as HTMLElement;
let isClickedOnExcluded = false;
let currentElement: HTMLElement | null = clickedElement;
while (currentElement) {
const currentClassList = currentElement.classList;
isClickedOnExcluded =
excludeClassNames?.some((className) =>
currentClassList.contains(className),
) ?? false;
if (isClickedOnExcluded) {
break;
}
currentElement = currentElement.parentElement;
}
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (
isListening &&
hasMouseDownHappened &&
!clickedOnAtLeastOneRef &&
!isMouseDownInside &&
!isClickedOnExcluded
) {
callback(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;
});
if (
!clickedOnAtLeastOneRef &&
!isMouseDownInside &&
isListening &&
hasMouseDownHappened
) {
callback(event);
}
}
},
[
getClickOutsideListenerIsActivatedState,
hotkeyScope,
enabled,
getClickOutsideListenerIsMouseDownInsideState,
getClickOutsideListenerMouseDownHappenedState,
mode,
refs,
excludeClassNames,
callback,
],
);
useEffect(() => {
document.addEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.addEventListener('click', handleClickOutside, { capture: true });
document.addEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.addEventListener('touchend', handleClickOutside, {
capture: true,
});
return () => {
document.removeEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.removeEventListener('click', handleClickOutside, {
capture: true,
});
document.removeEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.removeEventListener('touchend', handleClickOutside, {
capture: true,
});
};
}, [refs, callback, mode, handleClickOutside, handleMouseDown]);
};