Skip to content

Commit 8628a99

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

File tree

3 files changed

+81
-67
lines changed

3 files changed

+81
-67
lines changed

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

Lines changed: 70 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,68 @@ 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+
if (viewport < MD_WIDTH) {
64+
if (this.hammerInstance) return
65+
66+
const hammer = new Hammer(this.node.current)
67+
hammer.get('pan').set({ direction: Hammer.DIRECTION_VERTICAL, threshold: 0 })
68+
hammer.on('panmove', this.handlePanMove)
69+
hammer.on('panend', this.handlePanEnd)
70+
hammer.on('pancancel', this.handlePanCancel)
71+
this.hammerInstance = hammer
72+
} else {
73+
this.teardownHammer()
74+
}
75+
}
76+
77+
handlePanMove(ev) {
78+
if (ev.direction === Hammer.DIRECTION_DOWN || ev.deltaY > 0) {
79+
this.setState({ dragOffset: ev.deltaY, isDragging: true })
80+
}
81+
}
82+
83+
handlePanEnd(ev) {
84+
const shouldClose = ev.deltaY > 80 || ev.velocityY > 0.35
85+
if (shouldClose) {
86+
this.props.onClose()
87+
return
88+
}
89+
90+
// Snap back
91+
this.setState({ dragOffset: 0, isDragging: false })
92+
}
93+
94+
handlePanCancel() {
95+
this.setState({ dragOffset: 0, isDragging: false })
96+
}
97+
98+
getDragStyle() {
99+
const { dragOffset, isDragging } = this.state
100+
const clamped = Math.max(0, dragOffset)
101+
const opacity = Math.max(0, Math.min(1, 1 - clamped / 200))
102+
return {
103+
transform: `translateY(${clamped}px)`,
104+
opacity,
105+
transition: isDragging ? 'none' : 'transform 150ms ease-out, opacity 150ms ease-out'
106+
}
107+
}
108+
109+
teardownHammer() {
110+
if (this.hammerInstance) {
111+
this.hammerInstance.off('panmove', this.handlePanMove)
112+
this.hammerInstance.off('panend', this.handlePanEnd)
113+
this.hammerInstance.off('pancancel', this.handlePanCancel)
114+
this.hammerInstance.destroy()
115+
this.hammerInstance = null
116+
}
55117
}
56118

57119
/**
@@ -70,65 +132,9 @@ class Modal extends React.Component {
70132
} else {
71133
styleObject.maxWidth = '880px'
72134
}
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-
}
80135
return styleObject
81136
}
82137

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-
132138
render() {
133139
return createPortal(
134140
<>
@@ -145,12 +151,9 @@ class Modal extends React.Component {
145151
<div
146152
ref={this.node}
147153
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()}
154+
style={{ ...this.getStyle(), ...this.getDragStyle() }}
149155
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
150156
tabIndex={0}
151-
onTouchStart={this.handleTouchStart}
152-
onTouchMove={this.handleTouchMove}
153-
onTouchEnd={this.handleTouchEnd}
154157
>
155158
<FocusOnMount focusableRef={this.node} />
156159
{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)