From 2307ee9ea48ed7fc7fcdf0d2477d35edc6bc4a37 Mon Sep 17 00:00:00 2001 From: lukKowalski Date: Thu, 12 Feb 2026 22:33:22 +0100 Subject: [PATCH] feat: Added new hook - useDocumentVisibility --- docs/useDocumentVisibility.md | 29 ++++++++ src/index.ts | 1 + src/useDocumentVisibility.ts | 19 +++++ stories/useDocumentVisibility.story.tsx | 22 ++++++ tests/useDocumentVisibility.test.ts | 92 +++++++++++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 docs/useDocumentVisibility.md create mode 100644 src/useDocumentVisibility.ts create mode 100644 stories/useDocumentVisibility.story.tsx create mode 100644 tests/useDocumentVisibility.test.ts diff --git a/docs/useDocumentVisibility.md b/docs/useDocumentVisibility.md new file mode 100644 index 0000000000..4ffb1a16bc --- /dev/null +++ b/docs/useDocumentVisibility.md @@ -0,0 +1,29 @@ +# `useDocumentVisibility` + +React sensor hook that tracks document visibility state using the [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API). + +## Usage + +```jsx +import {useDocumentVisibility} from 'react-use'; + +const Demo = () => { + const defaultState = document.visibilityState === 'visible'; + const isVisible = useDocumentVisibility(defaultState); + + return ( +
+ Document is {isVisible ? 'visible' : 'hidden'} +
+ ); +}; +``` + +## Reference + +```js +const isVisible = useDocumentVisibility(initialState); +``` + +- `initialState` — `boolean`, optional initial state before the actual visibility is determined, defaults to `false`. +- `isVisible` — `boolean`, whether the document is currently visible (tab is in foreground). diff --git a/src/index.ts b/src/index.ts index 62b69356b7..42981a6394 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export { default as useCustomCompareEffect } from './useCustomCompareEffect'; export { default as useDebounce } from './useDebounce'; export { default as useDeepCompareEffect } from './useDeepCompareEffect'; export { default as useDefault } from './useDefault'; +export { default as useDocumentVisibility } from './useDocumentVisibility'; export { default as useDrop } from './useDrop'; export { default as useDropArea } from './useDropArea'; export { default as useEffectOnce } from './useEffectOnce'; diff --git a/src/useDocumentVisibility.ts b/src/useDocumentVisibility.ts new file mode 100644 index 0000000000..1f7b7f1ae8 --- /dev/null +++ b/src/useDocumentVisibility.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +const useDocumentVisibility = (defaultState: boolean = false) => { + const [isVisible, setIsVisible] = useState(defaultState); + + useEffect(() => { + const handleVisibilityChange = () => setIsVisible(document.visibilityState === 'visible'); + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + return isVisible; +}; + +export default useDocumentVisibility; diff --git a/stories/useDocumentVisibility.story.tsx b/stories/useDocumentVisibility.story.tsx new file mode 100644 index 0000000000..c97396c565 --- /dev/null +++ b/stories/useDocumentVisibility.story.tsx @@ -0,0 +1,22 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import useDocumentVisibility from '../src/useDocumentVisibility'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const defaultState = document.visibilityState === 'visible'; + const isVisible = useDocumentVisibility(defaultState); + + return ( +
+

Switch to another browser tab to see the visibility state change.

+
+ Document is {isVisible ? '👁️ Visible' : '🙈 Hidden'} +
+
+ ); +}; + +storiesOf('Sensors/useDocumentVisibility', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/tests/useDocumentVisibility.test.ts b/tests/useDocumentVisibility.test.ts new file mode 100644 index 0000000000..5ddc0ed09d --- /dev/null +++ b/tests/useDocumentVisibility.test.ts @@ -0,0 +1,92 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useDocumentVisibility from '../src/useDocumentVisibility'; + +describe('useDocumentVisibility', () => { + const originalVisibilityState = document.visibilityState; + + afterEach(() => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: originalVisibilityState, + }); + }); + + it('should be defined', () => { + expect(useDocumentVisibility).toBeDefined(); + }); + + it('should return false initially', () => { + const { result } = renderHook(() => useDocumentVisibility()); + + expect(result.current).toBe(false); + }); + + it('should return true initially when initialState is true', () => { + const { result } = renderHook(() => useDocumentVisibility(true)); + + expect(result.current).toBe(true); + }); + + it('should return false initially when initialState is false', () => { + const { result } = renderHook(() => useDocumentVisibility(false)); + + expect(result.current).toBe(false); + }); + + it('should return true when document becomes visible', () => { + const { result } = renderHook(() => useDocumentVisibility(true)); + + act(() => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + expect(result.current).toBe(true); + }); + + it('should return false when document becomes hidden', () => { + const { result } = renderHook(() => useDocumentVisibility()); + + act(() => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBe(true); + + act(() => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBe(false); + }); + + it('should add event listener on mount', () => { + const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); + + renderHook(() => useDocumentVisibility()); + + expect(addEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + + addEventListenerSpy.mockRestore(); + }); + + it('should remove event listener on unmount', () => { + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + + const { unmount } = renderHook(() => useDocumentVisibility()); + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + + removeEventListenerSpy.mockRestore(); + }); +});