Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/COMPT-31-dom-event-hooks.md
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`
21 changes: 11 additions & 10 deletions .github/instructions/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand All @@ -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

---

Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
83 changes: 83 additions & 0 deletions src/hooks/useClickOutside.test.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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V1gBiTWT8gsVpEe&open=AZ0-6V1gBiTWT8gsVpEe&pullRequest=8
});

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V1gBiTWT8gsVpEf&open=AZ0-6V1gBiTWT8gsVpEf&pullRequest=8
document.body.removeChild(outsideNode);

Check warning on line 49 in src/hooks/useClickOutside.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V1gBiTWT8gsVpEg&open=AZ0-6V1gBiTWT8gsVpEg&pullRequest=8
});

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V1gBiTWT8gsVpEh&open=AZ0-6V1gBiTWT8gsVpEh&pullRequest=8
});

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();
});
});
29 changes: 29 additions & 0 deletions src/hooks/useClickOutside.ts
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;
});
Copy link

Copilot AI Mar 30, 2026

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 handler changes, add [handler] as the dependency list to avoid unnecessary effect executions.

Suggested change
});
}, [handler]);

Copilot uses AI. Check for mistakes.

useEffect(() => {
if (typeof window === 'undefined') return;

Check warning on line 14 in src/hooks/useClickOutside.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V2NBiTWT8gsVpEo&open=AZ0-6V2NBiTWT8gsVpEo&pullRequest=8

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]);
}
107 changes: 107 additions & 0 deletions src/hooks/useIntersectionObserver.test.ts
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this public static property readonly.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V3IBiTWT8gsVpEx&open=AZ0-6V3IBiTWT8gsVpEx&pullRequest=8
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V3IBiTWT8gsVpEy&open=AZ0-6V3IBiTWT8gsVpEy&pullRequest=8
});

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V3IBiTWT8gsVpEz&open=AZ0-6V3IBiTWT8gsVpEz&pullRequest=8
});

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V3IBiTWT8gsVpE0&open=AZ0-6V3IBiTWT8gsVpE0&pullRequest=8
});

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to not always return the same value.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V3IBiTWT8gsVpE1&open=AZ0-6V3IBiTWT8gsVpE1&pullRequest=8
if (typeof window === 'undefined') return null;

Check warning on line 101 in src/hooks/useIntersectionObserver.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V3IBiTWT8gsVpE2&open=AZ0-6V3IBiTWT8gsVpE2&pullRequest=8
return null;
};

expect(getDefault()).toBeNull();
});
});
24 changes: 24 additions & 0 deletions src/hooks/useIntersectionObserver.ts
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-6V2BBiTWT8gsVpEn&open=AZ0-6V2BBiTWT8gsVpEn&pullRequest=8

const observer = new IntersectionObserver(([newEntry]) => {
if (newEntry) setEntry(newEntry);
}, optionsRef.current);

Comment on lines +11 to +16
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new IntersectionObserver(...) will throw in environments where the API is unavailable (older browsers, some test/embedded webviews). Add a feature-detection guard (e.g., check typeof IntersectionObserver !== 'undefined') and return null / no-op when unsupported to keep the hook safe to import/use.

Copilot uses AI. Check for mistakes.
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [ref]);
Comment on lines +8 to +21
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect captures the initial options and observes only the initial ref.current. If options change or the ref starts pointing to a different element after mount, the observer won’t be recreated/re-attached, so the hook can return stale results. Consider tracking the observed element (e.g., const node = ref.current) and including node + options in the effect dependencies (or updating optionsRef.current when options changes).

Suggested change
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]);

Copilot uses AI. Check for mistakes.

return entry;
}
Loading
Loading