diff --git a/packages/vkui/src/components/NavTransitionDirectionContext/NavTransitionDirectionContext.test.tsx b/packages/vkui/src/components/NavTransitionDirectionContext/NavTransitionDirectionContext.test.tsx new file mode 100644 index 0000000000..accd2be623 --- /dev/null +++ b/packages/vkui/src/components/NavTransitionDirectionContext/NavTransitionDirectionContext.test.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { ConfigProvider } from '../ConfigProvider/ConfigProvider'; +import { Panel } from '../Panel/Panel'; +import { Root } from '../Root/Root'; +import { View } from '../View/View'; +import { useNavDirection } from './NavTransitionDirectionContext'; + +function setup({ withAnimationsMode }: { withAnimationsMode: boolean }) { + const TestContent = () => { + const direction = useNavDirection(); + return Direction: {direction || 'undefined'}; + }; + + const TestComponent = ({ + activeView, + activePanel = 'v2.1', + }: { + activeView: string; + activePanel?: string; + }) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); + }; + + return { TestComponent }; +} + +describe('useNavTransition', () => { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + + it.each([ + ['properly detects transition direction without animations', false], + ['properly detects transition direction with animations', true], + ])('%s', (_name, withAnimationsMode) => { + const { TestComponent } = setup({ withAnimationsMode }); + // transition between views + const component = render(); + expect(screen.queryByText('Direction: undefined')).toBeTruthy(); + + component.rerender(); + expect(screen.queryByText('Direction: forwards')).toBeTruthy(); + + component.rerender(); + expect(screen.queryByText('Direction: backwards')).toBeTruthy(); + + component.rerender(); + act(() => { + jest.runAllTimers(); + }); + expect(screen.queryByText('Direction: backwards')).toBeTruthy(); + + component.rerender(); + act(() => { + jest.runAllTimers(); + }); + expect(screen.queryByText('Direction: forwards')).toBeTruthy(); + + component.rerender(); + act(() => { + jest.runAllTimers(); + }); + expect(screen.queryByText('Direction: forwards')).toBeTruthy(); + + // transition between panels + component.rerender(); + act(() => { + jest.runAllTimers(); + }); + expect(screen.queryByText('Direction: backwards')).toBeTruthy(); + + component.rerender(); + expect(screen.queryByText('Direction: forwards')).toBeTruthy(); + + component.rerender(); + expect(screen.queryByText('Direction: backwards')).toBeTruthy(); + + component.rerender(); + expect(screen.queryByText('Direction: forwards')).toBeTruthy(); + + component.rerender(); + expect(screen.queryByText('Direction: backwards')).toBeTruthy(); + + // transition to another view + component.rerender(); + expect(screen.queryByText('Direction: forwards')).toBeTruthy(); + }); +}); diff --git a/packages/vkui/src/components/NavTransitionDirectionContext/NavTransitionDirectionContext.tsx b/packages/vkui/src/components/NavTransitionDirectionContext/NavTransitionDirectionContext.tsx new file mode 100644 index 0000000000..68fd2129fc --- /dev/null +++ b/packages/vkui/src/components/NavTransitionDirectionContext/NavTransitionDirectionContext.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +type DirectionContextType = boolean | undefined; + +const TransitionDirectionContext = React.createContext(undefined); + +export const NavTransitionDirectionProvider = ({ + children, + isBack: isBackProp, +}: React.PropsWithChildren<{ isBack: DirectionContextType }>) => { + const parentIsBack = React.useContext(TransitionDirectionContext); + // if local isBack is undefined then transition happend on the parent side (probably Root) + const isBack = isBackProp !== undefined ? isBackProp : parentIsBack; + + // 'direction' should always represent the direction state of the panel on mount + // save the on mount value of the panel to the state + // to make sure we don't trigger new re-render for the panel + // due to change in the prop passed to provider + const [isBackOnMount] = React.useState(isBack); + + return ( + + {children} + + ); +}; + +export type TransitionDirection = undefined | 'forwards' | 'backwards'; + +export const useNavDirection = (): TransitionDirection => { + const isBack = React.useContext(TransitionDirectionContext); + const transitionDirection = isBack === undefined ? undefined : isBack ? 'backwards' : 'forwards'; + + return transitionDirection; +}; diff --git a/packages/vkui/src/components/Root/Readme.md b/packages/vkui/src/components/Root/Readme.md index e1608903de..caaf1e700f 100644 --- a/packages/vkui/src/components/Root/Readme.md +++ b/packages/vkui/src/components/Root/Readme.md @@ -4,6 +4,9 @@ При смене значения свойства `activeView` происходит плавный переход от одной `View` к другой. Как только он заканчивается, вызывается свойство-функция `onTransition`. +Чтобы понять был это переход вперёд или назад можно воспользоваться хуком [`useNavDirection()`](#/View?id=usenavdirection_example). +Этот хук работает даже если анимации выключены (``). + ```jsx const [activeView, setActiveView] = useState('view1'); diff --git a/packages/vkui/src/components/Root/Root.tsx b/packages/vkui/src/components/Root/Root.tsx index 32bb5c6edb..5f41a6eaf2 100644 --- a/packages/vkui/src/components/Root/Root.tsx +++ b/packages/vkui/src/components/Root/Root.tsx @@ -10,6 +10,7 @@ import { warnOnce } from '../../lib/warnOnce'; import { ScrollContext } from '../AppRoot/ScrollContext'; import { useConfigProvider } from '../ConfigProvider/ConfigProviderContext'; import { NavTransitionProvider } from '../NavTransitionContext/NavTransitionContext'; +import { NavTransitionDirectionProvider } from '../NavTransitionDirectionContext/NavTransitionDirectionContext'; import { SplitColContext } from '../SplitCol/SplitColContext'; import styles from './Root.module.css'; @@ -145,16 +146,18 @@ export const Root = ({ transition && viewId === activeView && !isBack && styles['Root__view--show-forward'], )} > - -
- {view} -
-
+ + +
+ {view} +
+
+
); })} diff --git a/packages/vkui/src/components/View/Readme.md b/packages/vkui/src/components/View/Readme.md index a3d536d79a..5bcb70ac04 100644 --- a/packages/vkui/src/components/View/Readme.md +++ b/packages/vkui/src/components/View/Readme.md @@ -4,6 +4,8 @@ При смене значения свойства `activePanel` происходит плавный переход от одной панели к другой. Как только он заканчивается, вызывается свойство-функция `onTransition`. +Чтобы понять был это переход вперёд или назад можно воспользоваться хуком [`useNavDirection()`](#/View?id=usenavdirection_example). Этот хук работает даже если анимации выключены (``). + ```jsx const [activePanel, setActivePanel] = useState('panel1'); @@ -35,7 +37,9 @@ const [activePanel, setActivePanel] = useState('panel1'); ; ``` -### [iOS Swipe Back](https://vkcom.github.io/VKUI/#/View?id=iosswipeback) +
+ +## [iOS Swipe Back](https://vkcom.github.io/VKUI/#/View?id=iosswipeback) В iOS есть возможность свайпнуть от левого края назад, чтобы перейти на предыдущую панель. Для того, чтобы повторить такое поведение в VKUI, нужно: @@ -195,3 +199,285 @@ const SettingsPanelContent = ({ name, onChangeName }) => {
; ``` + +
+ +## [useNavDirection(): определение типа перехода (вперёд/назад), с которым была отрисована панель.](#/View?id=usenavdirection_example) + +Хук `useNavDirection()` возвращает одно из трёх значений: + +- `undefined` означает, что компонент был смонтирован без перехода (тип перехода может быть не определён при самом первом монтировании приложения, когда ещё не было переходов между [View](#/View) и [Panel](#/Panel)); +- `"forwards"` переход вперёд; +- `"backwards"` переход назад. + +Xук возвращает правильное значение даже если анимация **выключена** через [ConfigProvider](#/ConfigProvider) (``). + +Значение известно ещё до завершения анимации и определяется один раз, при первом монтировании панели. + +Этот хук можно использовать для определения типа анимации перехода не только между `Panel` внутри одного `View`, но и между `View` внутри `Root`. + +
+ +Хук также работает в режиме [iOS Swipe Back](#/View?id=iosswipeback). Тип перехода известен как только пользователь начал движение. + +
+ +В примере ниже c помощью спиннера имитируется загрузка данных если панель отрисована с анимацией перехода вперед. +Используется два `View` и по три `Panel` компонента в каждом, чтобы показать, что тип перехода известен как при переходе между `View`, так и при переходе между `Panel`. + +На третьем `View` пример со свайпом в iOS от левого края назад, где видно, что панель на которую идёт переход определяет его тип в самом начале свайпа. + +```jsx +const Content = () => { + const direction = useNavDirection(); + + const [spinner, setSpinner] = useState(null); + + React.useEffect( + function simulateDataLoadingWhenMovingForwards() { + let timerId = null; + const loadData = () => { + setSpinner(); + timerId = setTimeout(() => setSpinner(null), 1000); + }; + + if (direction !== 'backwards') { + loadData(); + } + + return () => clearTimeout(timerId); + }, + [direction], + ); + + return ( +
+ + Направление перехода:{' '} + {direction === 'forwards' + ? 'вперёд' + : direction === 'backwards' + ? 'назад' + : 'не определено'} + + {spinner} +
+ ); +}; + +const Example = () => { + const [activeView, setActiveView] = useState('view1'); + const [activePanel, setActivePanel] = useState(1); + + const [swipeViewHistory, setSwipeViewHistory] = useState([`swipeView.${activePanel}`]); + const pushSwipeViewHistory = React.useCallback((panel) => { + setSwipeViewHistory((prevHistory) => [...prevHistory, `swipeView.${panel}`]); + }, []); + const onSwipeBack = React.useCallback(() => { + const newHistory = swipeViewHistory.slice(0, -1); + setSwipeViewHistory(newHistory); + + const newActiveSwipeViewPanel = newHistory[newHistory.length - 1]; + const swipeViewPanel = +newActiveSwipeViewPanel.split('swipeView.')[1]; + setActivePanel(swipeViewPanel); + }, [swipeViewHistory]); + + handleActivePanelSet = React.useCallback( + (panel) => { + setActivePanel(panel); + if (activeView === 'swipeView') { + pushSwipeViewHistory(panel); + } + }, + [pushSwipeViewHistory, activeView], + ); + + handleActiveViewSet = React.useCallback( + (view) => { + if (view === 'swipeView') { + const defaultSwipeViewActivePanel = 1; + setSwipeViewHistory([`swipeView.${defaultSwipeViewActivePanel}`]); + setActivePanel(defaultSwipeViewActivePanel); + } + setActiveView(view); + }, + [activePanel], + ); + + const navigationButtons = ( + + ); + + const swipeViewActivePanel = swipeViewHistory[swipeViewHistory.length - 1]; + return ( + + + + + + + Панель 1.1 + {navigationButtons} + + + + Панель 1.2 + {navigationButtons} + + + + Панель 1.3 + {navigationButtons} + + + + + + Панель 2.1 + {navigationButtons} + + + + Панель 2.2 + {navigationButtons} + + + + Панель 2.3 + {navigationButtons} + + + + + + П.1 iOS Swipe Back + {navigationButtons} + + + + П.2 iOS Swipe Back + {navigationButtons} + + + + П.3 iOS Swipe Back + {navigationButtons} + + + + + + + + ); +}; + +function NavigationButtons({ activePanel, activeView, setActiveView, setActivePanel }) { + return ( + <> + + {activeView === 'view1' ? ( + <> + Перейти на View 1 + setActiveView('view2')}>Перейти на View 2 + setActiveView('swipeView')}> + Перейти на пример с iOS Swipe Back + + + ) : activeView === 'view2' ? ( + <> + setActiveView('view1')}>Назад View 1 + Перейти на View 2 + setActiveView('swipeView')}> + Перейти на пример с iOS Swipe Back + + + ) : ( + activeView === 'swipeView' && ( + <> + setActiveView('view1')}>Назад на View 1 + setActiveView('view2')}>Назад на View 2 + Перейти на пример с iOS Swipe Back + + ) + )} + + + + + {activeView === 'view1' ? ( + <> + {Array.from({ length: 3 }, (_, index) => ( + + ))} + + ) : activeView === 'view2' ? ( + <> + {Array.from({ length: 3 }, (_, index) => ( + + ))} + + ) : ( + activeView === 'swipeView' && ( + + ) + )} + + + + + + ); +} + +function PanelNavigationButton({ activePanel, viewNumber, panelNumber, setActivePanel }) { + const goOrBack = activePanel <= panelNumber ? 'Перейти' : 'Назад'; + return ( + setActivePanel(panelNumber)}> + {goOrBack} на панель {viewNumber}.{panelNumber} + + ); +} + +function SwipeViewNavigationButton({ activePanel, setActivePanel }) { + return ( + <> + {activePanel === 1 && ( + Перейдите на панель 2 чтобы можно было свайпнуть назад + )} + {activePanel > 1 && ( + Теперь свайпните от левого края направо, чтобы вернуться + )} + {activePanel < 3 && ( + setActivePanel(activePanel + 1)}> + Перейти на панель {activePanel + 1} + + )} + + ); +} + +; +``` diff --git a/packages/vkui/src/components/View/View.test.tsx b/packages/vkui/src/components/View/View.test.tsx index 3c0c7ce6b8..30092a5dea 100644 --- a/packages/vkui/src/components/View/View.test.tsx +++ b/packages/vkui/src/components/View/View.test.tsx @@ -5,16 +5,13 @@ import { Platform } from '../../lib/platform'; import { baselineComponent, mockScrollContext, mountTest } from '../../testing/utils'; import { HasChildren } from '../../types'; import { ConfigProvider } from '../ConfigProvider/ConfigProvider'; +import { useNavDirection } from '../NavTransitionDirectionContext/NavTransitionDirectionContext'; import { Panel } from '../Panel/Panel'; import { scrollsCache, View, type ViewProps } from './View'; -import { ViewInfinite } from './ViewInfinite'; // Basically the same as Root.test.tsx -describe.each([ - ['View', View], - ['ViewInfinite', ViewInfinite], -])('%s', (name, View) => { +describe('View', () => { beforeAll(() => jest.useFakeTimers()); afterAll(() => jest.useRealTimers()); baselineComponent((props: ViewProps) => , { @@ -104,45 +101,7 @@ describe.each([ describe('can swipeBack', () => { let nowMock: jest.SpyInstance; - const setupSwipeBack = ( - Wrapper: ComponentType = Fragment, - children: any = null, - initialProps: Partial = {}, - ) => { - const events = { - onSwipeBack: jest.fn(), - onTransition: jest.fn(), - onSwipeBackStart: jest.fn(), - onSwipeBackCancel: jest.fn(), - }; - const SwipeBack = (p: Partial) => ( - - - - - {children} - - - - ); - const h = render(); - act(() => { - jest.runAllTimers(); - }); - const view = h.container.firstElementChild as Element; - // force detect x-swipe - fireEvent.mouseDown(view, { clientX: 50, clientY: 100 }); - fireEvent.mouseMove(view, { clientX: 40, clientY: 100 }); - nowMock = jest.spyOn(Date, 'now'); - return { view, ...events, rerender: h.rerender, SwipeBack }; - }; + beforeEach(() => (nowMock = jest.spyOn(Date, 'now'))); afterEach(() => nowMock && nowMock.mockClear()); it('cancels swipeBack on swipe left', () => { const { view, ...events } = setupSwipeBack(); @@ -161,6 +120,28 @@ describe.each([ expect(events.onSwipeBack).toBeCalledTimes(1); expect(events.onSwipeBackCancel).not.toBeCalled(); }); + it('detects transition direction on swipeBack with useNavDirection() hook', async () => { + const PanelContent = () => { + const direction = useNavDirection(); + + return Direction: {direction || 'undefined'}; + }; + const { view } = setupSwipeBack({ + childrenForPanel1: , + childrenForPanel2: , + shouldForceDetectXSwipe: false, + }); + + // only panel 2 visible by default with undefined direction + expect(screen.queryByText('Direction: undefined')).toBeTruthy(); + expect(screen.queryByText('Direction: backwards')).toBeFalsy(); + fireEvent.mouseDown(view, { clientX: 50, clientY: 100 }); + fireEvent.mouseMove(view, { clientX: 40, clientY: 100 }); + + // both panels are visible and the content on panel 1 knows about backwards direction + expect(screen.queryByText('Direction: undefined')).toBeTruthy(); + expect(screen.queryByText('Direction: backwards')).toBeTruthy(); + }); describe('does not swipeback on', () => { it.each<[string, ReactNode, Partial]>([ ['input', , {}], @@ -175,8 +156,12 @@ describe.each([
, { onSwipeBackStart: () => 'prevent' }, ], - ])('%s', (_name, cmp, props) => { - const { view, ...events } = setupSwipeBack(Fragment, cmp, props); + ])('%s', (_name, component, props) => { + const { view, ...events } = setupSwipeBack({ + Wrapper: Fragment, + childrenForPanel2: component, + initialProps: props, + }); fireEvent.mouseMove(screen.getByTestId('ex'), { clientX: window.innerWidth + 1, clientY: 100, @@ -230,20 +215,19 @@ describe.each([ expect(document.getElementById('p1')).toBeTruthy(); expect(document.getElementById('p2')).toBeNull(); }); - name === 'View' && - it('restores scroll after swipeBack', () => { - let y = 101; - scrollsCache['scroll']['p1'] = 22; - const [MockScroll, scrollTo] = mockScrollContext(() => y); - const { view, rerender, SwipeBack } = setupSwipeBack(MockScroll); - fireEvent.mouseMove(view, { - clientX: window.innerWidth + 1, - clientY: 100, - }); - fireEvent.mouseUp(view); - rerender(); - expect(scrollTo).toBeCalledWith(0, 22); + it('restores scroll after swipeBack', () => { + let y = 101; + scrollsCache['scroll']['p1'] = 22; + const [MockScroll, scrollTo] = mockScrollContext(() => y); + const { view, rerender, SwipeBack } = setupSwipeBack({ Wrapper: MockScroll }); + fireEvent.mouseMove(view, { + clientX: window.innerWidth + 1, + clientY: 100, }); + fireEvent.mouseUp(view); + rerender(); + expect(scrollTo).toBeCalledWith(0, 22); + }); }); describe('scroll control', () => { @@ -302,3 +286,52 @@ describe.each([ }); }); }); + +function setupSwipeBack({ + Wrapper = Fragment, + childrenForPanel1 = null, + childrenForPanel2 = null, + initialProps = {}, + shouldForceDetectXSwipe = true, +}: { + Wrapper?: ComponentType; + childrenForPanel1?: any; + childrenForPanel2?: any; + initialProps?: Partial; + shouldForceDetectXSwipe?: boolean; +} = {}) { + const events = { + onSwipeBack: jest.fn(), + onTransition: jest.fn(), + onSwipeBackStart: jest.fn(), + onSwipeBackCancel: jest.fn(), + }; + const SwipeBack = (p: Partial) => ( + + + + {childrenForPanel1} + {childrenForPanel2} + + + + ); + const component = render(); + act(() => { + jest.runAllTimers(); + }); + const view = component.container.firstElementChild as Element; + // force detect x-swipe + if (shouldForceDetectXSwipe) { + fireEvent.mouseDown(view, { clientX: 50, clientY: 100 }); + fireEvent.mouseMove(view, { clientX: 40, clientY: 100 }); + } + return { view, ...events, rerender: component.rerender, SwipeBack }; +} diff --git a/packages/vkui/src/components/View/View.tsx b/packages/vkui/src/components/View/View.tsx index 520f6459f4..068b5b7bee 100644 --- a/packages/vkui/src/components/View/View.tsx +++ b/packages/vkui/src/components/View/View.tsx @@ -13,6 +13,7 @@ import { warnOnce } from '../../lib/warnOnce'; import { useScroll } from '../AppRoot/ScrollContext'; import { useConfigProvider } from '../ConfigProvider/ConfigProviderContext'; import { NavTransitionProvider } from '../NavTransitionContext/NavTransitionContext'; +import { NavTransitionDirectionProvider } from '../NavTransitionDirectionContext/NavTransitionDirectionContext'; import { useSplitCol } from '../SplitCol/SplitColContext'; import { Touch, TouchEvent } from '../Touch/Touch'; import { swipeBackExcluded } from './utils'; @@ -119,7 +120,7 @@ export const View = ({ const [prevPanel, setPrevPanel] = React.useState(null); const [nextPanel, setNextPanel] = React.useState(null); - const [swipingBack, setSwipingBack] = React.useState(false); + const [swipingBack, setSwipingBack] = React.useState(undefined); const [swipeBackPrevented, setSwipeBackPrevented] = React.useState(false); const [swipeBackStartX, setSwipeBackStartX] = React.useState(0); const [swipeBackShift, setSwipeBackShift] = React.useState(0); @@ -170,7 +171,7 @@ export const View = ({ setVisiblePanels([activePanelProp]); setActivePanel(activePanelProp); setAnimated(false); - setIsBack(undefined); + setIsBack(isBackTransition); afterTransition.current = () => { scroll?.scrollTo(0, isBackTransition ? scrolls.current[activePanelProp] : 0); @@ -423,6 +424,7 @@ export const View = ({ setSwipeBackShift(0); setActivePanel(nextPanel); setVisiblePanels([nextPanel]); + setIsBack(true); afterTransition.current = () => { if (nextPanel !== null) { @@ -534,11 +536,13 @@ export const View = ({ marginTop: compensateScroll ? -(scrolls.current[panelId] ?? 0) : undefined, }} > - - {panel} - + + + {panel} + +
); diff --git a/packages/vkui/src/components/View/ViewInfinite.test.tsx b/packages/vkui/src/components/View/ViewInfinite.test.tsx new file mode 100644 index 0000000000..2247c3f5ab --- /dev/null +++ b/packages/vkui/src/components/View/ViewInfinite.test.tsx @@ -0,0 +1,337 @@ +import React from 'react'; +import { type ComponentType, Fragment, type ReactNode } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { Platform } from '../../lib/platform'; +import { baselineComponent, mockScrollContext, mountTest } from '../../testing/utils'; +import { HasChildren } from '../../types'; +import { ConfigProvider } from '../ConfigProvider/ConfigProvider'; +import { useNavDirection } from '../NavTransitionDirectionContext/NavTransitionDirectionContext'; +import { Panel } from '../Panel/Panel'; +import { scrollsCache, ViewInfinite, type ViewInfiniteProps } from './ViewInfinite'; + +// Basically the same as View.test.tsx + +describe('ViewInfinite', () => { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + baselineComponent((props: ViewInfiniteProps) => , { + // TODO [a11y]: "Exceeded timeout of 5000 ms for a test. + // Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout." + a11y: false, + }); + + describe('With Panel', () => + mountTest(() => ( + + + + ))); + + describe('shows active panel', () => { + const panels = [, ]; + it('on mount', () => { + render({panels}); + expect(document.getElementById('p1')).not.toBeNull(); + expect(document.getElementById('p2')).toBeNull(); + }); + it('after prop update', () => { + render({panels}).rerender( + {panels}, + ); + act(() => { + jest.runAllTimers(); + }); + expect(document.getElementById('p1')).toBeNull(); + expect(document.getElementById('p2')).not.toBeNull(); + }); + it('calls onTransition', () => { + const onTransition = jest.fn(); + render( + + {panels} + , + ).rerender( + + {panels} + , + ); + act(() => { + jest.runAllTimers(); + }); + expect(onTransition).toBeCalledTimes(1); + expect(onTransition).toBeCalledWith({ + from: 'p1', + to: 'p2', + isBack: false, + }); + }); + it('detects back transition', () => { + const onTransition = jest.fn(); + render( + + {panels} + , + ).rerender( + + {panels} + , + ); + act(() => { + jest.runAllTimers(); + }); + expect(onTransition).toBeCalledWith({ + from: 'p2', + to: 'p1', + isBack: true, + }); + }); + }); + + describe('blurs active element', () => { + const renderFocused = () => render(); + it('on activePanel change', () => { + renderFocused(); + const panels = [, ]; + render({panels}).rerender( + {panels}, + ); + expect(document.activeElement === document.body).toBe(true); + }); + }); + + describe('can swipeBack', () => { + let nowMock: jest.SpyInstance; + beforeEach(() => (nowMock = jest.spyOn(Date, 'now'))); + afterEach(() => nowMock && nowMock.mockClear()); + it('cancels swipeBack on swipe left', () => { + const { view, ...events } = setupSwipeBack(); + expect(events.onSwipeBackStart).toBeCalledTimes(1); + fireEvent.mouseUp(view, { clientX: 0, clientY: 100 }); + expect(events.onSwipeBack).not.toBeCalled(); + expect(events.onSwipeBackCancel).toBeCalledTimes(1); + }); + it('does swipeBack immediately on overscroll', () => { + const { view, ...events } = setupSwipeBack(); + fireEvent.mouseMove(view, { + clientX: window.innerWidth + 1, + clientY: 100, + }); + fireEvent.mouseUp(view); + expect(events.onSwipeBack).toBeCalledTimes(1); + expect(events.onSwipeBackCancel).not.toBeCalled(); + }); + it('detects transition direction on swipeBack with useNavDirection() hook', async () => { + const PanelContent = () => { + const direction = useNavDirection(); + + return Direction: {direction || 'undefined'}; + }; + const { view } = setupSwipeBack({ + childrenForPanel1: , + childrenForPanel2: , + shouldForceDetectXSwipe: false, + }); + + // only panel 2 visible by default with undefined direction + expect(screen.queryByText('Direction: undefined')).toBeTruthy(); + expect(screen.queryByText('Direction: backwards')).toBeFalsy(); + fireEvent.mouseDown(view, { clientX: 50, clientY: 100 }); + fireEvent.mouseMove(view, { clientX: 40, clientY: 100 }); + + // both panels are visible and the content on panel 1 knows about backwards direction + expect(screen.queryByText('Direction: undefined')).toBeTruthy(); + expect(screen.queryByText('Direction: backwards')).toBeTruthy(); + }); + describe('does not swipeback on', () => { + it.each<[string, ReactNode, Partial]>([ + ['input', , {}], + ['textarea',