11import React , { useEffect } from 'react'
2+ import Hammer from 'hammerjs'
23import { createPortal } from 'react-dom'
34import { isModifierPressed , isTyping , Keybind } from '../../keybinding'
45import { 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 }
0 commit comments