Skip to content

Commit 680d2dc

Browse files
committed
Use hammerjs for swipe-to-close modal behaviour
1 parent 73a14a1 commit 680d2dc

File tree

3 files changed

+82
-67
lines changed

3 files changed

+82
-67
lines changed

assets/js/dashboard/stats/modals/modal.js

Lines changed: 71 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useEffect } from 'react'
2+
import Hammer from 'hammerjs'
23
import { createPortal } from 'react-dom'
34
import { isModifierPressed, isTyping, Keybind } from '../../keybinding'
45
import { rootRoute } from '../../router'
@@ -15,16 +16,15 @@ class Modal extends React.Component {
1516
this.state = {
1617
viewport: DEFAULT_WIDTH,
1718
dragOffset: 0,
18-
isClosing: false
19+
isDragging: false
1920
}
2021
this.node = React.createRef()
22+
this.hammerInstance = null
2123
this.handleClickOutside = this.handleClickOutside.bind(this)
2224
this.handleResize = this.handleResize.bind(this)
23-
this.handleTouchStart = this.handleTouchStart.bind(this)
24-
this.handleTouchMove = this.handleTouchMove.bind(this)
25-
this.handleTouchEnd = this.handleTouchEnd.bind(this)
26-
this.touchStartY = null
27-
this.lastTouchY = null
25+
this.handlePanMove = this.handlePanMove.bind(this)
26+
this.handlePanEnd = this.handlePanEnd.bind(this)
27+
this.handlePanCancel = this.handlePanCancel.bind(this)
2828
}
2929

