Skip to content

Commit

Permalink
✨ Feature: add useScrollLock hook (#479)
Browse files Browse the repository at this point in the history
* ✨ Feature: add `useScrollLock` hook

* 🔖 Added Changeset

* Improve useScrollLock (see comment)

- remove the internal state
- add JSDoc param details
- add width reflow support
- simplify a bit the useEffect
- make the demo working
- remove eslint-disable comment

* deprecated useLockedBody replaced by useScrollLock

* make width reflow optional

* use useScrollLock in the website

---------

Co-authored-by: Julien <juliencaron@protonmail.com>
  • Loading branch information
BlankParticle and juliencrn authored Feb 21, 2024
1 parent 61a3b68 commit 649ef39
Show file tree
Hide file tree
Showing 12 changed files with 266 additions and 2 deletions.
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

0 comments on commit 649ef39

Please sign in to comment.