Refactor drag selection: Replace external library with custom implementation and add auto-scroll (#12134)
Closes #12076 Closes #11764 Replaced the `@air/react-drag-to-select` library with a custom implementation to get better control over the selection behavior and add auto-scroll functionality. **What changed:** - Removed external drag selection dependency - Built custom drag selection from scratch using pointer events -- @charlesBochet - Added auto-scroll when dragging near container edges - Fixed boundary detection so selection stays within intended areas - Added proper `data-select-disable` support for checkboxes and other non-selectable elements The new implementation gives us full control over the selection logic and eliminates the external dependency while adding the auto-scroll feature that was **not** requested 😂 **Auto Scroll** https://github.com/user-attachments/assets/3509966d-5b6e-4f6c-a77a-f9a2bf26049f related to #12076 https://github.com/user-attachments/assets/2837f80e-728c-4739-a0e2-b8d7bc83a21a **Also fixed:** - Record board column height not extending to the bottom (styling issue I found while working on this) before: <img width="1512" alt="Screenshot 2025-05-19 at 23 58 54" src="https://github.com/user-attachments/assets/602b310f-7ef6-44f6-99e9-da5ff59b31d3" /> after: <img width="1512" alt="Screenshot 2025-05-19 at 23 56 40" src="https://github.com/user-attachments/assets/1d0ecb5c-49e0-4f03-be3b-154a6f16a7a4" /> --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@air/react-drag-to-select": "^5.0.8",
|
|
||||||
"@apollo/client": "^3.7.17",
|
"@apollo/client": "^3.7.17",
|
||||||
"@apollo/server": "^4.7.3",
|
"@apollo/server": "^4.7.3",
|
||||||
"@aws-sdk/client-lambda": "^3.614.0",
|
"@aws-sdk/client-lambda": "^3.614.0",
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/Mod
|
|||||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||||
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
|
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
|
||||||
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
|
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
|
||||||
|
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
|
||||||
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';
|
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||||
@ -45,10 +46,13 @@ const StyledContainer = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
min-height: 100%;
|
||||||
|
position: relative;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledColumnContainer = styled.div`
|
const StyledColumnContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
& > *:not(:first-of-type) {
|
& > *:not(:first-of-type) {
|
||||||
border-left: 1px solid ${({ theme }) => theme.border.color.light};
|
border-left: 1px solid ${({ theme }) => theme.border.color.light};
|
||||||
}
|
}
|
||||||
@ -57,12 +61,14 @@ const StyledColumnContainer = styled.div`
|
|||||||
const StyledContainerContainer = styled.div`
|
const StyledContainerContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: calc(100% - ${({ theme }) => theme.spacing(2)});
|
||||||
|
height: min-content;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledBoardContentContainer = styled.div`
|
const StyledBoardContentContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100% - 48px);
|
flex: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const RecordBoard = () => {
|
export const RecordBoard = () => {
|
||||||
@ -233,13 +239,18 @@ export const RecordBoard = () => {
|
|||||||
))}
|
))}
|
||||||
</StyledColumnContainer>
|
</StyledColumnContainer>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
|
|
||||||
|
<DragSelect
|
||||||
|
selectableItemsContainerRef={boardRef}
|
||||||
|
onDragSelectionEnd={handleDragSelectionEnd}
|
||||||
|
onDragSelectionChange={setRecordAsSelected}
|
||||||
|
onDragSelectionStart={handleDragSelectionStart}
|
||||||
|
scrollWrapperComponentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`}
|
||||||
|
selectionBoundaryClass={
|
||||||
|
RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS
|
||||||
|
}
|
||||||
|
/>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
<DragSelect
|
|
||||||
dragSelectable={boardRef}
|
|
||||||
onDragSelectionEnd={handleDragSelectionEnd}
|
|
||||||
onDragSelectionChange={setRecordAsSelected}
|
|
||||||
onDragSelectionStart={handleDragSelectionStart}
|
|
||||||
/>
|
|
||||||
</StyledBoardContentContainer>
|
</StyledBoardContentContainer>
|
||||||
</StyledContainerContainer>
|
</StyledContainerContainer>
|
||||||
</ScrollWrapper>
|
</ScrollWrapper>
|
||||||
|
|||||||
@ -14,6 +14,8 @@ const StyledColumn = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
min-height: 100%;
|
||||||
|
flex: 1;
|
||||||
padding: ${({ theme }) => theme.spacing(2)};
|
padding: ${({ theme }) => theme.spacing(2)};
|
||||||
padding-top: 0px;
|
padding-top: 0px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hook
|
|||||||
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
|
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
|
||||||
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
|
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
|
||||||
import { PageBody } from '@/ui/layout/page/components/PageBody';
|
import { PageBody } from '@/ui/layout/page/components/PageBody';
|
||||||
|
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
|
||||||
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
|
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
|
||||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||||
@ -90,7 +91,9 @@ export const RecordIndexContainerGater = () => {
|
|||||||
/>
|
/>
|
||||||
<RecordIndexPageHeader />
|
<RecordIndexPageHeader />
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<StyledIndexContainer>
|
<StyledIndexContainer
|
||||||
|
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
|
||||||
|
>
|
||||||
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
|
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
|
||||||
<RecordIndexContainer />
|
<RecordIndexContainer />
|
||||||
</StyledIndexContainer>
|
</StyledIndexContainer>
|
||||||
|
|||||||
@ -76,6 +76,7 @@ export const RecordTable = () => {
|
|||||||
handleDragSelectionEnd={handleDragSelectionEnd}
|
handleDragSelectionEnd={handleDragSelectionEnd}
|
||||||
setRowSelected={setRowSelected}
|
setRowSelected={setRowSelected}
|
||||||
hasRecordGroups={hasRecordGroups}
|
hasRecordGroups={hasRecordGroups}
|
||||||
|
recordTableId={recordTableId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import { RecordTableNoRecordGroupBody } from '@/object-record/record-table/recor
|
|||||||
import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody';
|
import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody';
|
||||||
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
|
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
|
||||||
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
|
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
|
||||||
|
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
const StyledTableWithPointerEvents = styled(StyledTable)<{
|
const StyledTableWithPointerEvents = styled(StyledTable)<{
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
@ -16,12 +17,20 @@ const StyledTableWithPointerEvents = styled(StyledTable)<{
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledTableContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
export interface RecordTableContentProps {
|
export interface RecordTableContentProps {
|
||||||
tableBodyRef: React.RefObject<HTMLTableElement>;
|
tableBodyRef: React.RefObject<HTMLTableElement>;
|
||||||
handleDragSelectionStart: () => void;
|
handleDragSelectionStart: () => void;
|
||||||
handleDragSelectionEnd: () => void;
|
handleDragSelectionEnd: () => void;
|
||||||
setRowSelected: (rowId: string, selected: boolean) => void;
|
setRowSelected: (rowId: string, selected: boolean) => void;
|
||||||
hasRecordGroups: boolean;
|
hasRecordGroups: boolean;
|
||||||
|
recordTableId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RecordTableContent = ({
|
export const RecordTableContent = ({
|
||||||
@ -30,8 +39,10 @@ export const RecordTableContent = ({
|
|||||||
handleDragSelectionEnd,
|
handleDragSelectionEnd,
|
||||||
setRowSelected,
|
setRowSelected,
|
||||||
hasRecordGroups,
|
hasRecordGroups,
|
||||||
|
recordTableId,
|
||||||
}: RecordTableContentProps) => {
|
}: RecordTableContentProps) => {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleDragStart = () => {
|
const handleDragStart = () => {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
@ -44,7 +55,7 @@ export const RecordTableContent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<StyledTableContainer ref={containerRef}>
|
||||||
<StyledTableWithPointerEvents ref={tableBodyRef} isDragging={isDragging}>
|
<StyledTableWithPointerEvents ref={tableBodyRef} isDragging={isDragging}>
|
||||||
<RecordTableHeader />
|
<RecordTableHeader />
|
||||||
{hasRecordGroups ? (
|
{hasRecordGroups ? (
|
||||||
@ -56,11 +67,13 @@ export const RecordTableContent = ({
|
|||||||
<RecordTableStickyBottomEffect />
|
<RecordTableStickyBottomEffect />
|
||||||
</StyledTableWithPointerEvents>
|
</StyledTableWithPointerEvents>
|
||||||
<DragSelect
|
<DragSelect
|
||||||
dragSelectable={tableBodyRef}
|
selectableItemsContainerRef={containerRef}
|
||||||
onDragSelectionStart={handleDragStart}
|
onDragSelectionStart={handleDragStart}
|
||||||
onDragSelectionChange={setRowSelected}
|
onDragSelectionChange={setRowSelected}
|
||||||
onDragSelectionEnd={handleDragEnd}
|
onDragSelectionEnd={handleDragEnd}
|
||||||
|
scrollWrapperComponentInstanceId={`record-table-scroll-${recordTableId}`}
|
||||||
|
selectionBoundaryClass={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
|
||||||
/>
|
/>
|
||||||
</>
|
</StyledTableContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export const RecordTableCellCheckbox = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledRecordTableTd isSelected={isSelected} hasRightBorder={false}>
|
<StyledRecordTableTd isSelected={isSelected} hasRightBorder={false}>
|
||||||
<StyledContainer onClick={handleClick}>
|
<StyledContainer onClick={handleClick} data-select-disable>
|
||||||
<Checkbox hoverable checked={isSelected} />
|
<Checkbox hoverable checked={isSelected} />
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
</StyledRecordTableTd>
|
</StyledRecordTableTd>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { tableColumnsComponentState } from '@/object-record/record-table/states/
|
|||||||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||||
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
|
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
|
||||||
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
|
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
|
||||||
|
import { PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
|
||||||
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
|
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
|
||||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||||
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
||||||
@ -138,16 +139,19 @@ export const RecordTableHeaderCell = ({
|
|||||||
|
|
||||||
const { handleColumnsChange } = useTableColumns();
|
const { handleColumnsChange } = useTableColumns();
|
||||||
|
|
||||||
const handleResizeHandlerStart = useCallback((positionX: number) => {
|
const handleResizeHandlerStart = useCallback<PointerEventListener>(
|
||||||
setInitialPointerPositionX(positionX);
|
({ x }) => {
|
||||||
}, []);
|
setInitialPointerPositionX(x);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [iconVisibility, setIconVisibility] = useState(false);
|
const [iconVisibility, setIconVisibility] = useState(false);
|
||||||
|
|
||||||
const handleResizeHandlerMove = useCallback(
|
const handleResizeHandlerMove = useCallback<PointerEventListener>(
|
||||||
(positionX: number) => {
|
({ x }) => {
|
||||||
if (!initialPointerPositionX) return;
|
if (!initialPointerPositionX) return;
|
||||||
setResizeFieldOffset(positionX - initialPointerPositionX);
|
setResizeFieldOffset(x - initialPointerPositionX);
|
||||||
},
|
},
|
||||||
[setResizeFieldOffset, initialPointerPositionX],
|
[setResizeFieldOffset, initialPointerPositionX],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export const RecordTableHeaderCheckboxColumn = () => {
|
|||||||
<StyledColumnHeaderCell
|
<StyledColumnHeaderCell
|
||||||
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
|
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
|
||||||
>
|
>
|
||||||
<StyledContainer>
|
<StyledContainer data-select-disable>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
hoverable
|
hoverable
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
|||||||
@ -1,76 +1,250 @@
|
|||||||
import {
|
import styled from '@emotion/styled';
|
||||||
boxesIntersect,
|
import { RefObject, useCallback, useState } from 'react';
|
||||||
useSelectionContainer,
|
|
||||||
} from '@air/react-drag-to-select';
|
|
||||||
import { useTheme } from '@emotion/react';
|
|
||||||
import { RefObject } from 'react';
|
|
||||||
|
|
||||||
|
import { useDragSelectWithAutoScroll } from '@/ui/utilities/drag-select/hooks/useDragSelectWithAutoScroll';
|
||||||
|
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||||
import { useDragSelect } from '../hooks/useDragSelect';
|
import { useDragSelect } from '../hooks/useDragSelect';
|
||||||
import { RGBA } from 'twenty-ui/theme';
|
import { SelectionBox } from '../types/SelectionBox';
|
||||||
|
import { isValidSelectionStart } from '../utils/selectionBoxValidation';
|
||||||
|
|
||||||
type DragSelectProps = {
|
type DragSelectProps = {
|
||||||
dragSelectable: RefObject<HTMLElement>;
|
selectableItemsContainerRef: RefObject<HTMLElement>;
|
||||||
onDragSelectionChange: (id: string, selected: boolean) => void;
|
onDragSelectionChange: (id: string, selected: boolean) => void;
|
||||||
onDragSelectionStart?: (event: MouseEvent) => void;
|
onDragSelectionStart?: (event: MouseEvent | TouchEvent) => void;
|
||||||
onDragSelectionEnd?: (event: MouseEvent) => void;
|
onDragSelectionEnd?: (event: MouseEvent | TouchEvent) => void;
|
||||||
|
scrollWrapperComponentInstanceId?: string;
|
||||||
|
selectionBoundaryClass?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Position = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledDragSelection = styled.div<SelectionBox>`
|
||||||
|
position: absolute;
|
||||||
|
z-index: 99;
|
||||||
|
opacity: 0.2;
|
||||||
|
border: 1px solid ${({ theme }) => theme.color.blue10};
|
||||||
|
background: ${({ theme }) => theme.color.blue30};
|
||||||
|
top: ${({ top }) => top}px;
|
||||||
|
left: ${({ left }) => left}px;
|
||||||
|
width: ${({ width }) => width}px;
|
||||||
|
height: ${({ height }) => height}px;
|
||||||
|
`;
|
||||||
|
|
||||||
export const DragSelect = ({
|
export const DragSelect = ({
|
||||||
dragSelectable,
|
selectableItemsContainerRef,
|
||||||
onDragSelectionChange,
|
onDragSelectionChange,
|
||||||
onDragSelectionStart,
|
onDragSelectionStart,
|
||||||
onDragSelectionEnd,
|
onDragSelectionEnd,
|
||||||
|
scrollWrapperComponentInstanceId,
|
||||||
|
selectionBoundaryClass,
|
||||||
}: DragSelectProps) => {
|
}: DragSelectProps) => {
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const { isDragSelectionStartEnabled } = useDragSelect();
|
const { isDragSelectionStartEnabled } = useDragSelect();
|
||||||
|
|
||||||
const { DragSelection } = useSelectionContainer({
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
shouldStartSelecting: (target) => {
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
|
|
||||||
|
const boxesIntersect = useCallback(
|
||||||
|
(boxA: SelectionBox, boxB: SelectionBox) =>
|
||||||
|
boxA.left <= boxB.left + boxB.width &&
|
||||||
|
boxA.left + boxA.width >= boxB.left &&
|
||||||
|
boxA.top <= boxB.top + boxB.height &&
|
||||||
|
boxA.top + boxA.height >= boxB.top,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { handleAutoScroll } = useDragSelectWithAutoScroll({
|
||||||
|
scrollWrapperComponentInstanceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [startPoint, setStartPoint] = useState<Position | null>(null);
|
||||||
|
const [endPoint, setEndPoint] = useState<Position | null>(null);
|
||||||
|
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
|
||||||
|
|
||||||
|
const getPositionRelativeToContainer = useCallback(
|
||||||
|
(x: number, y: number) => {
|
||||||
|
const containerRect =
|
||||||
|
selectableItemsContainerRef.current?.getBoundingClientRect();
|
||||||
|
if (!containerRect) {
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
return { x: x - containerRect.left, y: y - containerRect.top };
|
||||||
|
},
|
||||||
|
[selectableItemsContainerRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
useTrackPointer({
|
||||||
|
onMouseDown: ({ x, y, event }) => {
|
||||||
|
const { x: relativeX, y: relativeY } = getPositionRelativeToContainer(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldStartSelecting(event.target)) {
|
||||||
|
setIsDragging(true);
|
||||||
|
setIsSelecting(false);
|
||||||
|
setStartPoint({
|
||||||
|
x: relativeX,
|
||||||
|
y: relativeY,
|
||||||
|
});
|
||||||
|
setEndPoint({
|
||||||
|
x: relativeX,
|
||||||
|
y: relativeY,
|
||||||
|
});
|
||||||
|
setSelectionBox({
|
||||||
|
top: relativeY,
|
||||||
|
left: relativeX,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
onMouseMove: ({ x, y, event }) => {
|
||||||
|
if (isDragging) {
|
||||||
|
const { x: relativeX, y: relativeY } = getPositionRelativeToContainer(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isDefined(startPoint) ||
|
||||||
|
!isDefined(endPoint) ||
|
||||||
|
!isDefined(selectionBox)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEndPoint = { ...endPoint };
|
||||||
|
|
||||||
|
newEndPoint.x = relativeX;
|
||||||
|
newEndPoint.y = relativeY;
|
||||||
|
|
||||||
|
if (!isDeeplyEqual(newEndPoint, endPoint)) {
|
||||||
|
setEndPoint(newEndPoint);
|
||||||
|
|
||||||
|
const newSelectionBox = {
|
||||||
|
top: Math.min(startPoint.y, newEndPoint.y),
|
||||||
|
left: Math.min(startPoint.x, newEndPoint.x),
|
||||||
|
width: Math.abs(newEndPoint.x - startPoint.x),
|
||||||
|
height: Math.abs(newEndPoint.y - startPoint.y),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isValidSelectionStart(newSelectionBox)) {
|
||||||
|
if (!isSelecting) {
|
||||||
|
setIsSelecting(true);
|
||||||
|
onDragSelectionStart?.(event);
|
||||||
|
}
|
||||||
|
setSelectionBox(newSelectionBox);
|
||||||
|
} else if (isSelecting) {
|
||||||
|
setSelectionBox(newSelectionBox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelecting && isDefined(selectionBox)) {
|
||||||
|
const scrollAwareBox = {
|
||||||
|
...selectionBox,
|
||||||
|
top: selectionBox.top + window.scrollY,
|
||||||
|
left: selectionBox.left + window.scrollX,
|
||||||
|
};
|
||||||
|
|
||||||
|
Array.from(
|
||||||
|
selectableItemsContainerRef.current?.querySelectorAll(
|
||||||
|
'[data-selectable-id]',
|
||||||
|
) ?? [],
|
||||||
|
).forEach((item) => {
|
||||||
|
const id = item.getAttribute('data-selectable-id');
|
||||||
|
if (!isDefined(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const itemBox = item.getBoundingClientRect();
|
||||||
|
|
||||||
|
const { x: boxX, y: boxY } = getPositionRelativeToContainer(
|
||||||
|
itemBox.left,
|
||||||
|
itemBox.top,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
boxesIntersect(scrollAwareBox, {
|
||||||
|
width: itemBox.width,
|
||||||
|
height: itemBox.height,
|
||||||
|
top: boxY,
|
||||||
|
left: boxX,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
onDragSelectionChange(id, true);
|
||||||
|
} else {
|
||||||
|
onDragSelectionChange(id, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAutoScroll(x, y);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMouseUp: ({ event }) => {
|
||||||
|
if (isSelecting) {
|
||||||
|
onDragSelectionEnd?.(event);
|
||||||
|
}
|
||||||
|
setIsDragging(false);
|
||||||
|
setIsSelecting(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldStartSelecting = useCallback(
|
||||||
|
(target: EventTarget | null) => {
|
||||||
if (!isDragSelectionStartEnabled()) {
|
if (!isDragSelectionStartEnabled()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(target instanceof Node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionBoundaryElement = selectionBoundaryClass
|
||||||
|
? (selectableItemsContainerRef.current?.closest(
|
||||||
|
`.${selectionBoundaryClass}`,
|
||||||
|
) ?? selectableItemsContainerRef.current)
|
||||||
|
: selectableItemsContainerRef.current;
|
||||||
|
|
||||||
|
if (!selectionBoundaryElement?.contains(target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (target instanceof HTMLElement || target instanceof SVGElement) {
|
if (target instanceof HTMLElement || target instanceof SVGElement) {
|
||||||
let el = target;
|
let el = target;
|
||||||
while (el.parentElement && !el.dataset.selectDisable) {
|
while (el.parentElement && !el.dataset.selectDisable) {
|
||||||
el = el.parentElement;
|
el = el.parentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
return el.dataset.selectDisable !== 'true';
|
if (el.dataset.selectDisable === 'true') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onSelectionStart: onDragSelectionStart,
|
[
|
||||||
onSelectionEnd: onDragSelectionEnd,
|
isDragSelectionStartEnabled,
|
||||||
onSelectionChange: (box) => {
|
selectableItemsContainerRef,
|
||||||
const scrollAwareBox = {
|
selectionBoundaryClass,
|
||||||
...box,
|
],
|
||||||
top: box.top + window.scrollY,
|
);
|
||||||
left: box.left + window.scrollX,
|
|
||||||
};
|
|
||||||
Array.from(
|
|
||||||
dragSelectable.current?.querySelectorAll('[data-selectable-id]') ?? [],
|
|
||||||
).forEach((item) => {
|
|
||||||
const id = item.getAttribute('data-selectable-id');
|
|
||||||
if (!id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (boxesIntersect(scrollAwareBox, item.getBoundingClientRect())) {
|
|
||||||
onDragSelectionChange(id, true);
|
|
||||||
} else {
|
|
||||||
onDragSelectionChange(id, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
selectionProps: {
|
|
||||||
style: {
|
|
||||||
border: `1px solid ${theme.color.blue10}`,
|
|
||||||
background: RGBA(theme.color.blue30, 0.4),
|
|
||||||
position: `absolute`,
|
|
||||||
zIndex: 99,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return <DragSelection />;
|
return (
|
||||||
|
isDragging &&
|
||||||
|
isSelecting &&
|
||||||
|
isDefined(selectionBox) && (
|
||||||
|
<StyledDragSelection
|
||||||
|
top={selectionBox.top}
|
||||||
|
left={selectionBox.left}
|
||||||
|
width={selectionBox.width}
|
||||||
|
height={selectionBox.height}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,270 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { userEvent, within } from '@storybook/test';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||||
|
import { DragSelect } from '../DragSelect';
|
||||||
|
|
||||||
|
const StyledContainer = styled.div`
|
||||||
|
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: ${({ theme }) => theme.spacing(4)};
|
||||||
|
position: relative;
|
||||||
|
width: 600px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledSelectableItem = styled.div<{ selected?: boolean }>`
|
||||||
|
width: 100px;
|
||||||
|
height: 80px;
|
||||||
|
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
|
background: ${({ theme, selected }) =>
|
||||||
|
selected ? theme.color.blue10 : theme.background.secondary};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: ${({ theme }) => theme.spacing(1)};
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${({ theme, selected }) =>
|
||||||
|
selected ? theme.color.blue20 : theme.background.tertiary};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledLargeGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
padding: ${({ theme }) => theme.spacing(4)};
|
||||||
|
width: 900px;
|
||||||
|
height: 600px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledScrollableWrapper = styled(ScrollWrapper)`
|
||||||
|
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
height: 300px;
|
||||||
|
width: 600px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type SelectableItemProps = {
|
||||||
|
id: string;
|
||||||
|
selected: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectableItem = ({ id, selected, children }: SelectableItemProps) => (
|
||||||
|
<StyledSelectableItem data-selectable-id={id} selected={selected}>
|
||||||
|
{children}
|
||||||
|
</StyledSelectableItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
type BasicDragSelectDemoProps = {
|
||||||
|
itemCount?: number;
|
||||||
|
disableSelection?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BasicDragSelectDemo = ({
|
||||||
|
itemCount = 12,
|
||||||
|
disableSelection = false,
|
||||||
|
}: BasicDragSelectDemoProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleSelectionChange = (id: string, selected: boolean) => {
|
||||||
|
setSelectedItems((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (selected) {
|
||||||
|
newSet.add(id);
|
||||||
|
} else {
|
||||||
|
newSet.delete(id);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer ref={containerRef}>
|
||||||
|
<StyledGrid>
|
||||||
|
{Array.from({ length: itemCount }, (_, index) => (
|
||||||
|
<SelectableItem
|
||||||
|
key={index}
|
||||||
|
id={`item-${index}`}
|
||||||
|
selected={selectedItems.has(`item-${index}`)}
|
||||||
|
>
|
||||||
|
Item {index + 1}
|
||||||
|
</SelectableItem>
|
||||||
|
))}
|
||||||
|
</StyledGrid>
|
||||||
|
|
||||||
|
{!disableSelection && (
|
||||||
|
<DragSelect
|
||||||
|
selectableItemsContainerRef={containerRef}
|
||||||
|
onDragSelectionChange={handleSelectionChange}
|
||||||
|
onDragSelectionStart={() => {}}
|
||||||
|
onDragSelectionEnd={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StyledContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScrollableDragSelectDemo = () => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleSelectionChange = (id: string, selected: boolean) => {
|
||||||
|
setSelectedItems((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (selected) {
|
||||||
|
newSet.add(id);
|
||||||
|
} else {
|
||||||
|
newSet.delete(id);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledScrollableWrapper componentInstanceId="scrollable-demo">
|
||||||
|
<div ref={containerRef} style={{ position: 'relative', padding: '16px' }}>
|
||||||
|
<StyledLargeGrid>
|
||||||
|
{Array.from({ length: 36 }, (_, index) => (
|
||||||
|
<SelectableItem
|
||||||
|
key={index}
|
||||||
|
id={`scroll-item-${index}`}
|
||||||
|
selected={selectedItems.has(`scroll-item-${index}`)}
|
||||||
|
>
|
||||||
|
Item {index + 1}
|
||||||
|
</SelectableItem>
|
||||||
|
))}
|
||||||
|
</StyledLargeGrid>
|
||||||
|
|
||||||
|
<DragSelect
|
||||||
|
selectableItemsContainerRef={containerRef}
|
||||||
|
onDragSelectionChange={handleSelectionChange}
|
||||||
|
scrollWrapperComponentInstanceId="scrollable-demo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StyledScrollableWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof DragSelect> = {
|
||||||
|
title: 'UI/Utilities/DragSelect/DragSelect',
|
||||||
|
component: DragSelect,
|
||||||
|
decorators: [ComponentDecorator],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: `
|
||||||
|
The DragSelect component enables users to select multiple items by dragging a selection box.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Mouse-based drag selection with visual feedback
|
||||||
|
- Intersection detection for item selection
|
||||||
|
- Auto-scroll support for large containers (when used with ScrollWrapper)
|
||||||
|
- Configurable selection boundaries
|
||||||
|
- Integration with selectable items via data attributes
|
||||||
|
- Universal compatibility - works with or without ScrollWrapper
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
- Items must have \`data-selectable-id\` attribute
|
||||||
|
- Use \`data-select-disable="true"\` to disable selection on specific elements
|
||||||
|
- For auto-scroll functionality, wrap in ScrollWrapper and provide \`scrollWrapperComponentInstanceId\`
|
||||||
|
- Can work without ScrollWrapper for basic drag selection (auto-scroll gracefully disabled)
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
selectableItemsContainerRef: { control: false },
|
||||||
|
onDragSelectionChange: { control: false },
|
||||||
|
onDragSelectionStart: { control: false },
|
||||||
|
onDragSelectionEnd: { control: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof DragSelect>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <BasicDragSelectDemo />,
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Basic drag selection with a grid of selectable items. Works without ScrollWrapper - drag selection is universal and can work in any container.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithAutoScroll: Story = {
|
||||||
|
render: () => <ScrollableDragSelectDemo />,
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Drag selection with auto-scroll support. The container will automatically scroll when dragging near the edges. Uses ScrollWrapper for auto-scroll functionality.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InteractiveDragSelection: Story = {
|
||||||
|
render: () => <BasicDragSelectDemo />,
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await canvas.findByText('Item 1');
|
||||||
|
|
||||||
|
const container = canvasElement
|
||||||
|
.querySelector('[data-selectable-id="item-0"]')
|
||||||
|
?.closest('div');
|
||||||
|
|
||||||
|
if (isDefined(container)) {
|
||||||
|
await userEvent.pointer([
|
||||||
|
{ target: container, coords: { x: 50, y: 50 } },
|
||||||
|
{ keys: '[MouseLeft>]', coords: { x: 50, y: 50 } },
|
||||||
|
{ coords: { x: 200, y: 150 } },
|
||||||
|
{ keys: '[/MouseLeft]' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Automated interaction test showing drag selection behavior. Watch as items get selected during the drag operation.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => <BasicDragSelectDemo disableSelection={true} />,
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Component without drag selection enabled. Items are displayed but cannot be selected via dragging.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { RecoilRoot } from 'recoil';
|
||||||
|
|
||||||
|
import { PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
|
||||||
|
import { useDragSelect } from '../../hooks/useDragSelect';
|
||||||
|
import { DragSelect } from '../DragSelect';
|
||||||
|
|
||||||
|
jest.mock('../../hooks/useDragSelect');
|
||||||
|
jest.mock('../../hooks/useDragSelectWithAutoScroll', () => ({
|
||||||
|
useDragSelectWithAutoScroll: () => ({
|
||||||
|
handleAutoScroll: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/ui/utilities/pointer-event/hooks/useTrackPointer', () => ({
|
||||||
|
useTrackPointer: ({ onMouseDown }: { onMouseDown: PointerEventListener }) => {
|
||||||
|
(window as any).trackPointerCallbacks = {
|
||||||
|
onMouseDown,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseDragSelect = useDragSelect as jest.MockedFunction<
|
||||||
|
typeof useDragSelect
|
||||||
|
>;
|
||||||
|
|
||||||
|
describe('DragSelect', () => {
|
||||||
|
const mockOnDragSelectionChange = jest.fn();
|
||||||
|
const mockSelectableContainer = document.createElement('div');
|
||||||
|
const mockContainerRef = { current: mockSelectableContainer };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUseDragSelect.mockReturnValue({
|
||||||
|
isDragSelectionStartEnabled: jest.fn().mockReturnValue(true),
|
||||||
|
setDragSelectionStartEnabled: jest.fn(),
|
||||||
|
});
|
||||||
|
mockSelectableContainer.getBoundingClientRect = jest.fn().mockReturnValue({
|
||||||
|
left: 100,
|
||||||
|
top: 100,
|
||||||
|
width: 500,
|
||||||
|
height: 400,
|
||||||
|
});
|
||||||
|
(window as any).trackPointerCallbacks = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderDragSelect = (selectionBoundaryClass?: string) => {
|
||||||
|
return render(
|
||||||
|
<RecoilRoot>
|
||||||
|
<DragSelect
|
||||||
|
selectableItemsContainerRef={mockContainerRef}
|
||||||
|
onDragSelectionChange={mockOnDragSelectionChange}
|
||||||
|
selectionBoundaryClass={selectionBoundaryClass}
|
||||||
|
/>
|
||||||
|
</RecoilRoot>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not start selection when target has data-select-disable', () => {
|
||||||
|
renderDragSelect();
|
||||||
|
|
||||||
|
const callbacks = (window as any).trackPointerCallbacks;
|
||||||
|
const mockTarget = document.createElement('div');
|
||||||
|
mockTarget.dataset.selectDisable = 'true';
|
||||||
|
mockSelectableContainer.appendChild(mockTarget);
|
||||||
|
mockSelectableContainer.contains = jest.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
const mockEvent = {
|
||||||
|
target: mockTarget,
|
||||||
|
preventDefault: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
callbacks.onMouseDown({
|
||||||
|
x: 150,
|
||||||
|
y: 150,
|
||||||
|
event: mockEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null container ref without crashing', () => {
|
||||||
|
const nullRef = { current: null };
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(
|
||||||
|
<RecoilRoot>
|
||||||
|
<DragSelect
|
||||||
|
selectableItemsContainerRef={nullRef}
|
||||||
|
onDragSelectionChange={mockOnDragSelectionChange}
|
||||||
|
/>
|
||||||
|
</RecoilRoot>,
|
||||||
|
);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use selection boundary class when provided', () => {
|
||||||
|
const selectionBoundaryClass = 'custom-boundary';
|
||||||
|
|
||||||
|
renderDragSelect(selectionBoundaryClass);
|
||||||
|
|
||||||
|
const callbacks = (window as any).trackPointerCallbacks;
|
||||||
|
const mockTarget = document.createElement('div');
|
||||||
|
const mockBoundaryElement = document.createElement('div');
|
||||||
|
mockBoundaryElement.className = selectionBoundaryClass;
|
||||||
|
|
||||||
|
mockSelectableContainer.closest = jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(mockBoundaryElement);
|
||||||
|
mockBoundaryElement.contains = jest.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
callbacks.onMouseDown({
|
||||||
|
x: 150,
|
||||||
|
y: 150,
|
||||||
|
event: { target: mockTarget, preventDefault: jest.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSelectableContainer.closest).toHaveBeenCalledWith(
|
||||||
|
`.${selectionBoundaryClass}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work without scrollWrapperComponentInstanceId (universal compatibility)', () => {
|
||||||
|
expect(() => {
|
||||||
|
render(
|
||||||
|
<RecoilRoot>
|
||||||
|
<DragSelect
|
||||||
|
selectableItemsContainerRef={mockContainerRef}
|
||||||
|
onDragSelectionChange={mockOnDragSelectionChange}
|
||||||
|
/>
|
||||||
|
</RecoilRoot>,
|
||||||
|
);
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
const callbacks = (window as any).trackPointerCallbacks;
|
||||||
|
const mockTarget = document.createElement('div');
|
||||||
|
mockSelectableContainer.appendChild(mockTarget);
|
||||||
|
mockSelectableContainer.contains = jest.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
callbacks.onMouseDown({
|
||||||
|
x: 150,
|
||||||
|
y: 150,
|
||||||
|
event: { target: mockTarget, preventDefault: jest.fn() },
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export const AUTO_SCROLL_EDGE_THRESHOLD_PX = 20;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export const AUTO_SCROLL_MAX_SPEED_PX = 15;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export const RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS =
|
||||||
|
'record-index-container-gater-for-drag-select';
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
|
||||||
|
import { useDragSelectWithAutoScroll } from '../useDragSelectWithAutoScroll';
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext',
|
||||||
|
() => ({
|
||||||
|
useComponentInstanceStateContext: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('useDragSelectWithAutoScroll', () => {
|
||||||
|
const mockUseComponentInstanceStateContext = jest.mocked(
|
||||||
|
useComponentInstanceStateContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMockScrollElement = (bounds = {}) => {
|
||||||
|
const defaultBounds = {
|
||||||
|
top: 100,
|
||||||
|
left: 100,
|
||||||
|
bottom: 400,
|
||||||
|
right: 600,
|
||||||
|
width: 500,
|
||||||
|
height: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
const element = {
|
||||||
|
getBoundingClientRect: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ ...defaultBounds, ...bounds }),
|
||||||
|
scrollTo: jest.fn(),
|
||||||
|
scrollTop: 50,
|
||||||
|
scrollLeft: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalGetElementById = document.getElementById;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.getElementById = originalGetElementById;
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('instance ID resolution', () => {
|
||||||
|
it('should prioritize explicit scrollWrapperComponentInstanceId over context', () => {
|
||||||
|
mockUseComponentInstanceStateContext.mockReturnValue({
|
||||||
|
instanceId: 'context-instance',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockElement = createMockScrollElement();
|
||||||
|
document.getElementById = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((id) =>
|
||||||
|
id === 'scroll-wrapper-explicit-instance' ? mockElement : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useDragSelectWithAutoScroll({
|
||||||
|
scrollWrapperComponentInstanceId: 'explicit-instance',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(105, 250);
|
||||||
|
|
||||||
|
expect(document.getElementById).toHaveBeenCalledWith(
|
||||||
|
'scroll-wrapper-explicit-instance',
|
||||||
|
);
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use context instance ID when no explicit ID provided', () => {
|
||||||
|
mockUseComponentInstanceStateContext.mockReturnValue({
|
||||||
|
instanceId: 'context-instance',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockElement = createMockScrollElement();
|
||||||
|
document.getElementById = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((id) =>
|
||||||
|
id === 'scroll-wrapper-context-instance' ? mockElement : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(105, 250);
|
||||||
|
|
||||||
|
expect(document.getElementById).toHaveBeenCalledWith(
|
||||||
|
'scroll-wrapper-context-instance',
|
||||||
|
);
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not attempt scrolling when no instance ID available', () => {
|
||||||
|
mockUseComponentInstanceStateContext.mockReturnValue(null);
|
||||||
|
document.getElementById = jest.fn();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(105, 105);
|
||||||
|
|
||||||
|
expect(document.getElementById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge detection and scroll calculations', () => {
|
||||||
|
let mockElement: ReturnType<typeof createMockScrollElement>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseComponentInstanceStateContext.mockReturnValue({
|
||||||
|
instanceId: 'test-instance',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockElement = createMockScrollElement({
|
||||||
|
top: 100,
|
||||||
|
left: 100,
|
||||||
|
bottom: 400,
|
||||||
|
right: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById = jest.fn().mockReturnValue(mockElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct scroll amounts for vertical scrolling', () => {
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(300, 105);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: 35,
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(300, 395);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: 65,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct scroll amounts for horizontal scrolling', () => {
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(105, 250);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalledWith({
|
||||||
|
left: 10,
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(595, 250);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalledWith({
|
||||||
|
left: 40,
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent negative scroll values', () => {
|
||||||
|
mockElement.scrollTop = 5;
|
||||||
|
mockElement.scrollLeft = 3;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(105, 105);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalledWith({
|
||||||
|
left: 0,
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not scroll when mouse is in safe zone', () => {
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(350, 250);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(125, 250);
|
||||||
|
result.current.handleAutoScroll(575, 250);
|
||||||
|
result.current.handleAutoScroll(300, 125);
|
||||||
|
result.current.handleAutoScroll(300, 375);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle exact edge threshold boundaries', () => {
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(119, 250);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).toHaveBeenCalledWith({
|
||||||
|
left: 10,
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
result.current.handleAutoScroll(120, 250);
|
||||||
|
|
||||||
|
expect(mockElement.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseComponentInstanceStateContext.mockReturnValue({
|
||||||
|
instanceId: 'test-instance',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing DOM element gracefully', () => {
|
||||||
|
document.getElementById = jest.fn().mockReturnValue(null);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
result.current.handleAutoScroll(105, 105);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle element without getBoundingClientRect', () => {
|
||||||
|
const brokenElement = { scrollTo: jest.fn() };
|
||||||
|
document.getElementById = jest.fn().mockReturnValue(brokenElement);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDragSelectWithAutoScroll({}));
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
result.current.handleAutoScroll(105, 105);
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { ScrollWrapperComponentInstanceContext } from '@/ui/utilities/scroll/states/contexts/ScrollWrapperComponentInstanceContext';
|
||||||
|
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
|
||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { AUTO_SCROLL_EDGE_THRESHOLD_PX } from '../constants/AutoScrollEdgeThresholdPx';
|
||||||
|
import { AUTO_SCROLL_MAX_SPEED_PX } from '../constants/AutoScrollMaxSpeedPx';
|
||||||
|
|
||||||
|
type UseDragSelectWithAutoScrollProps = {
|
||||||
|
scrollWrapperComponentInstanceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDragSelectWithAutoScroll = ({
|
||||||
|
scrollWrapperComponentInstanceId,
|
||||||
|
}: UseDragSelectWithAutoScrollProps) => {
|
||||||
|
const instanceStateContext = useComponentInstanceStateContext(
|
||||||
|
ScrollWrapperComponentInstanceContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
const instanceIdFromContext = instanceStateContext?.instanceId;
|
||||||
|
|
||||||
|
const scrollWrapperInstanceId = useMemo(() => {
|
||||||
|
if (isNonEmptyString(scrollWrapperComponentInstanceId)) {
|
||||||
|
return scrollWrapperComponentInstanceId;
|
||||||
|
} else if (isNonEmptyString(instanceIdFromContext)) {
|
||||||
|
return instanceIdFromContext;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [scrollWrapperComponentInstanceId, instanceIdFromContext]);
|
||||||
|
|
||||||
|
const hasScrollWrapper = isDefined(scrollWrapperInstanceId);
|
||||||
|
|
||||||
|
const handleAutoScroll = useCallback(
|
||||||
|
(mouseX: number, mouseY: number) => {
|
||||||
|
if (!hasScrollWrapper || !scrollWrapperInstanceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollWrapperHTMLElement = document.getElementById(
|
||||||
|
`scroll-wrapper-${scrollWrapperInstanceId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!scrollWrapperHTMLElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = scrollWrapperHTMLElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const nearTop =
|
||||||
|
mouseY - containerRect.top < AUTO_SCROLL_EDGE_THRESHOLD_PX;
|
||||||
|
const nearBottom =
|
||||||
|
containerRect.bottom - mouseY < AUTO_SCROLL_EDGE_THRESHOLD_PX;
|
||||||
|
const nearLeft =
|
||||||
|
mouseX - containerRect.left < AUTO_SCROLL_EDGE_THRESHOLD_PX;
|
||||||
|
const nearRight =
|
||||||
|
containerRect.right - mouseX < AUTO_SCROLL_EDGE_THRESHOLD_PX;
|
||||||
|
|
||||||
|
const currentScrollTop = scrollWrapperHTMLElement.scrollTop;
|
||||||
|
const currentScrollLeft = scrollWrapperHTMLElement.scrollLeft;
|
||||||
|
|
||||||
|
if (nearTop) {
|
||||||
|
const newScrollTop = Math.max(
|
||||||
|
0,
|
||||||
|
currentScrollTop - AUTO_SCROLL_MAX_SPEED_PX,
|
||||||
|
);
|
||||||
|
scrollWrapperHTMLElement.scrollTo({ top: newScrollTop });
|
||||||
|
} else if (nearBottom) {
|
||||||
|
const newScrollTop = currentScrollTop + AUTO_SCROLL_MAX_SPEED_PX;
|
||||||
|
scrollWrapperHTMLElement.scrollTo({ top: newScrollTop });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearLeft) {
|
||||||
|
const newScrollLeft = Math.max(
|
||||||
|
0,
|
||||||
|
currentScrollLeft - AUTO_SCROLL_MAX_SPEED_PX,
|
||||||
|
);
|
||||||
|
scrollWrapperHTMLElement.scrollTo({
|
||||||
|
left: newScrollLeft,
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
} else if (nearRight) {
|
||||||
|
const newScrollLeft = currentScrollLeft + AUTO_SCROLL_MAX_SPEED_PX;
|
||||||
|
scrollWrapperHTMLElement.scrollTo({
|
||||||
|
left: newScrollLeft,
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasScrollWrapper, scrollWrapperInstanceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleAutoScroll,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export type SelectionBox = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { SelectionBox } from '../../types/SelectionBox';
|
||||||
|
import { isValidSelectionStart } from '../selectionBoxValidation';
|
||||||
|
|
||||||
|
describe('selectionBoxValidation', () => {
|
||||||
|
describe('isValidSelectionStart', () => {
|
||||||
|
it('should return true for selection box with minimum valid size', () => {
|
||||||
|
const selectionBox: SelectionBox = {
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidSelectionStart(selectionBox)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for selection box with zero width', () => {
|
||||||
|
const selectionBox: SelectionBox = {
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
width: 0,
|
||||||
|
height: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidSelectionStart(selectionBox)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for selection box with zero height', () => {
|
||||||
|
const selectionBox: SelectionBox = {
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
width: 5,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidSelectionStart(selectionBox)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for selection box with both zero width and height', () => {
|
||||||
|
const selectionBox: SelectionBox = {
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidSelectionStart(selectionBox)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for large selection box', () => {
|
||||||
|
const selectionBox: SelectionBox = {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 1000,
|
||||||
|
height: 800,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidSelectionStart(selectionBox)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative dimensions', () => {
|
||||||
|
const selectionBox: SelectionBox = {
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
width: -5,
|
||||||
|
height: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidSelectionStart(selectionBox)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fractional dimensions', () => {
|
||||||
|
const selectionBox: SelectionBox = {
|
||||||
|
top: 10.5,
|
||||||
|
left: 10.5,
|
||||||
|
width: 2.3,
|
||||||
|
height: 3.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidSelectionStart(selectionBox)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for fractional dimensions with large area', () => {
|
||||||
|
const selectionBox: SelectionBox = {
|
||||||
|
top: 10.5,
|
||||||
|
left: 10.5,
|
||||||
|
width: 4.0,
|
||||||
|
height: 3.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidSelectionStart(selectionBox)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { SelectionBox } from '@/ui/utilities/drag-select/types/SelectionBox';
|
||||||
|
|
||||||
|
const calculateBoxArea = (box: SelectionBox): number => {
|
||||||
|
return box.width * box.height;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidSelectionStart = (box: SelectionBox): boolean => {
|
||||||
|
return calculateBoxArea(box) > 10;
|
||||||
|
};
|
||||||
@ -17,7 +17,11 @@ describe('useTrackPointer', () => {
|
|||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onMouseDown).toHaveBeenCalledWith(150, 250);
|
expect(onMouseDown).toHaveBeenCalledWith({
|
||||||
|
x: 150,
|
||||||
|
y: 250,
|
||||||
|
event: expect.any(MouseEvent),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should call onMouseUp when mouse up event is triggered', () => {
|
it('Should call onMouseUp when mouse up event is triggered', () => {
|
||||||
@ -34,7 +38,11 @@ describe('useTrackPointer', () => {
|
|||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onMouseUp).toHaveBeenCalledWith(200, 300);
|
expect(onMouseUp).toHaveBeenCalledWith({
|
||||||
|
x: 200,
|
||||||
|
y: 300,
|
||||||
|
event: expect.any(MouseEvent),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should call onInternalMouseMove when mouse move event is triggered', () => {
|
it('Should call onInternalMouseMove when mouse move event is triggered', () => {
|
||||||
@ -51,6 +59,77 @@ describe('useTrackPointer', () => {
|
|||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onInternalMouseMove).toHaveBeenCalledWith(150, 250);
|
expect(onInternalMouseMove).toHaveBeenCalledWith({
|
||||||
|
x: 150,
|
||||||
|
y: 250,
|
||||||
|
event: expect.any(MouseEvent),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should pass the correct event object to the callback', () => {
|
||||||
|
const onMouseDown = jest.fn();
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useTrackPointer({
|
||||||
|
onMouseDown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const event = new MouseEvent('mousedown', { clientX: 100, clientY: 200 });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const calledWith = onMouseDown.mock.calls[0][0];
|
||||||
|
expect(calledWith.event).toBeInstanceOf(MouseEvent);
|
||||||
|
expect(calledWith.event.type).toBe('mousedown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should handle touch events correctly', () => {
|
||||||
|
const onMouseDown = jest.fn();
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useTrackPointer({
|
||||||
|
onMouseDown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const touchEvent = new TouchEvent('touchstart', {
|
||||||
|
changedTouches: [
|
||||||
|
{
|
||||||
|
clientX: 120,
|
||||||
|
clientY: 180,
|
||||||
|
} as Touch,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
document.dispatchEvent(touchEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onMouseDown.mock.calls.length > 0) {
|
||||||
|
const calledWith = onMouseDown.mock.calls[0][0];
|
||||||
|
expect(calledWith.x).toBe(120);
|
||||||
|
expect(calledWith.y).toBe(180);
|
||||||
|
expect(calledWith.event).toBeInstanceOf(TouchEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should not track pointer when shouldTrackPointer is false', () => {
|
||||||
|
const onMouseDown = jest.fn();
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useTrackPointer({
|
||||||
|
shouldTrackPointer: false,
|
||||||
|
onMouseDown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const event = new MouseEvent('mousedown', { clientX: 150, clientY: 250 });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onMouseDown).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
|
import { PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
type MouseListener = (positionX: number, positionY: number) => void;
|
|
||||||
|
|
||||||
export const useTrackPointer = ({
|
export const useTrackPointer = ({
|
||||||
shouldTrackPointer = true,
|
shouldTrackPointer = true,
|
||||||
onMouseMove,
|
onMouseMove,
|
||||||
@ -9,9 +8,9 @@ export const useTrackPointer = ({
|
|||||||
onMouseUp,
|
onMouseUp,
|
||||||
}: {
|
}: {
|
||||||
shouldTrackPointer?: boolean;
|
shouldTrackPointer?: boolean;
|
||||||
onMouseMove?: MouseListener;
|
onMouseMove?: PointerEventListener;
|
||||||
onMouseDown?: MouseListener;
|
onMouseDown?: PointerEventListener;
|
||||||
onMouseUp?: MouseListener;
|
onMouseUp?: PointerEventListener;
|
||||||
}) => {
|
}) => {
|
||||||
const extractPosition = useCallback((event: MouseEvent | TouchEvent) => {
|
const extractPosition = useCallback((event: MouseEvent | TouchEvent) => {
|
||||||
const clientX =
|
const clientX =
|
||||||
@ -25,7 +24,7 @@ export const useTrackPointer = ({
|
|||||||
const onInternalMouseMove = useCallback(
|
const onInternalMouseMove = useCallback(
|
||||||
(event: MouseEvent | TouchEvent) => {
|
(event: MouseEvent | TouchEvent) => {
|
||||||
const { clientX, clientY } = extractPosition(event);
|
const { clientX, clientY } = extractPosition(event);
|
||||||
onMouseMove?.(clientX, clientY);
|
onMouseMove?.({ x: clientX, y: clientY, event });
|
||||||
},
|
},
|
||||||
[onMouseMove, extractPosition],
|
[onMouseMove, extractPosition],
|
||||||
);
|
);
|
||||||
@ -33,7 +32,7 @@ export const useTrackPointer = ({
|
|||||||
const onInternalMouseDown = useCallback(
|
const onInternalMouseDown = useCallback(
|
||||||
(event: MouseEvent | TouchEvent) => {
|
(event: MouseEvent | TouchEvent) => {
|
||||||
const { clientX, clientY } = extractPosition(event);
|
const { clientX, clientY } = extractPosition(event);
|
||||||
onMouseDown?.(clientX, clientY);
|
onMouseDown?.({ x: clientX, y: clientY, event });
|
||||||
},
|
},
|
||||||
[onMouseDown, extractPosition],
|
[onMouseDown, extractPosition],
|
||||||
);
|
);
|
||||||
@ -41,7 +40,7 @@ export const useTrackPointer = ({
|
|||||||
const onInternalMouseUp = useCallback(
|
const onInternalMouseUp = useCallback(
|
||||||
(event: MouseEvent | TouchEvent) => {
|
(event: MouseEvent | TouchEvent) => {
|
||||||
const { clientX, clientY } = extractPosition(event);
|
const { clientX, clientY } = extractPosition(event);
|
||||||
onMouseUp?.(clientX, clientY);
|
onMouseUp?.({ x: clientX, y: clientY, event });
|
||||||
},
|
},
|
||||||
[onMouseUp, extractPosition],
|
[onMouseUp, extractPosition],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
export type PointerEventListener = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
event: MouseEvent | TouchEvent;
|
||||||
|
}) => void;
|
||||||
20
yarn.lock
20
yarn.lock
@ -24,18 +24,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@air/react-drag-to-select@npm:^5.0.8":
|
|
||||||
version: 5.0.8
|
|
||||||
resolution: "@air/react-drag-to-select@npm:5.0.8"
|
|
||||||
dependencies:
|
|
||||||
react-style-object-to-css: "npm:^1.1.2"
|
|
||||||
peerDependencies:
|
|
||||||
react: 16 - 18
|
|
||||||
react-dom: 16 - 18
|
|
||||||
checksum: 10c0/a7005e863ab0dc93ae789a8093d8284f6f4c519a8f23c54136f493e014b2a10dcab42c83521e75a83fdac5806d55a38f417b108386c684b050a4fbc4071fb419
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@algolia/autocomplete-core@npm:1.9.3":
|
"@algolia/autocomplete-core@npm:1.9.3":
|
||||||
version: 1.9.3
|
version: 1.9.3
|
||||||
resolution: "@algolia/autocomplete-core@npm:1.9.3"
|
resolution: "@algolia/autocomplete-core@npm:1.9.3"
|
||||||
@ -50271,13 +50259,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-style-object-to-css@npm:^1.1.2":
|
|
||||||
version: 1.1.2
|
|
||||||
resolution: "react-style-object-to-css@npm:1.1.2"
|
|
||||||
checksum: 10c0/c2154dd99723dbc2c359a167401d65b8bae300b9e7ed0c7609b617340d941faa4af822d855257b6f0e36522e30665650c7263f4ed8d556217e246048fbc7bb7c
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"react-style-singleton@npm:^2.2.1":
|
"react-style-singleton@npm:^2.2.1":
|
||||||
version: 2.2.1
|
version: 2.2.1
|
||||||
resolution: "react-style-singleton@npm:2.2.1"
|
resolution: "react-style-singleton@npm:2.2.1"
|
||||||
@ -55783,7 +55764,6 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "twenty@workspace:."
|
resolution: "twenty@workspace:."
|
||||||
dependencies:
|
dependencies:
|
||||||
"@air/react-drag-to-select": "npm:^5.0.8"
|
|
||||||
"@apollo/client": "npm:^3.7.17"
|
"@apollo/client": "npm:^3.7.17"
|
||||||
"@apollo/server": "npm:^4.7.3"
|
"@apollo/server": "npm:^4.7.3"
|
||||||
"@aws-sdk/client-lambda": "npm:^3.614.0"
|
"@aws-sdk/client-lambda": "npm:^3.614.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user