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

@ -1,5 +1,5 @@
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client'; import * as Apollo from '@apollo/client';
import { gql } from '@apollo/client';
export type Maybe<T> = T | null; export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>; export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };

View File

@ -14,7 +14,12 @@ import {
} from '@/action-menu/types/ActionMenuEntry'; } from '@/action-menu/types/ActionMenuEntry';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui'; import {
IconCheckbox,
IconHeart,
IconTrash,
getCanvasElementForDropdownTesting,
} from 'twenty-ui';
const deleteMock = jest.fn(); const deleteMock = jest.fn();
const markAsDoneMock = jest.fn(); const markAsDoneMock = jest.fn();
@ -107,7 +112,9 @@ export const WithInteractions: Story = {
args: { args: {
actionMenuId: 'story', actionMenuId: 'story',
}, },
play: async ({ canvasElement }) => { play: async () => {
const canvasElement = getCanvasElementForDropdownTesting();
const canvas = within(canvasElement); const canvas = within(canvasElement);
const deleteButton = await canvas.findByText('Delete'); const deleteButton = await canvas.findByText('Delete');

View File

@ -15,6 +15,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { userEvent, waitFor, within } from '@storybook/test'; import { userEvent, waitFor, within } from '@storybook/test';
import { import {
ComponentDecorator, ComponentDecorator,
getCanvasElementForDropdownTesting,
IconFileExport, IconFileExport,
IconHeart, IconHeart,
IconTrash, IconTrash,
@ -117,8 +118,8 @@ export const WithButtonClicks: Story = {
args: { args: {
actionMenuId: 'story-action-menu', actionMenuId: 'story-action-menu',
}, },
play: async ({ canvasElement }) => { play: async () => {
const canvas = within(canvasElement); const canvas = within(getCanvasElementForDropdownTesting());
let actionButton = await canvas.findByText('Actions'); let actionButton = await canvas.findByText('Actions');
await userEvent.click(actionButton); await userEvent.click(actionButton);

View File

@ -32,7 +32,7 @@ import { SelectableItem } from '@/ui/layout/selectable-list/components/Selectabl
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useListenClickOutsideV2 } from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -414,7 +414,7 @@ export const CommandMenu = () => {
cmd.scope === CommandScope.Global, cmd.scope === CommandScope.Global,
); );
useListenClickOutsideV2({ useListenClickOutside({
refs: [commandMenuRef], refs: [commandMenuRef],
callback: closeCommandMenu, callback: closeCommandMenu,
listenerId: 'COMMAND_MENU_LISTENER_ID', listenerId: 'COMMAND_MENU_LISTENER_ID',

View File

@ -16,7 +16,6 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
@ -167,43 +166,41 @@ export const ObjectFilterDropdownFilterSelect = ({
setObjectFilterDropdownSearchInput(event.target.value) setObjectFilterDropdownSearchInput(event.target.value)
} }
/> />
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer"> <SelectableList
<SelectableList hotkeyScope={FiltersHotkeyScope.ObjectFilterDropdownButton}
hotkeyScope={FiltersHotkeyScope.ObjectFilterDropdownButton} selectableItemIdArray={selectableListItemIds}
selectableItemIdArray={selectableListItemIds} selectableListId={OBJECT_FILTER_DROPDOWN_ID}
selectableListId={OBJECT_FILTER_DROPDOWN_ID} onEnter={handleEnter}
onEnter={handleEnter} >
> <DropdownMenuItemsContainer>
<DropdownMenuItemsContainer> {visibleColumnsFilterDefinitions.map(
{visibleColumnsFilterDefinitions.map( (visibleFilterDefinition, index) => (
(visibleFilterDefinition, index) => ( <SelectableItem
<SelectableItem itemId={visibleFilterDefinition.fieldMetadataId}
itemId={visibleFilterDefinition.fieldMetadataId} key={`visible-select-filter-${index}`}
key={`visible-select-filter-${index}`} >
> <ObjectFilterDropdownFilterSelectMenuItem
<ObjectFilterDropdownFilterSelectMenuItem filterDefinition={visibleFilterDefinition}
filterDefinition={visibleFilterDefinition} />
/> </SelectableItem>
</SelectableItem> ),
), )}
)} {shoudShowSeparator && <DropdownMenuSeparator />}
{shoudShowSeparator && <DropdownMenuSeparator />} {hiddenColumnsFilterDefinitions.map(
{hiddenColumnsFilterDefinitions.map( (hiddenFilterDefinition, index) => (
(hiddenFilterDefinition, index) => ( <SelectableItem
<SelectableItem itemId={hiddenFilterDefinition.fieldMetadataId}
itemId={hiddenFilterDefinition.fieldMetadataId} key={`hidden-select-filter-${index}`}
key={`hidden-select-filter-${index}`} >
> <ObjectFilterDropdownFilterSelectMenuItem
<ObjectFilterDropdownFilterSelectMenuItem filterDefinition={hiddenFilterDefinition}
filterDefinition={hiddenFilterDefinition} />
/> </SelectableItem>
</SelectableItem> ),
), )}
)} </DropdownMenuItemsContainer>
</DropdownMenuItemsContainer> </SelectableList>
</SelectableList> {shouldShowAdvancedFilterButton && <AdvancedFilterButton />}
{shouldShowAdvancedFilterButton && <AdvancedFilterButton />}
</ScrollWrapper>
</> </>
); );
}; };

View File

@ -28,9 +28,11 @@ export const ObjectFilterDropdownOperandDropdown = ({
const theme = useTheme(); const theme = useTheme();
const dropdownId = `${filterDropdownId}-operand-dropdown`;
return ( return (
<Dropdown <Dropdown
dropdownId={`${filterDropdownId}-operand-dropdown`} dropdownId={dropdownId}
clickableComponent={ clickableComponent={
<StyledDropdownMenuHeader <StyledDropdownMenuHeader
key={'selected-filter-operand'} key={'selected-filter-operand'}

View File

@ -15,7 +15,6 @@ import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/inter
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { MenuItem, MenuItemMultiSelect } from 'twenty-ui'; import { MenuItem, MenuItemMultiSelect } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
@ -163,23 +162,21 @@ export const ObjectFilterDropdownOptionSelect = () => {
} }
}} }}
> >
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer"> <DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuItemsContainer hasMaxHeight> {optionsInDropdown?.map((option) => (
{optionsInDropdown?.map((option) => ( <MenuItemMultiSelect
<MenuItemMultiSelect key={option.id}
key={option.id} selected={option.isSelected}
selected={option.isSelected} isKeySelected={option.id === selectedItemId}
isKeySelected={option.id === selectedItemId} onSelectChange={(selected) =>
onSelectChange={(selected) => handleMultipleOptionSelectChange(option, selected)
handleMultipleOptionSelectChange(option, selected) }
} text={option.label}
text={option.label} color={option.color}
color={option.color} className=""
className="" />
/> ))}
))} </DropdownMenuItemsContainer>
</DropdownMenuItemsContainer>
</ScrollWrapper>
{showNoResult && <MenuItem text="No result" />} {showNoResult && <MenuItem text="No result" />}
</SelectableList> </SelectableList>
); );

View File

@ -11,7 +11,10 @@ import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-sta
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { within } from '@storybook/test'; import { within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui'; import {
ComponentDecorator,
getCanvasElementForDropdownTesting,
} from 'twenty-ui';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator'; import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
@ -120,7 +123,7 @@ type Story = StoryObj<typeof TaskGroups>;
export const Default: Story = { export const Default: Story = {
play: async () => { play: async () => {
const canvas = within(document.body); const canvas = within(getCanvasElementForDropdownTesting());
const filterButton = await canvas.findByText('Filter'); const filterButton = await canvas.findByText('Filter');
@ -142,7 +145,7 @@ export const Default: Story = {
export const Date: Story = { export const Date: Story = {
play: async () => { play: async () => {
const canvas = within(document.body); const canvas = within(getCanvasElementForDropdownTesting());
const filterButton = await canvas.findByText('Filter'); const filterButton = await canvas.findByText('Filter');
@ -156,7 +159,7 @@ export const Date: Story = {
export const Number: Story = { export const Number: Story = {
play: async () => { play: async () => {
const canvas = within(document.body); const canvas = within(getCanvasElementForDropdownTesting());
const filterButton = await canvas.findByText('Filter'); const filterButton = await canvas.findByText('Filter');

View File

@ -6,7 +6,6 @@ import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hook
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
@ -53,19 +52,17 @@ export const ObjectOptionsDropdownFieldsContent = () => {
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}> <DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
Fields Fields
</DropdownMenuHeader> </DropdownMenuHeader>
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer"> <ViewFieldsVisibilityDropdownSection
<ViewFieldsVisibilityDropdownSection title="Visible"
title="Visible" fields={visibleRecordFields}
fields={visibleRecordFields} isDraggable
isDraggable onDragEnd={handleReorderFields}
onDragEnd={handleReorderFields} onVisibilityChange={handleChangeFieldVisibility}
onVisibilityChange={handleChangeFieldVisibility} showSubheader={false}
showSubheader={false} showDragGrip={true}
showDragGrip={true} />
/>
</ScrollWrapper>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer withoutScrollWrapper>
<MenuItemNavigate <MenuItemNavigate
onClick={() => onContentChange('hiddenFields')} onClick={() => onContentChange('hiddenFields')}
LeftIcon={IconEyeOff} LeftIcon={IconEyeOff}

View File

@ -18,7 +18,6 @@ import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenu
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
@ -71,19 +70,16 @@ export const ObjectOptionsDropdownHiddenFieldsContent = () => {
Hidden Fields Hidden Fields
</DropdownMenuHeader> </DropdownMenuHeader>
{hiddenRecordFields.length > 0 && ( {hiddenRecordFields.length > 0 && (
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer"> <ViewFieldsVisibilityDropdownSection
<ViewFieldsVisibilityDropdownSection title="Hidden"
title="Hidden" fields={hiddenRecordFields}
fields={hiddenRecordFields} isDraggable={false}
isDraggable={false} onVisibilityChange={handleChangeFieldVisibility}
onVisibilityChange={handleChangeFieldVisibility} showSubheader={false}
showSubheader={false} showDragGrip={false}
showDragGrip={false} />
/>
</ScrollWrapper>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<UndecoratedLink <UndecoratedLink
to={settingsUrl} to={settingsUrl}
onClick={() => { onClick={() => {
@ -91,7 +87,7 @@ export const ObjectOptionsDropdownHiddenFieldsContent = () => {
closeDropdown(); closeDropdown();
}} }}
> >
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer withoutScrollWrapper>
<MenuItem LeftIcon={IconSettings} text="Edit Fields" /> <MenuItem LeftIcon={IconSettings} text="Edit Fields" />
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
</UndecoratedLink> </UndecoratedLink>

View File

@ -15,7 +15,6 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton'; import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useContext } from 'react'; import { useContext } from 'react';
import { SORT_DIRECTIONS } from '../types/SortDirection'; import { SORT_DIRECTIONS } from '../types/SortDirection';
@ -184,39 +183,37 @@ export const ObjectSortDropdownButton = ({
setObjectSortDropdownSearchInput(event.target.value) setObjectSortDropdownSearchInput(event.target.value)
} }
/> />
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer"> <DropdownMenuItemsContainer>
<DropdownMenuItemsContainer> {visibleColumnsSortDefinitions.map(
{visibleColumnsSortDefinitions.map( (visibleSortDefinition, index) => (
(visibleSortDefinition, index) => ( <MenuItem
<MenuItem testId={`visible-select-sort-${index}`}
testId={`visible-select-sort-${index}`} key={index}
key={index} onClick={() => {
onClick={() => { setObjectSortDropdownSearchInput('');
setObjectSortDropdownSearchInput(''); handleAddSort(visibleSortDefinition);
handleAddSort(visibleSortDefinition); }}
}} LeftIcon={getIcon(visibleSortDefinition.iconName)}
LeftIcon={getIcon(visibleSortDefinition.iconName)} text={visibleSortDefinition.label}
text={visibleSortDefinition.label} />
/> ),
), )}
)} {shoudShowSeparator && <DropdownMenuSeparator />}
{shoudShowSeparator && <DropdownMenuSeparator />} {hiddenColumnsSortDefinitions.map(
{hiddenColumnsSortDefinitions.map( (hiddenSortDefinition, index) => (
(hiddenSortDefinition, index) => ( <MenuItem
<MenuItem testId={`hidden-select-sort-${index}`}
testId={`hidden-select-sort-${index}`} key={index}
key={index} onClick={() => {
onClick={() => { setObjectSortDropdownSearchInput('');
setObjectSortDropdownSearchInput(''); handleAddSort(hiddenSortDefinition);
handleAddSort(hiddenSortDefinition); }}
}} LeftIcon={getIcon(hiddenSortDefinition.iconName)}
LeftIcon={getIcon(hiddenSortDefinition.iconName)} text={hiddenSortDefinition.label}
text={hiddenSortDefinition.label} />
/> ),
), )}
)} </DropdownMenuItemsContainer>
</DropdownMenuItemsContainer>
</ScrollWrapper>
</> </>
} }
onClose={handleDropdownButtonClose} onClose={handleDropdownButtonClose}

View File

@ -22,7 +22,7 @@ import { recordStoreFamilyState } from '@/object-record/record-store/states/reco
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutsideV2 } from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
@ -81,7 +81,7 @@ export const RecordBoard = () => {
const { resetRecordSelection, setRecordAsSelected } = const { resetRecordSelection, setRecordAsSelected } =
useRecordBoardSelection(recordBoardId); useRecordBoardSelection(recordBoardId);
useListenClickOutsideV2({ useListenClickOutside({
excludeClassNames: [ excludeClassNames: [
'bottom-bar', 'bottom-bar',
'action-menu-dropdown', 'action-menu-dropdown',

View File

@ -34,6 +34,7 @@ export const RecordBoardColumnDropdownMenu = ({
useListenClickOutside({ useListenClickOutside({
refs: [boardColumnMenuRef], refs: [boardColumnMenuRef],
callback: closeMenu, callback: closeMenu,
listenerId: 'record-board-column-dropdown-menu',
}); });
return ( return (

View File

@ -48,7 +48,10 @@ type FieldInputProps = {
recordFieldInputdId: string; recordFieldInputdId: string;
onSubmit?: FieldInputEvent; onSubmit?: FieldInputEvent;
onCancel?: () => void; onCancel?: () => void;
onClickOutside?: FieldInputEvent; onClickOutside?: (
persist: () => void,
event: MouseEvent | TouchEvent,
) => void;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onTab?: FieldInputEvent; onTab?: FieldInputEvent;
@ -78,7 +81,10 @@ export const FieldInput = ({
) : isFieldRelationFromManyObjects(fieldDefinition) ? ( ) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldInput onSubmit={onSubmit} /> <RelationFromManyFieldInput onSubmit={onSubmit} />
) : isFieldPhones(fieldDefinition) ? ( ) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldInput onCancel={onCancel} /> <PhonesFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldText(fieldDefinition) ? ( ) : isFieldText(fieldDefinition) ? (
<TextFieldInput <TextFieldInput
onEnter={onEnter} onEnter={onEnter}
@ -88,7 +94,10 @@ export const FieldInput = ({
onShiftTab={onShiftTab} onShiftTab={onShiftTab}
/> />
) : isFieldEmails(fieldDefinition) ? ( ) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldInput onCancel={onCancel} /> <EmailsFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldFullName(fieldDefinition) ? ( ) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput <FullNameFieldInput
onEnter={onEnter} onEnter={onEnter}
@ -122,7 +131,10 @@ export const FieldInput = ({
onShiftTab={onShiftTab} onShiftTab={onShiftTab}
/> />
) : isFieldLinks(fieldDefinition) ? ( ) : isFieldLinks(fieldDefinition) ? (
<LinksFieldInput onCancel={onCancel} /> <LinksFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldCurrency(fieldDefinition) ? ( ) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldInput <CurrencyFieldInput
onEnter={onEnter} onEnter={onEnter}
@ -158,7 +170,10 @@ export const FieldInput = ({
) : isFieldRichText(fieldDefinition) ? ( ) : isFieldRichText(fieldDefinition) ? (
<RichTextFieldInput /> <RichTextFieldInput />
) : isFieldArray(fieldDefinition) ? ( ) : isFieldArray(fieldDefinition) ? (
<ArrayFieldInput onCancel={onCancel} /> <ArrayFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : ( ) : (
<></> <></>
)} )}

View File

@ -108,9 +108,6 @@ export const FormSelectFieldInput = ({
goBackToPreviousHotkeyScope(); goBackToPreviousHotkeyScope();
}; };
const [selectWrapperRef, setSelectWrapperRef] =
useState<HTMLDivElement | null>(null);
const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([]); const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([]);
const { resetSelectedItem } = useSelectableList( const { resetSelectedItem } = useSelectableList(
@ -206,7 +203,6 @@ export const FormSelectFieldInput = ({
<StyledFormFieldInputRowContainer> <StyledFormFieldInputRowContainer>
<StyledFormFieldInputInputContainer <StyledFormFieldInputInputContainer
ref={setSelectWrapperRef}
hasRightElement={isDefined(VariablePicker)} hasRightElement={isDefined(VariablePicker)}
> >
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (
@ -231,7 +227,6 @@ export const FormSelectFieldInput = ({
selectableItemIdArray={optionIds} selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope} hotkeyScope={hotkeyScope}
onEnter={handleSelectEnter} onEnter={handleSelectEnter}
selectWrapperRef={selectWrapperRef}
onOptionSelected={handleSubmit} onOptionSelected={handleSubmit}
options={field.metadata.options} options={field.metadata.options}
onCancel={onCancel} onCancel={onCancel}

View File

@ -4,10 +4,13 @@ import { AddressInput } from '@/ui/field/input/components/AddressInput';
import { usePersistField } from '../../../hooks/usePersistField'; import { usePersistField } from '../../../hooks/usePersistField';
import { FieldInputEvent } from './DateTimeFieldInput'; import {
FieldInputClickOutsideEvent,
FieldInputEvent,
} from './DateTimeFieldInput';
export type AddressFieldInputProps = { export type AddressFieldInputProps = {
onClickOutside?: FieldInputEvent; onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onTab?: FieldInputEvent; onTab?: FieldInputEvent;
@ -60,7 +63,7 @@ export const AddressFieldInput = ({
event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
newAddress: FieldAddressDraftValue, newAddress: FieldAddressDraftValue,
) => { ) => {
onClickOutside?.(() => persistField(convertToAddress(newAddress))); onClickOutside?.(() => persistField(convertToAddress(newAddress)), event);
}; };
const handleChange = (newAddress: FieldAddressDraftValue) => { const handleChange = (newAddress: FieldAddressDraftValue) => {

View File

@ -6,9 +6,13 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
type ArrayFieldInputProps = { type ArrayFieldInputProps = {
onCancel?: () => void; onCancel?: () => void;
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
}; };
export const ArrayFieldInput = ({ onCancel }: ArrayFieldInputProps) => { export const ArrayFieldInput = ({
onCancel,
onClickOutside,
}: ArrayFieldInputProps) => {
const { persistArrayField, hotkeyScope, fieldValue } = useArrayField(); const { persistArrayField, hotkeyScope, fieldValue } = useArrayField();
const arrayItems = useMemo<Array<string>>( const arrayItems = useMemo<Array<string>>(
@ -23,6 +27,7 @@ export const ArrayFieldInput = ({ onCancel }: ArrayFieldInputProps) => {
items={arrayItems} items={arrayItems}
onPersist={persistArrayField} onPersist={persistArrayField}
onCancel={onCancel} onCancel={onCancel}
onClickOutside={onClickOutside}
placeholder="Enter value" placeholder="Enter value"
fieldMetadataType={FieldMetadataType.Array} fieldMetadataType={FieldMetadataType.Array}
renderItem={({ value, index, handleEdit, handleDelete }) => ( renderItem={({ value, index, handleEdit, handleDelete }) => (

View File

@ -8,10 +8,13 @@ import { FieldInputOverlay } from '../../../../../ui/field/input/components/Fiel
import { useCurrencyField } from '../../hooks/useCurrencyField'; import { useCurrencyField } from '../../hooks/useCurrencyField';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { FieldInputEvent } from './DateTimeFieldInput'; import {
FieldInputClickOutsideEvent,
FieldInputEvent,
} from './DateTimeFieldInput';
type CurrencyFieldInputProps = { type CurrencyFieldInputProps = {
onClickOutside?: FieldInputEvent; onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onTab?: FieldInputEvent; onTab?: FieldInputEvent;
@ -79,7 +82,7 @@ export const CurrencyFieldInput = ({
amountText: newValue, amountText: newValue,
currencyCode, currencyCode,
}); });
}); }, event);
}; };
const handleTab = (newValue: string) => { const handleTab = (newValue: string) => {

View File

@ -5,11 +5,12 @@ import { DateInput } from '@/ui/field/input/components/DateInput';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { usePersistField } from '../../../hooks/usePersistField'; import { usePersistField } from '../../../hooks/usePersistField';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/meta-types/input/components/DateTimeFieldInput';
type FieldInputEvent = (persist: () => void) => void; type FieldInputEvent = (persist: () => void) => void;
type DateFieldInputProps = { type DateFieldInputProps = {
onClickOutside?: FieldInputEvent; onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onClear?: FieldInputEvent; onClear?: FieldInputEvent;
@ -50,10 +51,10 @@ export const DateFieldInput = ({
}; };
const handleClickOutside = ( const handleClickOutside = (
_event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
newDate: Nullable<Date>, newDate: Nullable<Date>,
) => { ) => {
onClickOutside?.(() => persistDate(newDate)); onClickOutside?.(() => persistDate(newDate), event);
}; };
const handleChange = (newDate: Nullable<Date>) => { const handleChange = (newDate: Nullable<Date>) => {

View File

@ -6,9 +6,13 @@ import { usePersistField } from '../../../hooks/usePersistField';
import { useDateTimeField } from '../../hooks/useDateTimeField'; import { useDateTimeField } from '../../hooks/useDateTimeField';
export type FieldInputEvent = (persist: () => void) => void; export type FieldInputEvent = (persist: () => void) => void;
export type FieldInputClickOutsideEvent = (
persist: () => void,
event: MouseEvent | TouchEvent,
) => void;
export type DateTimeFieldInputProps = { export type DateTimeFieldInputProps = {
onClickOutside?: FieldInputEvent; onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onClear?: FieldInputEvent; onClear?: FieldInputEvent;
@ -45,10 +49,10 @@ export const DateTimeFieldInput = ({
}; };
const handleClickOutside = ( const handleClickOutside = (
_event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
newDate: Nullable<Date>, newDate: Nullable<Date>,
) => { ) => {
onClickOutside?.(() => persistDate(newDate)); onClickOutside?.(() => persistDate(newDate), event);
}; };
const handleChange = (newDate: Nullable<Date>) => { const handleChange = (newDate: Nullable<Date>) => {

View File

@ -8,9 +8,13 @@ import { MultiItemFieldInput } from './MultiItemFieldInput';
type EmailsFieldInputProps = { type EmailsFieldInputProps = {
onCancel?: () => void; onCancel?: () => void;
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
}; };
export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => { export const EmailsFieldInput = ({
onCancel,
onClickOutside,
}: EmailsFieldInputProps) => {
const { persistEmailsField, hotkeyScope, fieldValue } = useEmailsField(); const { persistEmailsField, hotkeyScope, fieldValue } = useEmailsField();
const emails = useMemo<string[]>( const emails = useMemo<string[]>(
@ -45,6 +49,7 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
items={emails} items={emails}
onPersist={handlePersistEmails} onPersist={handlePersistEmails}
onCancel={onCancel} onCancel={onCancel}
onClickOutside={onClickOutside}
placeholder="Email" placeholder="Email"
fieldMetadataType={FieldMetadataType.Emails} fieldMetadataType={FieldMetadataType.Emails}
validateInput={validateInput} validateInput={validateInput}

View File

@ -4,7 +4,10 @@ import { DoubleTextInput } from '@/ui/field/input/components/DoubleTextInput';
import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay'; import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay';
import { isDoubleTextFieldEmpty } from '@/object-record/record-field/meta-types/input/utils/isDoubleTextFieldEmpty'; import { isDoubleTextFieldEmpty } from '@/object-record/record-field/meta-types/input/utils/isDoubleTextFieldEmpty';
import { FieldInputEvent } from './DateTimeFieldInput'; import {
FieldInputClickOutsideEvent,
FieldInputEvent,
} from './DateTimeFieldInput';
const FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS = const FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS =
'First name'; 'First name';
@ -13,7 +16,7 @@ const LAST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS =
'Last name'; 'Last name';
type FullNameFieldInputProps = { type FullNameFieldInputProps = {
onClickOutside?: FieldInputEvent; onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onTab?: FieldInputEvent; onTab?: FieldInputEvent;
@ -57,8 +60,9 @@ export const FullNameFieldInput = ({
event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
newDoubleText: FieldDoubleText, newDoubleText: FieldDoubleText,
) => { ) => {
onClickOutside?.(() => onClickOutside?.(
persistFullNameField(convertToFullName(newDoubleText)), () => persistFullNameField(convertToFullName(newDoubleText)),
event,
); );
}; };

View File

@ -8,9 +8,13 @@ import { MultiItemFieldInput } from './MultiItemFieldInput';
type LinksFieldInputProps = { type LinksFieldInputProps = {
onCancel?: () => void; onCancel?: () => void;
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
}; };
export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => { export const LinksFieldInput = ({
onCancel,
onClickOutside,
}: LinksFieldInputProps) => {
const { persistLinksField, hotkeyScope, fieldValue } = useLinksField(); const { persistLinksField, hotkeyScope, fieldValue } = useLinksField();
const links = useMemo<{ url: string; label: string }[]>( const links = useMemo<{ url: string; label: string }[]>(
@ -49,6 +53,7 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
items={links} items={links}
onPersist={handlePersistLinks} onPersist={handlePersistLinks}
onCancel={onCancel} onCancel={onCancel}
onClickOutside={onClickOutside}
placeholder="URL" placeholder="URL"
fieldMetadataType={FieldMetadataType.Links} fieldMetadataType={FieldMetadataType.Links}
validateInput={(input) => ({ validateInput={(input) => ({

View File

@ -41,6 +41,7 @@ type MultiItemFieldInputProps<T> = {
newItemLabel?: string; newItemLabel?: string;
fieldMetadataType: FieldMetadataType; fieldMetadataType: FieldMetadataType;
renderInput?: DropdownMenuInputProps['renderInput']; renderInput?: DropdownMenuInputProps['renderInput'];
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
}; };
// Todo: the API of this component does not look healthy: we have renderInput, renderItem, formatInput, ... // Todo: the API of this component does not look healthy: we have renderInput, renderItem, formatInput, ...
@ -57,24 +58,19 @@ export const MultiItemFieldInput = <T,>({
newItemLabel, newItemLabel,
fieldMetadataType, fieldMetadataType,
renderInput, renderInput,
onClickOutside,
}: MultiItemFieldInputProps<T>) => { }: MultiItemFieldInputProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const handleDropdownClose = () => { const handleDropdownClose = () => {
onCancel?.(); onCancel?.();
}; };
const handleDropdownCloseOutside = (event: MouseEvent | TouchEvent) => {
event.stopImmediatePropagation();
if (inputValue?.trim().length > 0) {
handleSubmitInput();
} else {
onCancel?.();
}
};
useListenClickOutside({ useListenClickOutside({
refs: [containerRef], refs: [containerRef],
callback: handleDropdownCloseOutside, callback: (event) => {
onClickOutside?.(event);
},
listenerId: hotkeyScope,
}); });
useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope); useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope);

View File

@ -1,7 +1,7 @@
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItemWithOptionDropdown } from '@/ui/navigation/menu-item/components/MenuItemWithOptionDropdown'; import { MenuItemWithOptionDropdown } from '@/ui/navigation/menu-item/components/MenuItemWithOptionDropdown';
import { useState } from 'react'; import React, { useState } from 'react';
import { import {
IconBookmark, IconBookmark,
IconBookmarkPlus, IconBookmarkPlus,
@ -37,23 +37,29 @@ export const MultiItemFieldMenuItem = <T,>({
const handleMouseEnter = () => setIsHovered(true); const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = () => { const handleMouseLeave = () => {
setIsHovered(false); setIsHovered(false);
if (isDropdownOpen) {
closeDropdown();
}
}; };
const handleDeleteClick = () => { const handleDeleteClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
closeDropdown(); closeDropdown();
setIsHovered(false); setIsHovered(false);
onDelete?.(); onDelete?.();
}; };
const handleSetAsPrimaryClick = () => { const handleSetAsPrimaryClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
closeDropdown(); closeDropdown();
onSetAsPrimary?.(); onSetAsPrimary?.();
}; };
const handleEditClick = () => { const handleEditClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
closeDropdown(); closeDropdown();
onEdit?.(); onEdit?.();
}; };

View File

@ -88,6 +88,7 @@ export const MultiSelectFieldInput = ({
} }
resetSelectedItem(); resetSelectedItem();
}, },
listenerId: 'MultiSelectFieldInput',
}); });
const optionIds = filteredOptionsInDropDown.map((option) => option.value); const optionIds = filteredOptionsInDropDown.map((option) => option.value);

View File

@ -2,11 +2,12 @@ import { TextInput } from '@/ui/field/input/components/TextInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay'; import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
import { useNumberField } from '../../hooks/useNumberField'; import { useNumberField } from '../../hooks/useNumberField';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/meta-types/input/components/DateTimeFieldInput';
export type FieldInputEvent = (persist: () => void) => void; export type FieldInputEvent = (persist: () => void) => void;
export type NumberFieldInputProps = { export type NumberFieldInputProps = {
onClickOutside?: FieldInputEvent; onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onTab?: FieldInputEvent; onTab?: FieldInputEvent;
@ -40,7 +41,7 @@ export const NumberFieldInput = ({
event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
newText: string, newText: string,
) => { ) => {
onClickOutside?.(() => persistNumberField(newText)); onClickOutside?.(() => persistNumberField(newText), event);
}; };
const handleTab = (newText: string) => { const handleTab = (newText: string) => {

View File

@ -49,10 +49,14 @@ const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
type PhonesFieldInputProps = { type PhonesFieldInputProps = {
onCancel?: () => void; onCancel?: () => void;
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
}; };
export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => { export const PhonesFieldInput = ({
const { persistPhonesField, hotkeyScope, fieldDefinition, fieldValue } = onCancel,
onClickOutside,
}: PhonesFieldInputProps) => {
const { persistPhonesField, hotkeyScope, fieldValue, fieldDefinition } =
usePhonesField(); usePhonesField();
const phones = createPhonesFromFieldValue(fieldValue); const phones = createPhonesFromFieldValue(fieldValue);
@ -83,6 +87,7 @@ export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => {
<MultiItemFieldInput <MultiItemFieldInput
items={phones} items={phones}
onPersist={handlePersistPhones} onPersist={handlePersistPhones}
onClickOutside={onClickOutside}
onCancel={onCancel} onCancel={onCancel}
placeholder="Phone" placeholder="Phone"
fieldMetadataType={FieldMetadataType.Phones} fieldMetadataType={FieldMetadataType.Phones}

View File

@ -3,10 +3,13 @@ import { TextAreaInput } from '@/ui/field/input/components/TextAreaInput';
import { useJsonField } from '../../hooks/useJsonField'; import { useJsonField } from '../../hooks/useJsonField';
import { FieldInputEvent } from './DateTimeFieldInput'; import {
FieldInputClickOutsideEvent,
FieldInputEvent,
} from './DateTimeFieldInput';
type RawJsonFieldInputProps = { type RawJsonFieldInputProps = {
onClickOutside?: FieldInputEvent; onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onTab?: FieldInputEvent; onTab?: FieldInputEvent;
@ -37,10 +40,10 @@ export const RawJsonFieldInput = ({
}; };
const handleClickOutside = ( const handleClickOutside = (
_event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
newText: string, newText: string,
) => { ) => {
onClickOutside?.(() => persistJsonField(newText)); onClickOutside?.(() => persistJsonField(newText), event);
}; };
const handleTab = (newText: string) => { const handleTab = (newText: string) => {

View File

@ -21,8 +21,6 @@ export const SelectFieldInput = ({
}: SelectFieldInputProps) => { }: SelectFieldInputProps) => {
const { persistField, fieldDefinition, fieldValue, hotkeyScope } = const { persistField, fieldDefinition, fieldValue, hotkeyScope } =
useSelectField(); useSelectField();
const [selectWrapperRef, setSelectWrapperRef] =
useState<HTMLDivElement | null>(null);
const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([]); const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([]);
@ -62,7 +60,7 @@ export const SelectFieldInput = ({
]; ];
return ( return (
<div ref={setSelectWrapperRef}> <div>
<SelectInput <SelectInput
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST} selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
selectableItemIdArray={optionIds} selectableItemIdArray={optionIds}
@ -76,7 +74,6 @@ export const SelectFieldInput = ({
resetSelectedItem(); resetSelectedItem();
} }
}} }}
selectWrapperRef={selectWrapperRef}
onOptionSelected={handleSubmit} onOptionSelected={handleSubmit}
options={fieldDefinition.metadata.options} options={fieldDefinition.metadata.options}
onCancel={onCancel} onCancel={onCancel}

View File

@ -5,10 +5,13 @@ import { usePersistField } from '../../../hooks/usePersistField';
import { useTextField } from '../../hooks/useTextField'; import { useTextField } from '../../hooks/useTextField';
import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndefinedIfWhitespacesOnly'; import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndefinedIfWhitespacesOnly';
import { FieldInputEvent } from './DateTimeFieldInput'; import {
FieldInputClickOutsideEvent,
FieldInputEvent,
} from './DateTimeFieldInput';
export type TextFieldInputProps = { export type TextFieldInputProps = {
onClickOutside?: FieldInputEvent; onClickOutside?: FieldInputClickOutsideEvent;
onEnter?: FieldInputEvent; onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent; onEscape?: FieldInputEvent;
onTab?: FieldInputEvent; onTab?: FieldInputEvent;
@ -38,7 +41,7 @@ export const TextFieldInput = ({
event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
newText: string, newText: string,
) => { ) => {
onClickOutside?.(() => persistField(newText.trim())); onClickOutside?.(() => persistField(newText.trim()), event);
}; };
const handleTab = (newText: string) => { const handleTab = (newText: string) => {

View File

@ -1,12 +1,5 @@
import { Decorator, Meta, StoryObj } from '@storybook/react'; import { Decorator, Meta, StoryObj } from '@storybook/react';
import { import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
expect,
fireEvent,
fn,
userEvent,
waitFor,
within,
} from '@storybook/test';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
@ -26,6 +19,7 @@ import {
import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider'; import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext'; import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
import { getCanvasElementForDropdownTesting } from 'twenty-ui';
import { import {
RelationToOneFieldInput, RelationToOneFieldInput,
RelationToOneFieldInputProps, RelationToOneFieldInputProps,
@ -136,8 +130,8 @@ export const Default: Story = {
export const Submit: Story = { export const Submit: Story = {
decorators: [ComponentWithRecoilScopeDecorator], decorators: [ComponentWithRecoilScopeDecorator],
play: async ({ canvasElement }) => { play: async () => {
const canvas = within(canvasElement); const canvas = within(getCanvasElementForDropdownTesting());
expect(submitJestFn).toHaveBeenCalledTimes(0); expect(submitJestFn).toHaveBeenCalledTimes(0);
@ -154,16 +148,16 @@ export const Submit: Story = {
export const Cancel: Story = { export const Cancel: Story = {
decorators: [ComponentWithRecoilScopeDecorator], decorators: [ComponentWithRecoilScopeDecorator],
play: async ({ canvasElement }) => { play: async () => {
const canvas = within(canvasElement); const canvas = within(getCanvasElementForDropdownTesting());
expect(cancelJestFn).toHaveBeenCalledTimes(0); expect(cancelJestFn).toHaveBeenCalledTimes(0);
await canvas.findByText('John Wick', undefined, { timeout: 3000 }); await canvas.findByText('John Wick', undefined, { timeout: 3000 });
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');
fireEvent.click(emptyDiv);
await waitFor(() => { await waitFor(() => {
userEvent.click(emptyDiv);
expect(cancelJestFn).toHaveBeenCalledTimes(1); expect(cancelJestFn).toHaveBeenCalledTimes(1);
}); });
}, },

View File

@ -28,10 +28,10 @@ export const useRegisterInputEvents = <T>({
useListenClickOutside({ useListenClickOutside({
refs: [inputRef, copyRef].filter(isDefined), refs: [inputRef, copyRef].filter(isDefined),
callback: (event) => { callback: (event) => {
event.stopImmediatePropagation();
onClickOutside?.(event, inputValue); onClickOutside?.(event, inputValue);
}, },
enabled: isDefined(onClickOutside), enabled: isDefined(onClickOutside),
listenerId: hotkeyScope,
}); });
useScopedHotkeys( useScopedHotkeys(

View File

@ -14,7 +14,11 @@ import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types
import { useInlineCell } from '../hooks/useInlineCell'; import { useInlineCell } from '../hooks/useInlineCell';
import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly'; import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/meta-types/input/components/DateTimeFieldInput';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId'; import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { useRecoilCallback } from 'recoil';
import { RecordInlineCellContainer } from './RecordInlineCellContainer'; import { RecordInlineCellContainer } from './RecordInlineCellContainer';
import { import {
RecordInlineCellContext, RecordInlineCellContext,
@ -65,10 +69,30 @@ export const RecordInlineCell = ({ loading }: RecordInlineCellProps) => {
closeInlineCell(); closeInlineCell();
}; };
const handleClickOutside: FieldInputEvent = (persistField) => { const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback(
persistField(); ({ snapshot }) =>
closeInlineCell(); (persistField, event) => {
}; const recordFieldDropdownId = getDropdownFocusIdForRecordField(
recordId,
fieldDefinition.fieldMetadataId,
'inline-cell',
);
const activeDropdownFocusId = snapshot
.getLoadable(activeDropdownFocusIdState)
.getValue();
if (recordFieldDropdownId !== activeDropdownFocusId) {
return;
}
event.stopImmediatePropagation();
persistField();
closeInlineCell();
},
[closeInlineCell, fieldDefinition.fieldMetadataId, recordId],
);
const { getIcon } = useIcons(); const { getIcon } = useIcons();

View File

@ -18,11 +18,10 @@ const StyledInlineCellInput = styled.div`
display: flex; display: flex;
min-height: 32px; min-height: 32px;
min-width: 240px;
width: inherit; width: 240px;
z-index: 1000; z-index: 30;
`; `;
type RecordInlineCellEditModeProps = { type RecordInlineCellEditModeProps = {

View File

@ -7,6 +7,9 @@ import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { useInitDraftValueV2 } from '@/object-record/record-field/hooks/useInitDraftValueV2'; import { useInitDraftValueV2 } from '@/object-record/record-field/hooks/useInitDraftValueV2';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { isInlineCellInEditModeScopedState } from '../states/isInlineCellInEditModeScopedState'; import { isInlineCellInEditModeScopedState } from '../states/isInlineCellInEditModeScopedState';
import { InlineCellHotkeyScope } from '../types/InlineCellHotkeyScope'; import { InlineCellHotkeyScope } from '../types/InlineCellHotkeyScope';
@ -21,6 +24,11 @@ export const useInlineCell = () => {
isInlineCellInEditModeScopedState(recoilScopeId), isInlineCellInEditModeScopedState(recoilScopeId),
); );
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const { const {
setHotkeyScopeAndMemorizePreviousScope, setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope, goBackToPreviousHotkeyScope,
@ -32,6 +40,8 @@ export const useInlineCell = () => {
setIsInlineCellInEditMode(false); setIsInlineCellInEditMode(false);
goBackToPreviousHotkeyScope(); goBackToPreviousHotkeyScope();
goBackToPreviousDropdownFocusId();
}; };
const openInlineCell = (customEditHotkeyScopeForField?: HotkeyScope) => { const openInlineCell = (customEditHotkeyScopeForField?: HotkeyScope) => {
@ -46,6 +56,14 @@ export const useInlineCell = () => {
} else { } else {
setHotkeyScopeAndMemorizePreviousScope(InlineCellHotkeyScope.InlineCell); setHotkeyScopeAndMemorizePreviousScope(InlineCellHotkeyScope.InlineCell);
} }
setActiveDropdownFocusIdAndMemorizePrevious(
getDropdownFocusIdForRecordField(
recordId,
fieldDefinition.fieldMetadataId,
'inline-cell',
),
);
}; };
return { return {

View File

@ -2,6 +2,7 @@ import { useRecoilCallback } from 'recoil';
import { currentTableCellInEditModePositionComponentState } from '@/object-record/record-table/states/currentTableCellInEditModePositionComponentState'; import { currentTableCellInEditModePositionComponentState } from '@/object-record/record-table/states/currentTableCellInEditModePositionComponentState';
import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState'; import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
@ -16,6 +17,9 @@ export const useCloseCurrentTableCellInEditMode = (recordTableId?: string) => {
recordTableId, recordTableId,
); );
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
return useRecoilCallback( return useRecoilCallback(
({ set, snapshot }) => { ({ set, snapshot }) => {
return async () => { return async () => {
@ -28,8 +32,14 @@ export const useCloseCurrentTableCellInEditMode = (recordTableId?: string) => {
isTableCellInEditModeFamilyState(currentTableCellInEditModePosition), isTableCellInEditModeFamilyState(currentTableCellInEditModePosition),
false, false,
); );
goBackToPreviousDropdownFocusId();
}; };
}, },
[currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState], [
currentTableCellInEditModePositionState,
isTableCellInEditModeFamilyState,
goBackToPreviousDropdownFocusId,
],
); );
}; };

View File

@ -5,7 +5,7 @@ import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutsideV2 } from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
type RecordTableBodyUnselectEffectProps = { type RecordTableBodyUnselectEffectProps = {
tableBodyRef: React.RefObject<HTMLDivElement>; tableBodyRef: React.RefObject<HTMLDivElement>;
@ -32,7 +32,7 @@ export const RecordTableBodyUnselectEffect = ({
TableHotkeyScope.Table, TableHotkeyScope.Table,
); );
useListenClickOutsideV2({ useListenClickOutside({
excludeClassNames: [ excludeClassNames: [
'bottom-bar', 'bottom-bar',
'action-menu-dropdown', 'action-menu-dropdown',

View File

@ -4,19 +4,13 @@ import { FieldInput } from '@/object-record/record-field/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly'; import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId'; import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates'; import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { useSetRecoilState } from 'recoil'; import { useRecoilCallback } from 'recoil';
export const RecordTableCellFieldInput = () => { export const RecordTableCellFieldInput = () => {
const { getClickOutsideListenerIsActivatedState } =
useClickOustideListenerStates(RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID);
const setClickOutsideListenerIsActivated = useSetRecoilState(
getClickOutsideListenerIsActivatedState,
);
const { onUpsertRecord, onMoveFocus, onCloseTableCell } = const { onUpsertRecord, onMoveFocus, onCloseTableCell } =
useContext(RecordTableContext); useContext(RecordTableContext);
@ -48,17 +42,35 @@ export const RecordTableCellFieldInput = () => {
onCloseTableCell(); onCloseTableCell();
}; };
const handleClickOutside: FieldInputEvent = (persistField) => { const handleClickOutside = useRecoilCallback(
setClickOutsideListenerIsActivated(false); ({ snapshot }) =>
(persistField: () => void, event: MouseEvent | TouchEvent) => {
const dropdownFocusId = getDropdownFocusIdForRecordField(
recordId,
fieldDefinition.fieldMetadataId,
'table-cell',
);
onUpsertRecord({ const activeDropdownFocusId = snapshot
persistField, .getLoadable(activeDropdownFocusIdState)
recordId, .getValue();
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell(); if (activeDropdownFocusId !== dropdownFocusId) {
}; return;
}
event.stopImmediatePropagation();
onUpsertRecord({
persistField,
recordId,
fieldName: fieldDefinition.metadata.fieldName,
});
onCloseTableCell();
},
[fieldDefinition, onCloseTableCell, onUpsertRecord, recordId],
);
const handleEscape: FieldInputEvent = (persistField) => { const handleEscape: FieldInputEvent = (persistField) => {
onUpsertRecord({ onUpsertRecord({

View File

@ -10,13 +10,11 @@ import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEm
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
import { useToggleEditOnlyInput } from '@/object-record/record-field/hooks/useToggleEditOnlyInput'; import { useToggleEditOnlyInput } from '@/object-record/record-field/hooks/useToggleEditOnlyInput';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { useCloseCurrentTableCellInEditMode } from '@/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode';
import { RecordTableCellButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellButton'; import { RecordTableCellButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellButton';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell'; import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { isSoftFocusUsingMouseState } from '@/object-record/record-table/states/isSoftFocusUsingMouseState'; import { isSoftFocusUsingMouseState } from '@/object-record/record-table/states/isSoftFocusUsingMouseState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey'; import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { TableHotkeyScope } from '../../types/TableHotkeyScope'; import { TableHotkeyScope } from '../../types/TableHotkeyScope';
@ -34,7 +32,6 @@ export const RecordTableCellSoftFocusMode = ({
nonEditModeContent, nonEditModeContent,
}: RecordTableCellSoftFocusModeProps) => { }: RecordTableCellSoftFocusModeProps) => {
const { columnIndex } = useContext(RecordTableCellContext); const { columnIndex } = useContext(RecordTableCellContext);
const closeCurrentTableCell = useCloseCurrentTableCellInEditMode();
const isFieldReadOnly = useIsFieldValueReadOnly(); const isFieldReadOnly = useIsFieldValueReadOnly();
@ -135,13 +132,6 @@ export const RecordTableCellSoftFocusMode = ({
*/ */
}; };
useListenClickOutside({
refs: [scrollRef],
callback: () => {
closeCurrentTableCell();
},
});
const isFirstColumn = columnIndex === 0; const isFirstColumn = columnIndex === 0;
const customButtonIcon = useGetButtonIcon(); const customButtonIcon = useGetButtonIcon();
const buttonIcon = isFirstColumn const buttonIcon = isFirstColumn

View File

@ -22,6 +22,8 @@ import { isDefined } from '~/utils/isDefined';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId'; import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates'; import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates';
import { useContext } from 'react'; import { useContext } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -69,6 +71,9 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
const openTableCell = useRecoilCallback( const openTableCell = useRecoilCallback(
({ snapshot, set }) => ({ snapshot, set }) =>
({ ({
@ -142,6 +147,14 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
DEFAULT_CELL_SCOPE.customScopes, DEFAULT_CELL_SCOPE.customScopes,
); );
} }
setActiveDropdownFocusIdAndMemorizePrevious(
getDropdownFocusIdForRecordField(
recordId,
fieldDefinition.fieldMetadataId,
'table-cell',
),
);
}, },
[ [
getClickOutsideListenerIsActivatedState, getClickOutsideListenerIsActivatedState,
@ -156,6 +169,7 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
setViewableRecordNameSingular, setViewableRecordNameSingular,
openRightDrawer, openRightDrawer,
setHotkeyScope, setHotkeyScope,
setActiveDropdownFocusIdAndMemorizePrevious,
], ],
); );

View File

@ -15,7 +15,6 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { onToggleColumnFilterComponentState } from '@/object-record/record-table/states/onToggleColumnFilterComponentState'; import { onToggleColumnFilterComponentState } from '@/object-record/record-table/states/onToggleColumnFilterComponentState';
import { onToggleColumnSortComponentState } from '@/object-record/record-table/states/onToggleColumnSortComponentState'; import { onToggleColumnSortComponentState } from '@/object-record/record-table/states/onToggleColumnSortComponentState';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useTableColumns } from '../../hooks/useTableColumns'; import { useTableColumns } from '../../hooks/useTableColumns';
import { ColumnDefinition } from '../../types/ColumnDefinition'; import { ColumnDefinition } from '../../types/ColumnDefinition';
@ -92,45 +91,43 @@ export const RecordTableColumnHeadDropdownMenu = ({
const canHide = column.isLabelIdentifier !== true; const canHide = column.isLabelIdentifier !== true;
return ( return (
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer"> <DropdownMenuItemsContainer>
<DropdownMenuItemsContainer> {isFilterable && (
{isFilterable && ( <MenuItem
<MenuItem LeftIcon={IconFilter}
LeftIcon={IconFilter} onClick={handleFilterClick}
onClick={handleFilterClick} text="Filter"
text="Filter" />
/> )}
)} {isSortable && (
{isSortable && ( <MenuItem
<MenuItem LeftIcon={IconSortDescending}
LeftIcon={IconSortDescending} onClick={handleSortClick}
onClick={handleSortClick} text="Sort"
text="Sort" />
/> )}
)} {showSeparator && <DropdownMenuSeparator />}
{showSeparator && <DropdownMenuSeparator />} {canMoveLeft && (
{canMoveLeft && ( <MenuItem
<MenuItem LeftIcon={IconArrowLeft}
LeftIcon={IconArrowLeft} onClick={handleColumnMoveLeft}
onClick={handleColumnMoveLeft} text="Move left"
text="Move left" />
/> )}
)} {canMoveRight && (
{canMoveRight && ( <MenuItem
<MenuItem LeftIcon={IconArrowRight}
LeftIcon={IconArrowRight} onClick={handleColumnMoveRight}
onClick={handleColumnMoveRight} text="Move right"
text="Move right" />
/> )}
)} {canHide && (
{canHide && ( <MenuItem
<MenuItem LeftIcon={IconEyeOff}
LeftIcon={IconEyeOff} onClick={handleColumnVisibility}
onClick={handleColumnVisibility} text="Hide"
text="Hide" />
/> )}
)} </DropdownMenuItemsContainer>
</DropdownMenuItemsContainer>
</ScrollWrapper>
); );
}; };

View File

@ -13,7 +13,6 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const RecordTableHeaderPlusButtonContent = () => { export const RecordTableHeaderPlusButtonContent = () => {
@ -42,20 +41,18 @@ export const RecordTableHeaderPlusButtonContent = () => {
return ( return (
<> <>
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer">
<DropdownMenuItemsContainer>
{hiddenTableColumns.map((column) => (
<MenuItem
key={column.fieldMetadataId}
onClick={() => handleAddColumn(column)}
LeftIcon={getIcon(column.iconName)}
text={column.label}
/>
))}
</DropdownMenuItemsContainer>
</ScrollWrapper>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
{hiddenTableColumns.map((column) => (
<MenuItem
key={column.fieldMetadataId}
onClick={() => handleAddColumn(column)}
LeftIcon={getIcon(column.iconName)}
text={column.label}
/>
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer withoutScrollWrapper>
<UndecoratedLink <UndecoratedLink
fullWidth fullWidth
to={`/settings/objects/${getObjectSlug(objectMetadataItem)}`} to={`/settings/objects/${getObjectSlug(objectMetadataItem)}`}

View File

@ -179,7 +179,11 @@ export const MultiRecordSelect = ({
{objectRecordsIdsMultiSelect?.length > 0 && ( {objectRecordsIdsMultiSelect?.length > 0 && (
<DropdownMenuSeparator /> <DropdownMenuSeparator />
)} )}
{isDefined(onCreate) && <div>{createNewButton}</div>} {isDefined(onCreate) && (
<DropdownMenuItemsContainer withoutScrollWrapper>
{createNewButton}
</DropdownMenuItemsContainer>
)}
</> </>
)} )}
</DropdownMenu> </DropdownMenu>

View File

@ -3,6 +3,7 @@ import { useEffect } from 'react';
import { MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordClickOutsideListenerId'; import { MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordClickOutsideListenerId';
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener'; import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
export const MultipleObjectRecordOnClickOutsideEffect = ({ export const MultipleObjectRecordOnClickOutsideEffect = ({
containerRef, containerRef,
@ -11,10 +12,6 @@ export const MultipleObjectRecordOnClickOutsideEffect = ({
containerRef: React.RefObject<HTMLDivElement>; containerRef: React.RefObject<HTMLDivElement>;
onClickOutside: () => void; onClickOutside: () => void;
}) => { }) => {
const { useListenClickOutside } = useClickOutsideListener(
MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID,
);
const { toggleClickOutsideListener: toggleRightDrawerClickOustideListener } = const { toggleClickOutsideListener: toggleRightDrawerClickOustideListener } =
useClickOutsideListener(RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID); useClickOutsideListener(RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID);
@ -35,6 +32,7 @@ export const MultipleObjectRecordOnClickOutsideEffect = ({
onClickOutside(); onClickOutside();
}, },
listenerId: MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID,
}); });
return <></>; return <></>;

View File

@ -41,6 +41,7 @@ export const SingleRecordSelect = ({
onCancel(); onCancel();
} }
}, },
listenerId: 'single-record-select',
}); });
return ( return (

View File

@ -59,27 +59,6 @@ export const SingleRecordSelectMenuItemsWithSearch = ({
/> />
); );
const results = (
<SingleRecordSelectMenuItems
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={
records.recordsToSelect.length === 1
? records.recordsToSelect[0]
: undefined
}
shouldSelectEmptyOption={selectedRecordIds?.length === 0}
hotkeyScope={recordPickerInstanceId}
isFiltered={!!recordPickerSearchFilter}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
);
return ( return (
<> <>
{dropdownPlacement?.includes('end') && ( {dropdownPlacement?.includes('end') && (
@ -88,7 +67,26 @@ export const SingleRecordSelectMenuItemsWithSearch = ({
{createNewButton} {createNewButton}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
{records.recordsToSelect.length > 0 && <DropdownMenuSeparator />} {records.recordsToSelect.length > 0 && <DropdownMenuSeparator />}
{records.recordsToSelect.length > 0 && results} {records.recordsToSelect.length > 0 && (
<SingleRecordSelectMenuItems
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={
records.recordsToSelect.length === 1
? records.recordsToSelect[0]
: undefined
}
shouldSelectEmptyOption={selectedRecordIds?.length === 0}
hotkeyScope={recordPickerInstanceId}
isFiltered={!!recordPickerSearchFilter}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
@ -97,7 +95,26 @@ export const SingleRecordSelectMenuItemsWithSearch = ({
isUndefinedOrNull(dropdownPlacement)) && ( isUndefinedOrNull(dropdownPlacement)) && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{records.recordsToSelect.length > 0 && results} {records.recordsToSelect.length > 0 && (
<SingleRecordSelectMenuItems
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={
records.recordsToSelect.length === 1
? records.recordsToSelect[0]
: undefined
}
shouldSelectEmptyOption={selectedRecordIds?.length === 0}
hotkeyScope={recordPickerInstanceId}
isFiltered={!!recordPickerSearchFilter}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
)}
{records.recordsToSelect.length > 0 && isDefined(onCreate) && ( {records.recordsToSelect.length > 0 && isDefined(onCreate) && (
<DropdownMenuSeparator /> <DropdownMenuSeparator />
)} )}

View File

@ -0,0 +1,7 @@
export const getDropdownFocusIdForRecordField = (
recordId: string,
fieldMetadataId: string,
componentType: 'table-cell' | 'inline-cell',
) => {
return `dropdown-${componentType}-record-${recordId}-field-${fieldMetadataId}`;
};

View File

@ -19,12 +19,10 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
type SettingsAccountsRowDropdownMenuProps = { type SettingsAccountsRowDropdownMenuProps = {
account: ConnectedAccount; account: ConnectedAccount;
className?: string;
}; };
export const SettingsAccountsRowDropdownMenu = ({ export const SettingsAccountsRowDropdownMenu = ({
account, account,
className,
}: SettingsAccountsRowDropdownMenuProps) => { }: SettingsAccountsRowDropdownMenuProps) => {
const dropdownId = `settings-account-row-${account.id}`; const dropdownId = `settings-account-row-${account.id}`;
@ -39,7 +37,6 @@ export const SettingsAccountsRowDropdownMenu = ({
return ( return (
<Dropdown <Dropdown
dropdownId={dropdownId} dropdownId={dropdownId}
className={className}
dropdownPlacement="right-start" dropdownPlacement="right-start"
dropdownHotkeyScope={{ scope: dropdownId }} dropdownHotkeyScope={{ scope: dropdownId }}
clickableComponent={ clickableComponent={

View File

@ -101,6 +101,7 @@ export const MatchColumnSelect = ({
callback: () => { callback: () => {
setIsOpen(false); setIsOpen(false);
}, },
listenerId: 'match-column-select',
}); });
useUpdateEffect(() => { useUpdateEffect(() => {

View File

@ -75,8 +75,6 @@ export const SubMatchingSelect = <T extends string>({
const options = getFieldOptions(fields, column.value) as SelectOption[]; const options = getFieldOptions(fields, column.value) as SelectOption[];
const value = options.find((opt) => opt.value === option.value); const value = options.find((opt) => opt.value === option.value);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectWrapperRef, setSelectWrapperRef] =
useState<HTMLDivElement | null>(null);
const theme = useTheme(); const theme = useTheme();
@ -106,17 +104,14 @@ export const SubMatchingSelect = <T extends string>({
cursor="pointer" cursor="pointer"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
id="control" id="control"
ref={setSelectWrapperRef}
> >
<Tag <Tag
text={value?.label ?? placeholder} text={value?.label ?? placeholder}
color={value?.color as TagColor} color={value?.color as TagColor}
/> />
<StyledIconChevronDown size={theme.icon.size.md} /> <StyledIconChevronDown size={theme.icon.size.md} />
{isOpen && ( {isOpen && (
<SelectInput <SelectInput
parentRef={selectWrapperRef}
defaultOption={value} defaultOption={value}
options={options} options={options}
onOptionSelected={handleSelect} onOptionSelected={handleSelect}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,12 +37,14 @@ export const DropdownMenuItemsContainer = ({
children, children,
hasMaxHeight, hasMaxHeight,
className, className,
withoutScrollWrapper,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
hasMaxHeight?: boolean; hasMaxHeight?: boolean;
className?: string; className?: string;
withoutScrollWrapper?: boolean;
}) => { }) => {
return ( return withoutScrollWrapper === true ? (
<StyledDropdownMenuItemsExternalContainer <StyledDropdownMenuItemsExternalContainer
hasMaxHeight={hasMaxHeight} hasMaxHeight={hasMaxHeight}
className={className} className={className}
@ -59,5 +61,16 @@ export const DropdownMenuItemsContainer = ({
</StyledDropdownMenuItemsInternalContainer> </StyledDropdownMenuItemsInternalContainer>
)} )}
</StyledDropdownMenuItemsExternalContainer> </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 { useRecoilState } from 'recoil';
import { useDropdownStates } from '@/ui/layout/dropdown/hooks/internal/useDropdownStates'; 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 { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
import { useCallback } from 'react'; import { useCallback } from 'react';
@ -17,6 +19,12 @@ export const useDropdown = (dropdownId?: string) => {
dropdownScopeId: getScopeIdOrUndefinedFromComponentId(dropdownId), dropdownScopeId: getScopeIdOrUndefinedFromComponentId(dropdownId),
}); });
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const { const {
setHotkeyScopeAndMemorizePreviousScope, setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope, goBackToPreviousHotkeyScope,
@ -34,17 +42,28 @@ export const useDropdown = (dropdownId?: string) => {
useRecoilState(isDropdownOpenState); useRecoilState(isDropdownOpenState);
const closeDropdown = useCallback(() => { const closeDropdown = useCallback(() => {
goBackToPreviousHotkeyScope(); if (isDropdownOpen) {
setIsDropdownOpen(false); goBackToPreviousHotkeyScope();
}, [goBackToPreviousHotkeyScope, setIsDropdownOpen]); setIsDropdownOpen(false);
goBackToPreviousDropdownFocusId();
}
}, [
isDropdownOpen,
goBackToPreviousHotkeyScope,
setIsDropdownOpen,
goBackToPreviousDropdownFocusId,
]);
const openDropdown = () => { const openDropdown = () => {
setIsDropdownOpen(true); if (!isDropdownOpen) {
if (isDefined(dropdownHotkeyScope)) { setIsDropdownOpen(true);
setHotkeyScopeAndMemorizePreviousScope( setActiveDropdownFocusIdAndMemorizePrevious(dropdownId ?? scopeId);
dropdownHotkeyScope.scope, if (isDefined(dropdownHotkeyScope)) {
dropdownHotkeyScope.customScopes, 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 { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown';
import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement'; import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
const StyledContainer = styled.div` const StyledContainer = styled.div`
@ -101,20 +100,18 @@ export const ExpandableList = ({
resetFirstHiddenChildIndex(); resetFirstHiddenChildIndex();
}, [isChipCountDisplayed, children.length, resetFirstHiddenChildIndex]); }, [isChipCountDisplayed, children.length, resetFirstHiddenChildIndex]);
useListenClickOutside({ const handleClickOutside = () => {
refs: [containerRef], setIsListExpanded(false);
callback: () => {
// Handle container resize if (
if ( childrenContainerElement?.clientWidth !== previousChildrenContainerWidth
childrenContainerElement?.clientWidth !== previousChildrenContainerWidth ) {
) { resetFirstHiddenChildIndex();
resetFirstHiddenChildIndex(); setPreviousChildrenContainerWidth(
setPreviousChildrenContainerWidth( childrenContainerElement?.clientWidth ?? 0,
childrenContainerElement?.clientWidth ?? 0, );
); }
} };
},
});
return ( return (
<StyledContainer <StyledContainer
@ -163,10 +160,7 @@ export const ExpandableList = ({
{isListExpanded && ( {isListExpanded && (
<ExpandedListDropdown <ExpandedListDropdown
anchorElement={containerRef.current ?? undefined} anchorElement={containerRef.current ?? undefined}
onClickOutside={() => { onClickOutside={handleClickOutside}
resetFirstHiddenChildIndex();
setIsListExpanded(false);
}}
withBorder={withExpandedListBorder} withBorder={withExpandedListBorder}
> >
{children} {children}

View File

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

View File

@ -3,8 +3,8 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { import {
ClickOutsideMode, ClickOutsideMode,
useListenClickOutsideV2, useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2'; } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@ -207,7 +207,7 @@ export const Modal = ({
hotkeyScope, hotkeyScope,
); );
useListenClickOutsideV2({ useListenClickOutside({
refs: [modalRef], refs: [modalRef],
listenerId: 'MODAL_CLICK_OUTSIDE_LISTENER_ID', listenerId: 'MODAL_CLICK_OUTSIDE_LISTENER_ID',
callback: () => { 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 { isRightDrawerAnimationCompletedState } from '@/ui/layout/right-drawer/states/isRightDrawerAnimationCompletedState';
import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState'; 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 { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useRef } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { useRightDrawer } from '../hooks/useRightDrawer';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState'; import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState'; 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 { RightDrawerRouter } from './RightDrawerRouter';
import { workflowReactFlowRefState } from '@/workflow/states/workflowReactFlowRefState';
const StyledContainer = styled(motion.div)<{ isRightDrawerMinimized: boolean }>` const StyledContainer = styled(motion.div)<{ isRightDrawerMinimized: boolean }>`
background: ${({ theme }) => theme.background.primary}; background: ${({ theme }) => theme.background.primary};
@ -40,7 +33,7 @@ const StyledContainer = styled(motion.div)<{ isRightDrawerMinimized: boolean }>`
right: 0; right: 0;
top: 0; top: 0;
z-index: 100; z-index: 30;
.modal-backdrop { .modal-backdrop {
background: ${({ theme }) => theme.background.overlayTertiary}; background: ${({ theme }) => theme.background.overlayTertiary};
@ -56,39 +49,6 @@ const StyledRightDrawer = styled.div`
export const RightDrawer = () => { export const RightDrawer = () => {
const theme = useTheme(); 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 isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState);
const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState); const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState);
@ -99,52 +59,6 @@ export const RightDrawer = () => {
const rightDrawerPage = useRecoilValue(rightDrawerPageState); 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 isMobile = useIsMobile();
const targetVariantForAnimation: RightDrawerAnimationVariant = const targetVariantForAnimation: RightDrawerAnimationVariant =
@ -168,13 +82,13 @@ export const RightDrawer = () => {
<StyledContainer <StyledContainer
isRightDrawerMinimized={isRightDrawerMinimized} isRightDrawerMinimized={isRightDrawerMinimized}
animate={targetVariantForAnimation} animate={targetVariantForAnimation}
variants={animationVariants} variants={RIGHT_DRAWER_ANIMATION_VARIANTS}
transition={{ transition={{
duration: theme.animation.duration.normal, duration: theme.animation.duration.normal,
}} }}
onAnimationComplete={handleAnimationComplete} onAnimationComplete={handleAnimationComplete}
> >
<StyledRightDrawer ref={rightDrawerRef}> <StyledRightDrawer>
{isRightDrawerOpen && <RightDrawerRouter />} {isRightDrawerOpen && <RightDrawerRouter />}
</StyledRightDrawer> </StyledRightDrawer>
</StyledContainer> </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 { RightDrawerRecord } from '@/object-record/record-right-drawer/components/RightDrawerRecord';
import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState'; 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 { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage'; import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage';
import { RightDrawerWorkflowEditStep } from '@/workflow/components/RightDrawerWorkflowEditStep'; import { RightDrawerWorkflowEditStep } from '@/workflow/components/RightDrawerWorkflowEditStep';
import { RightDrawerWorkflowSelectAction } from '@/workflow/components/RightDrawerWorkflowSelectAction'; import { RightDrawerWorkflowSelectAction } from '@/workflow/components/RightDrawerWorkflowSelectAction';
import { RightDrawerWorkflowSelectTriggerType } from '@/workflow/components/RightDrawerWorkflowSelectTriggerType';
import { RightDrawerWorkflowViewStep } from '@/workflow/components/RightDrawerWorkflowViewStep'; import { RightDrawerWorkflowViewStep } from '@/workflow/components/RightDrawerWorkflowViewStep';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { rightDrawerPageState } from '../states/rightDrawerPageState'; import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages'; 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` const StyledRightDrawerBody = styled.div`
display: flex; display: flex;
@ -61,13 +55,13 @@ export const RightDrawerRouter = () => {
const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState); const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState);
return ( return (
<StyledRightDrawerPage> <RightDrawerContainer>
<RightDrawerTopBar /> <RightDrawerTopBar />
{!isRightDrawerMinimized && ( {!isRightDrawerMinimized && (
<StyledRightDrawerBody> <StyledRightDrawerBody>
{rightDrawerPageComponent} {rightDrawerPageComponent}
</StyledRightDrawerBody> </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(); event.stopImmediatePropagation();
onClickOutside(event, value); onClickOutside(event, value);
}, },
listenerId: 'navigation-drawer-input',
}); });
const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const handleChange = (event: ChangeEvent<HTMLInputElement>) => {

View File

@ -1,12 +1,12 @@
import { fireEvent, renderHook } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { fireEvent, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { import {
ClickOutsideMode, ClickOutsideMode,
useListenClickOutsideV2, useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2'; } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
const containerRef = React.createRef<HTMLDivElement>(); const containerRef = React.createRef<HTMLDivElement>();
@ -19,13 +19,13 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
); );
const listenerId = 'listenerId'; const listenerId = 'listenerId';
describe('useListenClickOutsideV2', () => { describe('useListenClickOutside', () => {
it('should trigger the callback when clicking outside the specified refs', () => { it('should trigger the callback when clicking outside the specified refs', () => {
const callback = jest.fn(); const callback = jest.fn();
renderHook( renderHook(
() => () =>
useListenClickOutsideV2({ useListenClickOutside({
refs: [containerRef], refs: [containerRef],
callback, callback,
listenerId, listenerId,
@ -46,7 +46,7 @@ describe('useListenClickOutsideV2', () => {
renderHook( renderHook(
() => () =>
useListenClickOutsideV2({ useListenClickOutside({
refs: [nullRef], refs: [nullRef],
callback, callback,
mode: ClickOutsideMode.comparePixels, mode: ClickOutsideMode.comparePixels,
@ -68,7 +68,7 @@ describe('useListenClickOutsideV2', () => {
renderHook( renderHook(
() => () =>
useListenClickOutsideV2({ useListenClickOutside({
refs: [containerRef], refs: [containerRef],
callback, callback,
listenerId, listenerId,
@ -91,7 +91,7 @@ describe('useListenClickOutsideV2', () => {
renderHook( renderHook(
() => () =>
useListenClickOutsideV2({ useListenClickOutside({
refs: [containerRef], refs: [containerRef],
callback, callback,
mode: ClickOutsideMode.comparePixels, 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 { useRecoilCallback } from 'recoil';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates'; 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 { ClickOutsideListenerCallback } from '@/ui/utilities/pointer-event/types/ClickOutsideListenerCallback';
import { toSpliced } from '~/utils/array/toSpliced'; import { toSpliced } from '~/utils/array/toSpliced';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
@ -17,35 +14,6 @@ export const useClickOutsideListener = (componentId: string) => {
getClickOutsideListenerMouseDownHappenedState, getClickOutsideListenerMouseDownHappenedState,
} = useClickOustideListenerStates(componentId); } = 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( const toggleClickOutsideListener = useRecoilCallback(
({ set }) => ({ set }) =>
(activated: boolean) => { (activated: boolean) => {
@ -152,7 +120,6 @@ export const useClickOutsideListener = (componentId: string) => {
}; };
return { return {
useListenClickOutside,
toggleClickOutsideListener, toggleClickOutsideListener,
useRegisterClickOutsideListenerCallback, 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 { export enum ClickOutsideMode {
comparePixels = 'comparePixels', comparePixels = 'comparePixels',
compareHTMLRef = 'compareHTMLRef', compareHTMLRef = 'compareHTMLRef',
} }
export const useListenClickOutside = <T extends Element>({ export type ClickOutsideListenerProps<T extends Element> = {
refs,
callback,
mode = ClickOutsideMode.compareHTMLRef,
enabled = true,
}: {
refs: Array<React.RefObject<T>>; refs: Array<React.RefObject<T>>;
excludeClassNames?: string[];
callback: (event: MouseEvent | TouchEvent) => void; callback: (event: MouseEvent | TouchEvent) => void;
mode?: ClickOutsideMode; mode?: ClickOutsideMode;
listenerId: string;
hotkeyScope?: string;
enabled?: boolean; 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(() => { useEffect(() => {
const handleMouseDown = (event: MouseEvent | TouchEvent) => { document.addEventListener('mousedown', handleMouseDown, {
if (mode === ClickOutsideMode.compareHTMLRef) { capture: true,
const clickedOnAtLeastOneRef = refs });
.filter((ref) => !!ref.current) document.addEventListener('click', handleClickOutside, { capture: true });
.some((ref) => ref.current?.contains(event.target as Node)); document.addEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.addEventListener('touchend', handleClickOutside, {
capture: true,
});
setIsMouseDownInside(clickedOnAtLeastOneRef); return () => {
} document.removeEventListener('mousedown', handleMouseDown, {
capture: true,
if (mode === ClickOutsideMode.comparePixels) { });
const clickedOnAtLeastOneRef = refs document.removeEventListener('click', handleClickOutside, {
.filter((ref) => !!ref.current) capture: true,
.some((ref) => { });
if (!ref.current) { document.removeEventListener('touchstart', handleMouseDown, {
return false; capture: true,
} });
document.removeEventListener('touchend', handleClickOutside, {
const { x, y, width, height } = ref.current.getBoundingClientRect(); capture: true,
});
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);
}
}; };
}, [refs, callback, mode, handleClickOutside, handleMouseDown]);
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]);
}; };

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]);
};

View File

@ -3,7 +3,7 @@ import {
OnDragEndResponder, OnDragEndResponder,
ResponderProvided, ResponderProvided,
} from '@hello-pangea/dnd'; } from '@hello-pangea/dnd';
import { useRef, useState } from 'react'; import { useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
AppTooltip, AppTooltip,
@ -20,7 +20,6 @@ import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableIt
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader'; import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
@ -80,15 +79,6 @@ export const ViewFieldsVisibilityDropdownSection = ({
return iconButtons.length ? iconButtons : undefined; return iconButtons.length ? iconButtons : undefined;
}; };
const ref = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [ref],
callback: () => {
setOpenToolTipIndex(undefined);
},
});
const { nonDraggableItems = [], draggableItems = [] } = isDraggable const { nonDraggableItems = [], draggableItems = [] } = isDraggable
? groupArrayItemsBy(fields, ({ isLabelIdentifier }) => ? groupArrayItemsBy(fields, ({ isLabelIdentifier }) =>
isLabelIdentifier ? 'nonDraggableItems' : 'draggableItems', isLabelIdentifier ? 'nonDraggableItems' : 'draggableItems',
@ -96,7 +86,7 @@ export const ViewFieldsVisibilityDropdownSection = ({
: { nonDraggableItems: fields, draggableItems: [] }; : { nonDraggableItems: fields, draggableItems: [] };
return ( return (
<div ref={ref}> <>
{showSubheader && ( {showSubheader && (
<StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader> <StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader>
)} )}
@ -158,6 +148,6 @@ export const ViewFieldsVisibilityDropdownSection = ({
/>, />,
document.body, document.body,
)} )}
</div> </>
); );
}; };

View File

@ -5,3 +5,4 @@ export * from './decorators/ComponentWithRouterDecorator';
export * from './decorators/RouterDecorator'; export * from './decorators/RouterDecorator';
export * from './mocks/avatarUrlMock'; export * from './mocks/avatarUrlMock';
export * from './types/CatalogStory'; export * from './types/CatalogStory';
export * from './utils/getCanvasElementForDropdownTesting';

View File

@ -0,0 +1,15 @@
import { isDefined } from '@ui/utilities';
export const getCanvasElementForDropdownTesting = () => {
const canvasElement = document.getElementsByClassName(
'sb-show-main',
)[0] as HTMLElement | null;
if (!isDefined(canvasElement)) {
throw new Error(
`Canvas element not found for dropdown testing in storybook, verify that storybook still uses the class name "sb-show-main" for its body that displays stories.`,
);
}
return canvasElement;
};