Skip to content

Commit 4c4df1a

Browse files
author
Kubit
committed
Hook to enable swipe-down functionality for closing modals on mobile devices.
1 parent b4d40ba commit 4c4df1a

File tree

3 files changed

+218
-1
lines changed

3 files changed

+218
-1
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { act, fireEvent } from '@testing-library/react';
2+
import { renderHook } from '@testing-library/react-hooks';
3+
4+
import * as useMediaDevice from '@/hooks/useMediaDevice/useMediaDevice';
5+
import { windowMatchMedia } from '@/tests/windowMatchMedia';
6+
import { DeviceBreakpointsType } from '@/types';
7+
8+
import { useSwipeDown } from '../useSwipeDown';
9+
10+
let containerRefMock;
11+
let dragRefMock;
12+
let onCloseMock;
13+
let animationExitDurationMock;
14+
let containerRoot;
15+
16+
describe('useSwipeDown hook', () => {
17+
afterEach(() => {
18+
jest.clearAllMocks();
19+
jest.resetAllMocks();
20+
jest.restoreAllMocks();
21+
});
22+
beforeEach(() => {
23+
window.matchMedia = windowMatchMedia('onlyMobile');
24+
jest
25+
.spyOn(useMediaDevice, 'useMediaDevice')
26+
.mockImplementation(() => DeviceBreakpointsType.MOBILE);
27+
const container = document.createElement('div');
28+
const content = document.createElement('div');
29+
30+
containerRoot?.remove();
31+
32+
containerRoot = document.createElement('div');
33+
34+
container.appendChild(content);
35+
36+
containerRoot.appendChild(container);
37+
containerRefMock = {
38+
current: container,
39+
};
40+
dragRefMock = {
41+
current: content,
42+
};
43+
onCloseMock = jest.fn();
44+
animationExitDurationMock = 300;
45+
46+
document.body.appendChild(containerRoot);
47+
const { result } = renderHook(() => useSwipeDown(onCloseMock));
48+
49+
act(() => {
50+
result.current.setPopoverRef?.(containerRefMock.current);
51+
result.current.setDragIconRef?.(dragRefMock.current);
52+
});
53+
});
54+
describe('useSwipeDown hook', () => {
55+
it('should set popover and drag icon refs', () => {
56+
const { result } = renderHook(() => useSwipeDown());
57+
58+
act(() => {
59+
result.current.setPopoverRef?.(containerRefMock.current);
60+
result.current.setDragIconRef?.(dragRefMock.current);
61+
});
62+
63+
expect(result.current.setPopoverRef).toBeDefined();
64+
expect(result.current.setDragIconRef).toBeDefined();
65+
});
66+
it('Should swipeDown', () => {
67+
// Simulate down swipe
68+
fireEvent.mouseDown(dragRefMock.current, { clientY: 100 }); // Start swipe
69+
fireEvent.mouseMove(dragRefMock.current, { clientY: 50 }); // Swipe down
70+
fireEvent.mouseUp(dragRefMock.current, { clientY: 0 }); // End swipe
71+
expect(containerRefMock.current.style.bottom).toBe('0px');
72+
});
73+
it('Should not swipeDown on desktop', () => {
74+
window.matchMedia = windowMatchMedia('onlyDesktop');
75+
jest
76+
.spyOn(useMediaDevice, 'useMediaDevice')
77+
.mockImplementation(() => DeviceBreakpointsType.DESKTOP);
78+
expect(containerRefMock.current.style.bottom).toBe('');
79+
});
80+
it('Should not swipeDown if the ref is not ready', () => {
81+
containerRefMock.current = null;
82+
renderHook(() => useSwipeDown(onCloseMock));
83+
// Simulate down swipe
84+
fireEvent.mouseDown(dragRefMock.current, { clientY: 100 }); // Start swipe
85+
fireEvent.mouseMove(dragRefMock.current, { clientY: 50 }); // Swipe down
86+
fireEvent.mouseUp(dragRefMock.current, { clientY: 0 }); // End swipe
87+
expect(containerRefMock.current).toBe(null);
88+
});
89+
it('If event type is touchstart, should set the yStart.current with the first touch clientY', () => {
90+
fireEvent.touchStart(dragRefMock.current, { touches: [{ clientY: 100 }] });
91+
expect(containerRefMock.current.style.bottom).toBe('');
92+
});
93+
it('If event type is touchmove, should set the yEnd.current with the last touch clientY and swipe down', () => {
94+
fireEvent.touchStart(dragRefMock.current, { touches: [{ clientY: 100 }] });
95+
fireEvent.touchMove(dragRefMock.current, { touches: [{ clientY: 50 }] });
96+
fireEvent.touchEnd(dragRefMock.current, { touches: [{ clientY: 0 }] });
97+
expect(containerRefMock.current.style.bottom).toBe('0px');
98+
});
99+
});
100+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { useCallback, useRef } from 'react';
2+
3+
import { ICssAnimationOptions } from '@/components/cssAnimation';
4+
import { convertDurationToNumber } from '@/utils/stringUtility/string.utility';
5+
6+
const distanceToTriggerClose = 30;
7+
8+
type ReturnType = {
9+
setPopoverRef?: (node: HTMLElement) => void;
10+
setDragIconRef?: (node: HTMLElement) => void;
11+
};
12+
13+
export const useSwipeDown = (
14+
animationOptions?: ICssAnimationOptions,
15+
handleClose?: () => void
16+
): ReturnType => {
17+
const containerRef = useRef<HTMLDivElement | null>(null);
18+
const dragRef = useRef<HTMLDivElement | null>(null);
19+
20+
const setPopoverRef = useCallback(node => {
21+
if (node) {
22+
containerRef.current = node;
23+
} else {
24+
containerRef.current = null;
25+
}
26+
}, []);
27+
28+
const setDragIconRef = useCallback(node => {
29+
if (node) {
30+
dragRef.current = node;
31+
dragRef?.current?.addEventListener('mousedown', startMove);
32+
dragRef?.current?.addEventListener('mousemove', currentMove);
33+
dragRef?.current?.addEventListener('mouseup', endMove);
34+
35+
dragRef?.current?.addEventListener('touchstart', startMove);
36+
dragRef?.current?.addEventListener('touchmove', currentMove);
37+
dragRef?.current?.addEventListener('touchend', endMove);
38+
} else {
39+
dragRef?.current?.removeEventListener('mousedown', startMove);
40+
dragRef?.current?.removeEventListener('mousemove', currentMove);
41+
dragRef?.current?.removeEventListener('mouseup', endMove);
42+
43+
dragRef?.current?.removeEventListener('touchstart', startMove);
44+
dragRef?.current?.removeEventListener('touchmove', currentMove);
45+
dragRef?.current?.removeEventListener('touchend', endMove);
46+
dragRef.current = null;
47+
}
48+
}, []);
49+
50+
const animationExitDuration =
51+
((convertDurationToNumber(animationOptions?.exitDuration) ||
52+
convertDurationToNumber(animationOptions?.duration) ||
53+
0) +
54+
(convertDurationToNumber(animationOptions?.delay) || 0)) *
55+
1000;
56+
57+
const currentBottom = useRef(0);
58+
const yStart = useRef(0);
59+
const yEnd = useRef<number | null>(0);
60+
const dragMove = useRef(false);
61+
62+
const startMove = e => {
63+
const swiperContent = containerRef?.current;
64+
if (!swiperContent) {
65+
return;
66+
}
67+
e.preventDefault?.();
68+
swiperContent.style.removeProperty('transition');
69+
70+
if (e.type === 'touchstart') {
71+
yStart.current = e.touches[0].clientY;
72+
} else {
73+
yStart.current = e.clientY;
74+
}
75+
dragMove.current = true;
76+
yEnd.current = null;
77+
};
78+
79+
const currentMove = e => {
80+
const swiperContent = containerRef?.current;
81+
if (!dragMove.current || !swiperContent) {
82+
return;
83+
}
84+
if (e.type === 'touchmove' && yEnd !== null) {
85+
yEnd.current = e.touches[0].clientY as number;
86+
} else {
87+
yEnd.current = e.clientY as number;
88+
}
89+
const currentMove = yStart.current - yEnd.current;
90+
if (yEnd.current < yStart.current) {
91+
return;
92+
}
93+
swiperContent.style.bottom = `${currentBottom.current + currentMove}px`;
94+
};
95+
96+
const endMove = () => {
97+
const swiperContent = containerRef?.current;
98+
if (!swiperContent || !yEnd.current) {
99+
return;
100+
}
101+
dragMove.current = false;
102+
swiperContent.style.setProperty('transition', `bottom ${animationExitDuration}ms linear`);
103+
104+
if (yEnd.current < yStart.current + distanceToTriggerClose) {
105+
swiperContent.style.bottom = '0px';
106+
return;
107+
}
108+
// Move modal
109+
const distance = currentBottom.current + swiperContent.scrollHeight;
110+
swiperContent.style.bottom = `-${distance}px`;
111+
setTimeout(() => {
112+
handleClose?.();
113+
}, animationExitDuration);
114+
};
115+
116+
return { setPopoverRef, setDragIconRef };
117+
};

src/utils/focusHandlers/focusHandlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22

33
const FOCUSABLE_QUERY_SELECTOR =
4-
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]):not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])';
4+
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]):not([tabindex="-1"]), summary, [tabindex]:not([tabindex="-1"])';
55

66
export const getFocusableDescendants = (element: HTMLElement): HTMLElement[] | boolean => {
77
const focusableNodes = Array.from(

0 commit comments

Comments
 (0)