mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-22 00:13:45 -05:00
Add drag&drop reordering for transactions within the same day (#6653)
* feat(desktop-client): add transaction drag-and-drop reordering Add the ability to manually reorder transactions within a date group using drag-and-drop in the TransactionsTable. This is useful for correcting the order of transactions that occurred on the same day. Prevent transaction reordering from falling back to the end of the day list when dropping before a split parent. Parent-level moves now target parent rows only, so regular transactions can be inserted between split parents on the same date. Key changes: Frontend: - Migrate drag-and-drop from react-dnd to react-aria hooks - Add transaction row drag handles and drop indicators - Implement onReorder handler in TransactionList - Restrict reordering to same-date transactions only - Disable drag-drop on aggregate views (categories, etc.) Backend: - Add transaction-move handler with validation - Implement moveTransaction in db layer with midpoint/shove algorithm - Add TRANSACTION_SORT_INCREMENT constant for consistent spacing - Handle split transaction subtransactions automatically - Inherit parent sort_order in child transactions during import Refactoring: - Remove useDragRef hook (replaced by react-aria) - Remove DndProvider wrapper from App.tsx - Update budget and sidebar components for new drag-drop API - Fix sort.tsx @ts-strict-ignore by adding proper types configure allowReorder for TransactionTable consumers - Account.tsx: Enable reordering for single-account views, add sort_order as tiebreaker for stable ordering when sorted by other columns - Calendar.tsx: Disable reordering in calendar report view (read-only context) * void promises
This commit is contained in:
@@ -1661,6 +1661,11 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
|
||||
maybeSortByPreviousField(this, sortPrevField, sortPrevAscDesc);
|
||||
|
||||
// Always add sort_order as a final tiebreaker to maintain stable ordering
|
||||
// when transactions have the same values in the sorted column(s)
|
||||
this.currentQuery = this.currentQuery.orderBy({ sort_order: sortAscDesc });
|
||||
|
||||
this.updateQuery(this.currentQuery, isFiltered);
|
||||
};
|
||||
|
||||
@@ -1861,6 +1866,12 @@ class AccountInternal extends PureComponent<
|
||||
accountId === 'onbudget' ||
|
||||
accountId === 'uncategorized'
|
||||
}
|
||||
allowReorder={
|
||||
!!accountId &&
|
||||
accountId !== 'offbudget' &&
|
||||
accountId !== 'onbudget' &&
|
||||
accountId !== 'uncategorized'
|
||||
}
|
||||
isAdding={this.state.isAdding}
|
||||
isNew={this.isNew}
|
||||
isMatched={this.isMatched}
|
||||
|
||||
@@ -697,6 +697,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
onMakeAsNonSplitTransactions={() => {}}
|
||||
showSelection={false}
|
||||
allowSplitTransaction={false}
|
||||
allowReorder={false}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
) : (
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { DropPosition as AriaDropPosition } from 'react-aria';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
@@ -150,7 +151,9 @@ type ItemPosition = 'first' | 'last' | null;
|
||||
export const DropHighlightPosContext = createContext<ItemPosition>(null);
|
||||
|
||||
type DropHighlightProps = {
|
||||
pos: DropPosition;
|
||||
// Supports legacy ('top'/'bottom') and react-aria ('before'/'after'/'on') positions
|
||||
// 'on' is not used in our UI but is included for type compatibility
|
||||
pos: DropPosition | AriaDropPosition | null;
|
||||
offset?: {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
@@ -159,15 +162,17 @@ type DropHighlightProps = {
|
||||
export function DropHighlight({ pos, offset }: DropHighlightProps) {
|
||||
const itemPos = useContext(DropHighlightPosContext);
|
||||
|
||||
if (pos == null) {
|
||||
// 'on' position is not supported for highlight (used for dropping onto items, not between)
|
||||
if (pos == null || pos === 'on') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topOffset = (itemPos === 'first' ? 2 : 0) + (offset?.top || 0);
|
||||
const bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset?.bottom || 0);
|
||||
|
||||
const posStyle =
|
||||
pos === 'top' ? { top: -2 + topOffset } : { bottom: -1 + bottomOffset };
|
||||
// Support both legacy ('top'/'bottom') and aria ('before'/'after') position names
|
||||
const isTop = pos === 'top' || pos === 'before';
|
||||
const posStyle = isTop ? { top: topOffset } : { bottom: bottomOffset };
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getUpcomingDays } from 'loot-core/shared/schedules';
|
||||
import {
|
||||
addSplitTransaction,
|
||||
applyTransactionDiff,
|
||||
isPreviewId,
|
||||
realizeTempTransactions,
|
||||
splitTransaction,
|
||||
updateTransaction,
|
||||
@@ -33,12 +34,13 @@ import { TransactionTable } from './TransactionsTable';
|
||||
import type { TransactionTableProps } from './TransactionsTable';
|
||||
|
||||
import type { TableHandleRef } from '@desktop-client/components/table';
|
||||
import { isValidBoundaryDrop } from '@desktop-client/hooks/useDragDrop';
|
||||
import type { DropPosition } from '@desktop-client/hooks/useDragDrop';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
// When data changes, there are two ways to update the UI:
|
||||
//
|
||||
// * Optimistic updates: we apply the needed updates to local data
|
||||
@@ -270,6 +272,8 @@ type TransactionListProps = Pick<
|
||||
allTransactions: TransactionEntity[];
|
||||
account: AccountEntity | undefined;
|
||||
category: CategoryEntity | undefined;
|
||||
isFiltered?: boolean;
|
||||
allowReorder?: boolean;
|
||||
onChange: (
|
||||
transaction: TransactionEntity,
|
||||
transactions: TransactionEntity[],
|
||||
@@ -298,6 +302,8 @@ export function TransactionList({
|
||||
isAdding,
|
||||
isNew,
|
||||
isMatched,
|
||||
isFiltered,
|
||||
allowReorder = true,
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
renderEmpty,
|
||||
@@ -599,6 +605,123 @@ export function TransactionList({
|
||||
[onApplyFilter],
|
||||
);
|
||||
|
||||
const onReorder = useCallback(
|
||||
async (id: string, dropPos: DropPosition, targetId: string) => {
|
||||
// Don't support reorder while sorted by non-date field or filtered
|
||||
if ((sortField && sortField !== 'date') || isFiltered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the transaction being dragged to determine if it's a child
|
||||
const draggedTrans = allTransactions.find(t => t.id === id);
|
||||
if (!draggedTrans) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Child transaction reordering: siblings only
|
||||
if (draggedTrans.is_child && draggedTrans.parent_id) {
|
||||
const siblings = allTransactions.filter(
|
||||
t => t.parent_id === draggedTrans.parent_id && !isPreviewId(t.id),
|
||||
);
|
||||
|
||||
const targetTransIdx = siblings.findIndex(t => t.id === targetId);
|
||||
if (targetTransIdx === -1) {
|
||||
return; // Target is not a sibling
|
||||
}
|
||||
|
||||
// Convert dropPos to API targetId for child reordering
|
||||
// API places transaction AFTER targetId; null means move to top of siblings
|
||||
let apiTargetId: string | null;
|
||||
if (dropPos === 'after') {
|
||||
apiTargetId = targetId;
|
||||
} else {
|
||||
const aboveIdx = targetTransIdx - 1;
|
||||
apiTargetId = aboveIdx >= 0 ? siblings[aboveIdx].id : null;
|
||||
}
|
||||
|
||||
await send('transaction-move', {
|
||||
id,
|
||||
accountId: draggedTrans.account,
|
||||
targetId: apiTargetId,
|
||||
});
|
||||
onRefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a reorderable list that excludes child and preview/scheduled transactions
|
||||
const reorderable = allTransactions.filter(
|
||||
t => !t.is_child && !isPreviewId(t.id),
|
||||
);
|
||||
|
||||
const transIdx = reorderable.findIndex(t => t.id === id);
|
||||
const targetTransIdx = reorderable.findIndex(t => t.id === targetId);
|
||||
|
||||
if (transIdx === -1 || targetTransIdx === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trans = reorderable[transIdx];
|
||||
const targetTrans = reorderable[targetTransIdx];
|
||||
const isAscending = sortField === 'date' && ascDesc === 'asc';
|
||||
|
||||
// Validate drop position: same date or at a date boundary
|
||||
let isValidDrop = targetTrans.date === trans.date;
|
||||
if (!isValidDrop) {
|
||||
const neighborIdx =
|
||||
dropPos === 'before' ? targetTransIdx - 1 : targetTransIdx + 1;
|
||||
const neighborTrans =
|
||||
neighborIdx >= 0 && neighborIdx < reorderable.length
|
||||
? reorderable[neighborIdx]
|
||||
: null;
|
||||
isValidDrop = isValidBoundaryDrop(
|
||||
dropPos,
|
||||
targetTrans.date,
|
||||
trans.date,
|
||||
neighborTrans?.date ?? null,
|
||||
isAscending,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidDrop) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert dropPos to API targetId
|
||||
// API places transaction AFTER targetId; null means move to top
|
||||
let apiTargetId: string | null;
|
||||
if (dropPos === 'after') {
|
||||
// Prevent inserting immediately after a split parent
|
||||
if (targetTrans.is_parent) {
|
||||
return;
|
||||
}
|
||||
apiTargetId = targetTrans.date === trans.date ? targetId : null;
|
||||
} else {
|
||||
const aboveIdx = targetTransIdx - 1;
|
||||
const aboveTrans = aboveIdx >= 0 ? reorderable[aboveIdx] : null;
|
||||
// For parent-level reordering, always anchor to parent transactions.
|
||||
// Using a child id here makes the backend miss the target and append.
|
||||
if (aboveTrans?.is_parent) {
|
||||
apiTargetId = aboveTrans.date === trans.date ? aboveTrans.id : null;
|
||||
} else {
|
||||
apiTargetId =
|
||||
aboveTrans && aboveTrans.date === trans.date ? aboveTrans.id : null;
|
||||
}
|
||||
}
|
||||
|
||||
await send('transaction-move', {
|
||||
id,
|
||||
accountId: trans.account,
|
||||
targetId: apiTargetId,
|
||||
});
|
||||
onRefetch();
|
||||
},
|
||||
[sortField, ascDesc, isFiltered, allTransactions, onRefetch],
|
||||
);
|
||||
|
||||
return (
|
||||
<TransactionTable
|
||||
ref={tableRef}
|
||||
@@ -636,6 +759,8 @@ export function TransactionList({
|
||||
onSort={onSort}
|
||||
sortField={sortField}
|
||||
ascDesc={ascDesc}
|
||||
isFiltered={isFiltered}
|
||||
onReorder={allowReorder ? onReorder : undefined}
|
||||
onBatchDelete={onBatchDelete}
|
||||
onBatchDuplicate={onBatchDuplicate}
|
||||
onBatchLinkSchedule={onBatchLinkSchedule}
|
||||
|
||||
@@ -448,7 +448,7 @@ describe('Transactions', () => {
|
||||
?.name
|
||||
: 'Categorize',
|
||||
);
|
||||
if (transaction.amount <= 0) {
|
||||
if (transaction.amount < 0) {
|
||||
expect(queryField(container, 'debit', 'div', idx).textContent).toBe(
|
||||
integerToCurrency(-transaction.amount),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
515
packages/desktop-client/src/hooks/useDragDrop.tsx
Normal file
515
packages/desktop-client/src/hooks/useDragDrop.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { Context, RefObject } from 'react';
|
||||
import {
|
||||
useDrag as useReactAriaDrag,
|
||||
useDrop as useReactAriaDrop,
|
||||
} from 'react-aria';
|
||||
import type {
|
||||
DragPreviewRenderer,
|
||||
DropPosition,
|
||||
TextDropItem,
|
||||
} from 'react-aria';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
// ===========================================================================
|
||||
// Migration Notes
|
||||
// ===========================================================================
|
||||
//
|
||||
// This module provides low-level drag-and-drop hooks based on react-aria.
|
||||
// These hooks are used for components that haven't yet migrated to
|
||||
// react-aria-components (ListBox, GridList, Table).
|
||||
//
|
||||
// When components are migrated to use react-aria-components, they should
|
||||
// use the higher-level `useDragAndDrop` hook from react-aria-components
|
||||
// instead of these low-level hooks.
|
||||
//
|
||||
// Migration path for each feature:
|
||||
//
|
||||
// | Current | react-aria-components equivalent |
|
||||
// |----------------------------|-------------------------------------------|
|
||||
// | useDrag + useDrop hooks | useDragAndDrop hook |
|
||||
// | preview ref (useDrag) | renderDragPreview option |
|
||||
// | DropHighlight component | renderDropIndicator + <DropIndicator> |
|
||||
// | onDrop callback | onReorder / onMove / onInsert callbacks |
|
||||
// | onLongHover callback | onDropActivate option (built-in) |
|
||||
// | DragState (start-preview) | onDragStart / onDragEnd callbacks |
|
||||
// | DropHighlightPosContext | Not needed (DropIndicator handles this) |
|
||||
//
|
||||
// ===========================================================================
|
||||
// Components Already Migrated (using useDragAndDrop + GridList/ListBox)
|
||||
// ===========================================================================
|
||||
//
|
||||
// Mobile:
|
||||
// - ExpenseGroupList.tsx → GridList + useDragAndDrop
|
||||
// - ExpenseCategoryList.tsx → GridList + useDragAndDrop
|
||||
// - AccountsPage.tsx (AccountList) → ListBox + useDragAndDrop
|
||||
//
|
||||
// ===========================================================================
|
||||
// Components Still Using react-dnd (To Migrate)
|
||||
// ===========================================================================
|
||||
//
|
||||
// Desktop Budget (currently uses react-dnd, should migrate to GridList):
|
||||
// - ExpenseCategory.tsx / SidebarCategory.tsx → react-dnd useDraggable/useDroppable
|
||||
// - ExpenseGroup.tsx / SidebarGroup.tsx → react-dnd useDraggable/useDroppable
|
||||
// - IncomeCategory.tsx → react-dnd useDraggable/useDroppable
|
||||
// - BudgetCategories.tsx → Parent component with DndProvider
|
||||
//
|
||||
// Desktop Sidebar (currently uses react-dnd, should migrate to ListBox):
|
||||
// - Account.tsx → react-dnd useDraggable/useDroppable
|
||||
// - Accounts.tsx → Parent component with DndProvider
|
||||
//
|
||||
// ===========================================================================
|
||||
// Components Using This Module (react-aria based)
|
||||
// ===========================================================================
|
||||
//
|
||||
// Transactions:
|
||||
// - TransactionsTable.tsx → Uses useDrag/useDrop from this module
|
||||
// - TransactionList.tsx → Uses isValidBoundaryDrop for validation
|
||||
//
|
||||
// These hooks wrap react-aria's low-level useDrag/useDrop and add:
|
||||
// - Two-phase drag start (preview then start) for UI coordination
|
||||
// - Manual drop position calculation based on cursor position
|
||||
// - Long hover detection via onDropActivate
|
||||
//
|
||||
// ===========================================================================
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
// Re-exported from react-aria for convenience
|
||||
export type { DropPosition };
|
||||
|
||||
/**
|
||||
* Check if a drop at a date boundary is valid.
|
||||
* A boundary drop is valid when the adjacent transaction has the same date as the dragged transaction.
|
||||
*
|
||||
* MIGRATION: This validation logic is specific to transaction reordering and
|
||||
* should be moved into the `onReorder` callback when migrating to useDragAndDrop.
|
||||
* The callback receives the dragged keys and drop target, from which you can
|
||||
* access the transaction data and perform this validation before applying changes.
|
||||
*
|
||||
* @param dropPos - The drop position relative to the target ('before' or 'after')
|
||||
* @param targetDate - The date of the transaction being dropped onto
|
||||
* @param draggedDate - The date of the transaction being dragged
|
||||
* @param neighborDate - The date of the adjacent transaction in the drop direction, or null if none
|
||||
* @param isAscending - Whether the list is sorted in ascending order (oldest first)
|
||||
* @returns True if the drop is valid at this boundary position
|
||||
*/
|
||||
export function isValidBoundaryDrop(
|
||||
dropPos: DropPosition,
|
||||
targetDate: string,
|
||||
draggedDate: string,
|
||||
neighborDate: string | null,
|
||||
isAscending: boolean,
|
||||
): boolean {
|
||||
if (!neighborDate || neighborDate !== draggedDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAscending) {
|
||||
// Ascending: older at top, newer at bottom
|
||||
// Valid: drop 'before' later-dated, or 'after' earlier-dated
|
||||
return (
|
||||
(dropPos === 'before' && targetDate > draggedDate) ||
|
||||
(dropPos === 'after' && targetDate < draggedDate)
|
||||
);
|
||||
}
|
||||
// Descending (default): newer at top, older at bottom
|
||||
// Valid: drop 'before' earlier-dated, or 'after' later-dated
|
||||
return (
|
||||
(dropPos === 'before' && targetDate < draggedDate) ||
|
||||
(dropPos === 'after' && targetDate > draggedDate)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current state of a drag operation.
|
||||
*
|
||||
* MIGRATION: When using useDragAndDrop from react-aria-components,
|
||||
* use onDragStart/onDragEnd callbacks instead. The 'start-preview'
|
||||
* state is used to show a preview before the actual drag starts,
|
||||
* which can be replaced by renderDragPreview option.
|
||||
*/
|
||||
export type DragState<T> = {
|
||||
state: 'start-preview' | 'start' | 'end';
|
||||
type?: string;
|
||||
item?: T;
|
||||
preview?: boolean;
|
||||
};
|
||||
|
||||
export type OnDragChangeCallback<T> = (
|
||||
drag: DragState<T>,
|
||||
) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* MIGRATION: When using useDragAndDrop, use onReorder callback instead.
|
||||
* The signature changes to: (e: { keys: Set<Key>, target: DropTarget }) => void
|
||||
*/
|
||||
export type OnDropCallback = (
|
||||
id: string,
|
||||
dropPos: DropPosition,
|
||||
targetId: string,
|
||||
) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* MIGRATION: Use onDropActivate option in useDragAndDrop instead.
|
||||
* react-aria calls this automatically after hovering for ~500ms.
|
||||
*/
|
||||
export type OnLongHoverCallback = () => Promise<void> | void;
|
||||
|
||||
// ============================================================
|
||||
// useDrag
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* MIGRATION: When migrating to useDragAndDrop from react-aria-components:
|
||||
* - Remove `preview` ref - use `renderDragPreview` option instead
|
||||
* - Remove `onDragChange` - use `onDragStart`/`onDragEnd` options instead
|
||||
* - Remove `canDrag` - use `isDisabled` on the collection component
|
||||
* - The `type` becomes part of `getItems` return value
|
||||
*/
|
||||
export type UseDragArgs<T> = {
|
||||
item?: T;
|
||||
type: string;
|
||||
canDrag: boolean;
|
||||
onDragChange?: OnDragChangeCallback<T>;
|
||||
/** MIGRATION: Replace with renderDragPreview option in useDragAndDrop */
|
||||
preview?: RefObject<DragPreviewRenderer | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Low-level drag hook for custom components.
|
||||
*
|
||||
* MIGRATION: Replace with useDragAndDrop from react-aria-components
|
||||
* when the parent component uses ListBox, GridList, or Table.
|
||||
*/
|
||||
export function useDrag<T extends { id: string }>({
|
||||
item,
|
||||
type,
|
||||
canDrag,
|
||||
onDragChange,
|
||||
preview,
|
||||
}: UseDragArgs<T>) {
|
||||
const _onDragChange = useRef(onDragChange);
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
_onDragChange.current = onDragChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragStartTimeoutRef.current) {
|
||||
clearTimeout(dragStartTimeoutRef.current);
|
||||
dragStartTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { dragProps, isDragging } = useReactAriaDrag({
|
||||
isDisabled: !canDrag,
|
||||
preview,
|
||||
getItems() {
|
||||
return [
|
||||
{
|
||||
[type]: JSON.stringify(item),
|
||||
'text/plain': item?.id ?? '',
|
||||
},
|
||||
];
|
||||
},
|
||||
onDragStart() {
|
||||
void _onDragChange.current?.({ state: 'start-preview', type, item });
|
||||
// Clear any pending timeout before scheduling a new one
|
||||
if (dragStartTimeoutRef.current) {
|
||||
clearTimeout(dragStartTimeoutRef.current);
|
||||
}
|
||||
// MIGRATION: This two-phase start (preview then start) pattern
|
||||
// is used to first show the drag preview, then update UI state.
|
||||
// With useDragAndDrop, use onDragStart for immediate feedback
|
||||
// and renderDragPreview for the preview element.
|
||||
dragStartTimeoutRef.current = setTimeout(() => {
|
||||
void _onDragChange.current?.({ state: 'start' });
|
||||
dragStartTimeoutRef.current = null;
|
||||
}, 0);
|
||||
},
|
||||
onDragEnd() {
|
||||
// Clear pending timeout to prevent stale 'start' callback after 'end'
|
||||
if (dragStartTimeoutRef.current) {
|
||||
clearTimeout(dragStartTimeoutRef.current);
|
||||
dragStartTimeoutRef.current = null;
|
||||
}
|
||||
void _onDragChange.current?.({ state: 'end', type, item });
|
||||
},
|
||||
});
|
||||
|
||||
return { dragRef, dragProps, isDragging };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// useDrop
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* MIGRATION: When migrating to useDragAndDrop from react-aria-components:
|
||||
* - Remove `types` - use `acceptedDragTypes` option instead
|
||||
* - Remove `id` - the collection handles item identification
|
||||
* - Replace `onDrop` with `onReorder`/`onMove`/`onInsert` options
|
||||
* - Replace `onLongHover` with `onDropActivate` option
|
||||
*/
|
||||
export type UseDropArgs = {
|
||||
types: string | string[];
|
||||
id: string;
|
||||
onDrop?: OnDropCallback;
|
||||
/** MIGRATION: Use onDropActivate option in useDragAndDrop instead */
|
||||
onLongHover?: OnLongHoverCallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Low-level drop hook for custom components.
|
||||
*
|
||||
* MIGRATION: Replace with useDragAndDrop from react-aria-components
|
||||
* when the parent component uses ListBox, GridList, or Table.
|
||||
* The drop position calculation and cursor tracking will be handled
|
||||
* automatically by the collection component.
|
||||
*/
|
||||
export function useDrop<T extends { id: string }>({
|
||||
types,
|
||||
id,
|
||||
onDrop,
|
||||
onLongHover,
|
||||
}: UseDropArgs) {
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
const [dropPos, setDropPos] = useState<DropPosition | null>(null);
|
||||
// Track if cursor is actually within this element's bounds (not just drag preview overlap)
|
||||
const [isCursorOver, setIsCursorOver] = useState(false);
|
||||
const lastClientY = useRef<number | null>(null);
|
||||
// Track if react-aria thinks we're a drop target (needed to attach dragover listener)
|
||||
const [isDropActive, setIsDropActive] = useState(false);
|
||||
|
||||
// Reset state when cursor leaves
|
||||
useEffect(() => {
|
||||
if (!isCursorOver) {
|
||||
setDropPos(null);
|
||||
lastClientY.current = null;
|
||||
}
|
||||
}, [isCursorOver]);
|
||||
|
||||
const acceptedTypes = Array.isArray(types) ? types : [types];
|
||||
|
||||
const { dropProps, isDropTarget } = useReactAriaDrop({
|
||||
ref: dropRef as RefObject<HTMLDivElement | null>,
|
||||
getDropOperation(dragTypes) {
|
||||
// Check if any of our accepted types are in the drag types
|
||||
const hasAcceptedType = acceptedTypes.some(t => dragTypes.has(t));
|
||||
return hasAcceptedType ? 'move' : 'cancel';
|
||||
},
|
||||
onDropEnter() {
|
||||
// Mark that a drag is active over this element (enables dragover listener)
|
||||
setIsDropActive(true);
|
||||
},
|
||||
onDropActivate() {
|
||||
// MIGRATION: This is already using react-aria's built-in long hover.
|
||||
// With useDragAndDrop, use the onDropActivate option directly.
|
||||
void onLongHover?.();
|
||||
},
|
||||
onDropExit() {
|
||||
setIsDropActive(false);
|
||||
setIsCursorOver(false);
|
||||
},
|
||||
async onDrop(e) {
|
||||
if (!onDrop) return;
|
||||
if (!dropRef.current) return;
|
||||
|
||||
// Find the first matching type
|
||||
const matchingType = acceptedTypes.find(t =>
|
||||
e.items.some(
|
||||
(item): item is TextDropItem =>
|
||||
item.kind === 'text' && item.types.has(t),
|
||||
),
|
||||
);
|
||||
|
||||
if (!matchingType) return;
|
||||
|
||||
const textItem = e.items.find(
|
||||
(item): item is TextDropItem =>
|
||||
item.kind === 'text' && item.types.has(matchingType),
|
||||
);
|
||||
|
||||
if (!textItem) return;
|
||||
|
||||
const data = await textItem.getText(matchingType);
|
||||
if (typeof data !== 'string' || !data.trim()) return;
|
||||
|
||||
let parsed: T;
|
||||
try {
|
||||
parsed = JSON.parse(data) as T;
|
||||
} catch {
|
||||
// Ignore malformed payloads
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate payload shape: must be an object with a non-empty string id
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof parsed.id !== 'string' ||
|
||||
!parsed.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// MIGRATION: With useDragAndDrop, the drop position is provided
|
||||
// in the event object (e.target.dropPosition) - no manual
|
||||
// calculation needed.
|
||||
const rect = dropRef.current.getBoundingClientRect();
|
||||
const hoverMiddleY = (rect.bottom - rect.top) / 2;
|
||||
const hoverClientY =
|
||||
lastClientY.current != null
|
||||
? lastClientY.current - rect.top
|
||||
: hoverMiddleY + 1;
|
||||
const pos: DropPosition =
|
||||
hoverClientY < hoverMiddleY ? 'before' : 'after';
|
||||
|
||||
void onDrop(parsed.id, pos, id);
|
||||
},
|
||||
});
|
||||
|
||||
// MIGRATION: This manual dragover tracking is not needed with
|
||||
// useDragAndDrop - the collection component handles it automatically.
|
||||
useEffect(() => {
|
||||
if (!isDropActive) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
if (!dropRef.current) return;
|
||||
|
||||
const rect = dropRef.current.getBoundingClientRect();
|
||||
const { clientX, clientY } = e;
|
||||
|
||||
// Check if cursor is actually within this element's bounds
|
||||
const cursorInBounds =
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom;
|
||||
|
||||
if (!cursorInBounds) {
|
||||
setIsCursorOver(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCursorOver(true);
|
||||
lastClientY.current = clientY;
|
||||
|
||||
const hoverMiddleY = (rect.bottom - rect.top) / 2;
|
||||
const hoverClientY = clientY - rect.top;
|
||||
const newPos: DropPosition =
|
||||
hoverClientY < hoverMiddleY ? 'before' : 'after';
|
||||
|
||||
setDropPos(newPos);
|
||||
};
|
||||
|
||||
const element = dropRef.current;
|
||||
element?.addEventListener('dragover', handleDragOver);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener('dragover', handleDragOver);
|
||||
};
|
||||
}, [isDropActive, dropRef]);
|
||||
|
||||
return {
|
||||
dropRef,
|
||||
dropProps,
|
||||
dropPos: isCursorOver ? dropPos : null,
|
||||
isDropTarget,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DropHighlight
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* MIGRATION: Replace with renderDropIndicator option and <DropIndicator>
|
||||
* component from react-aria-components. See ExpenseGroupList.tsx for example:
|
||||
*
|
||||
* ```tsx
|
||||
* renderDropIndicator: target => (
|
||||
* <DropIndicator
|
||||
* target={target}
|
||||
* className={css({
|
||||
* '&[data-drop-target]': {
|
||||
* height: 4,
|
||||
* backgroundColor: theme.tableBorderSeparator,
|
||||
* },
|
||||
* })}
|
||||
* />
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
type ItemPosition = 'first' | 'last' | null;
|
||||
|
||||
/**
|
||||
* MIGRATION: Not needed with useDragAndDrop - DropIndicator handles
|
||||
* positioning automatically based on the collection structure.
|
||||
*/
|
||||
export const DropHighlightPosContext: Context<ItemPosition> =
|
||||
createContext<ItemPosition>(null);
|
||||
|
||||
type DropHighlightProps = {
|
||||
pos: DropPosition | null;
|
||||
offset?: {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Visual indicator showing where a dragged item will be dropped.
|
||||
*
|
||||
* MIGRATION: Replace with <DropIndicator> from react-aria-components
|
||||
* when using useDragAndDrop. The DropIndicator component automatically
|
||||
* positions itself between items based on the drop target.
|
||||
*/
|
||||
export function DropHighlight({ pos, offset }: DropHighlightProps) {
|
||||
const itemPos = useContext(DropHighlightPosContext);
|
||||
|
||||
// 'on' position is not supported for highlight (used for dropping onto items, not between)
|
||||
if (pos == null || pos === 'on') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topOffset = (itemPos === 'first' ? 2 : 0) + (offset?.top ?? 0);
|
||||
const bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset?.bottom ?? 0);
|
||||
|
||||
const posStyle =
|
||||
pos === 'before' ? { top: topOffset } : { bottom: bottomOffset };
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 2,
|
||||
right: 2,
|
||||
borderRadius: 3,
|
||||
height: 3,
|
||||
background: theme.pageTextLink,
|
||||
zIndex: 10000,
|
||||
pointerEvents: 'none',
|
||||
...posStyle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user