diff --git a/components/drawer/drawer.tsx b/components/drawer/drawer.tsx index 5c3b31110..ef0e1b2b4 100644 --- a/components/drawer/drawer.tsx +++ b/components/drawer/drawer.tsx @@ -40,7 +40,7 @@ const DrawerComponent: React.FC> = ({ }: React.PropsWithChildren & typeof defaultProps) => { const portal = usePortal('drawer') const [visible, setVisible] = useState(false) - const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true }) + const [, setBodyHidden] = useBodyScroll(null, { delayReset: 300 }) const closeDrawer = () => { onClose && onClose() diff --git a/components/modal/modal.tsx b/components/modal/modal.tsx index 5d59016ff..be9768552 100644 --- a/components/modal/modal.tsx +++ b/components/modal/modal.tsx @@ -49,7 +49,7 @@ const ModalComponent: React.FC> = ({ }: React.PropsWithChildren & typeof defaultProps) => { const portal = usePortal('modal') const { SCALES } = useScale() - const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true }) + const [, setBodyHidden] = useBodyScroll(null, { delayReset: 300 }) const [visible, setVisible] = useState(false) const [withoutActionsChildren, ActionsChildren] = pickChild(children, ModalAction) const hasActions = ActionsChildren && React.Children.count(ActionsChildren) > 0 diff --git a/components/use-body-scroll/__tests__/body-scroll.test.tsx b/components/use-body-scroll/__tests__/body-scroll.test.tsx index 988e9c0f3..d7cd3db6a 100644 --- a/components/use-body-scroll/__tests__/body-scroll.test.tsx +++ b/components/use-body-scroll/__tests__/body-scroll.test.tsx @@ -1,6 +1,7 @@ import React, { RefObject } from 'react' import { useBodyScroll } from 'components' import { act, renderHook } from '@testing-library/react-hooks' +import { sleep } from 'tests/utils' describe('UseBodyScroll', () => { it('should work correctly', () => { @@ -14,7 +15,7 @@ describe('UseBodyScroll', () => { expect(result.current[0]).toBe(true) }) - it('should set overflow', () => { + it('should set overflow', async () => { const ref = React.createRef() ;(ref as any).current = document.createElement('div') const el = ref.current as HTMLDivElement @@ -24,10 +25,11 @@ describe('UseBodyScroll', () => { expect(el.style.overflow).toEqual('hidden') act(() => result.current[1](false)) - expect(el.style.overflow).toEqual('') + await sleep(10) + expect(el.style.overflow).not.toEqual('hidden') }) - it('the last value of overflow should be recovered after setHidden', () => { + it('the last value of overflow should be recovered after setHidden', async () => { const ref = React.createRef() const div = document.createElement('div') div.style.overflow = 'scroll' @@ -40,10 +42,11 @@ describe('UseBodyScroll', () => { expect(el.style.overflow).toEqual('hidden') act(() => result.current[1](false)) + await sleep(10) expect(el.style.overflow).toEqual('scroll') }) - it('should work correctly with multiple element', () => { + it('should work correctly with multiple element', async () => { const ref = React.createRef() ;(ref as any).current = document.createElement('div') const el = ref.current as HTMLDivElement @@ -61,59 +64,27 @@ describe('UseBodyScroll', () => { act(() => result.current[1](false)) act(() => result2.current[1](false)) + await sleep(10) expect(el.style.overflow).toEqual('') expect(el2.style.overflow).toEqual('') }) - it('should work correctly with iOS', () => { - Object.defineProperty(window.navigator, 'platform', { value: '', writable: true }) - ;(window.navigator as any).platform = 'iPhone' - const event = { preventDefault: jest.fn() } - + it('should work correctly with options', async () => { const ref = React.createRef() ;(ref as any).current = document.createElement('div') const el = ref.current as HTMLDivElement - const { result } = renderHook(() => useBodyScroll(ref)) + const { result } = renderHook(() => useBodyScroll(ref, { delayReset: 300 })) act(() => result.current[1](true)) - const touchEvent = new TouchEvent('touchmove', event as EventInit) - const MockEvent = Object.assign(touchEvent, event) - document.dispatchEvent(MockEvent) - - expect(el.style.overflow).not.toEqual('hidden') - expect(event.preventDefault).toHaveBeenCalled() - - // Touch events with multiple fingers do nothing - document.dispatchEvent( - new TouchEvent('touchmove', { - touches: [{}, {}, {}] as Array, - }), - ) - expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(el.style.overflow).toEqual('hidden') act(() => result.current[1](false)) - ;(window.navigator as any).platform = '' - }) - - it('should work correctly with options', () => { - Object.defineProperty(window.navigator, 'platform', { value: '', writable: true }) - ;(window.navigator as any).platform = 'iPhone' - const event = { preventDefault: jest.fn() } - - const ref = React.createRef() - ;(ref as any).current = document.createElement('div') - const el = ref.current as HTMLDivElement - const { result } = renderHook(() => useBodyScroll(ref, { scrollLayer: true })) - - act(() => result.current[1](true)) - const touchEvent = new TouchEvent('touchmove', event as EventInit) - const MockEvent = Object.assign(touchEvent, event) - document.dispatchEvent(MockEvent) - + await sleep(10) expect(el.style.overflow).toEqual('hidden') - expect(event.preventDefault).not.toHaveBeenCalled() - act(() => result.current[1](false)) - ;(window.navigator as any).platform = '' + await sleep(100) + expect(el.style.overflow).toEqual('hidden') + await sleep(250) + expect(el.style.overflow).not.toEqual('hidden') }) it('should work correctly when set element repeatedly', () => { diff --git a/components/use-body-scroll/use-body-scroll.ts b/components/use-body-scroll/use-body-scroll.ts index ed38129fc..c91db8e1a 100644 --- a/components/use-body-scroll/use-body-scroll.ts +++ b/components/use-body-scroll/use-body-scroll.ts @@ -1,29 +1,31 @@ import { Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from 'react' export type ElementStackItem = { - last: string + overflow: string + paddingRight: string } export type BodyScrollOptions = { - scrollLayer: boolean + scrollLayer?: boolean + delayReset?: number } const defaultOptions: BodyScrollOptions = { scrollLayer: false, + delayReset: 0, } const elementStack = new Map() -const isIos = () => { - /* istanbul ignore next */ - if (typeof window === 'undefined' || !window.navigator) return false - return /iP(ad|hone|od)/.test(window.navigator.platform) +const getOwnerPaddingRight = (element: Element): number => { + const owner = element?.ownerDocument || document + const view = owner.defaultView || window + return Number.parseInt(view.getComputedStyle(element).paddingRight, 10) || 0 } -const touchHandler = (event: TouchEvent): boolean => { - if (event.touches && event.touches.length > 1) return true - event.preventDefault() - return false +const getOwnerScrollbarWidth = (element: Element): number => { + const doc = element?.ownerDocument || document + return Math.abs(window.innerWidth - doc.documentElement.clientWidth) } const useBodyScroll = ( @@ -39,37 +41,37 @@ const useBodyScroll = ( ...(options || {}), } - // don't prevent touch event when layer contain scroll - const isIosWithCustom = () => { - if (safeOptions.scrollLayer) return false - return isIos() - } - useEffect(() => { if (!elRef || !elRef.current) return const lastOverflow = elRef.current.style.overflow if (hidden) { if (elementStack.has(elRef.current)) return - if (!isIosWithCustom()) { - elRef.current.style.overflow = 'hidden' - } else { - document.addEventListener('touchmove', touchHandler, { passive: false }) - } + const paddingRight = getOwnerPaddingRight(elRef.current) + const scrollbarWidth = getOwnerScrollbarWidth(elRef.current) elementStack.set(elRef.current, { - last: lastOverflow, + overflow: lastOverflow, + paddingRight: elRef.current.style.paddingRight, }) + elRef.current.style.overflow = 'hidden' + elRef.current.style.paddingRight = `${paddingRight + scrollbarWidth}px` return } // reset element overflow if (!elementStack.has(elRef.current)) return - if (!isIosWithCustom()) { - const store = elementStack.get(elRef.current) as ElementStackItem - elRef.current.style.overflow = store.last - } else { - document.removeEventListener('touchmove', touchHandler) + + const reset = (el: HTMLElement) => { + const store = elementStack.get(el) as ElementStackItem + if (!store) return + el.style.overflow = store.overflow + el.style.paddingRight = store.paddingRight + elementStack.delete(el) } - elementStack.delete(elRef.current) + + const timer = window.setTimeout(() => { + reset(elRef.current!) + window.clearTimeout(timer) + }, safeOptions.delayReset) }, [hidden, elRef]) return [hidden, setHidden] diff --git a/lib/components/layout/menu/menu.tsx b/lib/components/layout/menu/menu.tsx index 8eef44596..26e0057af 100644 --- a/lib/components/layout/menu/menu.tsx +++ b/lib/components/layout/menu/menu.tsx @@ -16,7 +16,7 @@ const Menu: React.FC = () => { const { isChinese } = useConfigs() const { tabbar: currentUrlTabValue, locale } = useLocale() const [expanded, setExpanded] = useState(false) - const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true }) + const [, setBodyHidden] = useBodyScroll(null, { delayReset: 300 }) const isMobile = useMediaQuery('xs', { match: 'down' }) const allSides = useMemo(() => Metadata[locale], [locale]) @@ -64,6 +64,27 @@ const Menu: React.FC = () => { }, [currentUrlTabValue, locale], ) + const [isLocked, setIsLocked] = useState(false) + + useEffect(() => { + const handler = () => { + const isLocked = document.body.style.overflow === 'hidden' + setIsLocked(last => (last !== isLocked ? isLocked : last)) + } + const observer = new MutationObserver(mutations => { + mutations.forEach(function (mutation) { + if (mutation.type !== 'attributes') return + handler() + }) + }) + + observer.observe(document.body, { + attributes: true, + }) + return () => { + observer.disconnect() + } + }, []) return ( <> @@ -132,8 +153,11 @@ const Menu: React.FC = () => { .menu { position: fixed; top: 0; + left: 0; + right: 0; + padding-right: ${isLocked ? 'var(--geist-page-scrollbar-width)' : 0}; height: var(--geist-page-nav-height); - width: 100%; + //width: 100%; backdrop-filter: saturate(180%) blur(5px); background-color: ${addColorAlpha(theme.palette.background, 0.8)}; box-shadow: ${theme.type === 'dark' diff --git a/package.json b/package.json index 77ffd1218..37533e67e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@geist-ui/core", - "version": "2.3.6", + "version": "2.3.7", "main": "dist/index.js", "module": "esm/index.js", "types": "esm/index.d.ts", diff --git a/pages/_app.tsx b/pages/_app.tsx index 75a8c868f..3139b55ad 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -81,9 +81,6 @@ const Application: NextPage> = ({ Component, pageProps }) => { diff --git a/pages/en-us/hooks/use-body-scroll.mdx b/pages/en-us/hooks/use-body-scroll.mdx index eb5f04759..bc91c0dee 100644 --- a/pages/en-us/hooks/use-body-scroll.mdx +++ b/pages/en-us/hooks/use-body-scroll.mdx @@ -1,5 +1,5 @@ import { Layout, Playground, Attributes } from 'lib/components' -import { Button, Spacer, Text, Link, useBodyScroll } from 'components' +import { Button, Spacer, Text, useBodyScroll } from 'components' export const meta = { title: 'useBodyScroll', @@ -10,7 +10,7 @@ export const meta = { Disable scrolling behavior for body or any element, it is useful for displaying popup element or menus. -This is custom React hooks, you need to follow the Basic Rules when you use it. +If the page's scrollbar is set to a user-defined `width` before scrolling is disabled, a `paddingRight` value will be added after disabling to adapt.