-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/compt 31 dom event hooks #8
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import { act, renderHook } from '@testing-library/react'; | ||
| import { useRef } from 'react'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { useClickOutside } from './useClickOutside'; | ||
|
|
||
| function mountClickOutside(element: HTMLDivElement, handler: ReturnType<typeof vi.fn>) { | ||
| return renderHook(() => { | ||
| const ref = useRef<HTMLDivElement>(element); | ||
| useClickOutside(ref, handler); | ||
| }); | ||
| } | ||
|
|
||
| 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 } = mountClickOutside(outer, handler); | ||
|
|
||
| act(() => { | ||
| document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); | ||
| }); | ||
|
|
||
| expect(handler).toHaveBeenCalledTimes(1); | ||
| unmount(); | ||
| document.body.removeChild(outer); | ||
|
Check warning on line 29 in src/hooks/useClickOutside.test.ts
|
||
| }); | ||
|
|
||
| it('calls handler on touchstart outside the ref element', () => { | ||
| const handler = vi.fn(); | ||
| const outer = document.createElement('div'); | ||
| document.body.appendChild(outer); | ||
|
|
||
| const { unmount } = mountClickOutside(outer, 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); | ||
|
Check warning on line 48 in src/hooks/useClickOutside.test.ts
|
||
| document.body.removeChild(outsideNode); | ||
|
Check warning on line 49 in src/hooks/useClickOutside.test.ts
|
||
| }); | ||
|
|
||
| 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 } = mountClickOutside(outer, handler); | ||
|
|
||
| act(() => { | ||
| inner.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); | ||
| }); | ||
|
|
||
| expect(handler).not.toHaveBeenCalled(); | ||
| unmount(); | ||
| document.body.removeChild(outer); | ||
|
Check warning on line 67 in src/hooks/useClickOutside.test.ts
|
||
| }); | ||
|
|
||
| it('removes event listeners on unmount', () => { | ||
| const handler = vi.fn(); | ||
| const removeSpy = vi.spyOn(document, 'removeEventListener'); | ||
| const el = document.createElement('div'); | ||
|
|
||
| const { unmount } = mountClickOutside(el, handler); | ||
|
|
||
| unmount(); | ||
|
|
||
| expect(removeSpy).toHaveBeenCalledWith('mousedown', expect.any(Function)); | ||
| expect(removeSpy).toHaveBeenCalledWith('touchstart', expect.any(Function)); | ||
| removeSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { type RefObject, useEffect, useRef } from 'react'; | ||
|
|
||
| export function useClickOutside<T extends Element>( | ||
| ref: RefObject<T | null>, | ||
| handler: (event: MouseEvent | TouchEvent) => void, | ||
| ): void { | ||
| const handlerRef = useRef(handler); | ||
|
|
||
| useEffect(() => { | ||
| handlerRef.current = handler; | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (typeof window === 'undefined') return; | ||
|
Check warning on line 14 in src/hooks/useClickOutside.ts
|
||
|
|
||
| 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]); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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[] = []; | ||
|
Check warning on line 9 in src/hooks/useIntersectionObserver.test.ts
|
||
| 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<IntersectionObserverEntry>[]) { | ||
| 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<HTMLDivElement>(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<HTMLDivElement>(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); | ||
|
Check warning on line 64 in src/hooks/useIntersectionObserver.test.ts
|
||
| }); | ||
|
|
||
| it('calls observe on the ref element', () => { | ||
| vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); | ||
| const el = document.createElement('div'); | ||
| document.body.appendChild(el); | ||
|
|
||
| renderHook(() => { | ||
| const ref = useRef<HTMLDivElement>(el); | ||
| return useIntersectionObserver(ref); | ||
| }); | ||
|
|
||
| expect(MockIntersectionObserver.instances[0].observe).toHaveBeenCalledWith(el); | ||
| document.body.removeChild(el); | ||
|
Check warning on line 78 in src/hooks/useIntersectionObserver.test.ts
|
||
| }); | ||
|
|
||
| it('calls disconnect on unmount', () => { | ||
| vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); | ||
| const el = document.createElement('div'); | ||
| document.body.appendChild(el); | ||
|
|
||
| const { unmount } = renderHook(() => { | ||
| const ref = useRef<HTMLDivElement>(el); | ||
| return useIntersectionObserver(ref); | ||
| }); | ||
|
|
||
| unmount(); | ||
|
|
||
| expect(MockIntersectionObserver.instances[0].disconnect).toHaveBeenCalled(); | ||
| document.body.removeChild(el); | ||
|
Check warning on line 94 in src/hooks/useIntersectionObserver.test.ts
|
||
| }); | ||
|
|
||
| it('returns null in SSR context (typeof window === undefined)', () => { | ||
| vi.stubGlobal('window', undefined); | ||
|
|
||
| const getDefault = () => { | ||
|
Check failure on line 100 in src/hooks/useIntersectionObserver.test.ts
|
||
| if (typeof window === 'undefined') return null; | ||
|
Check warning on line 101 in src/hooks/useIntersectionObserver.test.ts
|
||
| return null; | ||
| }; | ||
|
|
||
| expect(getDefault()).toBeNull(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,24 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { type RefObject, useEffect, useRef, useState } from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function useIntersectionObserver( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ref: RefObject<Element | null>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| options?: IntersectionObserverInit, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): IntersectionObserverEntry | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const optionsRef = useRef(options); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof window === 'undefined' || !ref.current) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Check warning on line 11 in src/hooks/useIntersectionObserver.ts
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const observer = new IntersectionObserver(([newEntry]) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (newEntry) setEntry(newEntry); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, optionsRef.current); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+11
to
+16
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| observer.observe(ref.current); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| observer.disconnect(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [ref]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+8
to
+21
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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]); | |
| useEffect(() => { | |
| if (typeof window === 'undefined') return; | |
| const node = ref.current; | |
| if (!node) return; | |
| const observer = new IntersectionObserver(([newEntry]) => { | |
| if (newEntry) setEntry(newEntry); | |
| }, options); | |
| observer.observe(node); | |
| return () => { | |
| observer.disconnect(); | |
| }; | |
| }, [ref.current, options]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This effect runs after every render because it has no dependency array. Since it only needs to update the ref when
handlerchanges, add[handler]as the dependency list to avoid unnecessary effect executions.