chore: native-like draggable mobile menus (#2599)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
})()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user