From 9d50cb981548661ee2a330fe3828aff9474310ad Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 21 Oct 2026 15:15:09 +0200 Subject: [PATCH 01/10] feat: make RHP resizable --- src/CONST/index.ts | 24 +++- src/components/ResizeHandle/getDraggedSize.ts | 19 ++++ src/components/ResizeHandle/usePointerDrag.ts | 91 +++++++++++++++ .../columnResize/ColumnResizeHandle/index.tsx | 6 +- .../columnResize/getDraggedColumnWidth.ts | 9 -- .../columnResize/useColumnResize/index.ts | 106 ++++-------------- src/hooks/useIsResizableSingleRHP.ts | 21 ++++ .../useModalStackScreenOptions.ts | 6 +- .../RHPResizeHandle/index.native.tsx | 6 + .../Navigators/RHPResizeHandle/index.tsx | 106 ++++++++++++++++++ .../Navigators/RightModalNavigator.tsx | 27 ++++- .../Navigators/getRHPFrameStyle/index.ts | 13 +-- .../useModalCardStyleInterpolator.ts | 5 +- .../AppNavigator/useRHPScreenOptions.ts | 62 ++++++---- .../singleRHPWidth/getSingleRHPWidthBounds.ts | 22 ++++ .../helpers/singleRHPWidth/index.native.ts | 18 +++ .../helpers/singleRHPWidth/index.ts | 44 ++++++++ src/styles/index.ts | 42 ++++++- tests/unit/GetRHPFrameStyleTest.ts | 24 ++-- tests/unit/getDraggedSizeTest.ts | 52 +++++++++ tests/unit/getSingleRHPWidthBoundsTest.ts | 56 +++++++++ 21 files changed, 608 insertions(+), 151 deletions(-) create mode 100644 src/components/ResizeHandle/getDraggedSize.ts create mode 100644 src/components/ResizeHandle/usePointerDrag.ts delete mode 100644 src/components/Table/columnResize/getDraggedColumnWidth.ts create mode 100644 src/hooks/useIsResizableSingleRHP.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.native.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx create mode 100644 src/libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds.ts create mode 100644 src/libs/Navigation/helpers/singleRHPWidth/index.native.ts create mode 100644 src/libs/Navigation/helpers/singleRHPWidth/index.ts create mode 100644 tests/unit/getDraggedSizeTest.ts create mode 100644 tests/unit/getSingleRHPWidthBoundsTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cd1d7ed0ed85..c1caaac86921 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1422,6 +1422,7 @@ const CONST = { ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', COMPANY_AGENTS: 'companyAgents', RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns', + RESIZABLE_RHP: 'resizableRHP', DESCRIBE_RULE: 'describeRule', }, BUTTON_STATES: { @@ -9770,6 +9771,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', @@ -9811,9 +9832,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, diff --git a/src/components/ResizeHandle/getDraggedSize.ts b/src/components/ResizeHandle/getDraggedSize.ts new file mode 100644 index 000000000000..94ef2afc4d45 --- /dev/null +++ b/src/components/ResizeHandle/getDraggedSize.ts @@ -0,0 +1,19 @@ +/** Turns pointer travel on a resize handle into a pane's or column's size. */ +import lodashClamp from 'lodash/clamp'; + +type DraggedSizeParams = { + /** `1` when the handle sits on the trailing edge, so moving right grows the size. `-1` when it sits on the leading edge. */ + axisSign: 1 | -1; + + min: number; + + /** Unbounded when omitted. */ + max?: number; +}; + +/** The size a drag puts a pane or column at: its start size plus the pointer's travel, rounded and kept between the bounds. The minimum wins over the maximum. */ +function getDraggedSize(startSize: number, startClientX: number, clientX: number, {axisSign, min, max = Infinity}: DraggedSizeParams): number { + return lodashClamp(Math.round(startSize + axisSign * (clientX - startClientX)), min, max); +} + +export default getDraggedSize; diff --git a/src/components/ResizeHandle/usePointerDrag.ts b/src/components/ResizeHandle/usePointerDrag.ts new file mode 100644 index 000000000000..f4c9bc2b54b2 --- /dev/null +++ b/src/components/ResizeHandle/usePointerDrag.ts @@ -0,0 +1,91 @@ +/** 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; + +type UsePointerDragParams = { + /** The drag in progress, `null` at rest. Owned by the caller, so code set up before the drag handlers can read it. */ + dragRef: RefObject; + + 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 = { + /** 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, 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'>; +}; + +function usePointerDrag({dragRef, onMove, onEnd}: UsePointerDragParams): PointerDrag { + 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(); + + // Keeps the drag on this handle once the pointer moves off it. + 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; diff --git a/src/components/Table/columnResize/ColumnResizeHandle/index.tsx b/src/components/Table/columnResize/ColumnResizeHandle/index.tsx index f9625332a3c6..b5c187b84680 100644 --- a/src/components/Table/columnResize/ColumnResizeHandle/index.tsx +++ b/src/components/Table/columnResize/ColumnResizeHandle/index.tsx @@ -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`, @@ -70,7 +70,7 @@ function ColumnResizeHandle({columnKey}: ColumnResizeHandleProps) {
)} diff --git a/src/components/Table/columnResize/getDraggedColumnWidth.ts b/src/components/Table/columnResize/getDraggedColumnWidth.ts deleted file mode 100644 index 4128f1a66be7..000000000000 --- a/src/components/Table/columnResize/getDraggedColumnWidth.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Turns pointer travel into a column width, kept above the drag floor. */ -import CONST from '@src/CONST'; - -/** The width a drag puts the column at: its start width plus the pointer's travel, no narrower than the floor. */ -function getDraggedColumnWidth(startWidth: number, startClientX: number, clientX: number, minWidth: number = CONST.TABLES.COLUMN_RESIZE.MIN_WIDTH): number { - return Math.max(Math.round(startWidth + (clientX - startClientX)), minWidth); -} - -export default getDraggedColumnWidth; diff --git a/src/components/Table/columnResize/useColumnResize/index.ts b/src/components/Table/columnResize/useColumnResize/index.ts index 7012a6dab99d..cffe2a6b00da 100644 --- a/src/components/Table/columnResize/useColumnResize/index.ts +++ b/src/components/Table/columnResize/useColumnResize/index.ts @@ -2,7 +2,8 @@ * Web column resizing by dragging a column's right edge. Widths live in CSS custom properties so React doesn't render * mid-drag, and the layout is saved to Onyx when the drag ends. */ -import getDraggedColumnWidth from '@components/Table/columnResize/getDraggedColumnWidth'; +import getDraggedSize from '@components/ResizeHandle/getDraggedSize'; +import usePointerDrag from '@components/ResizeHandle/usePointerDrag'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -12,7 +13,7 @@ import CONST from '@src/CONST'; import type React from 'react'; -import {useEffect, useRef} from 'react'; +import {useRef} from 'react'; import type {ColumnResizeController, ColumnResizeHandleDOMProps, UseColumnResizeParams} from './types'; @@ -27,7 +28,7 @@ function getHandleStyle(columnGap: number): React.CSSProperties { bottom: 0, // Spans the gap to the next column, with the grip and the line flush against this column's edge, so they read as part of the column being resized. right: -columnGap, - width: columnGap + CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH, + width: columnGap + CONST.RESIZE_HANDLE.INDICATOR_WIDTH, // Otherwise a touch drag on the handle is taken over by the table's own horizontal scrolling. touchAction: 'none', }; @@ -57,14 +58,8 @@ function useColumnResize({ const {scopeElementRef, setScopeElement, writeColumnWidth, readColumnWidth, clearLiveWidths} = useLiveColumnWidths({resolvedColumnWidths, dragRef}); const {revealIndicator, hideIndicator} = useResizeIndicator(scopeElementRef); - const resetDrag = () => { - dragRef.current = null; - document.body.style.cursor = ''; - }; - // Shared by pointerup, lost capture and cancel, so every way a drag can end keeps the width the user sees. const endDrag = (drag: Drag) => { - resetDrag(); hideIndicator(); const width = readColumnWidth(drag.columnKey) ?? drag.startWidth; @@ -86,84 +81,30 @@ function useColumnResize({ setGripHovered(event.currentTarget, isPointerOnHeading); }; - const handlePointerDown = (columnKey: string, event: React.PointerEvent) => { - // Secondary buttons open context menus rather than dragging. - if (event.button !== 0) { - return; - } - - // Keeps the drag from selecting the header's labels, and from reaching the column's sort button underneath. - event.preventDefault(); - event.stopPropagation(); - - // Keeps the drag on this handle once the pointer moves off it. - event.currentTarget.setPointerCapture(event.pointerId); + const pointerDrag = usePointerDrag({ + dragRef, + onMove: (drag, clientX) => { + // The line rides the handle, so it follows the clamped width, not the pointer. + const width = getDraggedSize(drag.startWidth, drag.startClientX, clientX, {axisSign: 1, min: dragMinWidths?.[drag.columnKey] ?? CONST.TABLES.COLUMN_RESIZE.MIN_WIDTH}); - revealIndicator(event.currentTarget); - - dragRef.current = { - columnKey, - startClientX: event.clientX, - startWidth: readColumnWidth(columnKey) ?? 0, - }; - // On the whole page mid-drag, so the cursor doesn't flicker once the pointer outruns the handle. - document.body.style.cursor = styles.cursorColResize.cursor ?? ''; - }; - - const handlePointerMove = (event: React.PointerEvent) => { - const drag = dragRef.current; - - if (!drag) { - return; - } - - // The line rides the handle, so it follows the clamped width, not the pointer. - const width = getDraggedColumnWidth(drag.startWidth, drag.startClientX, event.clientX, dragMinWidths?.[drag.columnKey]); - - for (const [columnKey, resizedWidth] of Object.entries(getResizedColumnWidths(drag.columnKey, width))) { - writeColumnWidth(columnKey, resizedWidth); - } - }; - - const handlePointerUp = (event: React.PointerEvent) => { - const drag = dragRef.current; - - if (!drag) { - return; - } - - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - - endDrag(drag); - syncGripHover(event); - }; - - /** Ends a drag whose pointer capture the browser reclaimed. Also fires after a normal pointerup, when it's a no-op. */ - const handleLostPointerCapture = (event: React.PointerEvent) => { - const drag = dragRef.current; + for (const [columnKey, resizedWidth] of Object.entries(getResizedColumnWidths(drag.columnKey, width))) { + writeColumnWidth(columnKey, resizedWidth); + } + }, + onEnd: (drag, event) => { + endDrag(drag); + syncGripHover(event); + }, + }); - if (!drag) { + const handlePointerDown = (columnKey: string, event: React.PointerEvent) => { + if (!pointerDrag.startDrag(event, {columnKey, startClientX: event.clientX, startWidth: readColumnWidth(columnKey) ?? 0})) { return; } - endDrag(drag); - syncGripHover(event); + revealIndicator(event.currentTarget); }; - // Unmounting mid-drag would otherwise leave the resize cursor on the document and a dangling drag. - useEffect( - () => () => { - if (!dragRef.current) { - return; - } - - resetDrag(); - }, - [], - ); - if (!columnResizingID) { return undefined; } @@ -177,10 +118,7 @@ function useColumnResize({ style: {...getHandleStyle(columnGap), ...styles.cursorColResize}, ref: trackHeadingHover, onPointerDown: (event) => handlePointerDown(columnKey, event), - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - onPointerCancel: handleLostPointerCapture, - onLostPointerCapture: handleLostPointerCapture, + ...pointerDrag.handlers, }; }; diff --git a/src/hooks/useIsResizableSingleRHP.ts b/src/hooks/useIsResizableSingleRHP.ts new file mode 100644 index 000000000000..8ffc06d6a867 --- /dev/null +++ b/src/hooks/useIsResizableSingleRHP.ts @@ -0,0 +1,21 @@ +/** Whether the RHP is a lone single-width card that the user can drag to resize. */ +import {useWideRHPState} from '@components/WideRHPContextProvider'; + +import CONST from '@src/CONST'; + +import usePermissions from './usePermissions'; +import useResponsiveLayout from './useResponsiveLayout'; + +/** Only on a wide layout, and only while no wide or super-wide RHP is open, since the single card then stacks on one. */ +function useIsResizableSingleRHP(): boolean { + const {isBetaEnabled} = usePermissions(); + const {wideRHPRouteKeys, superWideRHPRouteKeys} = useWideRHPState(); + + // The window's width, because inside the RHP shouldUseNarrowLayout is always true. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + return isBetaEnabled(CONST.BETAS.RESIZABLE_RHP) && !isSmallScreenWidth && wideRHPRouteKeys.length === 0 && superWideRHPRouteKeys.length === 0; +} + +export default useIsResizableSingleRHP; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts index 276bb98716b7..60041a102393 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts @@ -1,5 +1,6 @@ import {animatedSuperWideRHPWidth, useWideRHPState} from '@components/WideRHPContextProvider'; +import useIsResizableSingleRHP from '@hooks/useIsResizableSingleRHP'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -38,6 +39,7 @@ function useWideModalStackScreenOptions() { const {windowWidth} = useWindowDimensions(); const {wideRHPRouteKeys, superWideRHPRouteKeys} = useWideRHPState(); const {sidePanelOffset} = useSidePanelState(); + const isResizableSingleRHP = useIsResizableSingleRHP(); return useCallback<({route}: {route: PlatformStackRouteProp}) => PlatformStackNavigationOptions>( ({route}) => { @@ -70,6 +72,8 @@ function useWideModalStackScreenOptions() { cardStyle: styles.singleRHPExtendedCardInterpolatorStyles, containerStyle: styles.overflowVisible, }); + } else if (isResizableSingleRHP) { + cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, {cardStyle: styles.singleRHPCardInterpolatorStyles}); } } @@ -89,7 +93,7 @@ function useWideModalStackScreenOptions() { }, }; }, - [StyleUtils, isSmallScreenWidth, modalCardStyleInterpolator, sidePanelOffset, styles, superWideRHPRouteKeys, windowWidth, wideRHPRouteKeys], + [StyleUtils, isResizableSingleRHP, isSmallScreenWidth, modalCardStyleInterpolator, sidePanelOffset, styles, superWideRHPRouteKeys, windowWidth, wideRHPRouteKeys], ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.native.tsx new file mode 100644 index 000000000000..35eb387a0287 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.native.tsx @@ -0,0 +1,6 @@ +/** The RHP is full-screen on native, so there's no edge to drag. */ +function RHPResizeHandle() { + return null; +} + +export default RHPResizeHandle; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx new file mode 100644 index 000000000000..0565ea87760e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx @@ -0,0 +1,106 @@ +/** The draggable left edge of the single RHP card on web, which resizes the card. */ +import getDraggedSize from '@components/ResizeHandle/getDraggedSize'; +import usePointerDrag from '@components/ResizeHandle/usePointerDrag'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSidePanelState from '@hooks/useSidePanelState'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import {applySingleRHPWidth, clampSingleRHPWidth, getSingleRHPWidth} from '@libs/Navigation/helpers/singleRHPWidth'; +import getSingleRHPWidthBounds from '@libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import React, {useLayoutEffect, useRef, useState} from 'react'; +// The handle rides the Side Panel offset, which is a react-native Animated value. +// eslint-disable-next-line no-restricted-imports +import {Animated, View} from 'react-native'; + +type RHPResizeHandleProps = { + /** How far the Side Panel pushes the RHP card in from the window's right edge. */ + sidePanelOffset: Animated.Value; +}; + +type Drag = { + /** Where the pointer went down. */ + startClientX: number; + + /** The RHP's width when the drag started. */ + startWidth: number; +}; + +// DOM `div` styles, which the React Native style system can't type. +const HANDLE_STYLE: React.CSSProperties = { + position: 'absolute', + inset: 0, + touchAction: 'none', +}; + +// Flush against the outside of the card's border, which the handle straddles, so the line runs straight past the card's rounded corners. +const LINE_STYLE: React.CSSProperties = { + position: 'absolute', + top: 0, + bottom: 0, + right: '50%', + width: CONST.RESIZE_HANDLE.INDICATOR_WIDTH, + pointerEvents: 'none', +}; + +/** + * Drags the single RHP card's left edge. A sibling of the card rather than a child, because the card clips its overflow, + * and rendered after the overlay so it wins hit-testing over the scrim that closes the RHP. + */ +function RHPResizeHandle({sidePanelOffset}: RHPResizeHandleProps) { + const styles = useThemeStyles(); + const {windowWidth} = useWindowDimensions(); + const {isExtraLargeScreenWidth} = useResponsiveLayout(); + const {shouldHideSidePanel} = useSidePanelState(); + // Mirrors when SidePanelContextProvider shifts the RHP left by the panel's width. + const {min, max} = getSingleRHPWidthBounds(windowWidth, isExtraLargeScreenWidth && !shouldHideSidePanel ? variables.sidePanelWidth : 0); + const [isHovered, setIsHovered] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + const dragRef = useRef(null); + const pointerDrag = usePointerDrag({ + dragRef, + onMove: (drag, clientX) => applySingleRHPWidth(getDraggedSize(drag.startWidth, drag.startClientX, clientX, {axisSign: -1, min, max})), + onEnd: () => setIsDragging(false), + }); + + // A narrower window or an opening Side Panel lowers the max, so the width shrinks to fit. Before paint, so an RHP opened + // after the window shrank never shows a frame at the old width. + useLayoutEffect(() => clampSingleRHPWidth(min, max), [min, max]); + + return ( + // Anchored to the card's right edge, which only the Side Panel moves, then shifted the card's width further left. + + + {/* A plain `div` because it relies on DOM pointer capture. */} +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + onPointerDown={(event) => { + if (!pointerDrag.startDrag(event, {startClientX: event.clientX, startWidth: getSingleRHPWidth()})) { + return; + } + setIsDragging(true); + }} + {...pointerDrag.handlers} + > + {(isHovered || isDragging) && ( +
+ )} +
+ + + ); +} + +export default RHPResizeHandle; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 25a6a6592cca..9a8a12221443 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -2,6 +2,8 @@ import {DialogLabelProvider, useDialogLabelData} from '@components/DialogLabelCo import NoDropZone from '@components/DragAndDrop/NoDropZone'; import {expandedRHPProgress, thirdOverlayProgress, useWideRHPActions, useWideRHPState} from '@components/WideRHPContextProvider'; +import useIsResizableSingleRHP from '@hooks/useIsResizableSingleRHP'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -40,16 +42,17 @@ import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; import type {NavigatorScreenParams} from '@react-navigation/native'; import type {ComponentRef} from 'react'; -import type {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports -import {Animated, DeviceEventEmitter} from 'react-native'; +import {Animated, DeviceEventEmitter, View} from 'react-native'; import getRHPFrameStyle from './getRHPFrameStyle'; import {NarrowPaneContextProvider} from './NarrowPaneContext'; import RHPOverlay from './Overlay/RHPOverlay'; +import RHPResizeHandle from './RHPResizeHandle'; type RightModalNavigatorProps = PlatformStackScreenProps; @@ -100,6 +103,9 @@ type RightModalDialogFrameProps = { /** Animated style applied to the RHP container. */ style: React.ComponentProps['style']; + /** Style of a box around the stack navigator, which is rendered without one when this is undefined. */ + contentStyle?: StyleProp; + /** Callback ref for the container node so the provider can observe node identity changes. */ onContainerRef: (node: ComponentRef | null) => void; @@ -115,7 +121,7 @@ type RightModalDialogFrameProps = { * aria-label is applied only once the visible title is registered so JAWS can announce a named dialog; * Header also announces "{title}, dialog" via a polite live region when the title is ready. */ -function RightModalDialogFrame({hasDialogSemantics, style, onContainerRef, children}: RightModalDialogFrameProps) { +function RightModalDialogFrame({hasDialogSemantics, style, contentStyle, onContainerRef, children}: RightModalDialogFrameProps) { const {dialogAriaLabel} = useDialogLabelData(); const hasName = !!dialogAriaLabel; @@ -129,7 +135,7 @@ function RightModalDialogFrame({hasDialogSemantics, style, onContainerRef, child tabIndex={hasDialogSemantics ? -1 : undefined} style={style} > - {children} + {contentStyle ? {children} : children} ); } @@ -157,6 +163,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { const modalStackScreenOptions = useModalStackScreenOptions(); const styles = useThemeStyles(); const {sidePanelOffset} = useSidePanelState(); + const {isBetaEnabled} = usePermissions(); // When a fullscreen route is pre-inserted under the RHP, disable the slide-out animation // so the dismiss reveals the destination instantly. If the pre-insert is later cleaned up @@ -218,6 +225,16 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { (superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0 || shouldRenderSecondaryOverlayForRHPOnWideRHP || shouldRenderSecondaryOverlayForRHPOnSuperWideRHP); const frameStyle = getRHPFrameStyle({styles, animatedWidth, shouldUseNarrowLayout, shouldUseCenteredFrame}); + const isResizableRHPEnabled = isBetaEnabled(CONST.BETAS.RESIZABLE_RHP); + const isResizableSingleRHP = useIsResizableSingleRHP(); + + // The box stays mounted for any layout once the beta is on, because adding or removing it would remount the whole stack. + let stackContentStyle: StyleProp; + if (isResizableSingleRHP) { + stackContentStyle = styles.singleRHPStackMeasureBox; + } else if (isResizableRHPEnabled) { + stackContentStyle = styles.flex1; + } const screenListeners = useMemo( () => ({ @@ -302,6 +319,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { hasDialogSemantics={!isSmallScreenWidth} onContainerRef={setContainerNodeFromRef} style={frameStyle} + contentStyle={stackContentStyle} > )} + {isResizableSingleRHP && } ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts b/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts index 7c718d1c6370..ff97833a0f19 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts @@ -1,8 +1,6 @@ -import variables from '@styles/variables'; +import {getSingleRHPWidthStyleValue} from '@libs/Navigation/helpers/singleRHPWidth'; -// The frame width is added into react-navigation's card styles, which only understand react-native Animated. -// eslint-disable-next-line no-restricted-imports -import {Animated} from 'react-native'; +import variables from '@styles/variables'; import type GetRHPFrameStyle from './types'; @@ -16,10 +14,9 @@ const getRHPFrameStyle: GetRHPFrameStyle = ({styles, animatedWidth, shouldUseNar return [styles.pAbsolute, styles.RHPCenteredFrame, {width: animatedWidth}]; } - // Border-box card, so the border is added back on top of the width or the wide RHP's fixed-width panes get clipped. - // A plain number stays plain, because an animated node only resolves inside an Animated component. - const floatingCardWidth = - typeof animatedWidth === 'number' ? animatedWidth + 2 * variables.rhpFloatingCardBorderWidth : Animated.add(animatedWidth, 2 * variables.rhpFloatingCardBorderWidth); + // Border-box card, so the border is added back on top of the width or the content gets clipped. The width is read from CSS, + // where a drag of the single RHP's edge writes it. + const floatingCardWidth = getSingleRHPWidthStyleValue(2 * variables.rhpFloatingCardBorderWidth); // translateZ0 makes the frame a containing block that clips the fixed RHP screen to the radius. return [styles.pAbsolute, styles.overflowHidden, styles.translateZ0, styles.RHPFloatingCard, {width: floatingCardWidth}]; diff --git a/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts b/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts index b79f7baf2d35..2d2ef7307d0f 100644 --- a/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts +++ b/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts @@ -4,8 +4,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMobileChrome, isMobileSafari} from '@libs/Browser'; - -import variables from '@styles/variables'; +import {getSingleRHPWidth} from '@libs/Navigation/helpers/singleRHPWidth'; import type {StackCardInterpolatedStyle, StackCardInterpolationProps} from '@react-navigation/stack'; @@ -63,7 +62,7 @@ const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => { return {cardStyle: {...cardStyle, opacity: progress}}; } - const widthFallback = shouldUseNarrowLayout ? screen.width : variables.rhpWidth; + const widthFallback = shouldUseNarrowLayout ? screen.width : getSingleRHPWidth(); const distancePx = enter.kind === 'slide-and-fade' ? enter.distancePx : widthFallback; const translateX = Animated.multiply( diff --git a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts index 8607c027175f..c48d154377b6 100644 --- a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts @@ -1,37 +1,30 @@ import {useWideRHPState} from '@components/WideRHPContextProvider'; +import useIsResizableSingleRHP from '@hooks/useIsResizableSingleRHP'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {isSafari} from '@libs/Browser'; +import {getSingleRHPWidth} from '@libs/Navigation/helpers/singleRHPWidth'; import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types/NavigationOptions'; import CONST from '@src/CONST'; -import type {StackCardInterpolationProps} from '@react-navigation/stack'; +import type {StackCardInterpolationProps, StackCardStyleInterpolator} from '@react-navigation/stack'; import {CardStyleInterpolators} from '@react-navigation/stack'; import {useMemo} from 'react'; +import enhanceCardStyleInterpolator from './enhanceCardStyleInterpolator'; import RHP_WEB_TRANSITION_SPEC from './RHPTransitionSpec'; import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; -// This function is necessary for proper animation if a wide format RHP screen is visible. -// In such case for every narrow screen on top of the wide screen we use only half width. -// The other half is transparent. To account for that we will divide screen width to make sure the animations starts in the right spot. -const getModifiedCardStyleInterpolatorProps = (props: StackCardInterpolationProps): StackCardInterpolationProps => { - return { - ...props, - layouts: { - screen: { - ...props.layouts.screen, - width: props.layouts.screen.width / 2, - }, - }, - }; -}; +const withScreenWidth = (props: StackCardInterpolationProps, width: number): StackCardInterpolationProps => ({ + ...props, + layouts: {screen: {...props.layouts.screen, width}}, +}); const useRHPScreenOptions = (): PlatformStackNavigationOptions => { const styles = useThemeStyles(); @@ -48,19 +41,34 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { // A skinny RHP pushed over a centered wide card gets the same slide-and-fade as the centered cards instead of a full-width slide. const isWideRHPContext = !isSmallScreenWidth && (wideRHPRouteKeys.length > 0 || superWideRHPRouteKeys.length > 0); + const isResizableSingleRHP = useIsResizableSingleRHP(); + return useMemo(() => { + const getHorizontalIOSInterpolatorProps = (props: StackCardInterpolationProps) => { + // The resizable single RHP's stack measures a fixed-width box, so the slide distance comes from the dragged width instead. + if (isResizableSingleRHP) { + return withScreenWidth(props, getSingleRHPWidth()); + } + // A narrow screen on top of a wide RHP uses only half the width, the other half being transparent, so it slides from there. + return shouldAdjustInterpolatorProps ? withScreenWidth(props, props.layouts.screen.width / 2) : props; + }; + + // Otherwise forHorizontalIOS misbehaves on Safari, so the custom interpolator replaces it there. + // eslint-disable-next-line no-nested-ternary + const cardStyleInterpolator: StackCardStyleInterpolator = isWideRHPContext + ? (props) => customInterpolator({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}) + : isSafari() + ? (props) => customInterpolator({props, enter: {kind: 'slide-from-width'}}) + : (props) => CardStyleInterpolators.forHorizontalIOS(getHorizontalIOSInterpolatorProps(props)); + return { headerShown: false, animation: Animations.SLIDE_FROM_RIGHT, gestureDirection: 'horizontal', web: { - // Otherwise forHorizontalIOS misbehaves on Safari, so the custom interpolator replaces it there. - // eslint-disable-next-line no-nested-ternary - cardStyleInterpolator: isWideRHPContext - ? (props) => customInterpolator({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}) - : isSafari() - ? (props) => customInterpolator({props, enter: {kind: 'slide-from-width'}}) - : (props) => CardStyleInterpolators.forHorizontalIOS(shouldAdjustInterpolatorProps ? getModifiedCardStyleInterpolatorProps(props) : props), + cardStyleInterpolator: isResizableSingleRHP + ? enhanceCardStyleInterpolator(cardStyleInterpolator, {cardStyle: styles.singleRHPCardInterpolatorStyles}) + : cardStyleInterpolator, presentation: Presentation.TRANSPARENT_MODAL, cardOverlayEnabled: false, cardStyle: styles.navigationScreenCardStyle, @@ -68,7 +76,15 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, }, }; - }, [customInterpolator, shouldAdjustInterpolatorProps, isWideRHPContext, isSmallScreenWidth, styles.navigationScreenCardStyle]); + }, [ + customInterpolator, + shouldAdjustInterpolatorProps, + isWideRHPContext, + isResizableSingleRHP, + isSmallScreenWidth, + styles.navigationScreenCardStyle, + styles.singleRHPCardInterpolatorStyles, + ]); }; export default useRHPScreenOptions; diff --git a/src/libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds.ts b/src/libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds.ts new file mode 100644 index 000000000000..ac0c4e9c0ebb --- /dev/null +++ b/src/libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds.ts @@ -0,0 +1,22 @@ +/** How narrow and how wide the single RHP can be dragged in a given window. */ +import calculateWideRHPWidth from '@libs/Navigation/helpers/calculateWideRHPWidth'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +type SingleRHPWidthBounds = { + min: number; + max: number; +}; + +/** The widest bound stays narrower than the wide RHP and leaves the content beside the card some room, but never drops below the narrowest one. */ +function getSingleRHPWidthBounds(windowWidth: number, sidePanelWidth: number): SingleRHPWidthBounds { + const min = CONST.RHP_RESIZE.MIN_WIDTH; + const widthLeftForCard = windowWidth - variables.rhpFloatingCardMargin - 2 * variables.rhpFloatingCardBorderWidth - sidePanelWidth - CONST.RHP_RESIZE.CONTENT_RESERVE_WIDTH; + + return {min, max: Math.max(min, Math.min(calculateWideRHPWidth(windowWidth) - CONST.RHP_RESIZE.WIDE_RHP_INSET, widthLeftForCard))}; +} + +export default getSingleRHPWidthBounds; +export type {SingleRHPWidthBounds}; diff --git a/src/libs/Navigation/helpers/singleRHPWidth/index.native.ts b/src/libs/Navigation/helpers/singleRHPWidth/index.native.ts new file mode 100644 index 000000000000..4c3e3d1caf57 --- /dev/null +++ b/src/libs/Navigation/helpers/singleRHPWidth/index.native.ts @@ -0,0 +1,18 @@ +import variables from '@styles/variables'; + +import type {DimensionValue} from 'react-native'; + +/** The RHP is full-screen on native, so its width never changes. */ +function getSingleRHPWidth(): number { + return variables.rhpWidth; +} + +function applySingleRHPWidth() {} + +function clampSingleRHPWidth() {} + +function getSingleRHPWidthStyleValue(offset: number): DimensionValue { + return variables.rhpWidth + offset; +} + +export {applySingleRHPWidth, clampSingleRHPWidth, getSingleRHPWidth, getSingleRHPWidthStyleValue}; diff --git a/src/libs/Navigation/helpers/singleRHPWidth/index.ts b/src/libs/Navigation/helpers/singleRHPWidth/index.ts new file mode 100644 index 000000000000..770480aa6bd8 --- /dev/null +++ b/src/libs/Navigation/helpers/singleRHPWidth/index.ts @@ -0,0 +1,44 @@ +/** The single RHP width a drag writes, kept in a CSS variable so styles follow it without a React render. */ +import variables from '@styles/variables'; + +import type {DimensionValue} from 'react-native'; + +import lodashClamp from 'lodash/clamp'; + +const SINGLE_RHP_WIDTH_VARIABLE = '--rhp-width'; + +/** The live single RHP width as a CSS expression. A drag rewrites the variable, so every style built on it follows without a React render. */ +const SINGLE_RHP_WIDTH_CSS = `var(${SINGLE_RHP_WIDTH_VARIABLE}, ${variables.rhpWidth}px)`; + +/** The width the user dragged to, kept apart from the shown width so a window that shrinks and grows back restores it. */ +let preferredSingleRHPWidth: number = variables.rhpWidth; + +let shownSingleRHPWidth: number = variables.rhpWidth; + +function getSingleRHPWidth(): number { + return shownSingleRHPWidth; +} + +function showSingleRHPWidth(width: number) { + shownSingleRHPWidth = width; + document.documentElement.style.setProperty(SINGLE_RHP_WIDTH_VARIABLE, `${width}px`); +} + +function applySingleRHPWidth(width: number) { + preferredSingleRHPWidth = width; + showSingleRHPWidth(width); +} + +/** Fits the preferred width into the current bounds without forgetting it. */ +function clampSingleRHPWidth(min: number, max: number) { + showSingleRHPWidth(lodashClamp(preferredSingleRHPWidth, min, max)); +} + +/** react-native-web passes a string width through to CSS verbatim. */ +function getSingleRHPWidthStyleValue(offset: number): DimensionValue { + // DimensionValue has no member for CSS functions, which react-native-web still renders as written. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return `calc(${SINGLE_RHP_WIDTH_CSS} + ${offset}px)` as DimensionValue; +} + +export {applySingleRHPWidth, clampSingleRHPWidth, getSingleRHPWidth, getSingleRHPWidthStyleValue}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 077457e9713c..876316b122ba 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5,6 +5,7 @@ import {animatedReceiptPaneRHPWidth, animatedWideRHPWidth} from '@components/Wid import {getBrowser, isMobile, isMobileSafari, isSafari} from '@libs/Browser'; import getPlatform from '@libs/getPlatform'; +import {getSingleRHPWidthStyleValue} from '@libs/Navigation/helpers/singleRHPWidth'; import CONST from '@src/CONST'; import type {Dimensions} from '@src/types/utils/Layout'; @@ -2877,7 +2878,7 @@ const staticStyles = (theme: ThemeColors) => borderColor: 'transparent', }, - tableColumnResizeIndicator: { + resizeIndicator: { backgroundColor: theme.iconMenu, }, @@ -6782,6 +6783,35 @@ const staticStyles = (theme: ThemeColors) => width: variables.rhpWidth, }, + // The floating card is the containing block (translateZ0), so a resizable single RHP's card fills it at whatever width + // it is dragged to, independent of the box react-navigation measures (singleRHPStackMeasureBox). + singleRHPCardInterpolatorStyles: { + ...positioning.pFixed, + top: 0, + right: 0, + width: '100%', + height: '100%', + }, + + // react-navigation's stack keeps the size of the box it renders in as React state, so this box keeps the default width + // while the single RHP is dragged and the stack never re-renders mid-drag. + singleRHPStackMeasureBox: { + ...positioning.pAbsolute, + top: 0, + right: 0, + bottom: 0, + width: variables.rhpWidth, + }, + + // Straddles the single RHP card's left edge. + rhpResizeHandleHitArea: { + ...positioning.pAbsolute, + top: 0, + bottom: 0, + width: CONST.RHP_RESIZE.HANDLE_HIT_WIDTH, + right: getSingleRHPWidthStyleValue(-CONST.RHP_RESIZE.HANDLE_HIT_WIDTH / 2), + }, + flexibleHeight: { height: 'auto', minHeight: 200, @@ -7122,6 +7152,16 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ + // Anchored to the single RHP card's right edge, so its child can sit a card's width further left. + rhpResizeHandleAnchor: (right: Animated.AnimatedAddition) => + ({ + ...positioning.pFixed, + top: variables.rhpFloatingCardMargin, + bottom: variables.rhpFloatingCardMargin, + width: 0, + right, + }) satisfies ViewStyle, + // Floats the offline indicator above a footer laid over the bottom of a tab root screen, without shrinking the // content that runs under the footer. offlineIndicatorAboveFooter: (footerHeight: number) => diff --git a/tests/unit/GetRHPFrameStyleTest.ts b/tests/unit/GetRHPFrameStyleTest.ts index 09bbc5def33a..07b6065904e5 100644 --- a/tests/unit/GetRHPFrameStyleTest.ts +++ b/tests/unit/GetRHPFrameStyleTest.ts @@ -13,8 +13,14 @@ import createMock from '../utils/createMock'; const getRHPFrameStyleWeb = jest.requireActual<{default: GetRHPFrameStyle}>('@libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts').default; const getRHPFrameStyleNative = jest.requireActual<{default: GetRHPFrameStyle}>('@libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.native.ts').default; +// The bare specifier resolves to the native stub under jest-expo, so the web card is built against the web width helper. +jest.mock('@libs/Navigation/helpers/singleRHPWidth', () => jest.requireActual>('@libs/Navigation/helpers/singleRHPWidth/index.ts')); + const RHP_WIDTH = 375; +/** The single RHP's width read from CSS, falling back to the default width, with the card's border added. */ +const FLOATING_CARD_WIDTH = `calc(var(--rhp-width, ${variables.rhpWidth}px) + ${2 * variables.rhpFloatingCardBorderWidth}px)`; + const styles = createMock({ pAbsolute: {position: 'absolute'}, r0: {right: 0}, @@ -34,25 +40,20 @@ function getWidth(style: ReturnType) { return (style as Array<{width?: unknown}>).at(-1)?.width; } -// An animated node only exposes its current value through this internal getter, hence the cast and the underscored-name disables. -function readAnimatedValue(width: unknown) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention, no-underscore-dangle - return (width as {__getValue: () => number}).__getValue(); -} - describe('getRHPFrameStyle', () => { - it('gives a standalone web RHP a floating card whose width absorbs the border', () => { + it('gives a standalone web RHP a floating card at the resizable width, absorbing the border', () => { // Given one RHP card on web with nothing stacked on or under it, the only case where the frame itself has to look like a card const params = {styles, animatedWidth: buildAnimatedWidth(), shouldUseNarrowLayout: false, shouldUseCenteredFrame: false}; // When the frame style is built for it const style = getRHPFrameStyleWeb(params); - // Then it gets the floating card with the width absorbing the border, because the card is border-box and its fixed-width panes clip without the compensation. + // Then it gets the floating card at the width a drag of its edge writes to CSS, which is the default width until a drag happens, + // plus the border, because the card is border-box and its content clips without the compensation. expect(style).toContain(styles.RHPFloatingCard); expect(style).not.toContain(styles.RHPCenteredFrame); expect(style).not.toContain(styles.r0); - expect(readAnimatedValue(getWidth(style))).toBe(RHP_WIDTH + 2 * variables.rhpFloatingCardBorderWidth); + expect(getWidth(style)).toBe(FLOATING_CARD_WIDTH); }); it('gives the stacked report flow an invisible frame that neither clips nor compensates for a border', () => { @@ -115,13 +116,12 @@ describe('getRHPFrameStyle - two-factor (MFA) security-code panel', () => { // When the frame style is built for it const style = getRHPFrameStyleWeb(params); - // Then it floats like any other RHP card, border included, so it lines up with the skinny cards behind it instead of standing out as a narrower panel. - // The width stays a plain number, since a panel that never animates its width must not need an Animated component. + // Then it floats like any other RHP card at the same width, border included, so it lines up with the skinny cards behind it instead of standing out as a narrower panel. expect(style).toContain(styles.RHPFloatingCard); expect(style).not.toContain(styles.r0); expect(style).not.toContain(styles.h100); expect(style).not.toContain(styles.RHPCenteredFrame); - expect(getWidth(style)).toBe(variables.rhpWidth + 2 * variables.rhpFloatingCardBorderWidth); + expect(getWidth(style)).toBe(FLOATING_CARD_WIDTH); }); it('keeps the panel full-bleed on native', () => { diff --git a/tests/unit/getDraggedSizeTest.ts b/tests/unit/getDraggedSizeTest.ts new file mode 100644 index 000000000000..f6d0e24c15ba --- /dev/null +++ b/tests/unit/getDraggedSizeTest.ts @@ -0,0 +1,52 @@ +import getDraggedSize from '@components/ResizeHandle/getDraggedSize'; + +describe('getDraggedSize', () => { + it('grows a trailing edge as the pointer moves right, rounding to a whole px', () => { + // Given a 200px column whose right edge is pressed at x=100 + // When the pointer moves 30.6px right + const size = getDraggedSize(200, 100, 130.6, {axisSign: 1, min: 48}); + + // Then the column grows by the travel, rounded so the layout stays on whole px + expect(size).toBe(231); + }); + + it('grows a leading edge as the pointer moves left', () => { + // Given a 440px pane whose left edge is pressed at x=500 + // When the pointer moves 60px left + const size = getDraggedSize(440, 500, 440, {axisSign: -1, min: 220, max: 880}); + + // Then the pane grows by 60px, because its left edge follows the pointer outward + expect(size).toBe(500); + }); + + it('stops at the bounds however far the pointer travels', () => { + // Given a 440px pane bounded to 220-880px + const bounds = {axisSign: -1, min: 220, max: 880} as const; + + // When the pointer travels far past either bound + const widest = getDraggedSize(440, 500, -1000, bounds); + const narrowest = getDraggedSize(440, 500, 2000, bounds); + + // Then the size stops at the bound it ran into, so the edge can't leave its range + expect(widest).toBe(880); + expect(narrowest).toBe(220); + }); + + it('keeps the minimum when a small window pushes the maximum below it', () => { + // Given a window so narrow that the maximum falls under the minimum + // When the pane is dragged wider + const size = getDraggedSize(220, 500, 400, {axisSign: -1, min: 220, max: 150}); + + // Then it stays at the minimum, so the pane never becomes unusably narrow + expect(size).toBe(220); + }); + + it('has no maximum unless one is given', () => { + // Given a column with only a minimum + // When it is dragged 5000px wider + const size = getDraggedSize(200, 0, 5000, {axisSign: 1, min: 48}); + + // Then nothing caps it, matching table columns, which grow into horizontal scroll + expect(size).toBe(5200); + }); +}); diff --git a/tests/unit/getSingleRHPWidthBoundsTest.ts b/tests/unit/getSingleRHPWidthBoundsTest.ts new file mode 100644 index 000000000000..a519daa2f7fb --- /dev/null +++ b/tests/unit/getSingleRHPWidthBoundsTest.ts @@ -0,0 +1,56 @@ +import calculateWideRHPWidth from '@libs/Navigation/helpers/calculateWideRHPWidth'; +import getSingleRHPWidthBounds from '@libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +// jest-expo resolves bare specifiers to index.native.ts, where the wide RHP has no width, so the web module is loaded explicitly. +jest.mock('@libs/Navigation/helpers/calculateReceiptPaneRHPWidth', () => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + jest.requireActual('@libs/Navigation/helpers/calculateReceiptPaneRHPWidth/index.ts'), +); + +/** What the window leaves for the card once the floating card's gutter, its borders, the Side Panel and the content reserve are taken. */ +function getWidthLeftForCard(windowWidth: number, sidePanelWidth: number) { + return windowWidth - variables.rhpFloatingCardMargin - 2 * variables.rhpFloatingCardBorderWidth - sidePanelWidth - CONST.RHP_RESIZE.CONTENT_RESERVE_WIDTH; +} + +describe('getSingleRHPWidthBounds', () => { + it('stops a wide window short of the wide RHP', () => { + // Given a wide window with the Side Panel closed, where the wide RHP is the tighter limit + const windowWidth = 1920; + expect(calculateWideRHPWidth(windowWidth) - CONST.RHP_RESIZE.WIDE_RHP_INSET).toBeLessThan(getWidthLeftForCard(windowWidth, 0)); + + // When the bounds are computed + const bounds = getSingleRHPWidthBounds(windowWidth, 0); + + // Then the single RHP can't grow past the inset below the wide RHP, so a single RHP stacked on a wide one never covers it + expect(bounds).toEqual({min: CONST.RHP_RESIZE.MIN_WIDTH, max: calculateWideRHPWidth(windowWidth) - CONST.RHP_RESIZE.WIDE_RHP_INSET}); + }); + + it('leaves the content beside the card its reserve when the Side Panel is open', () => { + // Given a window where the open Side Panel makes the content reserve the tighter limit + const windowWidth = 1400; + const sidePanelWidth = variables.sidePanelWidth; + expect(getWidthLeftForCard(windowWidth, sidePanelWidth)).toBeLessThan(calculateWideRHPWidth(windowWidth) - CONST.RHP_RESIZE.WIDE_RHP_INSET); + + // When the bounds are computed + const {max} = getSingleRHPWidthBounds(windowWidth, sidePanelWidth); + + // Then the card stops where the content to its left would drop below the reserve + expect(max).toBe(getWidthLeftForCard(windowWidth, sidePanelWidth)); + }); + + it('never lets the max drop below the min', () => { + // Given a window so narrow that the reserve leaves less than the narrowest card + const windowWidth = 900; + + // When the bounds are computed + const {min, max} = getSingleRHPWidthBounds(windowWidth, variables.sidePanelWidth); + + // Then the range collapses onto the min, so a drag can't push the card narrower than it may ever be + expect(max).toBe(min); + expect(min).toBe(CONST.RHP_RESIZE.MIN_WIDTH); + }); +}); From f02102ac7f1c9f3b1d03524349d64bdb9c197db4 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 21 Oct 2026 15:33:29 +0200 Subject: [PATCH 02/10] fix: remove unnecessary `axisSign` param --- src/components/ResizeHandle/getDraggedSize.ts | 11 +++---- .../columnResize/useColumnResize/index.ts | 2 +- .../Navigators/RHPResizeHandle/index.tsx | 2 +- tests/unit/getDraggedSizeTest.ts | 30 +++++++++---------- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/components/ResizeHandle/getDraggedSize.ts b/src/components/ResizeHandle/getDraggedSize.ts index 94ef2afc4d45..b0fa208fd42f 100644 --- a/src/components/ResizeHandle/getDraggedSize.ts +++ b/src/components/ResizeHandle/getDraggedSize.ts @@ -1,19 +1,16 @@ /** Turns pointer travel on a resize handle into a pane's or column's size. */ import lodashClamp from 'lodash/clamp'; -type DraggedSizeParams = { - /** `1` when the handle sits on the trailing edge, so moving right grows the size. `-1` when it sits on the leading edge. */ - axisSign: 1 | -1; - +type DraggedSizeBounds = { min: number; /** Unbounded when omitted. */ max?: number; }; -/** The size a drag puts a pane or column at: its start size plus the pointer's travel, rounded and kept between the bounds. The minimum wins over the maximum. */ -function getDraggedSize(startSize: number, startClientX: number, clientX: number, {axisSign, min, max = Infinity}: DraggedSizeParams): number { - return lodashClamp(Math.round(startSize + axisSign * (clientX - startClientX)), min, max); +/** The size a drag puts a pane or column at: its start size plus `travel`, rounded and kept between the bounds. `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; diff --git a/src/components/Table/columnResize/useColumnResize/index.ts b/src/components/Table/columnResize/useColumnResize/index.ts index cffe2a6b00da..181d49a34ce5 100644 --- a/src/components/Table/columnResize/useColumnResize/index.ts +++ b/src/components/Table/columnResize/useColumnResize/index.ts @@ -85,7 +85,7 @@ function useColumnResize({ dragRef, onMove: (drag, clientX) => { // The line rides the handle, so it follows the clamped width, not the pointer. - const width = getDraggedSize(drag.startWidth, drag.startClientX, clientX, {axisSign: 1, min: dragMinWidths?.[drag.columnKey] ?? CONST.TABLES.COLUMN_RESIZE.MIN_WIDTH}); + const width = getDraggedSize(drag.startWidth, clientX - drag.startClientX, {min: dragMinWidths?.[drag.columnKey] ?? CONST.TABLES.COLUMN_RESIZE.MIN_WIDTH}); for (const [columnKey, resizedWidth] of Object.entries(getResizedColumnWidths(drag.columnKey, width))) { writeColumnWidth(columnKey, resizedWidth); diff --git a/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx index 0565ea87760e..50f51a29260f 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx @@ -66,7 +66,7 @@ function RHPResizeHandle({sidePanelOffset}: RHPResizeHandleProps) { const dragRef = useRef(null); const pointerDrag = usePointerDrag({ dragRef, - onMove: (drag, clientX) => applySingleRHPWidth(getDraggedSize(drag.startWidth, drag.startClientX, clientX, {axisSign: -1, min, max})), + onMove: (drag, clientX) => applySingleRHPWidth(getDraggedSize(drag.startWidth, drag.startClientX - clientX, {min, max})), onEnd: () => setIsDragging(false), }); diff --git a/tests/unit/getDraggedSizeTest.ts b/tests/unit/getDraggedSizeTest.ts index f6d0e24c15ba..54ab627adfbd 100644 --- a/tests/unit/getDraggedSizeTest.ts +++ b/tests/unit/getDraggedSizeTest.ts @@ -1,31 +1,31 @@ import getDraggedSize from '@components/ResizeHandle/getDraggedSize'; describe('getDraggedSize', () => { - it('grows a trailing edge as the pointer moves right, rounding to a whole px', () => { - // Given a 200px column whose right edge is pressed at x=100 - // When the pointer moves 30.6px right - const size = getDraggedSize(200, 100, 130.6, {axisSign: 1, min: 48}); + it('adds the travel to the start size, rounding to a whole px', () => { + // Given a 200px column + // When the drag grows it by 30.6px + const size = getDraggedSize(200, 30.6, {min: 48}); // Then the column grows by the travel, rounded so the layout stays on whole px expect(size).toBe(231); }); - it('grows a leading edge as the pointer moves left', () => { - // Given a 440px pane whose left edge is pressed at x=500 - // When the pointer moves 60px left - const size = getDraggedSize(440, 500, 440, {axisSign: -1, min: 220, max: 880}); + it('shrinks the size when the travel is negative', () => { + // Given a 440px pane + // When the drag shrinks it by 60px + const size = getDraggedSize(440, -60, {min: 220, max: 880}); - // Then the pane grows by 60px, because its left edge follows the pointer outward - expect(size).toBe(500); + // Then the pane loses the 60px + expect(size).toBe(380); }); it('stops at the bounds however far the pointer travels', () => { // Given a 440px pane bounded to 220-880px - const bounds = {axisSign: -1, min: 220, max: 880} as const; + const bounds = {min: 220, max: 880}; // When the pointer travels far past either bound - const widest = getDraggedSize(440, 500, -1000, bounds); - const narrowest = getDraggedSize(440, 500, 2000, bounds); + const widest = getDraggedSize(440, 1500, bounds); + const narrowest = getDraggedSize(440, -1500, bounds); // Then the size stops at the bound it ran into, so the edge can't leave its range expect(widest).toBe(880); @@ -35,7 +35,7 @@ describe('getDraggedSize', () => { it('keeps the minimum when a small window pushes the maximum below it', () => { // Given a window so narrow that the maximum falls under the minimum // When the pane is dragged wider - const size = getDraggedSize(220, 500, 400, {axisSign: -1, min: 220, max: 150}); + const size = getDraggedSize(220, 100, {min: 220, max: 150}); // Then it stays at the minimum, so the pane never becomes unusably narrow expect(size).toBe(220); @@ -44,7 +44,7 @@ describe('getDraggedSize', () => { it('has no maximum unless one is given', () => { // Given a column with only a minimum // When it is dragged 5000px wider - const size = getDraggedSize(200, 0, 5000, {axisSign: 1, min: 48}); + const size = getDraggedSize(200, 5000, {min: 48}); // Then nothing caps it, matching table columns, which grow into horizontal scroll expect(size).toBe(5200); From ede5e974b23e6ef971c873e499ba7c92433e5a00 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 21 Oct 2026 16:06:07 +0200 Subject: [PATCH 03/10] fix: position `RHPResizeHandle` absolutely --- .../Navigators/RHPResizeHandle/index.tsx | 58 ++++++++----------- .../Navigators/RightModalNavigator.tsx | 2 +- src/styles/index.ts | 20 ++----- 3 files changed, 30 insertions(+), 50 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx index 50f51a29260f..9ec67539cb74 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx @@ -15,14 +15,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import React, {useLayoutEffect, useRef, useState} from 'react'; -// The handle rides the Side Panel offset, which is a react-native Animated value. -// eslint-disable-next-line no-restricted-imports -import {Animated, View} from 'react-native'; - -type RHPResizeHandleProps = { - /** How far the Side Panel pushes the RHP card in from the window's right edge. */ - sidePanelOffset: Animated.Value; -}; +import {View} from 'react-native'; type Drag = { /** Where the pointer went down. */ @@ -53,7 +46,7 @@ const LINE_STYLE: React.CSSProperties = { * Drags the single RHP card's left edge. A sibling of the card rather than a child, because the card clips its overflow, * and rendered after the overlay so it wins hit-testing over the scrim that closes the RHP. */ -function RHPResizeHandle({sidePanelOffset}: RHPResizeHandleProps) { +function RHPResizeHandle() { const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); const {isExtraLargeScreenWidth} = useResponsiveLayout(); @@ -75,31 +68,28 @@ function RHPResizeHandle({sidePanelOffset}: RHPResizeHandleProps) { useLayoutEffect(() => clampSingleRHPWidth(min, max), [min, max]); return ( - // Anchored to the card's right edge, which only the Side Panel moves, then shifted the card's width further left. - - - {/* A plain `div` because it relies on DOM pointer capture. */} -
setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - onPointerDown={(event) => { - if (!pointerDrag.startDrag(event, {startClientX: event.clientX, startWidth: getSingleRHPWidth()})) { - return; - } - setIsDragging(true); - }} - {...pointerDrag.handlers} - > - {(isHovered || isDragging) && ( -
- )} -
- - + + {/* A plain `div` because it relies on DOM pointer capture. */} +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + onPointerDown={(event) => { + if (!pointerDrag.startDrag(event, {startClientX: event.clientX, startWidth: getSingleRHPWidth()})) { + return; + } + setIsDragging(true); + }} + {...pointerDrag.handlers} + > + {(isHovered || isDragging) && ( +
+ )} +
+ ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 9a8a12221443..14494f3dbda3 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -615,7 +615,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { /> )} - {isResizableSingleRHP && } + {isResizableSingleRHP && } ); diff --git a/src/styles/index.ts b/src/styles/index.ts index 876316b122ba..456ad6a823a6 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6803,13 +6803,13 @@ const staticStyles = (theme: ThemeColors) => width: variables.rhpWidth, }, - // Straddles the single RHP card's left edge. - rhpResizeHandleHitArea: { + // Straddles the left edge of the single RHP card (RHPFloatingCard), which shares its containing block. + rhpResizeHandle: { ...positioning.pAbsolute, - top: 0, - bottom: 0, + top: variables.rhpFloatingCardMargin, + bottom: variables.rhpFloatingCardMargin, width: CONST.RHP_RESIZE.HANDLE_HIT_WIDTH, - right: getSingleRHPWidthStyleValue(-CONST.RHP_RESIZE.HANDLE_HIT_WIDTH / 2), + right: getSingleRHPWidthStyleValue(variables.rhpFloatingCardMargin + 2 * variables.rhpFloatingCardBorderWidth - CONST.RHP_RESIZE.HANDLE_HIT_WIDTH / 2), }, flexibleHeight: { @@ -7152,16 +7152,6 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ - // Anchored to the single RHP card's right edge, so its child can sit a card's width further left. - rhpResizeHandleAnchor: (right: Animated.AnimatedAddition) => - ({ - ...positioning.pFixed, - top: variables.rhpFloatingCardMargin, - bottom: variables.rhpFloatingCardMargin, - width: 0, - right, - }) satisfies ViewStyle, - // Floats the offline indicator above a footer laid over the bottom of a tab root screen, without shrinking the // content that runs under the footer. offlineIndicatorAboveFooter: (footerHeight: number) => From 1f48a0606f830f03ec647ca658f58e4743d52ba8 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 21 Oct 2026 16:33:20 +0200 Subject: [PATCH 04/10] fix: add saving to onyx --- src/ONYXKEYS.ts | 4 + .../DragAndDrop/NoDropZone/index.tsx | 11 ++- .../DragAndDrop/NoDropZone/types.ts | 6 +- src/hooks/useSingleRHPWidth.ts | 33 +++++++ src/libs/ExportOnyxState/masking.ts | 1 + ...ultifactorAuthenticationModalNavigator.tsx | 4 +- .../Navigators/RHPResizeHandle/index.tsx | 70 ++++++++++----- .../Navigators/RightModalNavigator.tsx | 9 +- .../Navigators/getRHPFrameStyle/index.ts | 7 +- .../Navigators/getRHPFrameStyle/types.ts | 3 + .../useModalCardStyleInterpolator.ts | 5 +- .../AppNavigator/useRHPScreenOptions.ts | 6 +- .../helpers/singleRHPWidth/index.native.ts | 18 ++-- .../helpers/singleRHPWidth/index.ts | 43 +++------ src/libs/actions/SingleRHPWidth.ts | 11 +++ src/styles/index.ts | 19 ++-- tests/unit/GetRHPFrameStyleTest.ts | 25 +++--- tests/unit/useSingleRHPWidthTest.ts | 89 +++++++++++++++++++ 18 files changed, 264 insertions(+), 100 deletions(-) create mode 100644 src/hooks/useSingleRHPWidth.ts create mode 100644 src/libs/actions/SingleRHPWidth.ts create mode 100644 tests/unit/useSingleRHPWidthTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f9f7b948289c..490451d0d4f6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -939,6 +939,9 @@ const ONYXKEYS = { /** Dragged table column widths, keyed by `columnResizingID` then column key. Per device, since ideal widths depend on window size. */ TABLE_COLUMN_WIDTHS: 'tableColumnWidths', + /** 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', @@ -2035,6 +2038,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_REPORT_LAYOUT_OPTION]: string; [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[]; [ONYXKEYS.TABLE_COLUMN_WIDTHS]: OnyxTypes.TableColumnWidths; + [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; diff --git a/src/components/DragAndDrop/NoDropZone/index.tsx b/src/components/DragAndDrop/NoDropZone/index.tsx index 9fb4b571c164..a60069b4772f 100644 --- a/src/components/DragAndDrop/NoDropZone/index.tsx +++ b/src/components/DragAndDrop/NoDropZone/index.tsx @@ -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'; @@ -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 | HTMLDivElement>(null); @@ -20,9 +21,13 @@ function NoDropZone({children}: NoDropZoneProps) { shouldAllowDrop: false, }); + const setNoDropZone = (element: ComponentRef | HTMLDivElement | null) => { + noDropZone.current = element; + }; + return ( {children} diff --git a/src/components/DragAndDrop/NoDropZone/types.ts b/src/components/DragAndDrop/NoDropZone/types.ts index 495e45b2d9aa..941275f44dcc 100644 --- a/src/components/DragAndDrop/NoDropZone/types.ts +++ b/src/components/DragAndDrop/NoDropZone/types.ts @@ -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 | HTMLElement>; }; export default NoDropZoneProps; diff --git a/src/hooks/useSingleRHPWidth.ts b/src/hooks/useSingleRHPWidth.ts new file mode 100644 index 000000000000..ec72afab4604 --- /dev/null +++ b/src/hooks/useSingleRHPWidth.ts @@ -0,0 +1,33 @@ +/** The single RHP's committed width, clamped to the bounds the window allows. */ +import getSingleRHPWidthBounds from '@libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds'; +import type {SingleRHPWidthBounds} from '@libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds'; + +import variables from '@styles/variables'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import lodashClamp from 'lodash/clamp'; + +import useOnyx from './useOnyx'; +import useResponsiveLayout from './useResponsiveLayout'; +import useSidePanelState from './useSidePanelState'; +import useWindowDimensions from './useWindowDimensions'; + +type SingleRHPWidth = SingleRHPWidthBounds & { + width: number; +}; + +/** Clamped at render rather than when saved, so a window that shrinks and grows back returns to the dragged width. */ +function useSingleRHPWidth(): SingleRHPWidth { + const [storedWidth] = useOnyx(ONYXKEYS.SINGLE_RHP_WIDTH); + const {windowWidth} = useWindowDimensions(); + const {isExtraLargeScreenWidth} = useResponsiveLayout(); + const {shouldHideSidePanel} = useSidePanelState(); + + // Mirrors when SidePanelContextProvider shifts the RHP left by the panel's width. + const {min, max} = getSingleRHPWidthBounds(windowWidth, isExtraLargeScreenWidth && !shouldHideSidePanel ? variables.sidePanelWidth : 0); + + return {width: lodashClamp(storedWidth ?? variables.rhpWidth, min, max), min, max}; +} + +export default useSingleRHPWidth; diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 3c8c7b8973d4..4f4951c1f3e0 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -375,6 +375,7 @@ const safeOnyxKeys = new Set([ // eslint-disable-next-line @typescript-eslint/no-deprecated -- the key is still on device until the migration drains it, so it stays in the safe-to-export set ONYXKEYS.SHOULD_USE_STAGING_SERVER, ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT, + ONYXKEYS.SINGLE_RHP_WIDTH, ONYXKEYS.STATUS_DRAFT_CUSTOM_CLEAR_AFTER_DATE, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, diff --git a/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx index 7f37a4cd94e1..decf8b534b00 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx @@ -8,6 +8,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelActions from '@hooks/useSidePanelActions'; import useSidePanelState from '@hooks/useSidePanelState'; +import useSingleRHPWidth from '@hooks/useSingleRHPWidth'; import useTheme from '@hooks/useTheme'; import useThemePreference from '@hooks/useThemePreference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -141,7 +142,8 @@ function MultifactorAuthenticationModalNavigator() { opacity: backdropProgress.get() * variables.rhpOverlayOpacity, })); - const frameStyle = getRHPFrameStyle({styles, animatedWidth: variables.rhpWidth, shouldUseNarrowLayout, shouldUseCenteredFrame: false}); + const {width: singleRHPWidth} = useSingleRHPWidth(); + const frameStyle = getRHPFrameStyle({styles, animatedWidth: variables.rhpWidth, singleRHPWidth, shouldUseNarrowLayout, shouldUseCenteredFrame: false}); if (phase === 'closed') { return null; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx index 9ec67539cb74..c195074beb60 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RHPResizeHandle/index.tsx @@ -2,27 +2,33 @@ import getDraggedSize from '@components/ResizeHandle/getDraggedSize'; import usePointerDrag from '@components/ResizeHandle/usePointerDrag'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSidePanelState from '@hooks/useSidePanelState'; +import useSingleRHPWidth from '@hooks/useSingleRHPWidth'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import {applySingleRHPWidth, clampSingleRHPWidth, getSingleRHPWidth} from '@libs/Navigation/helpers/singleRHPWidth'; -import getSingleRHPWidthBounds from '@libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds'; - -import variables from '@styles/variables'; +import {setSingleRHPWidth} from '@libs/actions/SingleRHPWidth'; +import {clearLiveSingleRHPWidth, writeLiveSingleRHPWidth} from '@libs/Navigation/helpers/singleRHPWidth'; import CONST from '@src/CONST'; -import React, {useLayoutEffect, useRef, useState} from 'react'; +import type {RefObject} from 'react'; + +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +type RHPResizeHandleProps = { + /** Holds the live width mid-drag. Contains the RHP cards and this handle, which all read it. */ + scopeRef: RefObject; +}; + type Drag = { /** Where the pointer went down. */ startClientX: number; /** The RHP's width when the drag started. */ startWidth: number; + + /** Last width painted into CSS, `undefined` until the pointer moves. */ + liveWidth?: number; }; // DOM `div` styles, which the React Native style system can't type. @@ -46,36 +52,58 @@ const LINE_STYLE: React.CSSProperties = { * Drags the single RHP card's left edge. A sibling of the card rather than a child, because the card clips its overflow, * and rendered after the overlay so it wins hit-testing over the scrim that closes the RHP. */ -function RHPResizeHandle() { +function RHPResizeHandle({scopeRef}: RHPResizeHandleProps) { const styles = useThemeStyles(); - const {windowWidth} = useWindowDimensions(); - const {isExtraLargeScreenWidth} = useResponsiveLayout(); - const {shouldHideSidePanel} = useSidePanelState(); - // Mirrors when SidePanelContextProvider shifts the RHP left by the panel's width. - const {min, max} = getSingleRHPWidthBounds(windowWidth, isExtraLargeScreenWidth && !shouldHideSidePanel ? variables.sidePanelWidth : 0); + const {width, min, max} = useSingleRHPWidth(); const [isHovered, setIsHovered] = useState(false); const [isDragging, setIsDragging] = useState(false); const dragRef = useRef(null); const pointerDrag = usePointerDrag({ dragRef, - onMove: (drag, clientX) => applySingleRHPWidth(getDraggedSize(drag.startWidth, drag.startClientX - clientX, {min, max})), - onEnd: () => setIsDragging(false), + onMove: (drag, clientX) => { + const liveWidth = getDraggedSize(drag.startWidth, drag.startClientX - clientX, {min, max}); + writeLiveSingleRHPWidth(scopeRef.current, liveWidth); + // eslint-disable-next-line no-param-reassign -- the drag is the ref's own object, read back when the drag ends + drag.liveWidth = liveWidth; + }, + onEnd: ({liveWidth}) => { + setIsDragging(false); + + // Nothing gets saved, so no render follows to clear the live width. + if (liveWidth === undefined || liveWidth === width) { + clearLiveSingleRHPWidth(scopeRef.current); + return; + } + + setSingleRHPWidth(liveWidth); + }, }); - // A narrower window or an opening Side Panel lowers the max, so the width shrinks to fit. Before paint, so an RHP opened - // after the window shrank never shows a frame at the old width. - useLayoutEffect(() => clampSingleRHPWidth(min, max), [min, max]); + // React now renders the saved width as the fallback, so drop the live one before paint. A window resize mid-drag leaves it to the drag. + useLayoutEffect(() => { + if (dragRef.current) { + return; + } + + clearLiveSingleRHPWidth(scopeRef.current); + }, [width, scopeRef]); + + // The scope outlives the handle, so unmounting before the save renders would otherwise pin the cards at the live width. + useEffect(() => { + const scope = scopeRef.current; + return () => clearLiveSingleRHPWidth(scope); + }, [scopeRef]); return ( - + {/* A plain `div` because it relies on DOM pointer capture. */}
setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} onPointerDown={(event) => { - if (!pointerDrag.startDrag(event, {startClientX: event.clientX, startWidth: getSingleRHPWidth()})) { + if (!pointerDrag.startDrag(event, {startClientX: event.clientX, startWidth: width})) { return; } setIsDragging(true); diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 14494f3dbda3..6c2fe0a642fe 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -6,6 +6,7 @@ import useIsResizableSingleRHP from '@hooks/useIsResizableSingleRHP'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; +import useSingleRHPWidth from '@hooks/useSingleRHPWidth'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -224,7 +225,9 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { !shouldUseNarrowLayout && (superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0 || shouldRenderSecondaryOverlayForRHPOnWideRHP || shouldRenderSecondaryOverlayForRHPOnSuperWideRHP); - const frameStyle = getRHPFrameStyle({styles, animatedWidth, shouldUseNarrowLayout, shouldUseCenteredFrame}); + const {width: resizableSingleRHPWidth} = useSingleRHPWidth(); + const singleRHPWidthScopeRef = useRef(null); + const frameStyle = getRHPFrameStyle({styles, animatedWidth, singleRHPWidth: resizableSingleRHPWidth, shouldUseNarrowLayout, shouldUseCenteredFrame}); const isResizableRHPEnabled = isBetaEnabled(CONST.BETAS.RESIZABLE_RHP); const isResizableSingleRHP = useIsResizableSingleRHP(); @@ -302,7 +305,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { return ( - + {!shouldUseNarrowLayout && ( )} - {isResizableSingleRHP && } + {isResizableSingleRHP && } ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts b/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts index ff97833a0f19..aa2e4d24c802 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts @@ -5,7 +5,7 @@ import variables from '@styles/variables'; import type GetRHPFrameStyle from './types'; /** Narrow keeps the full-bleed frame, the stacked report flow uses an invisible one (each card draws its own modal), everything else is a single floating card. */ -const getRHPFrameStyle: GetRHPFrameStyle = ({styles, animatedWidth, shouldUseNarrowLayout, shouldUseCenteredFrame}) => { +const getRHPFrameStyle: GetRHPFrameStyle = ({styles, animatedWidth, singleRHPWidth, shouldUseNarrowLayout, shouldUseCenteredFrame}) => { if (shouldUseNarrowLayout) { return [styles.pAbsolute, styles.r0, styles.h100, styles.overflowHidden, {width: '100%'}]; } @@ -14,9 +14,8 @@ const getRHPFrameStyle: GetRHPFrameStyle = ({styles, animatedWidth, shouldUseNar return [styles.pAbsolute, styles.RHPCenteredFrame, {width: animatedWidth}]; } - // Border-box card, so the border is added back on top of the width or the content gets clipped. The width is read from CSS, - // where a drag of the single RHP's edge writes it. - const floatingCardWidth = getSingleRHPWidthStyleValue(2 * variables.rhpFloatingCardBorderWidth); + // Border-box card, so the border is added back on top of the width or the content gets clipped. + const floatingCardWidth = getSingleRHPWidthStyleValue(singleRHPWidth, 2 * variables.rhpFloatingCardBorderWidth); // translateZ0 makes the frame a containing block that clips the fixed RHP screen to the radius. return [styles.pAbsolute, styles.overflowHidden, styles.translateZ0, styles.RHPFloatingCard, {width: floatingCardWidth}]; diff --git a/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/types.ts b/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/types.ts index 9a5f075dfb54..4d52de8f7fec 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/types.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/types.ts @@ -7,6 +7,9 @@ type RHPFrameStyleParams = { animatedWidth: OverlayPositionValue; + /** Committed width of the single floating card, which a drag overrides live in CSS. */ + singleRHPWidth: number; + shouldUseNarrowLayout: boolean; /** Whether a report or expense is stacked in the RHP, in which case every card draws its own modal. */ diff --git a/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts b/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts index 2d2ef7307d0f..61e384a62e16 100644 --- a/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts +++ b/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts @@ -1,10 +1,10 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; +import useSingleRHPWidth from '@hooks/useSingleRHPWidth'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMobileChrome, isMobileSafari} from '@libs/Browser'; -import {getSingleRHPWidth} from '@libs/Navigation/helpers/singleRHPWidth'; import type {StackCardInterpolatedStyle, StackCardInterpolationProps} from '@react-navigation/stack'; @@ -27,6 +27,7 @@ const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => { const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); const {sidePanelOffset, sidePanelNVP, isSidePanelTransitionEnded} = useSidePanelState(); + const {width: singleRHPWidth} = useSingleRHPWidth(); // Narrow mobile browsers compositing the stacked, transparent modal cards can flicker mid-slide on low-end devices. // Hardening the animated card (opaque background + dedicated compositor layer) avoids that glitch while keeping the animation. @@ -62,7 +63,7 @@ const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => { return {cardStyle: {...cardStyle, opacity: progress}}; } - const widthFallback = shouldUseNarrowLayout ? screen.width : getSingleRHPWidth(); + const widthFallback = shouldUseNarrowLayout ? screen.width : singleRHPWidth; const distancePx = enter.kind === 'slide-and-fade' ? enter.distancePx : widthFallback; const translateX = Animated.multiply( diff --git a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts index c48d154377b6..520f6c014119 100644 --- a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts @@ -2,10 +2,10 @@ import {useWideRHPState} from '@components/WideRHPContextProvider'; import useIsResizableSingleRHP from '@hooks/useIsResizableSingleRHP'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSingleRHPWidth from '@hooks/useSingleRHPWidth'; import useThemeStyles from '@hooks/useThemeStyles'; import {isSafari} from '@libs/Browser'; -import {getSingleRHPWidth} from '@libs/Navigation/helpers/singleRHPWidth'; import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types/NavigationOptions'; @@ -42,12 +42,13 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { const isWideRHPContext = !isSmallScreenWidth && (wideRHPRouteKeys.length > 0 || superWideRHPRouteKeys.length > 0); const isResizableSingleRHP = useIsResizableSingleRHP(); + const {width: singleRHPWidth} = useSingleRHPWidth(); return useMemo(() => { const getHorizontalIOSInterpolatorProps = (props: StackCardInterpolationProps) => { // The resizable single RHP's stack measures a fixed-width box, so the slide distance comes from the dragged width instead. if (isResizableSingleRHP) { - return withScreenWidth(props, getSingleRHPWidth()); + return withScreenWidth(props, singleRHPWidth); } // A narrow screen on top of a wide RHP uses only half the width, the other half being transparent, so it slides from there. return shouldAdjustInterpolatorProps ? withScreenWidth(props, props.layouts.screen.width / 2) : props; @@ -81,6 +82,7 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { shouldAdjustInterpolatorProps, isWideRHPContext, isResizableSingleRHP, + singleRHPWidth, isSmallScreenWidth, styles.navigationScreenCardStyle, styles.singleRHPCardInterpolatorStyles, diff --git a/src/libs/Navigation/helpers/singleRHPWidth/index.native.ts b/src/libs/Navigation/helpers/singleRHPWidth/index.native.ts index 4c3e3d1caf57..52e60d3cda9d 100644 --- a/src/libs/Navigation/helpers/singleRHPWidth/index.native.ts +++ b/src/libs/Navigation/helpers/singleRHPWidth/index.native.ts @@ -1,18 +1,12 @@ -import variables from '@styles/variables'; - import type {DimensionValue} from 'react-native'; -/** The RHP is full-screen on native, so its width never changes. */ -function getSingleRHPWidth(): number { - return variables.rhpWidth; -} - -function applySingleRHPWidth() {} +/** The RHP is full-screen on native, so nothing drags its width. */ +function writeLiveSingleRHPWidth() {} -function clampSingleRHPWidth() {} +function clearLiveSingleRHPWidth() {} -function getSingleRHPWidthStyleValue(offset: number): DimensionValue { - return variables.rhpWidth + offset; +function getSingleRHPWidthStyleValue(width: number, offset: number): DimensionValue { + return width + offset; } -export {applySingleRHPWidth, clampSingleRHPWidth, getSingleRHPWidth, getSingleRHPWidthStyleValue}; +export {clearLiveSingleRHPWidth, getSingleRHPWidthStyleValue, writeLiveSingleRHPWidth}; diff --git a/src/libs/Navigation/helpers/singleRHPWidth/index.ts b/src/libs/Navigation/helpers/singleRHPWidth/index.ts index 770480aa6bd8..5627db9b0ce6 100644 --- a/src/libs/Navigation/helpers/singleRHPWidth/index.ts +++ b/src/libs/Navigation/helpers/singleRHPWidth/index.ts @@ -1,44 +1,23 @@ -/** The single RHP width a drag writes, kept in a CSS variable so styles follow it without a React render. */ -import variables from '@styles/variables'; - +/** The single RHP width a drag paints straight onto the DOM, ahead of React, as a CSS variable that styles fall back from. */ import type {DimensionValue} from 'react-native'; -import lodashClamp from 'lodash/clamp'; - const SINGLE_RHP_WIDTH_VARIABLE = '--rhp-width'; -/** The live single RHP width as a CSS expression. A drag rewrites the variable, so every style built on it follows without a React render. */ -const SINGLE_RHP_WIDTH_CSS = `var(${SINGLE_RHP_WIDTH_VARIABLE}, ${variables.rhpWidth}px)`; - -/** The width the user dragged to, kept apart from the shown width so a window that shrinks and grows back restores it. */ -let preferredSingleRHPWidth: number = variables.rhpWidth; - -let shownSingleRHPWidth: number = variables.rhpWidth; - -function getSingleRHPWidth(): number { - return shownSingleRHPWidth; -} - -function showSingleRHPWidth(width: number) { - shownSingleRHPWidth = width; - document.documentElement.style.setProperty(SINGLE_RHP_WIDTH_VARIABLE, `${width}px`); -} - -function applySingleRHPWidth(width: number) { - preferredSingleRHPWidth = width; - showSingleRHPWidth(width); +/** `scope` must contain every element whose style reads the width: the RHP cards and the handle. */ +function writeLiveSingleRHPWidth(scope: HTMLElement | null, width: number) { + scope?.style.setProperty(SINGLE_RHP_WIDTH_VARIABLE, `${width}px`); } -/** Fits the preferred width into the current bounds without forgetting it. */ -function clampSingleRHPWidth(min: number, max: number) { - showSingleRHPWidth(lodashClamp(preferredSingleRHPWidth, min, max)); +/** Hands the width back to the committed one styles fall back to. */ +function clearLiveSingleRHPWidth(scope: HTMLElement | null) { + scope?.style.removeProperty(SINGLE_RHP_WIDTH_VARIABLE); } -/** react-native-web passes a string width through to CSS verbatim. */ -function getSingleRHPWidthStyleValue(offset: number): DimensionValue { +/** The live width mid-drag, the committed `width` otherwise. react-native-web passes a string width through to CSS verbatim. */ +function getSingleRHPWidthStyleValue(width: number, offset: number): DimensionValue { // DimensionValue has no member for CSS functions, which react-native-web still renders as written. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return `calc(${SINGLE_RHP_WIDTH_CSS} + ${offset}px)` as DimensionValue; + return `calc(var(${SINGLE_RHP_WIDTH_VARIABLE}, ${width}px) + ${offset}px)` as DimensionValue; } -export {applySingleRHPWidth, clampSingleRHPWidth, getSingleRHPWidth, getSingleRHPWidthStyleValue}; +export {clearLiveSingleRHPWidth, getSingleRHPWidthStyleValue, writeLiveSingleRHPWidth}; diff --git a/src/libs/actions/SingleRHPWidth.ts b/src/libs/actions/SingleRHPWidth.ts new file mode 100644 index 000000000000..5d41bb9e9916 --- /dev/null +++ b/src/libs/actions/SingleRHPWidth.ts @@ -0,0 +1,11 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +/** Saves the width once a drag ends, since mid-drag widths only live in CSS. */ +function setSingleRHPWidth(width: number) { + Onyx.set(ONYXKEYS.SINGLE_RHP_WIDTH, width); +} + +// eslint-disable-next-line import/prefer-default-export -- additional actions may be added here +export {setSingleRHPWidth}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 456ad6a823a6..bdfdddf9a4bb 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6803,15 +6803,6 @@ const staticStyles = (theme: ThemeColors) => width: variables.rhpWidth, }, - // Straddles the left edge of the single RHP card (RHPFloatingCard), which shares its containing block. - rhpResizeHandle: { - ...positioning.pAbsolute, - top: variables.rhpFloatingCardMargin, - bottom: variables.rhpFloatingCardMargin, - width: CONST.RHP_RESIZE.HANDLE_HIT_WIDTH, - right: getSingleRHPWidthStyleValue(variables.rhpFloatingCardMargin + 2 * variables.rhpFloatingCardBorderWidth - CONST.RHP_RESIZE.HANDLE_HIT_WIDTH / 2), - }, - flexibleHeight: { height: 'auto', minHeight: 200, @@ -7152,6 +7143,16 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ + // Straddles the left edge of the single RHP card (RHPFloatingCard), which shares its containing block. + rhpResizeHandle: (singleRHPWidth: number) => + ({ + ...positioning.pAbsolute, + top: variables.rhpFloatingCardMargin, + bottom: variables.rhpFloatingCardMargin, + width: CONST.RHP_RESIZE.HANDLE_HIT_WIDTH, + right: getSingleRHPWidthStyleValue(singleRHPWidth, variables.rhpFloatingCardMargin + 2 * variables.rhpFloatingCardBorderWidth - CONST.RHP_RESIZE.HANDLE_HIT_WIDTH / 2), + }) satisfies ViewStyle, + // Floats the offline indicator above a footer laid over the bottom of a tab root screen, without shrinking the // content that runs under the footer. offlineIndicatorAboveFooter: (footerHeight: number) => diff --git a/tests/unit/GetRHPFrameStyleTest.ts b/tests/unit/GetRHPFrameStyleTest.ts index 07b6065904e5..896dd57535b0 100644 --- a/tests/unit/GetRHPFrameStyleTest.ts +++ b/tests/unit/GetRHPFrameStyleTest.ts @@ -18,8 +18,13 @@ jest.mock('@libs/Navigation/helpers/singleRHPWidth', () => jest.requireActual({ pAbsolute: {position: 'absolute'}, @@ -43,23 +48,23 @@ function getWidth(style: ReturnType) { describe('getRHPFrameStyle', () => { it('gives a standalone web RHP a floating card at the resizable width, absorbing the border', () => { // Given one RHP card on web with nothing stacked on or under it, the only case where the frame itself has to look like a card - const params = {styles, animatedWidth: buildAnimatedWidth(), shouldUseNarrowLayout: false, shouldUseCenteredFrame: false}; + const params = {styles, animatedWidth: buildAnimatedWidth(), singleRHPWidth: SAVED_SINGLE_RHP_WIDTH, shouldUseNarrowLayout: false, shouldUseCenteredFrame: false}; // When the frame style is built for it const style = getRHPFrameStyleWeb(params); - // Then it gets the floating card at the width a drag of its edge writes to CSS, which is the default width until a drag happens, + // Then it gets the floating card at the width a drag of its edge writes to CSS, falling back to the saved width when no drag is in progress, // plus the border, because the card is border-box and its content clips without the compensation. expect(style).toContain(styles.RHPFloatingCard); expect(style).not.toContain(styles.RHPCenteredFrame); expect(style).not.toContain(styles.r0); - expect(getWidth(style)).toBe(FLOATING_CARD_WIDTH); + expect(getWidth(style)).toBe(getFloatingCardWidth(SAVED_SINGLE_RHP_WIDTH)); }); it('gives the stacked report flow an invisible frame that neither clips nor compensates for a border', () => { // Given a report with an expense stacked on top, where every card draws its own border and shadow and the frame only has to position them const animatedWidth = buildAnimatedWidth(); - const params = {styles, animatedWidth, shouldUseNarrowLayout: false, shouldUseCenteredFrame: true}; + const params = {styles, animatedWidth, singleRHPWidth: SAVED_SINGLE_RHP_WIDTH, shouldUseNarrowLayout: false, shouldUseCenteredFrame: true}; // When the frame style is built for it const style = getRHPFrameStyleWeb(params); @@ -73,7 +78,7 @@ describe('getRHPFrameStyle', () => { it('keeps the web narrow layout full-bleed', () => { // Given a phone-width window, where the RHP is the whole viewport and there is nothing to float or dim beside it - const params = {styles, animatedWidth: buildAnimatedWidth(), shouldUseNarrowLayout: true, shouldUseCenteredFrame: true}; + const params = {styles, animatedWidth: buildAnimatedWidth(), singleRHPWidth: SAVED_SINGLE_RHP_WIDTH, shouldUseNarrowLayout: true, shouldUseCenteredFrame: true}; // When the frame style is built on web const style = getRHPFrameStyleWeb(params); @@ -89,7 +94,7 @@ describe('getRHPFrameStyle', () => { it('keeps native full-bleed and leaves the width untouched', () => { // Given the same wide window on native, where the floating card treatment does not exist at all const animatedWidth = buildAnimatedWidth(); - const params = {styles, animatedWidth, shouldUseNarrowLayout: false, shouldUseCenteredFrame: true}; + const params = {styles, animatedWidth, singleRHPWidth: SAVED_SINGLE_RHP_WIDTH, shouldUseNarrowLayout: false, shouldUseCenteredFrame: true}; // When the native frame style is built for it const style = getRHPFrameStyleNative(params); @@ -107,7 +112,7 @@ describe('getRHPFrameStyle', () => { describe('getRHPFrameStyle - two-factor (MFA) security-code panel', () => { // The panel never animates its width, so it hands the helper a plain number, as MultifactorAuthenticationModalNavigator does. const mfaPanelWidth = variables.rhpWidth; - const mfaParams = {styles, shouldUseNarrowLayout: false, shouldUseCenteredFrame: false} as const; + const mfaParams = {styles, singleRHPWidth: variables.rhpWidth, shouldUseNarrowLayout: false, shouldUseCenteredFrame: false} as const; it('floats the panel as a card on wide web', () => { // Given the two-factor security-code panel on web, which measured 65px narrower than the RHP cards beside it because it sized off the sidebar width instead of variables.rhpWidth @@ -121,7 +126,7 @@ describe('getRHPFrameStyle - two-factor (MFA) security-code panel', () => { expect(style).not.toContain(styles.r0); expect(style).not.toContain(styles.h100); expect(style).not.toContain(styles.RHPCenteredFrame); - expect(getWidth(style)).toBe(FLOATING_CARD_WIDTH); + expect(getWidth(style)).toBe(getFloatingCardWidth(variables.rhpWidth)); }); it('keeps the panel full-bleed on native', () => { diff --git a/tests/unit/useSingleRHPWidthTest.ts b/tests/unit/useSingleRHPWidthTest.ts new file mode 100644 index 000000000000..2272f8b5907a --- /dev/null +++ b/tests/unit/useSingleRHPWidthTest.ts @@ -0,0 +1,89 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useSingleRHPWidth from '@hooks/useSingleRHPWidth'; + +import {setSingleRHPWidth} from '@libs/actions/SingleRHPWidth'; +import getSingleRHPWidthBounds from '@libs/Navigation/helpers/singleRHPWidth/getSingleRHPWidthBounds'; + +import variables from '@styles/variables'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// jest-expo resolves bare specifiers to index.native.ts, where the wide RHP has no width, so the web module is loaded explicitly. +jest.mock('@libs/Navigation/helpers/calculateReceiptPaneRHPWidth', () => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + jest.requireActual('@libs/Navigation/helpers/calculateReceiptPaneRHPWidth/index.ts'), +); + +const WIDE_WINDOW_WIDTH = 1920; +const NARROW_WINDOW_WIDTH = 1100; + +let mockWindowWidth = WIDE_WINDOW_WIDTH; +jest.mock('@hooks/useWindowDimensions', () => () => ({windowWidth: mockWindowWidth})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({isExtraLargeScreenWidth: false})); +jest.mock('@hooks/useSidePanelState', () => () => ({shouldHideSidePanel: true})); + +describe('useSingleRHPWidth', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockWindowWidth = WIDE_WINDOW_WIDTH; + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('starts at the default width before anything is saved', async () => { + // Given a user who never dragged the single RHP + // When its width is read + const {result} = renderHook(() => useSingleRHPWidth()); + await waitForBatchedUpdates(); + + // Then it is the default RHP width, so the beta changes nothing until a drag + expect(result.current.width).toBe(variables.rhpWidth); + }); + + it('follows the saved width once a drag ends', async () => { + // Given the single RHP on screen at its default width + const {result} = renderHook(() => useSingleRHPWidth()); + await waitForBatchedUpdates(); + + // When a drag saves a wider width + await act(async () => { + setSingleRHPWidth(700); + await waitForBatchedUpdates(); + }); + + // Then the committed width is the saved one, which every style falls back to once the live CSS width is dropped + expect(result.current.width).toBe(700); + }); + + it('fits a saved width into a narrower window and restores it when the window grows back', async () => { + // Given a saved width that only fits the wide window + const savedWidth = getSingleRHPWidthBounds(WIDE_WINDOW_WIDTH, 0).max; + const narrowMax = getSingleRHPWidthBounds(NARROW_WINDOW_WIDTH, 0).max; + expect(narrowMax).toBeLessThan(savedWidth); + await Onyx.set(ONYXKEYS.SINGLE_RHP_WIDTH, savedWidth); + const {result, rerender} = renderHook(() => useSingleRHPWidth()); + await waitForBatchedUpdates(); + + // When the window shrinks + mockWindowWidth = NARROW_WINDOW_WIDTH; + rerender({}); + + // Then the card shrinks to the widest it can be there + expect(result.current.width).toBe(narrowMax); + + // When the window grows back + mockWindowWidth = WIDE_WINDOW_WIDTH; + rerender({}); + + // Then the card returns to the width the user dragged to, because the clamp never overwrote what was saved + expect(result.current.width).toBe(savedWidth); + }); +}); From fb63600a04aac00526753dcdcbb4a49adda58ff2 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 21 Oct 2026 16:43:58 +0200 Subject: [PATCH 05/10] chore: improve comments --- src/components/ResizeHandle/getDraggedSize.ts | 2 +- src/components/ResizeHandle/usePointerDrag.ts | 1 - .../AppNavigator/Navigators/RHPResizeHandle/index.tsx | 3 --- .../helpers/singleRHPWidth/getSingleRHPWidthBounds.ts | 4 ++-- src/libs/Navigation/helpers/singleRHPWidth/index.ts | 3 +-- tests/unit/GetRHPFrameStyleTest.ts | 1 - 6 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/components/ResizeHandle/getDraggedSize.ts b/src/components/ResizeHandle/getDraggedSize.ts index b0fa208fd42f..16c61ac4917e 100644 --- a/src/components/ResizeHandle/getDraggedSize.ts +++ b/src/components/ResizeHandle/getDraggedSize.ts @@ -8,7 +8,7 @@ type DraggedSizeBounds = { max?: number; }; -/** The size a drag puts a pane or column at: its start size plus `travel`, rounded and kept between the bounds. `travel` is positive when the drag grows the size. The minimum wins over the maximum. */ +/** `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); } diff --git a/src/components/ResizeHandle/usePointerDrag.ts b/src/components/ResizeHandle/usePointerDrag.ts index f4c9bc2b54b2..212c7e7853c3 100644 --- a/src/components/ResizeHandle/usePointerDrag.ts +++ b/src/components/ResizeHandle/usePointerDrag.ts @@ -45,7 +45,6 @@ function usePointerDrag({dragRef, onMove, onEnd}: UsePointerDragParams jest.requireActual Date: Wed, 21 Oct 2026 16:47:14 +0200 Subject: [PATCH 06/10] fix: remove unnecessary tests --- tests/unit/GetRHPFrameStyleTest.ts | 51 ----------------------------- tests/unit/getDraggedSizeTest.ts | 9 ----- tests/unit/useSingleRHPWidthTest.ts | 21 +++--------- 3 files changed, 4 insertions(+), 77 deletions(-) diff --git a/tests/unit/GetRHPFrameStyleTest.ts b/tests/unit/GetRHPFrameStyleTest.ts index f393a97bf82e..ca4756b6c6c0 100644 --- a/tests/unit/GetRHPFrameStyleTest.ts +++ b/tests/unit/GetRHPFrameStyleTest.ts @@ -106,54 +106,3 @@ describe('getRHPFrameStyle', () => { expect(getWidth(style)).toBe(animatedWidth); }); }); - -// Regression for PR #101093: the two-factor security-code panel feeds the shared RHP width through getRHPFrameStyle, so it matches a normal RHP card. -describe('getRHPFrameStyle - two-factor (MFA) security-code panel', () => { - // The panel never animates its width, so it hands the helper a plain number, as MultifactorAuthenticationModalNavigator does. - const mfaPanelWidth = variables.rhpWidth; - const mfaParams = {styles, singleRHPWidth: variables.rhpWidth, shouldUseNarrowLayout: false, shouldUseCenteredFrame: false} as const; - - it('floats the panel as a card on wide web', () => { - // Given the two-factor security-code panel on web, which measured 65px narrower than the RHP cards beside it because it sized off the sidebar width instead of variables.rhpWidth - const params = {...mfaParams, animatedWidth: mfaPanelWidth}; - - // When the frame style is built for it - const style = getRHPFrameStyleWeb(params); - - // Then it floats like any other RHP card at the same width, border included, so it lines up with the skinny cards behind it instead of standing out as a narrower panel. - expect(style).toContain(styles.RHPFloatingCard); - expect(style).not.toContain(styles.r0); - expect(style).not.toContain(styles.h100); - expect(style).not.toContain(styles.RHPCenteredFrame); - expect(getWidth(style)).toBe(getFloatingCardWidth(variables.rhpWidth)); - }); - - it('keeps the panel full-bleed on native', () => { - // Given the same panel on native, where the RHP slides in over the whole screen - const animatedWidth = mfaPanelWidth; - const params = {...mfaParams, animatedWidth}; - - // When the native frame style is built for it - const style = getRHPFrameStyleNative(params); - - // Then it keeps the full-bleed panel the apps shipped with, at the width it was given, so the web floating card treatment cannot leak into native layouts. - expect(style).toContain(styles.r0); - expect(style).toContain(styles.h100); - expect(style).not.toContain(styles.RHPFloatingCard); - expect(getWidth(style)).toBe(animatedWidth); - }); - - it('keeps the panel full width on a narrow layout', () => { - // Given the panel opened on a phone-width window, where it is the only thing on screen - const params = {...mfaParams, shouldUseNarrowLayout: true, animatedWidth: mfaPanelWidth}; - - // When the web frame style is built for it - const style = getRHPFrameStyleWeb(params); - - // Then it fills the viewport, because at that width there is no card behind it to float above. - expect(style).toContain(styles.r0); - expect(style).toContain(styles.h100); - expect(style).not.toContain(styles.RHPFloatingCard); - expect(getWidth(style)).toBe('100%'); - }); -}); diff --git a/tests/unit/getDraggedSizeTest.ts b/tests/unit/getDraggedSizeTest.ts index 54ab627adfbd..4c1118433ae1 100644 --- a/tests/unit/getDraggedSizeTest.ts +++ b/tests/unit/getDraggedSizeTest.ts @@ -10,15 +10,6 @@ describe('getDraggedSize', () => { expect(size).toBe(231); }); - it('shrinks the size when the travel is negative', () => { - // Given a 440px pane - // When the drag shrinks it by 60px - const size = getDraggedSize(440, -60, {min: 220, max: 880}); - - // Then the pane loses the 60px - expect(size).toBe(380); - }); - it('stops at the bounds however far the pointer travels', () => { // Given a 440px pane bounded to 220-880px const bounds = {min: 220, max: 880}; diff --git a/tests/unit/useSingleRHPWidthTest.ts b/tests/unit/useSingleRHPWidthTest.ts index 2272f8b5907a..73155db3e85e 100644 --- a/tests/unit/useSingleRHPWidthTest.ts +++ b/tests/unit/useSingleRHPWidthTest.ts @@ -48,29 +48,16 @@ describe('useSingleRHPWidth', () => { expect(result.current.width).toBe(variables.rhpWidth); }); - it('follows the saved width once a drag ends', async () => { - // Given the single RHP on screen at its default width - const {result} = renderHook(() => useSingleRHPWidth()); - await waitForBatchedUpdates(); - - // When a drag saves a wider width - await act(async () => { - setSingleRHPWidth(700); - await waitForBatchedUpdates(); - }); - - // Then the committed width is the saved one, which every style falls back to once the live CSS width is dropped - expect(result.current.width).toBe(700); - }); - it('fits a saved width into a narrower window and restores it when the window grows back', async () => { // Given a saved width that only fits the wide window const savedWidth = getSingleRHPWidthBounds(WIDE_WINDOW_WIDTH, 0).max; const narrowMax = getSingleRHPWidthBounds(NARROW_WINDOW_WIDTH, 0).max; expect(narrowMax).toBeLessThan(savedWidth); - await Onyx.set(ONYXKEYS.SINGLE_RHP_WIDTH, savedWidth); const {result, rerender} = renderHook(() => useSingleRHPWidth()); - await waitForBatchedUpdates(); + await act(async () => { + setSingleRHPWidth(savedWidth); + await waitForBatchedUpdates(); + }); // When the window shrinks mockWindowWidth = NARROW_WINDOW_WIDTH; From 382861626192227a3fdbeaa7016ae2800fb130c4 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 21 Oct 2026 17:06:58 +0200 Subject: [PATCH 07/10] fix: clear data when beta off --- src/libs/actions/SingleRHPWidth.ts | 8 ++++++-- .../settings/Troubleshoot/DynamicBetaOverridesPage.tsx | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/SingleRHPWidth.ts b/src/libs/actions/SingleRHPWidth.ts index 5d41bb9e9916..deb834b4b1dc 100644 --- a/src/libs/actions/SingleRHPWidth.ts +++ b/src/libs/actions/SingleRHPWidth.ts @@ -7,5 +7,9 @@ function setSingleRHPWidth(width: number) { Onyx.set(ONYXKEYS.SINGLE_RHP_WIDTH, width); } -// eslint-disable-next-line import/prefer-default-export -- additional actions may be added here -export {setSingleRHPWidth}; +/** Drops the dragged width, so the RHP goes back to its default width. */ +function clearSingleRHPWidth() { + Onyx.set(ONYXKEYS.SINGLE_RHP_WIDTH, null); +} + +export {setSingleRHPWidth, clearSingleRHPWidth}; diff --git a/src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx b/src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx index 37260a6764e6..caef5340b87b 100644 --- a/src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx +++ b/src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx @@ -17,6 +17,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import Permissions, {canApplyBetaOverrides} from '@libs/Permissions'; +import {clearSingleRHPWidth} from '@userActions/SingleRHPWidth'; import {clearBetaOverride, clearBetaOverrides, setBetaOverride} from '@userActions/User'; import CONST from '@src/CONST'; @@ -53,6 +54,9 @@ function DynamicBetaOverridesPage() { // Only betas that differ from the account keep an override, so toggling back to the account value drops it const toggleBeta = (beta: Beta) => { const nextValue = !isBetaEnabled(beta); + if (beta === CONST.BETAS.RESIZABLE_RHP && !nextValue) { + clearSingleRHPWidth(); + } if (nextValue === isEnabledOnAccount(beta)) { clearBetaOverride(beta); return; From 66d9d7f61b19320c6cebeccb9be8a183c6746568 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 21 Oct 2026 17:33:23 +0200 Subject: [PATCH 08/10] fix: support MFA & RHP modals --- src/components/Modal/BaseModal.tsx | 6 + ...ultifactorAuthenticationModalNavigator.tsx | 150 ++++++++++-------- .../utils/generators/ModalStyleUtils.ts | 6 +- 3 files changed, 97 insertions(+), 65 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 86ece35927cb..7378c3a94fdc 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -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'; @@ -254,6 +256,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, @@ -284,6 +289,7 @@ function BaseModal({ enableEdgeToEdgeBottomSafeAreaPadding, shouldDisplayBelowModals, isStackedOnRHP, + rightDockedCardWidth, }); // When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode. diff --git a/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx index decf8b534b00..a4f220ffa20d 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx @@ -5,6 +5,7 @@ import {handleInitialScreenLayout, MFA_INITIAL_SCREEN, mfaNavigationRef, resetMf import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelActions from '@hooks/useSidePanelActions'; import useSidePanelState from '@hooks/useSidePanelState'; @@ -19,6 +20,7 @@ import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigati import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation'; +import enhanceCardStyleInterpolator from '@navigation/AppNavigator/enhanceCardStyleInterpolator'; import RHP_WEB_TRANSITION_SPEC from '@navigation/AppNavigator/RHPTransitionSpec'; import useModalCardStyleInterpolator from '@navigation/AppNavigator/useModalCardStyleInterpolator'; @@ -27,15 +29,18 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; +import viewRef from '@src/types/utils/viewRef'; import type {StackCardInterpolationProps} from '@react-navigation/stack'; +import type {StyleProp, ViewStyle} from 'react-native'; import {BaseNavigationContainer, NavigationIndependentTree, StackActions} from '@react-navigation/core'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import getRHPFrameStyle from './getRHPFrameStyle'; +import RHPResizeHandle from './RHPResizeHandle'; type Phase = 'open' | 'closing' | 'closed'; @@ -144,13 +149,91 @@ function MultifactorAuthenticationModalNavigator() { const {width: singleRHPWidth} = useSingleRHPWidth(); const frameStyle = getRHPFrameStyle({styles, animatedWidth: variables.rhpWidth, singleRHPWidth, shouldUseNarrowLayout, shouldUseCenteredFrame: false}); + const slideFromWidthInterpolator = (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, enter: {kind: 'slide-from-width'}}); + const {isBetaEnabled} = usePermissions(); + const isResizableRHPEnabled = isBetaEnabled(CONST.BETAS.RESIZABLE_RHP); + const isResizable = isResizableRHPEnabled && !shouldUseNarrowLayout; + const singleRHPWidthScopeRef = useRef(null); + + // The box stays mounted for any layout once the beta is on, because adding or removing it would remount the whole stack. + let stackContentStyle: StyleProp; + if (isResizable) { + stackContentStyle = styles.singleRHPStackMeasureBox; + } else if (isResizableRHPEnabled) { + stackContentStyle = styles.flex1; + } if (phase === 'closed') { return null; } + const stack = isStackReadyToMount && ( + + + + + + + + + + + + ); + return ( @@ -166,69 +249,8 @@ function MultifactorAuthenticationModalNavigator() { /> )} - - {isStackReadyToMount && ( - - - modalCardStyleInterpolator({props, enter: {kind: 'slide-from-width'}}), - transitionSpec: shouldUseNarrowLayout ? undefined : RHP_WEB_TRANSITION_SPEC, - }, - }} - > - - - - - - - - - )} - + {stackContentStyle ? {stack} : stack} + {isResizable && } = ({the enableEdgeToEdgeBottomSafeAreaPadding = false, shouldDisplayBelowModals = false, isStackedOnRHP = false, + rightDockedCardWidth = variables.rhpWidth, }): GetModalStyles => { const {windowWidth, isSmallScreenWidth} = windowDimensions; @@ -289,7 +293,7 @@ const createModalStyleUtils: StyleUtilGenerator = ({the break; case CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED: { // Border-box card inset on every side. Both terms must match RightModalNavigator's frame or the two cards sit 2px apart and their borders read as one doubled line. - const rightDockedBoxWidth = variables.rhpWidth + 2 * variables.rhpFloatingCardBorderWidth + 2 * variables.rhpFloatingCardMargin; + const rightDockedBoxWidth = rightDockedCardWidth + 2 * variables.rhpFloatingCardBorderWidth + 2 * variables.rhpFloatingCardMargin; modalStyle = { ...modalStyle, marginLeft: isSmallScreenWidth ? 0 : windowWidth - rightDockedBoxWidth, From d6788ab063b1781fa7179cac1dec286db41b0bd2 Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 22 Oct 2026 10:48:18 +0200 Subject: [PATCH 09/10] fix: check beta before using RHP width --- src/hooks/useSingleRHPWidth.ts | 8 +++++++- src/libs/actions/SingleRHPWidth.ts | 8 ++------ .../Troubleshoot/DynamicBetaOverridesPage.tsx | 4 ---- ...equestReportTransactionsNavigationTest.tsx | 1 + tests/unit/useSingleRHPWidthTest.ts | 20 +++++++++++++++++++ 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSingleRHPWidth.ts b/src/hooks/useSingleRHPWidth.ts index ec72afab4604..63020d0ab30c 100644 --- a/src/hooks/useSingleRHPWidth.ts +++ b/src/hooks/useSingleRHPWidth.ts @@ -4,11 +4,13 @@ import type {SingleRHPWidthBounds} from '@libs/Navigation/helpers/singleRHPWidth import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import lodashClamp from 'lodash/clamp'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; import useResponsiveLayout from './useResponsiveLayout'; import useSidePanelState from './useSidePanelState'; import useWindowDimensions from './useWindowDimensions'; @@ -20,6 +22,7 @@ type SingleRHPWidth = SingleRHPWidthBounds & { /** Clamped at render rather than when saved, so a window that shrinks and grows back returns to the dragged width. */ function useSingleRHPWidth(): SingleRHPWidth { const [storedWidth] = useOnyx(ONYXKEYS.SINGLE_RHP_WIDTH); + const {isBetaEnabled} = usePermissions(); const {windowWidth} = useWindowDimensions(); const {isExtraLargeScreenWidth} = useResponsiveLayout(); const {shouldHideSidePanel} = useSidePanelState(); @@ -27,7 +30,10 @@ function useSingleRHPWidth(): SingleRHPWidth { // Mirrors when SidePanelContextProvider shifts the RHP left by the panel's width. const {min, max} = getSingleRHPWidthBounds(windowWidth, isExtraLargeScreenWidth && !shouldHideSidePanel ? variables.sidePanelWidth : 0); - return {width: lodashClamp(storedWidth ?? variables.rhpWidth, min, max), min, max}; + // The beta can turn off without a chance to clear the saved width, and there is no handle to drag it back then. + const savedWidth = isBetaEnabled(CONST.BETAS.RESIZABLE_RHP) ? storedWidth : undefined; + + return {width: lodashClamp(savedWidth ?? variables.rhpWidth, min, max), min, max}; } export default useSingleRHPWidth; diff --git a/src/libs/actions/SingleRHPWidth.ts b/src/libs/actions/SingleRHPWidth.ts index deb834b4b1dc..e08ba22547e4 100644 --- a/src/libs/actions/SingleRHPWidth.ts +++ b/src/libs/actions/SingleRHPWidth.ts @@ -7,9 +7,5 @@ function setSingleRHPWidth(width: number) { Onyx.set(ONYXKEYS.SINGLE_RHP_WIDTH, width); } -/** Drops the dragged width, so the RHP goes back to its default width. */ -function clearSingleRHPWidth() { - Onyx.set(ONYXKEYS.SINGLE_RHP_WIDTH, null); -} - -export {setSingleRHPWidth, clearSingleRHPWidth}; +// eslint-disable-next-line import/prefer-default-export +export {setSingleRHPWidth}; diff --git a/src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx b/src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx index caef5340b87b..37260a6764e6 100644 --- a/src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx +++ b/src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx @@ -17,7 +17,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import Permissions, {canApplyBetaOverrides} from '@libs/Permissions'; -import {clearSingleRHPWidth} from '@userActions/SingleRHPWidth'; import {clearBetaOverride, clearBetaOverrides, setBetaOverride} from '@userActions/User'; import CONST from '@src/CONST'; @@ -54,9 +53,6 @@ function DynamicBetaOverridesPage() { // Only betas that differ from the account keep an override, so toggling back to the account value drops it const toggleBeta = (beta: Beta) => { const nextValue = !isBetaEnabled(beta); - if (beta === CONST.BETAS.RESIZABLE_RHP && !nextValue) { - clearSingleRHPWidth(); - } if (nextValue === isEnabledOnAccount(beta)) { clearBetaOverride(beta); return; diff --git a/tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx b/tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx index 5bb5c274d029..5b4b65bd234d 100644 --- a/tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx +++ b/tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx @@ -89,6 +89,7 @@ jest.mock('@components/Search/SearchContext', () => ({ jest.mock('@components/WideRHPContextProvider', () => ({ useWideRHPActions: () => ({markReportRHPWidth: jest.fn(), unmarkReportRHPWidth: jest.fn()}), + useWideRHPState: () => ({superWideRHPRouteKeys: [], wideRHPRouteKeys: []}), })); jest.mock('@components/OnyxListItemProvider', () => ({ diff --git a/tests/unit/useSingleRHPWidthTest.ts b/tests/unit/useSingleRHPWidthTest.ts index 73155db3e85e..326af199cf2e 100644 --- a/tests/unit/useSingleRHPWidthTest.ts +++ b/tests/unit/useSingleRHPWidthTest.ts @@ -27,6 +27,9 @@ jest.mock('@hooks/useWindowDimensions', () => () => ({windowWidth: mockWindowWid jest.mock('@hooks/useResponsiveLayout', () => () => ({isExtraLargeScreenWidth: false})); jest.mock('@hooks/useSidePanelState', () => () => ({shouldHideSidePanel: true})); +let mockIsResizableRHPEnabled = true; +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => mockIsResizableRHPEnabled})); + describe('useSingleRHPWidth', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -34,6 +37,7 @@ describe('useSingleRHPWidth', () => { beforeEach(async () => { mockWindowWidth = WIDE_WINDOW_WIDTH; + mockIsResizableRHPEnabled = true; await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -73,4 +77,20 @@ describe('useSingleRHPWidth', () => { // Then the card returns to the width the user dragged to, because the clamp never overwrote what was saved expect(result.current.width).toBe(savedWidth); }); + + it('ignores a saved width once the beta is off', async () => { + // Given a saved width left behind by a user whose beta was then turned off without clearing it + const savedWidth = getSingleRHPWidthBounds(WIDE_WINDOW_WIDTH, 0).max; + mockIsResizableRHPEnabled = false; + const {result} = renderHook(() => useSingleRHPWidth()); + + // When the width is read + await act(async () => { + setSingleRHPWidth(savedWidth); + await waitForBatchedUpdates(); + }); + + // Then it is the default width, because without the beta there is no handle to drag the card back + expect(result.current.width).toBe(variables.rhpWidth); + }); }); From 9e6fa558ac6c4ef75dc627bbf194968a4d4468ca Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 23 Oct 2026 16:53:01 +0200 Subject: [PATCH 10/10] fix: keep default RHP width on narrow window when beta is off --- src/hooks/useSingleRHPWidth.ts | 6 ++++-- tests/unit/useSingleRHPWidthTest.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSingleRHPWidth.ts b/src/hooks/useSingleRHPWidth.ts index 63020d0ab30c..23601a45ef58 100644 --- a/src/hooks/useSingleRHPWidth.ts +++ b/src/hooks/useSingleRHPWidth.ts @@ -31,9 +31,11 @@ function useSingleRHPWidth(): SingleRHPWidth { const {min, max} = getSingleRHPWidthBounds(windowWidth, isExtraLargeScreenWidth && !shouldHideSidePanel ? variables.sidePanelWidth : 0); // The beta can turn off without a chance to clear the saved width, and there is no handle to drag it back then. - const savedWidth = isBetaEnabled(CONST.BETAS.RESIZABLE_RHP) ? storedWidth : undefined; + if (!isBetaEnabled(CONST.BETAS.RESIZABLE_RHP)) { + return {width: variables.rhpWidth, min, max}; + } - return {width: lodashClamp(savedWidth ?? variables.rhpWidth, min, max), min, max}; + return {width: lodashClamp(storedWidth ?? variables.rhpWidth, min, max), min, max}; } export default useSingleRHPWidth; diff --git a/tests/unit/useSingleRHPWidthTest.ts b/tests/unit/useSingleRHPWidthTest.ts index 326af199cf2e..de64a752dd4c 100644 --- a/tests/unit/useSingleRHPWidthTest.ts +++ b/tests/unit/useSingleRHPWidthTest.ts @@ -93,4 +93,18 @@ describe('useSingleRHPWidth', () => { // Then it is the default width, because without the beta there is no handle to drag the card back expect(result.current.width).toBe(variables.rhpWidth); }); + + it('keeps the default width in a window too narrow for it once the beta is off', async () => { + // Given a user without the beta in a window whose resize bounds fall below the default width + mockIsResizableRHPEnabled = false; + mockWindowWidth = 805; + expect(getSingleRHPWidthBounds(mockWindowWidth, 0).max).toBeLessThan(variables.rhpWidth); + + // When the width is read + const {result} = renderHook(() => useSingleRHPWidth()); + await waitForBatchedUpdates(); + + // Then it is still the default width, because the resize bounds belong to the beta and must not change the RHP for everyone else + expect(result.current.width).toBe(variables.rhpWidth); + }); });