Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 3 commits into from
Mar 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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