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

✨ Feature: add useScrollLock hook #479

Merged
merged 6 commits into from
Feb 21, 2024
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
5 changes: 5 additions & 0 deletions .changeset/eight-wombats-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'usehooks-ts': patch
---

Deprecated useLockedBody replaced by useScrollLock
5 changes: 5 additions & 0 deletions .changeset/poor-spiders-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"usehooks-ts": minor
---

✨ Feature: add `useScrollLock` hook
4 changes: 2 additions & 2 deletions apps/www/src/components/mobile-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as React from 'react'

import Link from 'next/link'
import { useScrollLock } from 'usehooks-ts'

import { Logo } from '@/components/icons'
import { siteConfig } from '@/config/site'
import { useLockBody } from '@/hooks/use-lock-body'
import { cn } from '@/lib/utils'
import type { MainNavItem } from '@/types'

Expand All @@ -14,7 +14,7 @@ interface MobileNavProps {
}

export function MobileNav({ items, children }: MobileNavProps) {
useLockBody()
useScrollLock()

return (
<div
Expand Down
1 change: 1 addition & 0 deletions packages/usehooks-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export * from './useReadLocalStorage'
export * from './useResizeObserver'
export * from './useScreen'
export * from './useScript'
export * from './useScrollLock'
export * from './useSessionStorage'
export * from './useSsr'
export * from './useStep'
Expand Down
1 change: 1 addition & 0 deletions packages/usehooks-ts/src/useLockedBody/useLockedBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'

/**
* @deprecated - Use `useScrollLock` instead.
* Custom hook for locking and unlocking the body scroll to prevent scrolling.
* @param {?boolean} [initialLocked] - The initial state of body scroll lock (default to `false`).
* @param {?string} [rootId] - The ID of the root element to calculate scrollbar width (default to `___gatsby` to not introduce breaking change).
Expand Down
1 change: 1 addition & 0 deletions packages/usehooks-ts/src/useScrollLock/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useScrollLock'
30 changes: 30 additions & 0 deletions packages/usehooks-ts/src/useScrollLock/useScrollLock.demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useScrollLock } from './useScrollLock'

// Example 1: Auto lock the scroll of the body element when the modal mounts
export default function Modal() {
useScrollLock()
return <div>Modal</div>
}

// Example 2: Manually lock and unlock the scroll for a specific target
export function App() {
const { lock, unlock } = useScrollLock({
autoLock: false,
lockTarget: '#scrollable',
})

return (
<>
<div id="scrollable" style={{ maxHeight: '50vh', overflowY: 'scroll' }}>
{['red', 'blue', 'green'].map(color => (
<div key={color} style={{ backgroundColor: color, height: '30vh' }} />
))}
</div>

<div style={{ gap: 16, display: 'flex' }}>
<button onClick={lock}>Lock</button>
<button onClick={unlock}>Unlock</button>
</div>
</>
)
}
4 changes: 4 additions & 0 deletions packages/usehooks-ts/src/useScrollLock/useScrollLock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
A custom hook for locking and unlocking scroll.

It can be used when you need to automatically lock the scroll, like for a modal or a sidebar.
You can also use it to manually lock and unlock the scroll by disabling the `autoLock` feature.
105 changes: 105 additions & 0 deletions packages/usehooks-ts/src/useScrollLock/useScrollLock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { act, renderHook } from '@testing-library/react'

import { useScrollLock } from './useScrollLock'

describe('useScrollLock()', () => {
beforeEach(() => {
document.body.style.removeProperty('overflow')
})

it('should initially lock and unlock body', () => {
const { unmount } = renderHook(() => useScrollLock())

expect(document.body.style.overflowY).toBe('hidden')
unmount()
expect(document.body.style.overflowY).toBe('')
})

it('should initially lock and unlock the target element', () => {
const target = document.createElement('div')

document.body.appendChild(target)

const { unmount } = renderHook(() => useScrollLock({ lockTarget: target }))

expect(target.style.overflowY).toBe('hidden')
unmount()
expect(target.style.overflowY).toBe('')
})

it('should initially lock and unlock the target element by selector', () => {
const target = document.createElement('div')

target.id = 'target'
document.body.appendChild(target)

const { unmount } = renderHook(() =>
useScrollLock({ lockTarget: '#target' }),
)

expect(target.style.overflowY).toBe('hidden')
unmount()
expect(target.style.overflowY).toBe('')
})

it('should not initially lock and unlock', () => {
const { unmount } = renderHook(() => useScrollLock({ autoLock: false }))

expect(document.body.style.overflowY).toBe('')
unmount()
expect(document.body.style.overflowY).toBe('')
})

it('should lock and unlock manually', () => {
const { result } = renderHook(() => useScrollLock({ autoLock: false }))

expect(document.body.style.overflowY).toBe('')
act(() => {
result.current.lock()
})
expect(document.body.style.overflowY).toBe('hidden')
act(() => {
result.current.unlock()
})
expect(document.body.style.overflowY).toBe('')
})

it("should keep the original style of the target element when it's unlocked", () => {
const target = document.createElement('div')

target.style.overflowY = 'auto'
document.body.appendChild(target)

const { result } = renderHook(() => useScrollLock({ lockTarget: target }))

expect(target.style.overflowY).toBe('hidden')
act(() => {
result.current.unlock()
})
expect(target.style.overflowY).toBe('auto')
})

it('should unlock on unmount even with initial is locked', () => {
const { unmount, result } = renderHook(() =>
useScrollLock({ autoLock: false }),
)

expect(document.body.style.overflowY).toBe('')
act(() => {
result.current.lock()
})
expect(document.body.style.overflowY).toBe('hidden')
unmount()
expect(document.body.style.overflowY).toBe('')
})

it('should fallback to document.body if the target element is not found', () => {
const { unmount } = renderHook(() =>
useScrollLock({ lockTarget: '#non-existing' }),
)

expect(document.body.style.overflowY).toBe('hidden')
unmount()
expect(document.body.style.overflowY).toBe('')
})
})
110 changes: 110 additions & 0 deletions packages/usehooks-ts/src/useScrollLock/useScrollLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useLayoutEffect, useRef } from 'react'

interface UseScrollLockOptions {
autoLock: boolean
lockTarget: HTMLElement | string
widthReflow: boolean
}

interface UseScrollLockResult {
lock: () => void
unlock: () => void
}

type OriginalStyle = {
overflowY: CSSStyleDeclaration['overflowY']
paddingRight: CSSStyleDeclaration['paddingRight']
}

const IS_SERVER = typeof window === 'undefined'

/**
* A custom hook for auto/manual locking and unlocking scroll.
* @param {UseScrollLockOptions} [options] - Options to configure the hook, by default it will lock the scroll automatically.
* @param {boolean} [options.autoLock] - Whether to lock the scroll initially, by default it's true.
* @param {HTMLElement | string} [options.lockTarget] - The target element to lock the scroll, by default it's the body element.
* @param {boolean} [options.widthReflow] - Whether to prevent width reflow when locking the scroll, by default it's true.
* @returns {UseScrollLockResult} - The result object containing the lock and unlock functions.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-scroll-lock)
* @example
* export default function Modal() {
* // Lock the scroll when the modal is mounted, and unlock it when it's unmounted
* useScrollLock()
* // ...
* }
*
* @example
* export default function App() {
* // Manually lock and unlock the scroll
* const { lock, unlock } = useScrollLock({ autoLock: false })
*
* return (
* <div>
* <button onClick={lock}>Lock</button>
* <button onClick={unlock}>Unlock</button>
* <p>is Body Locked: {isLocked ? 'Yes' : 'No'}</p>
* </div>
* )
* }
*/
export function useScrollLock(
options: Partial<UseScrollLockOptions> = {},
): UseScrollLockResult {
const { autoLock = true, lockTarget, widthReflow = true } = options
const target = useRef<HTMLElement | null>(null)
const originalStyle = useRef<OriginalStyle | null>(null)

const lock = () => {
if (target.current) {
const { overflowY, paddingRight } = window.getComputedStyle(
target.current,
)

// Save the original styles
originalStyle.current = { overflowY, paddingRight }

// Lock the scroll
target.current.style.overflowY = 'hidden'

// prevent width reflow
if (widthReflow) {
const scrollbarWidth =
target.current.offsetWidth - target.current.scrollWidth
target.current.style.paddingRight = `${scrollbarWidth}px`
}
}
}

const unlock = () => {
if (target.current && originalStyle.current) {
target.current.style.overflowY = originalStyle.current.overflowY
target.current.style.paddingRight = originalStyle.current.paddingRight
}
}

useLayoutEffect(() => {
if (IS_SERVER) return

if (lockTarget) {
target.current =
typeof lockTarget === 'string'
? document.querySelector(lockTarget)
: lockTarget
}

if (!target.current) {
target.current = document.body
}

if (autoLock) {
lock()
}

return () => {
unlock()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoLock, lockTarget, widthReflow])

return { lock, unlock }
}
1 change: 1 addition & 0 deletions scripts/updateReadme.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const excludeHooks = [
'useUpdateEffect', // @deprecated
'useEffectOnce', // @deprecated
'useIsFirstRender', // @deprecated
'useLockedBody', // @deprecated
]

const markdown = fs
Expand Down
1 change: 1 addition & 0 deletions scripts/updateTestingIssue.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const excludeHooks = [
'useUpdateEffect', // @deprecated
'useEffectOnce', // @deprecated
'useIsFirstRender', // @deprecated
'useLockedBody', // @deprecated
'useIsomorphicLayoutEffect', // Combination of useLayoutEffect and useEffect without custom logic
]

Expand Down