3030
componentDidMount() {
@@ -36,6 +36,7 @@ class Modal extends React.Component {
3636
}
3737

3838
componentWillUnmount() {
39+
this.teardownHammer()
3940
document.body.style.overflow = null
4041
document.body.style.height = null
4142
document.removeEventListener('mousedown', this.handleClickOutside)
@@ -51,7 +52,69 @@ class Modal extends React.Component {
5152
}
5253

5354
handleResize() {
54-
this.setState({ viewport: window.innerWidth })
55+
const viewport = window.innerWidth
56+
this.setState({ viewport })
57+
this.updateSwipeListener(viewport)
58+
}
59+
60+
updateSwipeListener(viewport) {
61+
if (!this.node.current) return
62+
63+
// Enable swipe-to-close only on mobile widths.
64+
if (viewport < MD_WIDTH) {
65+
if (this.hammerInstance) return
66+
67+
const hammer = new Hammer(this.node.current)
68+
hammer.get('pan').set({ direction: Hammer.DIRECTION_VERTICAL, threshold: 0 })
69+
hammer.on('panmove', this.handlePanMove)
70+
hammer.on('panend', this.handlePanEnd)
71+
hammer.on('pancancel', this.handlePanCancel)
72+
this.hammerInstance = hammer
73+
} else {
74+
this.teardownHammer()
75+
}
76+
}
77+
78+
handlePanMove(ev) {
79+
if (ev.direction === Hammer.DIRECTION_DOWN || ev.deltaY > 0) {
80+
this.setState({ dragOffset: ev.deltaY, isDragging: true })
81+
}
82+
}
83+
84+
handlePanEnd(ev) {
85+
const shouldClose = ev.deltaY > 80 || ev.velocityY > 0.35
86+
if (shouldClose) {
87+
this.props.onClose()
88+
return
89+
}
90+
91+
// Snap back
92+
this.setState({ dragOffset: 0, isDragging: false })
93+
}
94+
95+
handlePanCancel() {
96+
this.setState({ dragOffset: 0, isDragging: false })
97+
}
98+
99+
getDragStyle() {
100+
const { dragOffset, isDragging } = this.state
101+
const clamped = Math.max(0, dragOffset)
102+
const opacity = Math.max(0, Math.min(1, 1 - clamped / 200))
103+
return {
104+
transform: `translateY(${clamped}px)`,
105+
opacity,
106+
transition: isDragging ? 'none' : 'transform 150ms ease-out, opacity 150ms ease-out'
107+
}
108+
}
109+
110+
teardownHammer() {
111+
if (this.hammerInstance) {
112+
this.hammerInstance.off('panmove', this.handlePanMove)
113+
this.hammerInstance.off('panend', this.handlePanEnd)
114+
this.hammerInstance.off('pancancel', this.handlePanCancel)
115+
this.hammerInstance.destroy()
116+
this.hammerInstance = null
117+
}
55118
}
56119

57120
/**
@@ -70,65 +133,9 @@ class Modal extends React.Component {
70133
} else {
71134
styleObject.maxWidth = '880px'
72135
}
73-
styleObject.transform = `translateY(${this.state.dragOffset}px)`
74-
if (this.state.isClosing) {
75-
styleObject.transition = 'transform 150ms ease-out'
76-
} else {
77-
styleObject.transition =
78-
this.state.dragOffset > 0 ? 'none' : 'transform 150ms ease-out'
79-
}
80136
return styleObject
81137
}
82138

83-
handleTouchStart(e) {
84-
if (this.state.viewport >= MD_WIDTH) return
85-
this.setState({ isClosing: false })
86-
const touch = e.touches[0]
87-
this.touchStartY = touch.clientY
88-
this.lastTouchY = touch.clientY
89-
}
90-
91-
handleTouchMove(e) {
92-
if (this.state.viewport >= MD_WIDTH) return
93-
if (this.touchStartY === null) return
94-
const touch = e.touches[0]
95-
const deltaY = touch.clientY - this.touchStartY
96-
this.lastTouchY = touch.clientY
97-
if (deltaY <= 0) {
98-
this.setState({ dragOffset: 0 })
99-
return
100-
}
101-
e.preventDefault()
102-
this.setState({ dragOffset: deltaY })
103-
}
104-
105-
handleTouchEnd() {
106-
if (this.state.viewport >= MD_WIDTH) return
107-
if (this.touchStartY === null || this.lastTouchY === null) {
108-
this.touchStartY = null
109-
this.lastTouchY = null
110-
return
111-
}
112-
const deltaY = this.lastTouchY - this.touchStartY
113-
this.touchStartY = null
114-
this.lastTouchY = null
115-
if (deltaY > 150) {
116-
this.setState(
117-
{
118-
dragOffset: Math.max(deltaY, window.innerHeight * 0.6),
119-
isClosing: true
120-
},
121-
() => {
122-
setTimeout(() => {
123-
this.props.onClose()
124-
}, 150)
125-
}
126-
)
127-
return
128-
}
129-
this.setState({ dragOffset: 0, isClosing: false })
130-
}
131-
132139
render() {
133140
return createPortal(
134141
<>
@@ -145,12 +152,9 @@ class Modal extends React.Component {
145152
<div
146153
ref={this.node}
147154
className="max-h-[calc(100dvh_-_var(--gap)*2)] min-h-[66vh] md:min-h-120 w-full flex flex-col bg-white p-3 md:px-6 md:py-4 overflow-hidden box-border transition-[height] duration-200 ease-in shadow-2xl rounded-t-lg md:rounded-lg dark:bg-gray-900 focus:outline-hidden"
148-
style={this.getStyle()}
155+
style={{ ...this.getStyle(), ...this.getDragStyle() }}
149156
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
150157
tabIndex={0}
151-
onTouchStart={this.handleTouchStart}
152-
onTouchMove={this.handleTouchMove}
153-
onTouchEnd={this.handleTouchEnd}
154158
>
155159
<FocusOnMount focusableRef={this.node} />
156160
{this.props.children}

assets/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"classnames": "^2.3.1",
2929
"d3": "^7.9.0",
3030
"dayjs": "^1.11.7",
31+
"hammerjs": "^2.0.8",
3132
"iframe-resizer": "^4.3.2",
3233
"react": "^18.3.1",
3334
"react-dom": "^18.3.1",

0 commit comments

Comments
 (0)