add drag/drop to column reordering

This commit is contained in:
jeffvli
2025-11-14 00:48:24 -08:00
parent 500947eb1f
commit e82c1d3a20
2 changed files with 351 additions and 145 deletions
@@ -9,6 +9,7 @@
} }
.item { .item {
position: relative;
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: var(--theme-spacing-md); gap: var(--theme-spacing-md);
@@ -24,3 +25,29 @@
outline: 2px solid var(--theme-colors-primary); outline: 2px solid var(--theme-colors-primary);
outline-offset: 2px; outline-offset: 2px;
} }
.item.dragging {
opacity: 0.5;
}
.item.dragged-over-top::before {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 1;
height: 2px;
content: '';
background-color: var(--theme-colors-primary);
}
.item.dragged-over-bottom::before {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
height: 2px;
content: '';
background-color: var(--theme-colors-primary);
}
@@ -1,8 +1,18 @@
import {
attachClosestEdge,
type Edge,
extractClosestEdge,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
import { useDebouncedState } from '@mantine/hooks'; import { useDebouncedState } from '@mantine/hooks';
import clsx from 'clsx'; import clsx from 'clsx';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import styles from './table-config.module.css'; import styles from './table-config.module.css';
@@ -25,6 +35,7 @@ import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input'; import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { ItemListKey, ListPaginationType } from '/@/shared/types/types'; import { ItemListKey, ListPaginationType } from '/@/shared/types/types';
interface TableConfigProps { interface TableConfigProps {
@@ -432,6 +443,23 @@ const TableColumnConfig = ({
})); }));
}, [value, searchColumns, fuse]); }, [value, searchColumns, fuse]);
const handleReorder = useCallback(
(idFrom: string, idTo: string, edge: Edge | null) => {
const idList = value.map((item) => item.id);
const newIdOrder = dndUtils.reorderById({
edge,
idFrom,
idTo,
list: idList,
});
// Map the new ID order back to full items
const newOrder = newIdOrder.map((id) => value.find((item) => item.id === id)!);
onChange(newOrder);
},
[onChange, value],
);
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
@@ -444,19 +472,172 @@ const TableColumnConfig = ({
size="xs" size="xs"
/> />
</Group> </Group>
<div style={{ userSelect: 'none' }}>
{filteredColumns.map(({ item, matches }) => ( {filteredColumns.map(({ item, matches }) => (
<motion.div <TableColumnItem
handleAlignCenter={handleAlignCenter}
handleAlignLeft={handleAlignLeft}
handleAlignRight={handleAlignRight}
handleAutoSize={handleAutoSize}
handleChangeEnabled={handleChangeEnabled}
handleMoveDown={handleMoveDown}
handleMoveUp={handleMoveUp}
handlePinToLeft={handlePinToLeft}
handlePinToRight={handlePinToRight}
handleReorder={handleReorder}
handleRowWidth={handleRowWidth}
item={item}
key={item.id}
label={labelMap[item.id]}
matches={matches}
/>
))}
</div>
</Stack>
);
};
const DragHandle = ({ dragHandleRef }: { dragHandleRef: React.RefObject<HTMLButtonElement> }) => {
const { t } = useTranslation();
return (
<ActionIcon
icon="dragVertical"
iconProps={{
size: 'md',
}}
ref={dragHandleRef}
size="xs"
style={{ cursor: 'grab' }}
tooltip={{
label: t('table.config.general.dragToReorder', {
postProcess: 'sentenceCase',
}),
}}
variant="transparent"
/>
);
};
const TableColumnItem = ({
handleAlignCenter,
handleAlignLeft,
handleAlignRight,
handleAutoSize,
handleChangeEnabled,
handleMoveDown,
handleMoveUp,
handlePinToLeft,
handlePinToRight,
handleReorder,
handleRowWidth,
item,
label,
matches,
}: {
handleAlignCenter: (item: ItemTableListColumnConfig) => void;
handleAlignLeft: (item: ItemTableListColumnConfig) => void;
handleAlignRight: (item: ItemTableListColumnConfig) => void;
handleAutoSize: (item: ItemTableListColumnConfig, checked: boolean) => void;
handleChangeEnabled: (item: ItemTableListColumnConfig, checked: boolean) => void;
handleMoveDown: (item: ItemTableListColumnConfig) => void;
handleMoveUp: (item: ItemTableListColumnConfig) => void;
handlePinToLeft: (item: ItemTableListColumnConfig) => void;
handlePinToRight: (item: ItemTableListColumnConfig) => void;
handleReorder: (idFrom: string, idTo: string, edge: Edge | null) => void;
handleRowWidth: (item: ItemTableListColumnConfig, number: number | string) => void;
item: ItemTableListColumnConfig;
label: string;
matches: null | readonly Fuse.FuseResultMatch[];
}) => {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLButtonElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);
useEffect(() => {
if (!ref.current || !dragHandleRef.current) {
return;
}
return combine(
draggable({
element: dragHandleRef.current,
getInitialData: () => {
const data = dndUtils.generateDragData({
id: [item.id],
operation: [DragOperation.REORDER],
type: DragTarget.TABLE_COLUMN,
});
return data;
},
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: (data) => {
disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });
},
}),
dropTargetForElements({
canDrop: (args) => {
const data = args.source.data as unknown as DragData;
const isSelf = (args.source.data.id as string[])[0] === item.id;
return dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) && !isSelf;
},
element: ref.current,
getData: ({ element, input }) => {
const data = dndUtils.generateDragData({
id: [item.id],
operation: [DragOperation.REORDER],
type: DragTarget.TABLE_COLUMN,
});
return attachClosestEdge(data, {
allowedEdges: ['top', 'bottom'],
element,
input,
});
},
onDrag: (args) => {
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);
setIsDraggedOver(closestEdgeOfTarget);
},
onDragLeave: () => {
setIsDraggedOver(null);
},
onDrop: (args) => {
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);
const from = args.source.data.id as string[];
const to = args.self.data.id as string[];
handleReorder(from[0], to[0], closestEdgeOfTarget);
setIsDraggedOver(null);
},
}),
);
}, [item.id, handleReorder]);
return (
<div
className={clsx(styles.item, { className={clsx(styles.item, {
[styles.draggedOverBottom]: isDraggedOver === 'bottom',
[styles.draggedOverTop]: isDraggedOver === 'top',
[styles.dragging]: isDragging,
[styles.matched]: matches && matches.length > 0, [styles.matched]: matches && matches.length > 0,
})} })}
key={item.id} ref={ref}
layout
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
<DragHandle dragHandleRef={dragHandleRef} />
<Checkbox <Checkbox
checked={item.isEnabled} checked={item.isEnabled}
id={item.id} id={item.id}
label={labelMap[item.id]} label={label}
onChange={(e) => handleChangeEnabled(item, e.currentTarget.checked)} onChange={(e) => handleChangeEnabled(item, e.currentTarget.checked)}
size="sm" size="sm"
/> />
@@ -585,8 +766,6 @@ const TableColumnConfig = ({
variant="subtle" variant="subtle"
/> />
</Group> </Group>
</motion.div> </div>
))}
</Stack>
); );
}; };