Skip to content

Commit

Permalink
fix(use-body-scroll): add scrollbar width to the container after scro…
Browse files Browse the repository at this point in the history
…llbar disabled (#757)

* fix(use-body-scroll): add scrollbar width to the container after scrollbar disabled

* docs: add width to fixed elements after disable scrollbar

* chore: release v2.3.7
  • Loading branch information
unix authored Mar 18, 2022
1 parent 2787229 commit 2abcb36
Show file tree
Hide file tree
Showing 9 changed files with 87 additions and 87 deletions.
2 changes: 1 addition & 1 deletion components/drawer/drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const DrawerComponent: React.FC<React.PropsWithChildren<DrawerProps>> = ({
}: React.PropsWithChildren<DrawerProps> & typeof defaultProps) => {
const portal = usePortal('drawer')
const [visible, setVisible] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const [, setBodyHidden] = useBodyScroll(null, { delayReset: 300 })

const closeDrawer = () => {
onClose && onClose()
Expand Down
2 changes: 1 addition & 1 deletion components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const ModalComponent: React.FC<React.PropsWithChildren<ModalProps>> = ({
}: React.PropsWithChildren<ModalProps> & 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<boolean>(false)
const [withoutActionsChildren, ActionsChildren] = pickChild(children, ModalAction)
const hasActions = ActionsChildren && React.Children.count(ActionsChildren) > 0
Expand Down
61 changes: 16 additions & 45 deletions components/use-body-scroll/__tests__/body-scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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<HTMLDivElement>()
;(ref as any).current = document.createElement('div')
const el = ref.current as HTMLDivElement
Expand All @@ -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<HTMLDivElement>()
const div = document.createElement('div')
div.style.overflow = 'scroll'
Expand All @@ -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<HTMLDivElement>()
;(ref as any).current = document.createElement('div')
const el = ref.current as HTMLDivElement
Expand All @@ -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<HTMLDivElement>()
;(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<Touch>,
}),
)
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<HTMLDivElement>()
;(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', () => {
Expand Down
58 changes: 30 additions & 28 deletions components/use-body-scroll/use-body-scroll.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement, ElementStackItem>()

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 = (
Expand All @@ -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]
Expand Down
28 changes: 26 additions & 2 deletions lib/components/layout/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const Menu: React.FC<unknown> = () => {
const { isChinese } = useConfigs()
const { tabbar: currentUrlTabValue, locale } = useLocale()
const [expanded, setExpanded] = useState<boolean>(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])

Expand Down Expand Up @@ -64,6 +64,27 @@ const Menu: React.FC<unknown> = () => {
},
[currentUrlTabValue, locale],
)
const [isLocked, setIsLocked] = useState<boolean>(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 (
<>
Expand Down Expand Up @@ -132,8 +153,11 @@ const Menu: React.FC<unknown> = () => {
.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'
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 5 additions & 4 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,6 @@ const Application: NextPage<AppProps<{}>> = ({ Component, pageProps }) => {
</MDXProvider>
</ConfigContext>
<style global jsx>{`
html {
--geist-page-nav-height: 64px;
}
.tag {
color: ${theme.palette.accents_5};
}
Expand Down Expand Up @@ -118,13 +115,17 @@ const Application: NextPage<AppProps<{}>> = ({ Component, pageProps }) => {
color: ${theme.palette.accents_3};
}
body::-webkit-scrollbar {
width: 0;
width: var(--geist-page-scrollbar-width);
background-color: ${theme.palette.accents_1};
}
body::-webkit-scrollbar-thumb {
background-color: ${theme.palette.accents_2};
border-radius: ${theme.layout.radius};
}
:root {
--geist-page-nav-height: 64px;
--geist-page-scrollbar-width: 4px;
}
`}</style>
</GeistProvider>
</>
Expand Down
6 changes: 3 additions & 3 deletions pages/en-us/hooks/use-body-scroll.mdx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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 <Link target="_blank" color href="https://reactjs.org/docs/hooks-rules.html">Basic Rules</Link> 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.

<Playground
desc="Click button to disable scrolling."
Expand All @@ -35,7 +35,7 @@ This is custom React hooks, you need to follow the <Link target="_blank" color h

```ts
type BodyScrollOptions = {
scrollLayer: boolean // whether Ref needs to scroll
delayReset: number // resume scrolling after a delay, default is 0
}

const useBodyScroll = (
Expand Down
6 changes: 4 additions & 2 deletions pages/zh-cn/hooks/use-body-scroll.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Layout, Playground, Attributes } from 'lib/components'
import { Button, Spacer, useBodyScroll, Text, Link } from 'components'
import { Button, Spacer, useBodyScroll, Text } from 'components'

export const meta = {
title: '锁定滚动 useBodyScroll',
Expand All @@ -10,6 +10,8 @@ export const meta = {

禁用 `Body` 或其他任何元素的滚动,这在显示弹窗或菜单时非常有帮助。

如果容器的滚动条在禁止滚动之前被自定义设置了宽度,那么在在禁止滚动后会为自动当前容器添加 `paddingRight` 作为补偿。

<Playground
desc="点击按钮以锁定滚动."
scope={{ Button, Spacer, useBodyScroll, Text }}
Expand All @@ -33,7 +35,7 @@ export const meta = {

```ts
type BodyScrollOptions = {
scrollLayer: boolean // 指定元素内部是否需要滚动
delayReset: number // 延迟一段时间后恢复滚动,默认 0
}

const useBodyScroll = (
Expand Down

0 comments on commit 2abcb36

Please sign in to comment.