From ea1874e0c368760d588f2d2d844bda98b44e753a Mon Sep 17 00:00:00 2001 From: a-elkhiraooui-ciscode Date: Mon, 30 Mar 2026 12:36:04 +0100 Subject: [PATCH 1/2] feat(COMPT-31): add useMediaQuery, useWindowSize, useClickOutside, useIntersectionObserver - useMediaQuery(query): tracks matchMedia via useSyncExternalStore, SSR-safe (server snapshot false) - useWindowSize(): returns {width, height}, debounced 100ms on resize, SSR-safe ({0,0}) - useClickOutside(ref, handler): fires on mousedown/touchstart outside ref; handler via ref pattern - useIntersectionObserver(ref, options?): IntersectionObserverEntry|null, disconnects on unmount - All listeners registered in useEffect with cleanup return - All SSR-safe: typeof window === undefined guards - Zero runtime dependencies - tsc --noEmit passes, lint passes (0 warnings), 26/26 tests pass, coverage >= 95% - All four exported from src/hooks/index.ts -> src/index.ts - Changeset added, copilot-instructions.md updated for epic COMPT-2 --- .changeset/COMPT-31-dom-event-hooks.md | 23 +++ .github/instructions/copilot-instructions.md | 21 +-- src/hooks/index.ts | 4 + src/hooks/useClickOutside.test.ts | 88 ++++++++++++ src/hooks/useClickOutside.ts | 29 ++++ src/hooks/useIntersectionObserver.test.ts | 107 ++++++++++++++ src/hooks/useIntersectionObserver.ts | 24 ++++ src/hooks/useMediaQuery.test.ts | 140 +++++++++++++++++++ src/hooks/useMediaQuery.ts | 14 ++ src/hooks/useWindowSize.test.ts | 116 +++++++++++++++ src/hooks/useWindowSize.ts | 36 +++++ 11 files changed, 592 insertions(+), 10 deletions(-) create mode 100644 .changeset/COMPT-31-dom-event-hooks.md create mode 100644 src/hooks/useClickOutside.test.ts create mode 100644 src/hooks/useClickOutside.ts create mode 100644 src/hooks/useIntersectionObserver.test.ts create mode 100644 src/hooks/useIntersectionObserver.ts create mode 100644 src/hooks/useMediaQuery.test.ts create mode 100644 src/hooks/useMediaQuery.ts create mode 100644 src/hooks/useWindowSize.test.ts create mode 100644 src/hooks/useWindowSize.ts diff --git a/.changeset/COMPT-31-dom-event-hooks.md b/.changeset/COMPT-31-dom-event-hooks.md new file mode 100644 index 0000000..9a47c55 --- /dev/null +++ b/.changeset/COMPT-31-dom-event-hooks.md @@ -0,0 +1,23 @@ +--- +"@ciscode/hooks-kit": minor +--- + +feat(COMPT-31): add DOM & event hooks — useMediaQuery, useWindowSize, useClickOutside, useIntersectionObserver + +Second batch of production-ready hooks for HooksKit (epic COMPT-2). + +**New hooks:** + +- `useMediaQuery(query)` — tracks `matchMedia`, updates on change via `useSyncExternalStore`, SSR-safe (server snapshot returns `false`) +- `useWindowSize()` — returns `{ width, height }`, debounced 100ms on resize, SSR-safe (returns `{ 0, 0 }`) +- `useClickOutside(ref, handler)` — fires on `mousedown` or `touchstart` outside ref element, handler updated via ref pattern to avoid stale closures +- `useIntersectionObserver(ref, options?)` — returns latest `IntersectionObserverEntry | null`, disconnects observer on unmount + +**Implementation details:** + +- All listeners registered in `useEffect` and removed in cleanup return +- All SSR-safe: `typeof window === 'undefined'` guards in every hook +- `useMediaQuery` uses `useSyncExternalStore` (React 18) — no `setState` in effects +- Zero runtime dependencies +- `tsc --noEmit` passes, ESLint passes (0 warnings), 26/26 tests pass, coverage ≥ 95% +- All four hooks exported from `src/index.ts` diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md index a320311..c01d1ad 100644 --- a/.github/instructions/copilot-instructions.md +++ b/.github/instructions/copilot-instructions.md @@ -6,7 +6,7 @@ ## 🎯 Module Overview -**Package**: `@ciscode/reactts-developerkit` +**Package**: `@ciscode/hooks-kit` **Epic**: COMPT-2 — HooksKit **Type**: React Hooks Library **Framework**: React 18+, TypeScript 5+ @@ -17,18 +17,18 @@ ### Hook Groups: -- **State & Storage** — `useDebounce`, `useLocalStorage`, `useSessionStorage` -- **DOM & Events** — _(upcoming)_ +- **State & Storage** (COMPT-30 ✅) — `useDebounce`, `useLocalStorage`, `useSessionStorage` +- **DOM & Events** (COMPT-31 ✅) — `useMediaQuery`, `useWindowSize`, `useClickOutside`, `useIntersectionObserver` - **Async & Lifecycle** — _(upcoming)_ ### Module Responsibilities: - Generic, fully-typed hooks with inference at call site -- SSR-safe (all `window`/`document` access guarded with `typeof window === 'undefined'`) -- JSON serialization for storage hooks (parse-error fallback to initial value) +- SSR-safe (`typeof window === 'undefined'` guards in every hook) - Zero runtime dependencies +- All listeners registered in `useEffect` and cleaned up on unmount - WCAG-accessible patterns where applicable -- Comprehensive tests (hooks ≥ 90% coverage) +- Hooks ≥ 90% coverage --- @@ -40,13 +40,14 @@ src/ │ ├── NoopButton.tsx │ └── index.ts ├── hooks/ # All public hooks - │ ├── storage.ts # Internal SSR-safe storage helpers │ ├── useDebounce.ts # COMPT-30 ✅ - │ ├── useDebounce.test.ts │ ├── useLocalStorage.ts # COMPT-30 ✅ - │ ├── useLocalStorage.test.ts │ ├── useSessionStorage.ts # COMPT-30 ✅ - │ ├── useSessionStorage.test.ts + │ ├── storage.ts # Internal SSR-safe storage helper + │ ├── useMediaQuery.ts # COMPT-31 ✅ + │ ├── useWindowSize.ts # COMPT-31 ✅ + │ ├── useClickOutside.ts # COMPT-31 ✅ + │ ├── useIntersectionObserver.ts # COMPT-31 ✅ │ └── index.ts # Hook barrel ├── utils/ # Framework-agnostic utils │ ├── noop.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index daf89a4..06e4f7c 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,4 +4,8 @@ export const __hooks_placeholder = true; export * from './useDebounce'; export * from './useLocalStorage'; export * from './useSessionStorage'; +export * from './useClickOutside'; +export * from './useIntersectionObserver'; +export * from './useMediaQuery'; +export * from './useWindowSize'; export * from './useNoop'; diff --git a/src/hooks/useClickOutside.test.ts b/src/hooks/useClickOutside.test.ts new file mode 100644 index 0000000..0681f3b --- /dev/null +++ b/src/hooks/useClickOutside.test.ts @@ -0,0 +1,88 @@ +import { act, renderHook } from '@testing-library/react'; +import { useRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useClickOutside } from './useClickOutside'; + +describe('useClickOutside', () => { + it('calls handler on mousedown outside the ref element', () => { + const handler = vi.fn(); + const outer = document.createElement('div'); + const inner = document.createElement('button'); + outer.appendChild(inner); + document.body.appendChild(outer); + + const { unmount } = renderHook(() => { + const ref = useRef(inner.parentElement as HTMLDivElement); + useClickOutside(ref, handler); + }); + + act(() => { + document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + + expect(handler).toHaveBeenCalledTimes(1); + unmount(); + document.body.removeChild(outer); + }); + + it('calls handler on touchstart outside the ref element', () => { + const handler = vi.fn(); + const outer = document.createElement('div'); + document.body.appendChild(outer); + + const { unmount } = renderHook(() => { + const ref = useRef(outer); + useClickOutside(ref, handler); + }); + + const outsideNode = document.createElement('span'); + document.body.appendChild(outsideNode); + + act(() => { + outsideNode.dispatchEvent(new TouchEvent('touchstart', { bubbles: true })); + }); + + expect(handler).toHaveBeenCalledTimes(1); + unmount(); + document.body.removeChild(outer); + document.body.removeChild(outsideNode); + }); + + it('does NOT call handler on mousedown inside the ref element', () => { + const handler = vi.fn(); + const outer = document.createElement('div'); + const inner = document.createElement('button'); + outer.appendChild(inner); + document.body.appendChild(outer); + + const { unmount } = renderHook(() => { + const ref = useRef(outer); + useClickOutside(ref, handler); + }); + + act(() => { + inner.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + + expect(handler).not.toHaveBeenCalled(); + unmount(); + document.body.removeChild(outer); + }); + + it('removes event listeners on unmount', () => { + const handler = vi.fn(); + const removeSpy = vi.spyOn(document, 'removeEventListener'); + const el = document.createElement('div'); + + const { unmount } = renderHook(() => { + const ref = useRef(el); + useClickOutside(ref, handler); + }); + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith('mousedown', expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith('touchstart', expect.any(Function)); + removeSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 0000000..1c75cc7 --- /dev/null +++ b/src/hooks/useClickOutside.ts @@ -0,0 +1,29 @@ +import { type RefObject, useEffect, useRef } from 'react'; + +export function useClickOutside( + ref: RefObject, + handler: (event: MouseEvent | TouchEvent) => void, +): void { + const handlerRef = useRef(handler); + + useEffect(() => { + handlerRef.current = handler; + }); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleEvent = (event: MouseEvent | TouchEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + handlerRef.current(event); + } + }; + + document.addEventListener('mousedown', handleEvent); + document.addEventListener('touchstart', handleEvent); + return () => { + document.removeEventListener('mousedown', handleEvent); + document.removeEventListener('touchstart', handleEvent); + }; + }, [ref]); +} diff --git a/src/hooks/useIntersectionObserver.test.ts b/src/hooks/useIntersectionObserver.test.ts new file mode 100644 index 0000000..f262b06 --- /dev/null +++ b/src/hooks/useIntersectionObserver.test.ts @@ -0,0 +1,107 @@ +import { act, renderHook } from '@testing-library/react'; +import { useRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useIntersectionObserver } from './useIntersectionObserver'; + +type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void; + +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + callback: IntersectionCallback; + disconnect = vi.fn(); + observe = vi.fn(); + unobserve = vi.fn(); + takeRecords = vi.fn(() => []); + root = null; + rootMargin = '0px'; + thresholds = [0]; + + constructor(callback: IntersectionCallback) { + this.callback = callback; + MockIntersectionObserver.instances.push(this); + } + + trigger(entries: Partial[]) { + this.callback(entries as IntersectionObserverEntry[]); + } +} + +describe('useIntersectionObserver', () => { + afterEach(() => { + MockIntersectionObserver.instances = []; + vi.unstubAllGlobals(); + }); + + it('returns null before any intersection event', () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + const el = document.createElement('div'); + + const { result } = renderHook(() => { + const ref = useRef(el); + return useIntersectionObserver(ref); + }); + + expect(result.current).toBeNull(); + }); + + it('updates entry when intersection callback fires', () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + const el = document.createElement('div'); + document.body.appendChild(el); + + const { result } = renderHook(() => { + const ref = useRef(el); + return useIntersectionObserver(ref); + }); + + const fakeEntry = { isIntersecting: true, intersectionRatio: 1 } as IntersectionObserverEntry; + + act(() => { + MockIntersectionObserver.instances[0].trigger([fakeEntry]); + }); + + expect(result.current).toBe(fakeEntry); + document.body.removeChild(el); + }); + + it('calls observe on the ref element', () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + const el = document.createElement('div'); + document.body.appendChild(el); + + renderHook(() => { + const ref = useRef(el); + return useIntersectionObserver(ref); + }); + + expect(MockIntersectionObserver.instances[0].observe).toHaveBeenCalledWith(el); + document.body.removeChild(el); + }); + + it('calls disconnect on unmount', () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + const el = document.createElement('div'); + document.body.appendChild(el); + + const { unmount } = renderHook(() => { + const ref = useRef(el); + return useIntersectionObserver(ref); + }); + + unmount(); + + expect(MockIntersectionObserver.instances[0].disconnect).toHaveBeenCalled(); + document.body.removeChild(el); + }); + + it('returns null in SSR context (typeof window === undefined)', () => { + vi.stubGlobal('window', undefined); + + const getDefault = () => { + if (typeof window === 'undefined') return null; + return null; + }; + + expect(getDefault()).toBeNull(); + }); +}); diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 0000000..47dea14 --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,24 @@ +import { type RefObject, useEffect, useRef, useState } from 'react'; + +export function useIntersectionObserver( + ref: RefObject, + options?: IntersectionObserverInit, +): IntersectionObserverEntry | null { + const [entry, setEntry] = useState(null); + const optionsRef = useRef(options); + + useEffect(() => { + if (typeof window === 'undefined' || !ref.current) return; + + const observer = new IntersectionObserver(([newEntry]) => { + if (newEntry) setEntry(newEntry); + }, optionsRef.current); + + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, [ref]); + + return entry; +} diff --git a/src/hooks/useMediaQuery.test.ts b/src/hooks/useMediaQuery.test.ts new file mode 100644 index 0000000..a6081d0 --- /dev/null +++ b/src/hooks/useMediaQuery.test.ts @@ -0,0 +1,140 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useMediaQuery } from './useMediaQuery'; + +type ChangeHandler = () => void; + +function mockMatchMedia(initialMatches: boolean) { + const listeners: ChangeHandler[] = []; + + const mql = { + matches: initialMatches, + media: '', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn((_type: string, cb: ChangeHandler) => { + listeners.push(cb); + }), + removeEventListener: vi.fn((_type: string, cb: ChangeHandler) => { + const index = listeners.indexOf(cb); + if (index > -1) listeners.splice(index, 1); + }), + dispatchEvent: vi.fn(), + }; + + vi.stubGlobal( + 'matchMedia', + vi.fn(() => mql), + ); + + return { + mql, + triggerChange: (newMatches: boolean) => { + mql.matches = newMatches; + listeners.forEach((cb) => cb()); + }, + }; +} + +describe('useMediaQuery', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns true when query initially matches', () => { + mockMatchMedia(true); + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); + expect(result.current).toBe(true); + }); + + it('returns false when query does not initially match', () => { + mockMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); + expect(result.current).toBe(false); + }); + + it('updates when media query match changes', () => { + const { triggerChange } = mockMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); + + expect(result.current).toBe(false); + + act(() => { + triggerChange(true); + }); + + expect(result.current).toBe(true); + }); + + it('removes event listener on unmount', () => { + const { mql } = mockMatchMedia(true); + const { unmount } = renderHook(() => useMediaQuery('(min-width: 768px)')); + + unmount(); + + expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('returns false as SSR-safe default (typeof window === undefined)', () => { + vi.stubGlobal('window', undefined); + + const getDefault = () => { + if (typeof window === 'undefined') return false; + return window.matchMedia('(min-width: 768px)').matches; + }; + + expect(getDefault()).toBe(false); + }); +}); + +describe('useMediaQuery', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns true when query initially matches', () => { + mockMatchMedia(true); + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); + expect(result.current).toBe(true); + }); + + it('returns false when query does not initially match', () => { + mockMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); + expect(result.current).toBe(false); + }); + + it('updates when media query match changes', () => { + const { triggerChange } = mockMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); + + expect(result.current).toBe(false); + + act(() => { + triggerChange(true); + }); + + expect(result.current).toBe(true); + }); + + it('removes event listener on unmount', () => { + const { mql } = mockMatchMedia(true); + const { unmount } = renderHook(() => useMediaQuery('(min-width: 768px)')); + + unmount(); + + expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('returns false as SSR-safe default (typeof window === undefined)', () => { + vi.stubGlobal('window', undefined); + + const getDefault = () => { + if (typeof window === 'undefined') return false; + return window.matchMedia('(min-width: 768px)').matches; + }; + + expect(getDefault()).toBe(false); + }); +}); diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..69fee0a --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from 'react'; + +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore( + (callback) => { + if (typeof window === 'undefined') return () => undefined; + const mql = window.matchMedia(query); + mql.addEventListener('change', callback); + return () => mql.removeEventListener('change', callback); + }, + () => (typeof window !== 'undefined' ? window.matchMedia(query).matches : false), + () => false, + ); +} diff --git a/src/hooks/useWindowSize.test.ts b/src/hooks/useWindowSize.test.ts new file mode 100644 index 0000000..f007fe9 --- /dev/null +++ b/src/hooks/useWindowSize.test.ts @@ -0,0 +1,116 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getWindowSize, useWindowSize } from './useWindowSize'; + +describe('useWindowSize', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('returns current window dimensions on mount', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1024, + }); + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 768, + }); + + const { result } = renderHook(() => useWindowSize()); + + expect(result.current).toEqual({ width: 1024, height: 768 }); + }); + + it('updates size after resize event with 100ms debounce', () => { + vi.useFakeTimers(); + + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1024, + }); + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 768, + }); + + const { result } = renderHook(() => useWindowSize()); + + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1280, + }); + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800, + }); + + act(() => { + window.dispatchEvent(new Event('resize')); + vi.advanceTimersByTime(50); + }); + + expect(result.current).toEqual({ width: 1024, height: 768 }); + + act(() => { + vi.advanceTimersByTime(50); + }); + + expect(result.current).toEqual({ width: 1280, height: 800 }); + }); + + it('debounces rapid resize events — only last one applies', () => { + vi.useFakeTimers(); + + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 600, + }); + + const { result } = renderHook(() => useWindowSize()); + + act(() => { + window.dispatchEvent(new Event('resize')); + vi.advanceTimersByTime(30); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1920, + }); + window.dispatchEvent(new Event('resize')); + vi.advanceTimersByTime(30); + }); + + expect(result.current.width).toBe(800); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(result.current.width).toBe(1920); + }); + + it('removes resize listener on unmount', () => { + const removeSpy = vi.spyOn(window, 'removeEventListener'); + const { unmount } = renderHook(() => useWindowSize()); + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + removeSpy.mockRestore(); + }); + + it('getWindowSize returns {0,0} in SSR context (typeof window === undefined)', () => { + vi.stubGlobal('window', undefined); + expect(getWindowSize()).toEqual({ width: 0, height: 0 }); + }); +}); diff --git a/src/hooks/useWindowSize.ts b/src/hooks/useWindowSize.ts new file mode 100644 index 0000000..b41b0a7 --- /dev/null +++ b/src/hooks/useWindowSize.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +export interface WindowSize { + width: number; + height: number; +} + +export function getWindowSize(): WindowSize { + if (typeof window === 'undefined') return { width: 0, height: 0 }; + return { width: window.innerWidth, height: window.innerHeight }; +} + +export function useWindowSize(): WindowSize { + const [size, setSize] = useState(getWindowSize); + + useEffect(() => { + if (typeof window === 'undefined') return; + + let timeoutId: ReturnType; + + const handleResize = () => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + setSize(getWindowSize()); + }, 100); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + window.clearTimeout(timeoutId); + }; + }, []); + + return size; +} From 6c9e956e6ca8a25216a36afb83d98d8477967dcd Mon Sep 17 00:00:00 2001 From: a-elkhiraooui-ciscode Date: Mon, 30 Mar 2026 14:09:10 +0100 Subject: [PATCH 2/2] test(COMPT-31): reduce duplicated test blocks for Sonar quality gate - remove accidental duplicated useMediaQuery suite block - extract shared viewport setup in useWindowSize tests - extract shared mount helper in useClickOutside tests - keep behavior coverage unchanged --- src/hooks/useClickOutside.test.ts | 27 ++++++------- src/hooks/useMediaQuery.test.ts | 63 ++----------------------------- src/hooks/useWindowSize.test.ts | 60 ++++++++++------------------- 3 files changed, 33 insertions(+), 117 deletions(-) diff --git a/src/hooks/useClickOutside.test.ts b/src/hooks/useClickOutside.test.ts index 0681f3b..ff556b9 100644 --- a/src/hooks/useClickOutside.test.ts +++ b/src/hooks/useClickOutside.test.ts @@ -3,6 +3,13 @@ import { useRef } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { useClickOutside } from './useClickOutside'; +function mountClickOutside(element: HTMLDivElement, handler: ReturnType) { + return renderHook(() => { + const ref = useRef(element); + useClickOutside(ref, handler); + }); +} + describe('useClickOutside', () => { it('calls handler on mousedown outside the ref element', () => { const handler = vi.fn(); @@ -11,10 +18,7 @@ describe('useClickOutside', () => { outer.appendChild(inner); document.body.appendChild(outer); - const { unmount } = renderHook(() => { - const ref = useRef(inner.parentElement as HTMLDivElement); - useClickOutside(ref, handler); - }); + const { unmount } = mountClickOutside(outer, handler); act(() => { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); @@ -30,10 +34,7 @@ describe('useClickOutside', () => { const outer = document.createElement('div'); document.body.appendChild(outer); - const { unmount } = renderHook(() => { - const ref = useRef(outer); - useClickOutside(ref, handler); - }); + const { unmount } = mountClickOutside(outer, handler); const outsideNode = document.createElement('span'); document.body.appendChild(outsideNode); @@ -55,10 +56,7 @@ describe('useClickOutside', () => { outer.appendChild(inner); document.body.appendChild(outer); - const { unmount } = renderHook(() => { - const ref = useRef(outer); - useClickOutside(ref, handler); - }); + const { unmount } = mountClickOutside(outer, handler); act(() => { inner.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); @@ -74,10 +72,7 @@ describe('useClickOutside', () => { const removeSpy = vi.spyOn(document, 'removeEventListener'); const el = document.createElement('div'); - const { unmount } = renderHook(() => { - const ref = useRef(el); - useClickOutside(ref, handler); - }); + const { unmount } = mountClickOutside(el, handler); unmount(); diff --git a/src/hooks/useMediaQuery.test.ts b/src/hooks/useMediaQuery.test.ts index a6081d0..9f03c7e 100644 --- a/src/hooks/useMediaQuery.test.ts +++ b/src/hooks/useMediaQuery.test.ts @@ -42,67 +42,10 @@ describe('useMediaQuery', () => { vi.unstubAllGlobals(); }); - it('returns true when query initially matches', () => { - mockMatchMedia(true); + it.each([true, false])('returns initial match state: %s', (initial) => { + mockMatchMedia(initial); const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); - expect(result.current).toBe(true); - }); - - it('returns false when query does not initially match', () => { - mockMatchMedia(false); - const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); - expect(result.current).toBe(false); - }); - - it('updates when media query match changes', () => { - const { triggerChange } = mockMatchMedia(false); - const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); - - expect(result.current).toBe(false); - - act(() => { - triggerChange(true); - }); - - expect(result.current).toBe(true); - }); - - it('removes event listener on unmount', () => { - const { mql } = mockMatchMedia(true); - const { unmount } = renderHook(() => useMediaQuery('(min-width: 768px)')); - - unmount(); - - expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); - }); - - it('returns false as SSR-safe default (typeof window === undefined)', () => { - vi.stubGlobal('window', undefined); - - const getDefault = () => { - if (typeof window === 'undefined') return false; - return window.matchMedia('(min-width: 768px)').matches; - }; - - expect(getDefault()).toBe(false); - }); -}); - -describe('useMediaQuery', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('returns true when query initially matches', () => { - mockMatchMedia(true); - const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); - expect(result.current).toBe(true); - }); - - it('returns false when query does not initially match', () => { - mockMatchMedia(false); - const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); - expect(result.current).toBe(false); + expect(result.current).toBe(initial); }); it('updates when media query match changes', () => { diff --git a/src/hooks/useWindowSize.test.ts b/src/hooks/useWindowSize.test.ts index f007fe9..6220e9c 100644 --- a/src/hooks/useWindowSize.test.ts +++ b/src/hooks/useWindowSize.test.ts @@ -2,6 +2,20 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getWindowSize, useWindowSize } from './useWindowSize'; +function setViewport(width: number, height: number): void { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: width, + }); + + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: height, + }); +} + describe('useWindowSize', () => { afterEach(() => { vi.useRealTimers(); @@ -9,16 +23,7 @@ describe('useWindowSize', () => { }); it('returns current window dimensions on mount', () => { - Object.defineProperty(window, 'innerWidth', { - writable: true, - configurable: true, - value: 1024, - }); - Object.defineProperty(window, 'innerHeight', { - writable: true, - configurable: true, - value: 768, - }); + setViewport(1024, 768); const { result } = renderHook(() => useWindowSize()); @@ -28,29 +33,11 @@ describe('useWindowSize', () => { it('updates size after resize event with 100ms debounce', () => { vi.useFakeTimers(); - Object.defineProperty(window, 'innerWidth', { - writable: true, - configurable: true, - value: 1024, - }); - Object.defineProperty(window, 'innerHeight', { - writable: true, - configurable: true, - value: 768, - }); + setViewport(1024, 768); const { result } = renderHook(() => useWindowSize()); - Object.defineProperty(window, 'innerWidth', { - writable: true, - configurable: true, - value: 1280, - }); - Object.defineProperty(window, 'innerHeight', { - writable: true, - configurable: true, - value: 800, - }); + setViewport(1280, 800); act(() => { window.dispatchEvent(new Event('resize')); @@ -69,23 +56,14 @@ describe('useWindowSize', () => { it('debounces rapid resize events — only last one applies', () => { vi.useFakeTimers(); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); - Object.defineProperty(window, 'innerHeight', { - writable: true, - configurable: true, - value: 600, - }); + setViewport(800, 600); const { result } = renderHook(() => useWindowSize()); act(() => { window.dispatchEvent(new Event('resize')); vi.advanceTimersByTime(30); - Object.defineProperty(window, 'innerWidth', { - writable: true, - configurable: true, - value: 1920, - }); + setViewport(1920, 1080); window.dispatchEvent(new Event('resize')); vi.advanceTimersByTime(30); });