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:
Stephen Brown II
2026-03-14 09:35:45 -04:00
committed by GitHub
parent 767f77fea3
commit f95cfbf82c
14 changed files with 1906 additions and 606 deletions

View File

@@ -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}

View File

@@ -697,6 +697,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
onMakeAsNonSplitTransactions={() => {}}
showSelection={false}
allowSplitTransaction={false}
allowReorder={false}
/>
</SplitsExpandedProvider>
) : (

View File

@@ -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

View File

@@ -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}

View File

@@ -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),
);

View 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,
}}
/>
);
}