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 :  - [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 : 
This commit is contained in:
@ -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(() => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>) => {
|
||||
|
||||
@ -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}
|
||||
`;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -168,7 +168,7 @@ export const Select = <Value extends SelectValue>({
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
{!!callToActionButton && (
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
<DropdownMenuItemsContainer hasMaxHeight withoutScrollWrapper>
|
||||
<MenuItem
|
||||
onClick={callToActionButton.onClick}
|
||||
LeftIcon={callToActionButton.Icon}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -32,7 +32,7 @@ export const CurrencyPickerDropdownSelect = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu width="240px" disableBlur>
|
||||
<DropdownMenu disableBlur>
|
||||
<DropdownMenuSearchInput
|
||||
value={searchFilter}
|
||||
onChange={(event) => setSearchFilter(event.target.value)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -18,7 +18,6 @@ const StyledHeader = styled.li`
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
|
||||
user-select: none;
|
||||
width: inherit;
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme, onClick }) =>
|
||||
|
||||
@ -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%);
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const activeDropdownFocusIdState = createState<string | null>({
|
||||
key: 'activeDropdownFocusIdState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const previousDropdownFocusIdState = createState<string | null>({
|
||||
key: 'previousDropdownFocusIdState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -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}
|
||||
|
||||
@ -46,8 +46,11 @@ export const ExpandedListDropdown = ({
|
||||
});
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [refs.floating],
|
||||
callback: onClickOutside ?? (() => {}),
|
||||
refs: [refs.domReference],
|
||||
callback: () => {
|
||||
onClickOutside?.();
|
||||
},
|
||||
listenerId: 'expandable-list',
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@ -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: () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
@ -96,6 +96,7 @@ export const NavigationDrawerInput = ({
|
||||
event.stopImmediatePropagation();
|
||||
onClickOutside(event, value);
|
||||
},
|
||||
listenerId: 'navigation-drawer-input',
|
||||
});
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@ -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,
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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]);
|
||||
};
|
||||
|
||||
@ -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]);
|
||||
};
|
||||
Reference in New Issue
Block a user