chore: native-like draggable mobile menus (#2599)

This commit is contained in:
Aman Harwara
2023-10-23 19:27:52 +05:30
committed by GitHub
parent 3ece5868c1
commit a850d3c989
8 changed files with 373 additions and 231 deletions

View File

@@ -18,7 +18,7 @@ const MenuSection = ({
)}
>
{title && <div className="px-3 py-1 text-sm font-semibold uppercase text-text lg:text-xs">{title}</div>}
<div className="divide-y divide-passive-3 rounded-md bg-passive-4 md:divide-none md:bg-transparent">
<div className="divide-y divide-passive-3 overflow-hidden rounded-md bg-passive-4 md:divide-none md:bg-transparent">
{children}
</div>
</div>

View File

@@ -17,9 +17,6 @@ const Animations = {
},
exit: {
keyframes: [
{
transform: 'translateY(0)',
},
{
transform: 'translateY(100%)',
},
@@ -81,11 +78,11 @@ const Animations = {
},
}
const MobileOptions = {
export const MobileModalAnimationOptions = {
easing: IosModalAnimationEasing,
duration: 250,
fill: 'forwards',
}
} as const
const NonMobileOptions = {
duration: 75,
@@ -102,7 +99,7 @@ export const useModalAnimation = (
open: isOpen,
enter: {
keyframes: isMobileScreen ? Animations[variant].enter.keyframes : Animations.nonMobile.enter.keyframes,
options: isMobileScreen ? MobileOptions : NonMobileOptions,
options: isMobileScreen ? MobileModalAnimationOptions : NonMobileOptions,
initialStyle: {
transformOrigin: isMobileScreen
? Animations[variant].enter.transformOrigin
@@ -117,7 +114,7 @@ export const useModalAnimation = (
},
exit: {
keyframes: isMobileScreen ? Animations[variant].exit.keyframes : Animations.nonMobile.exit.keyframes,
options: isMobileScreen ? MobileOptions : NonMobileOptions,
options: isMobileScreen ? MobileModalAnimationOptions : NonMobileOptions,
initialStyle: {
transformOrigin: isMobileScreen
? Animations[variant].exit.transformOrigin

View File

@@ -4,22 +4,7 @@ import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/u
import { useApplication } from '../ApplicationProvider'
import { ApplicationEvent, PrefKey, PrefDefaults } from '@standardnotes/snjs'
import { getScrollParent } from '@/Utils'
const supportsPassive = (() => {
let supportsPassive = false
try {
const opts = Object.defineProperty({}, 'passive', {
get: () => {
supportsPassive = true
},
})
window.addEventListener('test', null as never, opts)
window.removeEventListener('test', null as never, opts)
} catch (e) {
/* empty */
}
return supportsPassive
})()
import { SupportsPassiveListeners } from '@/Constants/Constants'
export const usePaneSwipeGesture = (
direction: 'left' | 'right',
@@ -113,7 +98,11 @@ export const usePaneSwipeGesture = (
closestScrollContainer = getScrollParent(event.target as HTMLElement)
if (closestScrollContainer) {
scrollContainerInitialOverflowY = closestScrollContainer.style.overflowY
closestScrollContainer.addEventListener('scroll', scrollListener, supportsPassive ? { passive: true } : false)
closestScrollContainer.addEventListener(
'scroll',
scrollListener,
SupportsPassiveListeners ? { passive: true } : false,
)
if (closestScrollContainer.scrollWidth > closestScrollContainer.clientWidth) {
scrollContainerAxis = 'x'
@@ -268,9 +257,9 @@ export const usePaneSwipeGesture = (
disposeUnderlay()
}
element.addEventListener('touchstart', touchStartListener, supportsPassive ? { passive: true } : false)
element.addEventListener('touchmove', touchMoveListener, supportsPassive ? { passive: true } : false)
element.addEventListener('touchend', touchEndListener, supportsPassive ? { passive: true } : false)
element.addEventListener('touchstart', touchStartListener, SupportsPassiveListeners ? { passive: true } : false)
element.addEventListener('touchmove', touchMoveListener, SupportsPassiveListeners ? { passive: true } : false)
element.addEventListener('touchend', touchEndListener, SupportsPassiveListeners ? { passive: true } : false)
return () => {
element.removeEventListener('touchstart', touchStartListener)

View File

@@ -1,13 +1,15 @@
import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile'
import { classNames } from '@standardnotes/snjs'
import { ReactNode, useCallback } from 'react'
import { ReactNode, useCallback, useEffect, useRef } from 'react'
import Portal from '../Portal/Portal'
import MobileModalAction from '../Modal/MobileModalAction'
import { useModalAnimation } from '../Modal/useModalAnimation'
import { MobileModalAnimationOptions, useModalAnimation } from '../Modal/useModalAnimation'
import MobileModalHeader from '../Modal/MobileModalHeader'
import { mergeRefs } from '@/Hooks/mergeRefs'
import { DialogWithClose } from '@/Utils/CloseOpenModalsAndPopovers'
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { SupportsPassiveListeners } from '@/Constants/Constants'
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
const DisableScroll = () => {
useDisableBodyScrollOnMobile()
@@ -31,7 +33,120 @@ const MobilePopoverContent = ({
className?: string
}) => {
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const [isMounted, setPopoverElement] = useModalAnimation(open, isMobileScreen)
const [isMounted, setPopoverElement, element] = useModalAnimation(open, isMobileScreen)
const [, setUnderlayElement] = useLifecycleAnimation({
open,
enter: {
keyframes: [
{
opacity: 0,
},
{
opacity: 0.6,
},
],
options: MobileModalAnimationOptions,
},
exit: {
keyframes: [
{
opacity: 0.6,
},
{
opacity: 0,
},
],
options: MobileModalAnimationOptions,
},
})
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const closestScrollContainer = scrollContainerRef.current
if (!element || !closestScrollContainer) {
return
}
let elementY = 0
let startY = 0
let closestScrollContainerScrollTop = 0
let isClosestScrollContainerScrolledAtStart = false
let containerScrollChangedAfterTouchStart = false
const touchStartHandler = (e: TouchEvent) => {
startY = e.touches[0].clientY
elementY = element.getBoundingClientRect().y
closestScrollContainerScrollTop = closestScrollContainer?.scrollTop || 0
isClosestScrollContainerScrolledAtStart = !!closestScrollContainer && closestScrollContainerScrollTop > 0
containerScrollChangedAfterTouchStart = false
}
const touchMoveHandler = (e: TouchEvent) => {
const deltaY = e.touches[0].clientY - startY
const latestClosestScrollContainerScrollTop = closestScrollContainer?.scrollTop || 0
if (latestClosestScrollContainerScrollTop !== closestScrollContainerScrollTop) {
containerScrollChangedAfterTouchStart = true
}
const isClosestScrollContainerScrolled = !!closestScrollContainer && latestClosestScrollContainerScrollTop > 0
const shouldNotDrag = isClosestScrollContainerScrolled
? true
: containerScrollChangedAfterTouchStart
? true
: isClosestScrollContainerScrolledAtStart
if (deltaY < 0 || shouldNotDrag) {
return
}
const y = element.getBoundingClientRect().y
if (y > elementY) {
closestScrollContainer.style.overflowY = 'hidden'
}
element.animate(
{
transform: [`translate3d(0, ${deltaY}px, 0)`],
},
{
duration: 0,
fill: 'forwards',
},
)
}
const touchEndHandler = () => {
const y = element.getBoundingClientRect().y
const threshold = window.innerHeight * 0.75
if (y > threshold && !isClosestScrollContainerScrolledAtStart) {
requestClose()
} else {
element.animate(
{
transform: ['translate3d(0, 0, 0)'],
},
{
duration: 200,
fill: 'forwards',
},
)
}
startY = 0
closestScrollContainer.style.overflowY = ''
}
element.addEventListener('touchstart', touchStartHandler, SupportsPassiveListeners ? { passive: true } : false)
element.addEventListener('touchmove', touchMoveHandler, SupportsPassiveListeners ? { passive: true } : false)
element.addEventListener('touchend', touchEndHandler, SupportsPassiveListeners ? { passive: true } : false)
return () => {
element.removeEventListener('touchstart', touchStartHandler)
element.removeEventListener('touchmove', touchMoveHandler)
element.removeEventListener('touchend', touchEndHandler)
}
}, [element, requestClose])
const addCloseMethod = useCallback(
(element: HTMLDivElement | null) => {
@@ -49,21 +164,27 @@ const MobilePopoverContent = ({
return (
<Portal>
<DisableScroll />
<div
ref={mergeRefs([setPopoverElement, addCloseMethod])}
className="fixed left-0 top-0 z-modal flex h-full w-full flex-col bg-default pb-safe-bottom pt-safe-top"
id={'popover/' + id}
data-popover={id}
data-mobile-popover
>
<MobileModalHeader className="border-b border-border px-2 py-1.5 text-lg">
<div />
<div className="flex items-center justify-center font-semibold">{title}</div>
<MobileModalAction type="primary" slot="right" action={requestClose}>
Done
</MobileModalAction>
</MobileModalHeader>
<div className={classNames('h-full overflow-y-auto', className)}>{children}</div>
<div className="fixed inset-0 z-modal">
<div className="absolute inset-0 z-0 bg-passive-4 opacity-0" ref={setUnderlayElement} />
<div
ref={mergeRefs([setPopoverElement, addCloseMethod])}
className="z-1 absolute bottom-0 flex max-h-[calc(100%_-_max(var(--safe-area-inset-top),2rem))] min-h-[40%] w-full flex-col rounded-t-xl bg-default pb-safe-bottom"
id={'popover/' + id}
data-popover={id}
data-mobile-popover
>
<div className="mx-auto mt-2 min-h-[0.3rem] w-12 rounded-full bg-passive-2" />
<MobileModalHeader className="border-b border-border px-2 py-1.5 text-lg">
<div />
<div className="flex items-center justify-center font-semibold">{title}</div>
<MobileModalAction type="primary" slot="right" action={requestClose}>
Done
</MobileModalAction>
</MobileModalHeader>
<div className={classNames('h-full overflow-y-auto overscroll-none', className)} ref={scrollContainerRef}>
{children}
</div>
</div>
</div>
</Portal>
)

View File

@@ -42,3 +42,19 @@ export const SuperEditorMetadata: EditorMetadata = {
iconClassName: 'text-accessory-tint-1',
iconTintNumber: 1,
}
export const SupportsPassiveListeners = (() => {
let supportsPassive = false
try {
const opts = Object.defineProperty({}, 'passive', {
get: () => {
supportsPassive = true
},
})
window.addEventListener('test', null as never, opts)
window.removeEventListener('test', null as never, opts)
} catch (e) {
/* empty */
}
return supportsPassive
})()

View File

@@ -1,4 +1,4 @@
import { RefCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { AnimationConfig } from '../Constants/AnimationConfigs'
import { useStateRef } from './useStateRef'
@@ -23,7 +23,7 @@ type Options = {
export const useLifecycleAnimation = (
{ open, enter, enterCallback, exit, exitCallback }: Options,
disabled = false,
): [boolean, RefCallback<HTMLElement | null>] => {
) => {
const [element, setElement] = useState<HTMLElement | null>(null)
const [isMounted, setIsMounted] = useState(() => open)
@@ -124,5 +124,5 @@ export const useLifecycleAnimation = (
}
}, [open, element, enterRef, enterCallbackRef, exitRef, exitCallbackRef, disabled])
return [isMounted, setElement]
return [isMounted, setElement, element] as const
}