Skip to content
Closed
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)
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 document now states SSR-safety is ensured via typeof window === 'undefined' guards “in every hook”, but useDebounce currently calls window.setTimeout directly without a guard. Either update useDebounce to be SSR-safe or soften this statement to avoid incorrect guidance.

Suggested change
- SSR-safe (`typeof window === 'undefined'` guards in every hook)
- SSR-safe by design (use `typeof window === 'undefined'` guards around browser-only APIs)

Copilot uses AI. Check for mistakes.
- 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;
});

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);

Comment on lines +97 to +99
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 “SSR context” test doesn’t exercise useIntersectionObserver (it asserts a local helper that always returns null). Add a test that the hook itself can be invoked without throwing when window/IntersectionObserver are unavailable, and returns null in that case.

Copilot generated this review using guidance from repository custom instructions.
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

Comment on lines +10 to +12
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.

Because the effect returns early when ref.current is falsy, and the dependency array is [ref], the observer may never attach in cases where the element appears later (e.g., conditional rendering that sets ref.current on a subsequent render). Consider a callback-ref / Element | null parameter, or otherwise re-running the effect when the observed node changes.

Copilot uses AI. Check for mistakes.
const observer = new IntersectionObserver(([newEntry]) => {
if (newEntry) setEntry(newEntry);
}, optionsRef.current);
Comment on lines +11 to +15
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 will throw at runtime in environments where IntersectionObserver is not available (e.g., older browsers or certain test setups). Consider guarding typeof IntersectionObserver === 'undefined' (or 'IntersectionObserver' in window) and returning null without creating an observer.

Copilot uses AI. Check for mistakes.

observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [ref]);
Comment on lines +1 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.

options changes are ignored: the observer is created with optionsRef.current (initialized once) and the effect does not re-run when options changes. Either document options as static, or include options in the effect and recreate the observer accordingly.

Suggested change
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;
const observer = new IntersectionObserver(([newEntry]) => {
if (newEntry) setEntry(newEntry);
}, optionsRef.current);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [ref]);
import { type RefObject, useEffect, useState } from 'react';
export function useIntersectionObserver(
ref: RefObject<Element | null>,
options?: IntersectionObserverInit,
): IntersectionObserverEntry | null {
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
useEffect(() => {
if (typeof window === 'undefined' || !ref.current) return;
const observer = new IntersectionObserver(([newEntry]) => {
if (newEntry) setEntry(newEntry);
}, options);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [ref, options]);

Copilot uses AI. Check for mistakes.

return entry;
}
Loading
Loading