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:
@ -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>
|
||||
|
||||
<DragSelect
|
||||
selectableItemsContainerRef={boardRef}
|
||||
onDragSelectionEnd={handleDragSelectionEnd}
|
||||
onDragSelectionChange={setRecordAsSelected}
|
||||
onDragSelectionStart={handleDragSelectionStart}
|
||||
scrollWrapperComponentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`}
|
||||
selectionBoundaryClass={
|
||||
RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS
|
||||
}
|
||||
/>
|
||||
</StyledContainer>
|
||||
<DragSelect
|
||||
dragSelectable={boardRef}
|
||||
onDragSelectionEnd={handleDragSelectionEnd}
|
||||
onDragSelectionChange={setRecordAsSelected}
|
||||
onDragSelectionStart={handleDragSelectionStart}
|
||||
/>
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user