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/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9426fdc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 1 + groups: + npm-dependencies: + patterns: + - "*" + assignees: + - CISCODE-MA/cloud-devops + labels: + - "dependencies" + - "npm" + commit-message: + prefix: "chore(deps)" + include: "scope" + rebase-strategy: auto diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md index 0c10e76..209b702 100644 --- a/.github/instructions/copilot-instructions.md +++ b/.github/instructions/copilot-instructions.md @@ -1,28 +1,34 @@ -# Copilot Instructions - React Component Library +# Copilot Instructions - HooksKit -> **Purpose**: Development guidelines for React component libraries - reusable, well-structured components for modern apps. +> **Purpose**: Development guidelines for HooksKit — production-ready React hooks with zero runtime deps. --- ## 🎯 Module Overview -**Package**: `@ciscode/ui-components` (example) -**Type**: React Component Library +**Package**: `@ciscode/hooks-kit` +**Epic**: COMPT-2 — HooksKit +**Type**: React Hooks Library **Framework**: React 18+, TypeScript 5+ -**Build**: Vite/tsup +**Build**: tsup **Testing**: Vitest + React Testing Library **Distribution**: NPM package -**Purpose**: Reusable, production-ready React components for building modern UIs +**Purpose**: 12 production-ready React hooks. Zero runtime deps. SSR-safe. -### Typical Module Responsibilities: +### Hook Groups: -- Atomic UI components (Button, Input, Card, etc.) -- Composite components (Form, Modal, Navigation, etc.) -- Hooks for common patterns -- Type definitions and props interfaces -- Accessibility compliance (WCAG 2.1 AA) -- Theming and customization -- Comprehensive documentation +- **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 (`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 +- Hooks ≥ 90% coverage --- @@ -30,29 +36,27 @@ ``` src/ - ├── components/ # React components - │ ├── Button/ - │ │ ├── Button.tsx # Component - │ │ ├── Button.test.tsx # Tests - │ │ ├── Button.types.ts # Props types - │ │ └── index.ts # Exports - │ ├── Input/ - │ ├── Modal/ - │ └── Form/ - ├── hooks/ # Custom hooks - │ ├── useModal.ts - │ ├── useForm.ts - │ └── useModal.test.ts - ├── context/ # Context providers - │ ├── ThemeContext.tsx - │ └── FormContext.tsx - ├── types/ # TypeScript types - │ └── common.types.ts - ├── utils/ # Utilities - │ └── classNameUtils.ts - └── index.ts # Public API + ├── components/ # Minimal supporting components + │ ├── NoopButton.tsx + │ └── index.ts + ├── hooks/ # All public hooks + │ ├── useDebounce.ts # COMPT-30 ✅ + │ ├── useLocalStorage.ts # COMPT-30 ✅ + │ ├── useSessionStorage.ts # COMPT-30 ✅ + │ ├── 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 + │ └── index.ts + └── index.ts # Public API (only entry point) ``` +> ⚠️ Only export from `src/index.ts`. Deep imports are forbidden. + --- ## 📝 Naming Conventions diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e69de29..8f5acbc 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,41 @@ +name: CI - PR Validation + +on: + pull_request: + branches: [develop] + +permissions: + contents: read + +jobs: + validate: + name: CI - PR Validation + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install + run: npm ci + + - name: Format (check) + run: npm run format + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8465462..8016885 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,30 +20,52 @@ jobs: with: fetch-depth: 0 - - name: Validate tag exists on this push + - name: Validate version tag and package.json run: | - TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || echo "") - if [[ -z "$TAG" ]]; then - echo "❌ No tag found on HEAD. This push did not include a version tag." - echo "To publish, merge to master with a tag: git tag v1.0.0 && git push origin master --tags" + PKG_VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/') + TAG="v${PKG_VERSION}" + + if [[ -z "$PKG_VERSION" ]]; then + echo "❌ ERROR: Could not read version from package.json" exit 1 fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "❌ Invalid tag format: $TAG. Expected: v*.*.*" + echo "❌ ERROR: Invalid version format in package.json: '$PKG_VERSION'" + echo "Expected format: x.y.z (e.g., 1.0.0, 0.2.3)" + exit 1 + fi + + if ! git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ ERROR: Tag $TAG not found!" + echo "" + echo "This typically happens when:" + echo " 1. You forgot to run 'npm version patch|minor|major' on your feature branch" + echo " 2. You didn't push the tag: git push origin --tags" + echo " 3. The tag was created locally but never pushed to remote" + echo "" + echo "📋 Correct workflow:" + echo " 1. On feat/** or feature/**: npm version patch (or minor/major)" + echo " 2. Push branch + tag: git push origin feat/your-feature --tags" + echo " 3. PR feat/** → develop, then PR develop → master" + echo " 4. Workflow automatically triggers on master push" + echo "" exit 1 fi - echo "✅ Valid tag found: $TAG" + + echo "✅ package.json version: $PKG_VERSION" + echo "✅ Tag $TAG exists in repo" echo "TAG_VERSION=$TAG" >> $GITHUB_ENV - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - registry-url: 'https://registry.npmjs.org' - cache: npm + node-version: "22" + registry-url: "https://registry.npmjs.org" + cache: "npm" - name: Install dependencies - run: npm install + run: npm ci - name: Build run: npm run build --if-present @@ -55,6 +77,6 @@ jobs: run: npm test --if-present 2>/dev/null || true - name: Publish to NPM - run: npm publish --access public --no-git-checks + run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 1ef2a3c..33082e4 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -3,16 +3,6 @@ name: CI - Release Check on: pull_request: branches: [master] - workflow_dispatch: - inputs: - sonar: - description: 'Run SonarCloud analysis' - required: true - default: 'false' - type: choice - options: - - 'false' - - 'true' concurrency: group: ci-release-${{ github.ref }} @@ -27,30 +17,25 @@ jobs: permissions: contents: read - # Update these values for your package: - # - SONAR_PROJECT_KEY: "CISCODE-MA_YourPackageName" env: - SONAR_HOST_URL: 'https://sonarcloud.io' - SONAR_ORGANIZATION: 'ciscode' - SONAR_PROJECT_KEY: 'CISCODE-MA_PACKAGE_NAME_TEMPLATE' + SONAR_HOST_URL: "https://sonarcloud.io" + SONAR_ORGANIZATION: "ciscode" + SONAR_PROJECT_KEY: "CISCODE-MA_HooksKit" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '20' - cache: npm + node-version: "22" + cache: "npm" - name: Install - run: npm install - - - name: Audit - run: npm audit --prod + run: npm ci - name: Format run: npm run format @@ -68,20 +53,19 @@ jobs: run: npm run build - name: SonarCloud Scan - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} with: args: > - -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} \ - -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} \ - -Dsonar.sources=src \ + -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} + -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} + -Dsonar.sources=src + -Dsonar.tests=test -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info - name: SonarCloud Quality Gate - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} uses: SonarSource/sonarqube-quality-gate-action@v1 timeout-minutes: 10 env: diff --git a/package.json b/package.json index e1abcf6..0d4c0a9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@ciscode/reactts-developerkit", - "version": "1.0.0", - "description": "React TypeScript hybrid library template (components + hooks + utils).", + "name": "@ciscode/hooks-kit", + "version": "0.0.0", + "description": "12 production-ready React hooks. Zero runtime deps. SSR-safe. Groups: state and storage / DOM and events / async and lifecycle.", "license": "MIT", "private": false, "type": "module", @@ -16,6 +16,10 @@ "require": "./dist/index.cjs" } }, + "repository": { + "type": "git", + "url": "git+https://github.com/CISCODE-MA/HooksKit.git" + }, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 6a94ddd..c02aa1d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,8 @@ // Example placeholder export — replace with real hooks later. export const __hooks_placeholder = true; +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; +}