Skip to content
24 changes: 21 additions & 3 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,7 @@ const CONST = {
ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns',
COMPANY_AGENTS: 'companyAgents',
RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns',
RESIZABLE_RHP: 'resizableRHP',
DESCRIBE_RULE: 'describeRule',
},
BUTTON_STATES: {
Expand Down Expand Up @@ -9803,6 +9804,26 @@ const CONST = {
},
},

/** A draggable edge that resizes a pane or a column horizontally. */
RESIZE_HANDLE: {
/** Width of the line drawn on a dragged edge, and of the grip shown while a table heading is hovered. */
INDICATOR_WIDTH: 2,
},

RHP_RESIZE: {
/** Width of the strip straddling the single RHP's left edge that picks up a drag, as wide as the floating card's gutter. */
HANDLE_HIT_WIDTH: 12,

/** Narrowest the single RHP can be dragged, in px. */
MIN_WIDTH: 380,

/** How much narrower, in px, the widest single RHP stays than a wide RHP, so a single RHP stacked on one never covers it. */
WIDE_RHP_INSET: 40,

/** Room, in px, the widest RHP still leaves for the content to its left. */
CONTENT_RESERVE_WIDTH: 360,
},

TABLES: {
FILTER_TYPE: {
SINGLE_SELECT: 'singleSelect',
Expand Down Expand Up @@ -9844,9 +9865,6 @@ const CONST = {
/** Narrowest drag width. Low enough to hide most content, but wide enough to keep the column's edge reachable. */
MIN_WIDTH: 48,

/** Width of the line shown while a column's edge is dragged, and of the grip shown while its heading is hovered. */
INDICATOR_WIDTH: 2,

/** Share of the heading's height the grip spans, centred vertically. */
GRIP_HEIGHT_RATIO: 0.8,

Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,9 @@ const ONYXKEYS = {
/** Stores whether the user views single-expense reports in the expense view or the table view */
NVP_SINGLE_EXPENSE_REPORT_VIEW: 'nvp_expensify_singleExpenseReportView',

/** Width the user dragged the single RHP to. Per device, since the ideal width depends on window size. */
SINGLE_RHP_WIDTH: 'singleRHPWidth',

/** Partial transaction data used for MFA authorize transaction preview */
TRANSACTIONS_PENDING_3DS_REVIEW: 'transactionsPending3DSReview',

Expand Down Expand Up @@ -2043,6 +2046,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
[ONYXKEYS.TABLE_COLUMN_WIDTHS]: OnyxTypes.TableColumnWidths;
[ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW]: ValueOf<typeof CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW>;
[ONYXKEYS.SINGLE_RHP_WIDTH]: number;
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
[ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record<string, SearchDataTypes>;
Expand Down
11 changes: 8 additions & 3 deletions src/components/DragAndDrop/NoDropZone/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import useDragAndDrop from '@hooks/useDragAndDrop';
import useThemeStyles from '@hooks/useThemeStyles';

import mergeRefs from '@libs/mergeRefs';

import htmlDivElementRef from '@src/types/utils/htmlDivElementRef';
import viewRef from '@src/types/utils/viewRef';

import type {ComponentRef} from 'react';

Expand All @@ -11,7 +12,7 @@ import {View} from 'react-native';

import type NoDropZoneProps from './types';

function NoDropZone({children}: NoDropZoneProps) {
function NoDropZone({children, ref}: NoDropZoneProps) {
const styles = useThemeStyles();
const noDropZone = useRef<ComponentRef<typeof View> | HTMLDivElement>(null);

Expand All @@ -20,9 +21,13 @@ function NoDropZone({children}: NoDropZoneProps) {
shouldAllowDrop: false,
});

const setNoDropZone = (element: ComponentRef<typeof View> | HTMLDivElement | null) => {
noDropZone.current = element;
};

return (
<View
ref={viewRef(noDropZone)}
ref={mergeRefs(setNoDropZone, ref)}
style={[styles.fullScreen]}
>
{children}
Expand Down
6 changes: 5 additions & 1 deletion src/components/DragAndDrop/NoDropZone/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type {ReactNode} from 'react';
import type {ComponentRef, ReactNode, Ref} from 'react';
import type {View} from 'react-native';

type NoDropZoneProps = {
children: ReactNode;

/** The zone's element on web. Never set on native, where the zone renders no element. */
ref?: Ref<ComponentRef<typeof View> | HTMLElement>;
};

export default NoDropZoneProps;
6 changes: 6 additions & 0 deletions src/components/Modal/BaseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import {PressableWithoutFeedback} from '@components/Pressable';
import ScreenWrapperOfflineIndicatorContext from '@components/ScreenWrapper/ScreenWrapperOfflineIndicatorContext';
import ScrollView from '@components/ScrollView';

import useIsResizableSingleRHP from '@hooks/useIsResizableSingleRHP';
import useKeyboardState from '@hooks/useKeyboardState';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useSidePanelState from '@hooks/useSidePanelState';
import useSingleRHPWidth from '@hooks/useSingleRHPWidth';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand Down Expand Up @@ -255,6 +257,9 @@ function BaseModal({

// A right docked modal opened from inside the RHP sits on the RHP's own card, so it must not dim it or add a second shadow.
const isStackedOnRHP = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED && !isSmallScreenWidth && (isInNarrowPane || isInNarrowPaneModal) && !shouldKeepRightDockedBackdropInNarrowPane;
const isResizableSingleRHP = useIsResizableSingleRHP();
const {width: singleRHPWidth} = useSingleRHPWidth();
const rightDockedCardWidth = isStackedOnRHP && isResizableSingleRHP ? singleRHPWidth : undefined;

const {
modalStyle,
Expand Down Expand Up @@ -285,6 +290,7 @@ function BaseModal({
enableEdgeToEdgeBottomSafeAreaPadding,
shouldDisplayBelowModals,
isStackedOnRHP,
rightDockedCardWidth,
});

// When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode.
Expand Down
16 changes: 16 additions & 0 deletions src/components/ResizeHandle/getDraggedSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Turns pointer travel on a resize handle into a pane's or column's size. */
import lodashClamp from 'lodash/clamp';

type DraggedSizeBounds = {
min: number;

/** Unbounded when omitted. */
max?: number;
};

/** `travel` is positive when the drag grows the size. The minimum wins over the maximum. */
function getDraggedSize(startSize: number, travel: number, {min, max = Infinity}: DraggedSizeBounds): number {
return lodashClamp(Math.round(startSize + travel), min, max);
}

export default getDraggedSize;
90 changes: 90 additions & 0 deletions src/components/ResizeHandle/usePointerDrag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/** The pointer lifecycle of dragging a resize handle on web, which relies on DOM pointer capture. */
import useThemeStyles from '@hooks/useThemeStyles';

import type React from 'react';
import type {RefObject} from 'react';

import {useEffect} from 'react';

type PointerDragEvent = React.PointerEvent<HTMLDivElement>;

type UsePointerDragParams<Drag> = {
/** The drag in progress, `null` at rest. Owned by the caller, so code set up before the drag handlers can read it. */
dragRef: RefObject<Drag | null>;

onMove: (drag: Drag, clientX: number) => void;

/** Called once however the drag ends: release, cancel, or the browser reclaiming capture. */
onEnd: (drag: Drag, event: PointerDragEvent) => void;
};

type PointerDrag<Drag> = {
/** Starts a drag from the handle's pointerdown. Returns `false` when the press doesn't start one. */
startDrag: (event: PointerDragEvent, drag: Drag) => boolean;

handlers: Pick<React.DOMAttributes<HTMLDivElement>, 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'>;
};

function usePointerDrag<Drag>({dragRef, onMove, onEnd}: UsePointerDragParams<Drag>): PointerDrag<Drag> {
const styles = useThemeStyles();

const setDrag = (drag: Drag | null) => {
// eslint-disable-next-line no-param-reassign -- the caller owns the ref, so code set up before the drag handlers can read the drag
dragRef.current = drag;
// On the whole page mid-drag, so the cursor doesn't flicker once the pointer outruns the handle.
document.body.style.cursor = drag ? (styles.cursorColResize.cursor ?? '') : '';
};

const startDrag = (event: PointerDragEvent, drag: Drag) => {
// Secondary buttons open context menus rather than dragging.
if (event.button !== 0) {
return false;
}

// Keeps the drag from selecting text, and from reaching whatever is pressable underneath.
event.preventDefault();
event.stopPropagation();

event.currentTarget.setPointerCapture(event.pointerId);

setDrag(drag);
return true;
};

const onPointerMove = (event: PointerDragEvent) => {
if (!dragRef.current) {
return;
}

onMove(dragRef.current, event.clientX);
};

/** The browser releases capture right after pointerup and pointercancel, so the lostpointercapture that follows is a no-op. */
const endDrag = (event: PointerDragEvent) => {
const drag = dragRef.current;

if (!drag) {
return;
}

setDrag(null);
onEnd(drag, event);
};

// Unmounting mid-drag would otherwise leave the resize cursor on the document and a dangling drag.
useEffect(
() => () => {
if (!dragRef.current) {
return;
}

setDrag(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs on unmount only
[],
);

return {startDrag, handlers: {onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, onLostPointerCapture: endDrag}};
}

export default usePointerDrag;
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ import React from 'react';
const BAR_STYLE: React.CSSProperties = {
position: 'absolute',
left: 0,
width: CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH,
width: CONST.RESIZE_HANDLE.INDICATOR_WIDTH,
};

const GRIP_STYLE: React.CSSProperties = {
...BAR_STYLE,
top: `${((1 - CONST.TABLES.COLUMN_RESIZE.GRIP_HEIGHT_RATIO) / 2) * 100}%`,
height: `${CONST.TABLES.COLUMN_RESIZE.GRIP_HEIGHT_RATIO * 100}%`,
borderRadius: CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH / 2,
borderRadius: CONST.RESIZE_HANDLE.INDICATOR_WIDTH / 2,
// Hidden while the line shows, since the line grew out of it.
opacity: `calc(var(${RESIZE_GRIP_HOVER_VARIABLE}, 0) * (1 - var(${RESIZE_INDICATOR_OPACITY_VARIABLE}, 0)))`,
transition: `opacity ${CONST.TABLES.COLUMN_RESIZE.INDICATOR_MORPH_DURATION}ms`,
Expand Down Expand Up @@ -70,7 +70,7 @@ function ColumnResizeHandle({columnKey}: ColumnResizeHandleProps) {
<div
aria-hidden
{...{[RESIZE_INDICATOR_DATA_ATTRIBUTE]: true}}
style={{...INDICATOR_STYLE, ...styles.tableColumnResizeIndicator}}
style={{...INDICATOR_STYLE, ...styles.resizeIndicator}}
/>
</div>
)}
Expand Down
9 changes: 0 additions & 9 deletions src/components/Table/columnResize/getDraggedColumnWidth.ts

This file was deleted.

Loading
Loading