diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 34f91ab6488b..4f9a37c0b285 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1441,6 +1441,7 @@ const CONST = { ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', COMPANY_AGENTS: 'companyAgents', RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns', + RESIZABLE_RHP: 'resizableRHP', DESCRIBE_RULE: 'describeRule', TRAVEL_APPROVAL: 'travelApproval', }, @@ -9825,6 +9826,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', @@ -9866,9 +9887,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/ONYXKEYS.ts b/src/ONYXKEYS.ts index 7ef5e2934576..156fd8d87c46 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -947,6 +947,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', @@ -2056,6 +2059,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/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 4a8f82b2e46f..cdc3be168232 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'; @@ -255,6 +257,9 @@ function BaseModal({ // A right docked modal opened from inside the RHP sits on the RHP's own card, so it must not dim it or add a second shadow. const isStackedOnRHP = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED && !isSmallScreenWidth && (isInNarrowPane || isInNarrowPaneModal) && !shouldKeepRightDockedBackdropInNarrowPane; + const isResizableSingleRHP = useIsResizableSingleRHP(); + const {width: singleRHPWidth} = useSingleRHPWidth(); + const rightDockedCardWidth = isStackedOnRHP && isResizableSingleRHP ? singleRHPWidth : undefined; const { modalStyle, @@ -285,6 +290,7 @@ function BaseModal({ enableEdgeToEdgeBottomSafeAreaPadding, shouldDisplayBelowModals, isStackedOnRHP, + rightDockedCardWidth, }); // When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode. diff --git a/src/components/ResizeHandle/getDraggedSize.ts b/src/components/ResizeHandle/getDraggedSize.ts new file mode 100644 index 000000000000..16c61ac4917e --- /dev/null +++ b/src/components/ResizeHandle/getDraggedSize.ts @@ -0,0 +1,16 @@ +/** Turns pointer travel on a resize handle into a pane's or column's size. */ +import lodashClamp from 'lodash/clamp'; + +type DraggedSizeBounds = { + min: number; + + /** Unbounded when omitted. */ + max?: number; +}; + +/** `travel` is positive when the drag grows the size. The minimum wins over the maximum. */ +function getDraggedSize(startSize: number, travel: number, {min, max = Infinity}: DraggedSizeBounds): number { + return lodashClamp(Math.round(startSize + travel), min, max); +} + +export default getDraggedSize; diff --git a/src/components/ResizeHandle/usePointerDrag.ts b/src/components/ResizeHandle/usePointerDrag.ts new file mode 100644 index 000000000000..212c7e7853c3 --- /dev/null +++ b/src/components/ResizeHandle/usePointerDrag.ts @@ -0,0 +1,90 @@ +/** The pointer lifecycle of dragging a resize handle on web, which relies on DOM pointer capture. */ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type React from 'react'; +import type {RefObject} from 'react'; + +import {useEffect} from 'react'; + +type PointerDragEvent = React.PointerEvent; + +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(); + + 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 5341783ea1dd..101101fe3543 100644 --- a/src/components/Table/columnResize/useColumnResize/index.ts +++ b/src/components/Table/columnResize/useColumnResize/index.ts @@ -2,8 +2,9 @@ * 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 getDraggedSize from '@components/ResizeHandle/getDraggedSize'; +import usePointerDrag from '@components/ResizeHandle/usePointerDrag'; import {SCROLL_EXTENT_FLOOR_VARIABLE, TABLE_COLUMN_SCROLLER_SELECTOR} from '@components/Table/columnResize/columnWidthExpressions'; -import getDraggedColumnWidth from '@components/Table/columnResize/getDraggedColumnWidth'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -13,7 +14,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'; @@ -28,7 +29,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', }; @@ -58,14 +59,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(); scopeElementRef.current?.style.removeProperty(SCROLL_EXTENT_FLOOR_VARIABLE); @@ -88,19 +83,27 @@ function useColumnResize({ setGripHovered(event.currentTarget, isPointerOnHeading); }; + 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, 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); + } + }, + onEnd: (drag, event) => { + endDrag(drag); + syncGripHover(event); + }, + }); + const handlePointerDown = (columnKey: string, event: React.PointerEvent) => { - // Secondary buttons open context menus rather than dragging. - if (event.button !== 0) { + if (!pointerDrag.startDrag(event, {columnKey, startClientX: event.clientX, startWidth: readColumnWidth(columnKey) ?? 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); - revealIndicator(event.currentTarget); // Shrinking a column while scrolled right would otherwise drag the pinned filter bar along with the columns. @@ -108,70 +111,8 @@ function useColumnResize({ if (scroller) { scopeElementRef.current?.style.setProperty(SCROLL_EXTENT_FLOOR_VARIABLE, `${scroller.scrollLeft + scroller.clientWidth}px`); } - - 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; - - if (!drag) { - return; - } - - endDrag(drag); - syncGripHover(event); - }; - - // 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; } @@ -185,10 +126,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/hooks/useSingleRHPWidth.ts b/src/hooks/useSingleRHPWidth.ts new file mode 100644 index 000000000000..23601a45ef58 --- /dev/null +++ b/src/hooks/useSingleRHPWidth.ts @@ -0,0 +1,41 @@ +/** 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 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'; + +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 {isBetaEnabled} = usePermissions(); + 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); + + // The beta can turn off without a chance to clear the saved width, and there is no handle to drag it back then. + if (!isBetaEnabled(CONST.BETAS.RESIZABLE_RHP)) { + return {width: variables.rhpWidth, min, max}; + } + + 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 9a9bb75c1ab5..c82129b757bb 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -385,6 +385,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/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/MultifactorAuthenticationModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx index 7f37a4cd94e1..a4f220ffa20d 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx @@ -5,9 +5,11 @@ 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'; +import useSingleRHPWidth from '@hooks/useSingleRHPWidth'; import useTheme from '@hooks/useTheme'; import useThemePreference from '@hooks/useThemePreference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -18,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'; @@ -26,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'; @@ -141,14 +147,93 @@ 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}); + 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 ( @@ -164,69 +249,8 @@ function MultifactorAuthenticationModalNavigator() { /> )} - - {isStackReadyToMount && ( - - - modalCardStyleInterpolator({props, enter: {kind: 'slide-from-width'}}), - transitionSpec: shouldUseNarrowLayout ? undefined : RHP_WEB_TRANSITION_SPEC, - }, - }} - > - - - - - - - - - )} - + {stackContentStyle ? {stack} : stack} + {isResizable && } ; +}; + +type Drag = { + startClientX: number; + + 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. +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({scopeRef}: RHPResizeHandleProps) { + const styles = useThemeStyles(); + 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) => { + 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); + }, + }); + + // 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: width})) { + 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..6c2fe0a642fe 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -2,8 +2,11 @@ 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 useSingleRHPWidth from '@hooks/useSingleRHPWidth'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -40,16 +43,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 +104,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 +122,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 +136,7 @@ function RightModalDialogFrame({hasDialogSemantics, style, onContainerRef, child tabIndex={hasDialogSemantics ? -1 : undefined} style={style} > - {children} + {contentStyle ? {children} : children} ); } @@ -157,6 +164,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 @@ -217,7 +225,19 @@ 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(); + + // 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( () => ({ @@ -285,7 +305,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { return ( - + {!shouldUseNarrowLayout && ( )} + {isResizableSingleRHP && } ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts b/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts index 7c718d1c6370..aa2e4d24c802 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/getRHPFrameStyle/index.ts @@ -1,13 +1,11 @@ -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'; /** 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%'}]; } @@ -16,10 +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 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. + 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 b79f7baf2d35..61e384a62e16 100644 --- a/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts +++ b/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts @@ -1,12 +1,11 @@ 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 variables from '@styles/variables'; - import type {StackCardInterpolatedStyle, StackCardInterpolationProps} from '@react-navigation/stack'; // Import Animated directly from 'react-native' as animations are used with navigation. @@ -28,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. @@ -63,7 +63,7 @@ const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => { return {cardStyle: {...cardStyle, opacity: progress}}; } - const widthFallback = shouldUseNarrowLayout ? screen.width : variables.rhpWidth; + 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 8607c027175f..520f6c014119 100644 --- a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts @@ -1,6 +1,8 @@ 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'; @@ -10,28 +12,19 @@ import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStac 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,35 @@ 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(); + 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, 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; + }; + + // 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 +77,16 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, }, }; - }, [customInterpolator, shouldAdjustInterpolatorProps, isWideRHPContext, isSmallScreenWidth, styles.navigationScreenCardStyle]); + }, [ + customInterpolator, + shouldAdjustInterpolatorProps, + isWideRHPContext, + isResizableSingleRHP, + singleRHPWidth, + 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..a9da2251cfc8 --- /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; +}; + +function getSingleRHPWidthBounds(windowWidth: number, sidePanelWidth: number): SingleRHPWidthBounds { + const min = CONST.RHP_RESIZE.MIN_WIDTH; + const widthBelowWideRHP = calculateWideRHPWidth(windowWidth) - CONST.RHP_RESIZE.WIDE_RHP_INSET; + const widthLeftForCard = windowWidth - variables.rhpFloatingCardMargin - 2 * variables.rhpFloatingCardBorderWidth - sidePanelWidth - CONST.RHP_RESIZE.CONTENT_RESERVE_WIDTH; + + return {min, max: Math.max(min, Math.min(widthBelowWideRHP, 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..52e60d3cda9d --- /dev/null +++ b/src/libs/Navigation/helpers/singleRHPWidth/index.native.ts @@ -0,0 +1,12 @@ +import type {DimensionValue} from 'react-native'; + +/** The RHP is full-screen on native, so nothing drags its width. */ +function writeLiveSingleRHPWidth() {} + +function clearLiveSingleRHPWidth() {} + +function getSingleRHPWidthStyleValue(width: number, offset: number): DimensionValue { + return width + offset; +} + +export {clearLiveSingleRHPWidth, getSingleRHPWidthStyleValue, writeLiveSingleRHPWidth}; diff --git a/src/libs/Navigation/helpers/singleRHPWidth/index.ts b/src/libs/Navigation/helpers/singleRHPWidth/index.ts new file mode 100644 index 000000000000..2dc3dab57685 --- /dev/null +++ b/src/libs/Navigation/helpers/singleRHPWidth/index.ts @@ -0,0 +1,22 @@ +/** 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'; + +const SINGLE_RHP_WIDTH_VARIABLE = '--rhp-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`); +} + +function clearLiveSingleRHPWidth(scope: HTMLElement | null) { + scope?.style.removeProperty(SINGLE_RHP_WIDTH_VARIABLE); +} + +/** The live width mid-drag, the committed `width` otherwise. */ +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(var(${SINGLE_RHP_WIDTH_VARIABLE}, ${width}px) + ${offset}px)` as DimensionValue; +} + +export {clearLiveSingleRHPWidth, getSingleRHPWidthStyleValue, writeLiveSingleRHPWidth}; diff --git a/src/libs/actions/SingleRHPWidth.ts b/src/libs/actions/SingleRHPWidth.ts new file mode 100644 index 000000000000..e08ba22547e4 --- /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 +export {setSingleRHPWidth}; diff --git a/src/styles/index.ts b/src/styles/index.ts index e8514c66f598..ddbab44c633a 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, }, @@ -6789,6 +6790,26 @@ 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, + }, + flexibleHeight: { height: 'auto', minHeight: 200, @@ -7158,6 +7179,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/src/styles/utils/generators/ModalStyleUtils.ts b/src/styles/utils/generators/ModalStyleUtils.ts index 99a6c3cef7c2..5622ee2e9d2a 100644 --- a/src/styles/utils/generators/ModalStyleUtils.ts +++ b/src/styles/utils/generators/ModalStyleUtils.ts @@ -55,6 +55,9 @@ type GetModalStylesOptions = { enableEdgeToEdgeBottomSafeAreaPadding?: boolean; shouldDisplayBelowModals?: boolean; isStackedOnRHP?: boolean; + + /** Width of a right docked modal's card, which must match the RHP card it covers. */ + rightDockedCardWidth?: number; }; type GetModalStylesStyleUtil = { @@ -73,6 +76,7 @@ const createModalStyleUtils: StyleUtilGenerator = ({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, diff --git a/tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx b/tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx index 7d3ec37bb841..078105fc304e 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/GetRHPFrameStyleTest.ts b/tests/unit/GetRHPFrameStyleTest.ts index 09bbc5def33a..ca4756b6c6c0 100644 --- a/tests/unit/GetRHPFrameStyleTest.ts +++ b/tests/unit/GetRHPFrameStyleTest.ts @@ -13,8 +13,18 @@ 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; +const SAVED_SINGLE_RHP_WIDTH = 600; + +/** The live width a drag writes to CSS, falling back to the saved width, with the card's border added. */ +function getFloatingCardWidth(singleRHPWidth: number) { + return `calc(var(--rhp-width, ${singleRHPWidth}px) + ${2 * variables.rhpFloatingCardBorderWidth}px)`; +} + const styles = createMock({ pAbsolute: {position: 'absolute'}, r0: {right: 0}, @@ -34,31 +44,26 @@ 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}; + 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 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, 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(readAnimatedValue(getWidth(style))).toBe(RHP_WIDTH + 2 * variables.rhpFloatingCardBorderWidth); + 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); @@ -72,7 +77,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); @@ -88,7 +93,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); @@ -101,55 +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, 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, 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. - 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); - }); - - 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 new file mode 100644 index 000000000000..4c1118433ae1 --- /dev/null +++ b/tests/unit/getDraggedSizeTest.ts @@ -0,0 +1,43 @@ +import getDraggedSize from '@components/ResizeHandle/getDraggedSize'; + +describe('getDraggedSize', () => { + 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('stops at the bounds however far the pointer travels', () => { + // Given a 440px pane bounded to 220-880px + const bounds = {min: 220, max: 880}; + + // When the pointer travels far past either bound + 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); + 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, 100, {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, 5000, {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); + }); +}); diff --git a/tests/unit/useSingleRHPWidthTest.ts b/tests/unit/useSingleRHPWidthTest.ts new file mode 100644 index 000000000000..de64a752dd4c --- /dev/null +++ b/tests/unit/useSingleRHPWidthTest.ts @@ -0,0 +1,110 @@ +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})); + +let mockIsResizableRHPEnabled = true; +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => mockIsResizableRHPEnabled})); + +describe('useSingleRHPWidth', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockWindowWidth = WIDE_WINDOW_WIDTH; + mockIsResizableRHPEnabled = true; + 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('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); + const {result, rerender} = renderHook(() => useSingleRHPWidth()); + await act(async () => { + setSingleRHPWidth(savedWidth); + 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); + }); + + 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); + }); + + 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); + }); +});