-
-
Notifications
You must be signed in to change notification settings - Fork 431
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Feature: add
useScrollLock
hook (#479)
* ✨ 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
1 parent
61a3b68
commit 649ef39
Showing
12 changed files
with
266 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'usehooks-ts': patch | ||
--- | ||
|
||
Deprecated useLockedBody replaced by useScrollLock |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"usehooks-ts": minor | ||
--- | ||
|
||
✨ Feature: add `useScrollLock` hook |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './useScrollLock' |
30 changes: 30 additions & 0 deletions
30
packages/usehooks-ts/src/useScrollLock/useScrollLock.demo.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
105
packages/usehooks-ts/src/useScrollLock/useScrollLock.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
110
packages/usehooks-ts/src/useScrollLock/useScrollLock.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters