diff --git a/bun.lockb b/bun.lockb index 4952705..25a1781 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package-lock.json b/package-lock.json index ec4f60a..c8ea812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,6 +106,7 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", + "@happy-dom/global-registrator": "^20.6.1", "@playwright/test": "^1.49.1", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", @@ -129,6 +130,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", + "happy-dom": "^20.6.1", "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -1408,6 +1410,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@emoji-mart/data": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", @@ -2823,6 +2847,20 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@happy-dom/global-registrator": { + "version": "20.6.1", + "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.6.1.tgz", + "integrity": "sha512-4Aji+soqukwUxq2DgHmkjxdGnG7hEiJuprqDlW4Wu6AQ0t8U9ItlICcM5to89pulIsEGrF1CkCoNrufQTcqb8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "happy-dom": "^20.6.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@hookform/resolvers": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", @@ -8232,6 +8270,23 @@ "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -10799,6 +10854,15 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -12245,6 +12309,16 @@ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -14088,6 +14162,37 @@ "uglify-js": "^3.1.4" } }, + "node_modules/happy-dom": { + "version": "20.6.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.6.1.tgz", + "integrity": "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^6.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -14831,7 +14936,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -20693,6 +20798,36 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -22472,7 +22607,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sanitize-filename": { diff --git a/package.json b/package.json index 827ca46..ac0a436 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", + "@happy-dom/global-registrator": "^20.6.1", "@playwright/test": "^1.49.1", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", @@ -149,6 +150,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", + "happy-dom": "^20.6.1", "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/src/hooks/use-mobile.test.tsx b/src/hooks/use-mobile.test.tsx new file mode 100644 index 0000000..6c93e3b --- /dev/null +++ b/src/hooks/use-mobile.test.tsx @@ -0,0 +1,165 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +GlobalRegistrator.register(); + +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"; +import { renderHook, act } from "@testing-library/react"; +import { useIsMobile } from "./use-mobile"; + +// The hook uses 768 as the breakpoint internally. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const MOBILE_BREAKPOINT = 768; + +describe("useIsMobile", () => { + let listeners: Record void)[]> = {}; + + // Store original implementation to restore later + let originalMatchMedia: any; + let originalInnerWidth: number; + + beforeEach(() => { + listeners = {}; + + // Store original implementation + originalMatchMedia = window.matchMedia; + originalInnerWidth = window.innerWidth; + + // Mock window.matchMedia + Object.defineProperty(window, "matchMedia", { + writable: true, + value: mock((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: mock(), // Deprecated + removeListener: mock(), // Deprecated + addEventListener: mock((type: string, listener: (e: MediaQueryListEvent) => void) => { + if (!listeners[type]) { + listeners[type] = []; + } + listeners[type].push(listener); + }), + removeEventListener: mock((type: string, listener: (e: MediaQueryListEvent) => void) => { + if (listeners[type]) { + listeners[type] = listeners[type].filter((l) => l !== listener); + } + }), + dispatchEvent: mock(), + })), + }); + + // Mock window.innerWidth + Object.defineProperty(window, "innerWidth", { + writable: true, + value: 1024, // Default to desktop + }); + }); + + afterEach(() => { + if (originalMatchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: originalMatchMedia, + }); + } + + if (originalInnerWidth !== undefined) { + Object.defineProperty(window, "innerWidth", { + writable: true, + value: originalInnerWidth, + }); + } + mock.restore(); + }); + + test("should return false when window width is greater than MOBILE_BREAKPOINT", () => { + // Set width to desktop size + Object.defineProperty(window, "innerWidth", { + writable: true, + value: 1024, + }); + + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + }); + + test("should return true when window width is less than MOBILE_BREAKPOINT", () => { + // Set width to mobile size + Object.defineProperty(window, "innerWidth", { + writable: true, + value: 500, + }); + + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(true); + }); + + test("should update value when window resizes to mobile", () => { + // Start with desktop + Object.defineProperty(window, "innerWidth", { + writable: true, + value: 1024, + }); + + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + + // Simulate resize to mobile + act(() => { + Object.defineProperty(window, "innerWidth", { + writable: true, + value: 500, + }); + + // Trigger the 'change' event on the media query list + // The hook listens to 'change' event + if (listeners["change"]) { + listeners["change"].forEach((listener) => + listener({ matches: true } as MediaQueryListEvent) + ); + } + }); + + expect(result.current).toBe(true); + }); + + test("should update value when window resizes to desktop", () => { + // Start with mobile + Object.defineProperty(window, "innerWidth", { + writable: true, + value: 500, + }); + + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(true); + + // Simulate resize to desktop + act(() => { + Object.defineProperty(window, "innerWidth", { + writable: true, + value: 1024, + }); + + // Trigger the 'change' event + if (listeners["change"]) { + listeners["change"].forEach((listener) => + listener({ matches: false } as MediaQueryListEvent) + ); + } + }); + + expect(result.current).toBe(false); + }); + + test("should cleanup event listener on unmount", () => { + const { unmount } = renderHook(() => useIsMobile()); + + // Get the mock instance + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mqlMock = (window.matchMedia as any).mock.results[0].value; + + unmount(); + + expect(mqlMock.removeEventListener).toHaveBeenCalledTimes(1); + expect(mqlMock.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + }); +});