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,
|
||||
"dependencies": {
|
||||
"@air/react-drag-to-select": "^5.0.8",
|
||||
"@apollo/client": "^3.7.17",
|
||||
"@apollo/server": "^4.7.3",
|
||||
"@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 { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
|
||||
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 { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
@ -45,10 +46,13 @@ const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledColumnContainer = styled.div`
|
||||
display: flex;
|
||||
|
||||
& > *:not(:first-of-type) {
|
||||
border-left: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
}
|
||||
@ -57,12 +61,14 @@ const StyledColumnContainer = styled.div`
|
||||
const StyledContainerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100% - ${({ theme }) => theme.spacing(2)});
|
||||
height: min-content;
|
||||
`;
|
||||
|
||||
const StyledBoardContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 48px);
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
export const RecordBoard = () => {
|
||||
@ -233,13 +239,18 @@ export const RecordBoard = () => {
|
||||
))}
|
||||
</StyledColumnContainer>
|
||||
</DragDropContext>
|
||||
</StyledContainer>
|
||||
|
||||
<DragSelect
|
||||
dragSelectable={boardRef}
|
||||
selectableItemsContainerRef={boardRef}
|
||||
onDragSelectionEnd={handleDragSelectionEnd}
|
||||
onDragSelectionChange={setRecordAsSelected}
|
||||
onDragSelectionStart={handleDragSelectionStart}
|
||||
scrollWrapperComponentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`}
|
||||
selectionBoundaryClass={
|
||||
RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS
|
||||
}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</StyledBoardContentContainer>
|
||||
</StyledContainerContainer>
|
||||
</ScrollWrapper>
|
||||
|
||||
@ -14,6 +14,8 @@ const StyledColumn = styled.div`
|
||||
flex-direction: column;
|
||||
max-width: 200px;
|
||||
min-width: 200px;
|
||||
min-height: 100%;
|
||||
flex: 1;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
padding-top: 0px;
|
||||
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 { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
|
||||
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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||
@ -90,7 +91,9 @@ export const RecordIndexContainerGater = () => {
|
||||
/>
|
||||
<RecordIndexPageHeader />
|
||||
<PageBody>
|
||||
<StyledIndexContainer>
|
||||
<StyledIndexContainer
|
||||
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
|
||||
>
|
||||
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
|
||||
<RecordIndexContainer />
|
||||
</StyledIndexContainer>
|
||||
|
||||
@ -76,6 +76,7 @@ export const RecordTable = () => {
|
||||
handleDragSelectionEnd={handleDragSelectionEnd}
|
||||
setRowSelected={setRowSelected}
|
||||
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 { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
|
||||
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 { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
const StyledTableWithPointerEvents = styled(StyledTable)<{
|
||||
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 {
|
||||
tableBodyRef: React.RefObject<HTMLTableElement>;
|
||||
handleDragSelectionStart: () => void;
|
||||
handleDragSelectionEnd: () => void;
|
||||
setRowSelected: (rowId: string, selected: boolean) => void;
|
||||
hasRecordGroups: boolean;
|
||||
recordTableId: string;
|
||||
}
|
||||
|
||||
export const RecordTableContent = ({
|
||||
@ -30,8 +39,10 @@ export const RecordTableContent = ({
|
||||
handleDragSelectionEnd,
|
||||
setRowSelected,
|
||||
hasRecordGroups,
|
||||
recordTableId,
|
||||
}: RecordTableContentProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDragStart = () => {
|
||||
setIsDragging(true);
|
||||
@ -44,7 +55,7 @@ export const RecordTableContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTableContainer ref={containerRef}>
|
||||
<StyledTableWithPointerEvents ref={tableBodyRef} isDragging={isDragging}>
|
||||
<RecordTableHeader />
|
||||
{hasRecordGroups ? (
|
||||
@ -56,11 +67,13 @@ export const RecordTableContent = ({
|
||||
<RecordTableStickyBottomEffect />
|
||||
</StyledTableWithPointerEvents>
|
||||
<DragSelect
|
||||
dragSelectable={tableBodyRef}
|
||||
selectableItemsContainerRef={containerRef}
|
||||
onDragSelectionStart={handleDragStart}
|
||||
onDragSelectionChange={setRowSelected}
|
||||
onDragSelectionEnd={handleDragEnd}
|
||||
scrollWrapperComponentInstanceId={`record-table-scroll-${recordTableId}`}
|
||||
selectionBoundaryClass={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
|
||||
/>
|
||||
</>
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -32,7 +32,7 @@ export const RecordTableCellCheckbox = () => {
|
||||
|
||||
return (
|
||||
<StyledRecordTableTd isSelected={isSelected} hasRightBorder={false}>
|
||||
<StyledContainer onClick={handleClick}>
|
||||
<StyledContainer onClick={handleClick} data-select-disable>
|
||||
<Checkbox hoverable checked={isSelected} />
|
||||
</StyledContainer>
|
||||
</StyledRecordTableTd>
|
||||
|
||||
@ -16,6 +16,7 @@ import { tableColumnsComponentState } from '@/object-record/record-table/states/
|
||||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
|
||||
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 { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
||||
@ -138,16 +139,19 @@ export const RecordTableHeaderCell = ({
|
||||
|
||||
const { handleColumnsChange } = useTableColumns();
|
||||
|
||||
const handleResizeHandlerStart = useCallback((positionX: number) => {
|
||||
setInitialPointerPositionX(positionX);
|
||||
}, []);
|
||||
const handleResizeHandlerStart = useCallback<PointerEventListener>(
|
||||
({ x }) => {
|
||||
setInitialPointerPositionX(x);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [iconVisibility, setIconVisibility] = useState(false);
|
||||
|
||||
const handleResizeHandlerMove = useCallback(
|
||||
(positionX: number) => {
|
||||
const handleResizeHandlerMove = useCallback<PointerEventListener>(
|
||||
({ x }) => {
|
||||
if (!initialPointerPositionX) return;
|
||||
setResizeFieldOffset(positionX - initialPointerPositionX);
|
||||
setResizeFieldOffset(x - initialPointerPositionX);
|
||||
},
|
||||
[setResizeFieldOffset, initialPointerPositionX],
|
||||
);
|
||||
|
||||
@ -82,7 +82,7 @@ export const RecordTableHeaderCheckboxColumn = () => {
|
||||
<StyledColumnHeaderCell
|
||||
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
|
||||
>
|
||||
<StyledContainer>
|
||||
<StyledContainer data-select-disable>
|
||||
<Checkbox
|
||||
hoverable
|
||||
checked={checked}
|
||||
|
||||
@ -1,76 +1,250 @@
|
||||
import {
|
||||
boxesIntersect,
|
||||
useSelectionContainer,
|
||||
} from '@air/react-drag-to-select';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { RefObject } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { RefObject, useCallback, useState } 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 { RGBA } from 'twenty-ui/theme';
|
||||
import { SelectionBox } from '../types/SelectionBox';
|
||||
import { isValidSelectionStart } from '../utils/selectionBoxValidation';
|
||||
|
||||
type DragSelectProps = {
|
||||
dragSelectable: RefObject<HTMLElement>;
|
||||
selectableItemsContainerRef: RefObject<HTMLElement>;
|
||||
onDragSelectionChange: (id: string, selected: boolean) => void;
|
||||
onDragSelectionStart?: (event: MouseEvent) => void;
|
||||
onDragSelectionEnd?: (event: MouseEvent) => void;
|
||||
onDragSelectionStart?: (event: MouseEvent | TouchEvent) => 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 = ({
|
||||
dragSelectable,
|
||||
selectableItemsContainerRef,
|
||||
onDragSelectionChange,
|
||||
onDragSelectionStart,
|
||||
onDragSelectionEnd,
|
||||
scrollWrapperComponentInstanceId,
|
||||
selectionBoundaryClass,
|
||||
}: DragSelectProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { isDragSelectionStartEnabled } = useDragSelect();
|
||||
|
||||
const { DragSelection } = useSelectionContainer({
|
||||
shouldStartSelecting: (target) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
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()) {
|
||||
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) {
|
||||
let el = target;
|
||||
while (el.parentElement && !el.dataset.selectDisable) {
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
return el.dataset.selectDisable !== 'true';
|
||||
if (el.dataset.selectDisable === 'true') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onSelectionStart: onDragSelectionStart,
|
||||
onSelectionEnd: onDragSelectionEnd,
|
||||
onSelectionChange: (box) => {
|
||||
const scrollAwareBox = {
|
||||
...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,
|
||||
},
|
||||
},
|
||||
});
|
||||
[
|
||||
isDragSelectionStartEnabled,
|
||||
selectableItemsContainerRef,
|
||||
selectionBoundaryClass,
|
||||
],
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@ -34,7 +38,11 @@ describe('useTrackPointer', () => {
|
||||
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', () => {
|
||||
@ -51,6 +59,77 @@ describe('useTrackPointer', () => {
|
||||
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';
|
||||
|
||||
type MouseListener = (positionX: number, positionY: number) => void;
|
||||
|
||||
export const useTrackPointer = ({
|
||||
shouldTrackPointer = true,
|
||||
onMouseMove,
|
||||
@ -9,9 +8,9 @@ export const useTrackPointer = ({
|
||||
onMouseUp,
|
||||
}: {
|
||||
shouldTrackPointer?: boolean;
|
||||
onMouseMove?: MouseListener;
|
||||
onMouseDown?: MouseListener;
|
||||
onMouseUp?: MouseListener;
|
||||
onMouseMove?: PointerEventListener;
|
||||
onMouseDown?: PointerEventListener;
|
||||
onMouseUp?: PointerEventListener;
|
||||
}) => {
|
||||
const extractPosition = useCallback((event: MouseEvent | TouchEvent) => {
|
||||
const clientX =
|
||||
@ -25,7 +24,7 @@ export const useTrackPointer = ({
|
||||
const onInternalMouseMove = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const { clientX, clientY } = extractPosition(event);
|
||||
onMouseMove?.(clientX, clientY);
|
||||
onMouseMove?.({ x: clientX, y: clientY, event });
|
||||
},
|
||||
[onMouseMove, extractPosition],
|
||||
);
|
||||
@ -33,7 +32,7 @@ export const useTrackPointer = ({
|
||||
const onInternalMouseDown = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const { clientX, clientY } = extractPosition(event);
|
||||
onMouseDown?.(clientX, clientY);
|
||||
onMouseDown?.({ x: clientX, y: clientY, event });
|
||||
},
|
||||
[onMouseDown, extractPosition],
|
||||
);
|
||||
@ -41,7 +40,7 @@ export const useTrackPointer = ({
|
||||
const onInternalMouseUp = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const { clientX, clientY } = extractPosition(event);
|
||||
onMouseUp?.(clientX, clientY);
|
||||
onMouseUp?.({ x: clientX, y: clientY, event });
|
||||
},
|
||||
[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
|
||||
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":
|
||||
version: 1.9.3
|
||||
resolution: "@algolia/autocomplete-core@npm:1.9.3"
|
||||
@ -50271,13 +50259,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.2.1
|
||||
resolution: "react-style-singleton@npm:2.2.1"
|
||||
@ -55783,7 +55764,6 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "twenty@workspace:."
|
||||
dependencies:
|
||||
"@air/react-drag-to-select": "npm:^5.0.8"
|
||||
"@apollo/client": "npm:^3.7.17"
|
||||
"@apollo/server": "npm:^4.7.3"
|
||||
"@aws-sdk/client-lambda": "npm:^3.614.0"
|
||||
|
||||
Reference in New Issue
Block a user