@buildinams/use-match-media
Advanced tools
Comparing version 0.1.0 to 0.2.1
@@ -6,7 +6,10 @@ import { Config, EventHandler, Query } from "./types"; | ||
* @param query - The query to match for. | ||
* @param config - _Optional_ configuration object. | ||
* @param config.defaultValue - _Optional_ fallback value that is returned in | ||
* SSR and first match of any unique query. Defaults to `false`. | ||
* @param config.isEnabled - _Optional_ whether or not to listen to events. | ||
* Defaults to `true`. | ||
* @param config.layoutEffect - _Optional_ whether or not to use | ||
* `useLayoutEffect` instead of `useEffect` on client. Defaults to `false`. | ||
* | ||
* @param config - Optional configuration object. | ||
* @param config.defaultValue - Fallback value that is returned in SSR and first match of any unique query. Defaults to `false`. | ||
* @param config.isEnabled - Lets you specify whether to listen for events or not. Defaults to `true`. | ||
* | ||
* @example | ||
@@ -22,4 +25,4 @@ * useMatchMedia("(pointer: coarse)") -> Checks if the browser matches coarse; | ||
*/ | ||
declare const useMatchMedia: (query: string, config?: Config) => boolean; | ||
declare const useMatchMedia: <TConfig extends Config = Config, TDefaultValue extends TConfig["defaultValue"] = TConfig["defaultValue"] extends undefined ? undefined : TConfig["defaultValue"] extends null ? null : never>(query: string, config?: TConfig | undefined) => boolean | TDefaultValue; | ||
export default useMatchMedia; | ||
export type { EventHandler, Query }; | ||
export type { Config, EventHandler, Query }; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
const react_1 = require("react"); | ||
const useIsomorphicEffect_1 = require("./useIsomorphicEffect"); | ||
const queries = new Map(); | ||
const getExistingMatch = (query, defaultValue = false) => { | ||
const matchedQuery = queries.get(query); | ||
// If query already exists, return its matched value, else default value | ||
return matchedQuery ? matchedQuery.matchMedia.matches : defaultValue; | ||
}; | ||
const createEventHandler = (query) => (event) => { | ||
const matchedQuery = queries.get(query); | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners.forEach((listener) => listener(event)); | ||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { | ||
if (k2 === undefined) k2 = k; | ||
var desc = Object.getOwnPropertyDescriptor(m, k); | ||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { | ||
desc = { enumerable: true, get: function() { return m[k]; } }; | ||
} | ||
Object.defineProperty(o, k2, desc); | ||
}) : (function(o, m, k, k2) { | ||
if (k2 === undefined) k2 = k; | ||
o[k2] = m[k]; | ||
})); | ||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { | ||
Object.defineProperty(o, "default", { enumerable: true, value: v }); | ||
}) : function(o, v) { | ||
o["default"] = v; | ||
}); | ||
var __importStar = (this && this.__importStar) || function (mod) { | ||
if (mod && mod.__esModule) return mod; | ||
var result = {}; | ||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); | ||
__setModuleDefault(result, mod); | ||
return result; | ||
}; | ||
const addListener = (query, listener) => { | ||
const matchedQuery = queries.get(query); | ||
// If query already exists, add this new listener to existing array | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners.push(listener); | ||
return matchedQuery.matchMedia.matches; | ||
} | ||
// Else, first query, so create it... | ||
const newQuery = { | ||
matchMedia: window.matchMedia(query), | ||
existingListeners: [listener], | ||
eventHandler: createEventHandler(query), | ||
}; | ||
queries.set(query, newQuery); | ||
// Listen to changes with fallback | ||
const currentMatchMedia = newQuery.matchMedia; | ||
if (!currentMatchMedia.addEventListener) { | ||
currentMatchMedia.addListener(newQuery.eventHandler); | ||
} | ||
else { | ||
currentMatchMedia.addEventListener("change", newQuery.eventHandler); | ||
} | ||
return newQuery.matchMedia.matches; | ||
}; | ||
const removeListener = (query, listener) => { | ||
const matchedQuery = queries.get(query); | ||
// If this matches, filter out this listener from existing array | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners = matchedQuery.existingListeners.filter((l) => l !== listener); | ||
} | ||
// Ignore unsubscribe below if theres any more existing listeners | ||
if (!matchedQuery || matchedQuery.existingListeners.length > 0) | ||
return; | ||
// Unsubscribe from changes with fallback | ||
const currentMatchMedia = matchedQuery.matchMedia; | ||
if (!currentMatchMedia.removeEventListener) { | ||
currentMatchMedia.removeListener(matchedQuery.eventHandler); | ||
} | ||
else { | ||
currentMatchMedia.removeEventListener("change", matchedQuery.eventHandler); | ||
} | ||
queries.delete(query); | ||
}; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
const react_1 = __importStar(require("react")); | ||
const useIsomorphicLayoutEffect_1 = require("./useIsomorphicLayoutEffect"); | ||
const QUERIES = new Map(); | ||
/** | ||
@@ -64,7 +33,10 @@ * Stateful hook that uses the matchMedia API. | ||
* @param query - The query to match for. | ||
* @param config - _Optional_ configuration object. | ||
* @param config.defaultValue - _Optional_ fallback value that is returned in | ||
* SSR and first match of any unique query. Defaults to `false`. | ||
* @param config.isEnabled - _Optional_ whether or not to listen to events. | ||
* Defaults to `true`. | ||
* @param config.layoutEffect - _Optional_ whether or not to use | ||
* `useLayoutEffect` instead of `useEffect` on client. Defaults to `false`. | ||
* | ||
* @param config - Optional configuration object. | ||
* @param config.defaultValue - Fallback value that is returned in SSR and first match of any unique query. Defaults to `false`. | ||
* @param config.isEnabled - Lets you specify whether to listen for events or not. Defaults to `true`. | ||
* | ||
* @example | ||
@@ -81,4 +53,66 @@ * useMatchMedia("(pointer: coarse)") -> Checks if the browser matches coarse; | ||
const useMatchMedia = (query, config) => { | ||
const [matches, setMatches] = (0, react_1.useState)(getExistingMatch(query, config === null || config === void 0 ? void 0 : config.defaultValue)); | ||
(0, useIsomorphicEffect_1.useIsomorphicEffect)(() => { | ||
const getExistingMatch = (0, react_1.useCallback)(() => { | ||
const matchedQuery = QUERIES.get(query); | ||
// If query already exists, return its matched value | ||
if (matchedQuery) | ||
return matchedQuery.matchMedia.matches; | ||
// Else, return config default value if it exists or fallback (false) | ||
return config && "defaultValue" in config | ||
? config.defaultValue | ||
: false; | ||
}, [config, query]); | ||
const createEventHandler = (query) => (event) => { | ||
const matchedQuery = QUERIES.get(query); | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners.forEach((listener) => listener(event)); | ||
} | ||
}; | ||
const addListener = (0, react_1.useCallback)((query, listener) => { | ||
const matchedQuery = QUERIES.get(query); | ||
// If query already exists, add this new listener to existing array | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners.push(listener); | ||
return matchedQuery.matchMedia.matches; | ||
} | ||
// Else, first query, so create it... | ||
const newQuery = { | ||
matchMedia: window.matchMedia(query), | ||
existingListeners: [listener], | ||
eventHandler: createEventHandler(query), | ||
}; | ||
QUERIES.set(query, newQuery); | ||
// Listen to changes with fallback | ||
const currentMatchMedia = newQuery.matchMedia; | ||
if (!currentMatchMedia.addEventListener) { | ||
currentMatchMedia.addListener(newQuery.eventHandler); | ||
} | ||
else { | ||
currentMatchMedia.addEventListener("change", newQuery.eventHandler); | ||
} | ||
return newQuery.matchMedia.matches; | ||
}, []); | ||
const removeListener = (0, react_1.useCallback)((query, listener) => { | ||
const matchedQuery = QUERIES.get(query); | ||
// If this matches, filter out this listener from existing array | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners = matchedQuery.existingListeners.filter((l) => l !== listener); | ||
} | ||
// Ignore unsubscribe below if theres any more existing listeners | ||
if (!matchedQuery || matchedQuery.existingListeners.length > 0) | ||
return; | ||
// Unsubscribe from changes with fallback | ||
const currentMatchMedia = matchedQuery.matchMedia; | ||
if (!currentMatchMedia.removeEventListener) { | ||
currentMatchMedia.removeListener(matchedQuery.eventHandler); | ||
} | ||
else { | ||
currentMatchMedia.removeEventListener("change", matchedQuery.eventHandler); | ||
} | ||
QUERIES.delete(query); | ||
}, []); | ||
const [matches, setMatches] = (0, react_1.useState)(getExistingMatch); | ||
const useEffect = (config === null || config === void 0 ? void 0 : config.layoutEffect) | ||
? useIsomorphicLayoutEffect_1.useIsomorphicLayoutEffect | ||
: react_1.default.useEffect; | ||
useEffect(() => { | ||
var _a; | ||
@@ -89,11 +123,11 @@ if ((_a = config === null || config === void 0 ? void 0 : config.isEnabled) !== null && _a !== void 0 ? _a : true) { | ||
}; | ||
// On mount we only checked if there was an existing match. So here we | ||
// set matches again in case this is the first of this query | ||
const matches = addListener(query, listener); | ||
setMatches(matches); | ||
return () => { | ||
removeListener(query, listener); | ||
}; | ||
return () => removeListener(query, listener); | ||
} | ||
}, [query]); | ||
}, [config === null || config === void 0 ? void 0 : config.isEnabled, query, addListener, removeListener]); | ||
return matches; | ||
}; | ||
exports.default = useMatchMedia; |
@@ -1,8 +0,2 @@ | ||
export type EventHandler = (event: MediaQueryListEvent) => void; | ||
export interface Query { | ||
matchMedia: MediaQueryList; | ||
existingListeners: EventHandler[]; | ||
eventHandler: EventHandler; | ||
} | ||
export interface Config { | ||
export type Config = { | ||
/** | ||
@@ -12,8 +6,16 @@ * Fallback value that is returned in SSR and first match of any unique query. | ||
*/ | ||
defaultValue?: boolean; | ||
defaultValue?: boolean | null; | ||
/** Whether or not to listen to events. Defaults to `true`. */ | ||
isEnabled?: boolean; | ||
/** | ||
* This can be used to conditionally enable / disable the event listener. | ||
* Defaults to `true`. | ||
* Whether or not to use `useLayoutEffect` instead of `useEffect` on client. | ||
* Defaults to `false`. | ||
*/ | ||
isEnabled?: boolean; | ||
} | ||
layoutEffect?: boolean; | ||
}; | ||
export type EventHandler = (event: MediaQueryListEvent) => void; | ||
export type Query = { | ||
matchMedia: MediaQueryList; | ||
existingListeners: EventHandler[]; | ||
eventHandler: EventHandler; | ||
}; |
import type { Config } from "@jest/types"; | ||
const config: Config.InitialOptions = { | ||
verbose: true, | ||
testEnvironment: "jsdom", | ||
roots: ["<rootDir>/tests"], | ||
verbose: true, | ||
testEnvironment: "jsdom", | ||
roots: ["<rootDir>/tests"], | ||
}; | ||
export default config; |
120
package.json
{ | ||
"name": "@buildinams/use-match-media", | ||
"description": "Stateful hook that uses the matchMedia API.", | ||
"version": "0.1.0", | ||
"license": "MIT", | ||
"author": "Build in Amsterdam <development@buildinamsterdam.com> (https://www.buildinamsterdam.com/)", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"homepage": "https://github.com/buildinamsterdam/use-match-media#readme", | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/buildinamsterdam/use-match-media.git" | ||
}, | ||
"bugs": { | ||
"url": "https://github.com/buildinamsterdam/use-match-media/issues" | ||
}, | ||
"keywords": [ | ||
"react", | ||
"hook", | ||
"react-hook", | ||
"match-media", | ||
"media-query" | ||
], | ||
"scripts": { | ||
"build": "tsc", | ||
"build:types": "tsc --emitDeclarationOnly", | ||
"prepublishOnly": "npm run build", | ||
"test": "jest", | ||
"test:watch": "jest --watch", | ||
"coverage": "jest --coverage", | ||
"lint": "NODE_ENV=test npm-run-all --parallel lint:*", | ||
"lint:script": "eslint \"src/**/*.{ts,tsx}\"", | ||
"lint:format": "prettier \"**/*.{md,yml}\" --check", | ||
"lint:type-check": "tsc --noEmit", | ||
"fix": "npm-run-all --sequential fix:*", | ||
"fix:js": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix", | ||
"fix:format": "prettier \"**/*.{md,yml}\" --write", | ||
"depcheck": "npx npm-check --update" | ||
}, | ||
"peerDependencies": { | ||
"react": ">=17.0.0 || 18" | ||
}, | ||
"devDependencies": { | ||
"@babel/preset-env": "^7.22.7", | ||
"@babel/preset-typescript": "^7.22.5", | ||
"@buildinams/lint": "^0.2.1", | ||
"@testing-library/react": "^14.0.0", | ||
"@types/jest": "^29.5.3", | ||
"@types/node": "^20.4.1", | ||
"@types/react": "^18.2.14", | ||
"@types/react-dom": "^18.2.6", | ||
"babel": "^6.23.0", | ||
"jest": "^29.6.1", | ||
"jest-environment-jsdom": "^29.6.1", | ||
"jest-matchmedia-mock": "^1.1.0", | ||
"npm-run-all": "^4.1.5", | ||
"react-dom": "^18.0.0", | ||
"ts-jest": "^29.1.1", | ||
"ts-node": "^10.9.1", | ||
"typescript": "^5.1.6" | ||
} | ||
"name": "@buildinams/use-match-media", | ||
"description": "Stateful hook that uses the matchMedia API.", | ||
"version": "0.2.1", | ||
"license": "MIT", | ||
"author": "Build in Amsterdam <development@buildinamsterdam.com> (https://www.buildinamsterdam.com/)", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"homepage": "https://github.com/buildinamsterdam/use-match-media#readme", | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/buildinamsterdam/use-match-media.git" | ||
}, | ||
"bugs": { | ||
"url": "https://github.com/buildinamsterdam/use-match-media/issues" | ||
}, | ||
"keywords": [ | ||
"react", | ||
"hook", | ||
"react-hook", | ||
"match-media", | ||
"media-query" | ||
], | ||
"scripts": { | ||
"build": "tsc", | ||
"build:types": "tsc --emitDeclarationOnly", | ||
"prepublishOnly": "npm run build", | ||
"test": "jest", | ||
"test:watch": "jest --watch", | ||
"coverage": "jest --coverage", | ||
"lint": "NODE_ENV=test npm-run-all --parallel lint:*", | ||
"lint:script": "eslint \"src/**/*.{ts,tsx}\"", | ||
"lint:format": "prettier \"**/*.{md,yml}\" --check", | ||
"lint:type-check": "tsc --noEmit", | ||
"fix": "npm-run-all --sequential fix:*", | ||
"fix:js": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix", | ||
"fix:format": "prettier \"**/*.{md,yml}\" --write", | ||
"depcheck": "npx npm-check --update" | ||
}, | ||
"peerDependencies": { | ||
"react": ">=17.0.0 || 18" | ||
}, | ||
"devDependencies": { | ||
"@babel/preset-env": "^7.23.8", | ||
"@babel/preset-typescript": "^7.23.3", | ||
"@buildinams/lint": "^0.4.0", | ||
"@testing-library/react": "^14.1.2", | ||
"@types/jest": "^29.5.11", | ||
"@types/node": "^20.11.3", | ||
"@types/react": "^18.2.48", | ||
"@types/react-dom": "^18.2.18", | ||
"babel": "^6.23.0", | ||
"jest": "^29.7.0", | ||
"jest-environment-jsdom": "^29.7.0", | ||
"jest-matchmedia-mock": "^1.1.0", | ||
"npm-run-all": "^4.1.5", | ||
"react-dom": "^18.0.0", | ||
"ts-jest": "^29.1.1", | ||
"ts-node": "^10.9.2", | ||
"typescript": "^5.3.3" | ||
} | ||
} |
@@ -49,3 +49,3 @@ # use-match-media | ||
If you want to provide a default value for the initial render (and in server), you can pass it as `defaultValue` within the _optional_ config object. | ||
If you want to provide a default value for the initial render (and in server), you can pass it as `defaultValue` within the _optional_ config object. This accepts `boolean`, `undefined`, or `null`. For example: | ||
@@ -56,3 +56,3 @@ ```tsx | ||
const MyComponent = () => { | ||
const isMobile = useMatchMedia("(max-width: 768px)", { defaultValue: true }); | ||
const isSmall = useMatchMedia("(max-width: 768px)", { defaultValue: true }); | ||
... | ||
@@ -65,3 +65,4 @@ }; | ||
- The default value will only be used on the initial render and SSR. By the second render, the hook will use the actual value matched. | ||
- If left `undefined`, the default value will be `false`. | ||
- If theres already a match for the query, the hook will use the actual value matched instead of the default value. | ||
- If left blank, the default value will be `false`. | ||
@@ -78,3 +79,3 @@ ## Conditionally Listening to Events | ||
const isMobile = useMatchMedia("(max-width: 768px)", { isEnabled }); | ||
const isSmall = useMatchMedia("(max-width: 768px)", { isEnabled }); | ||
... | ||
@@ -84,2 +85,17 @@ }; | ||
## Using Layout Effect | ||
By default, the hook will use `useEffect` to listen to events. However, you can use `useLayoutEffect` instead by passing `layoutEffect` in the config object. For example: | ||
```tsx | ||
import useMatchMedia from "@buildinams/use-match-media"; | ||
const MyComponent = () => { | ||
const isSmall = useMatchMedia("(max-width: 768px)", { layoutEffect: true }); | ||
... | ||
}; | ||
``` | ||
This is SSR safe, and will only use `useLayoutEffect` on the client. | ||
## Requirements | ||
@@ -86,0 +102,0 @@ |
215
src/index.ts
@@ -1,75 +0,8 @@ | ||
import { useState } from "react"; | ||
import React, { useCallback, useState } from "react"; | ||
import { Config, EventHandler, Query } from "./types"; | ||
import { useIsomorphicEffect } from "./useIsomorphicEffect"; | ||
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; | ||
const queries = new Map<string, Query>(); | ||
const QUERIES = new Map<string, Query>(); | ||
const getExistingMatch = (query: string, defaultValue = false) => { | ||
const matchedQuery = queries.get(query); | ||
// If query already exists, return its matched value, else default value | ||
return matchedQuery ? matchedQuery.matchMedia.matches : defaultValue; | ||
}; | ||
const createEventHandler = (query: string) => (event: MediaQueryListEvent) => { | ||
const matchedQuery = queries.get(query); | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners.forEach((listener) => listener(event)); | ||
} | ||
}; | ||
const addListener = (query: string, listener: EventHandler) => { | ||
const matchedQuery = queries.get(query); | ||
// If query already exists, add this new listener to existing array | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners.push(listener); | ||
return matchedQuery.matchMedia.matches; | ||
} | ||
// Else, first query, so create it... | ||
const newQuery = { | ||
matchMedia: window.matchMedia(query), | ||
existingListeners: [listener], | ||
eventHandler: createEventHandler(query), | ||
}; | ||
queries.set(query, newQuery); | ||
// Listen to changes with fallback | ||
const currentMatchMedia = newQuery.matchMedia; | ||
if (!currentMatchMedia.addEventListener) { | ||
currentMatchMedia.addListener(newQuery.eventHandler); | ||
} else { | ||
currentMatchMedia.addEventListener("change", newQuery.eventHandler); | ||
} | ||
return newQuery.matchMedia.matches; | ||
}; | ||
const removeListener = (query: string, listener: EventHandler) => { | ||
const matchedQuery = queries.get(query); | ||
// If this matches, filter out this listener from existing array | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners = matchedQuery.existingListeners.filter( | ||
(l) => l !== listener | ||
); | ||
} | ||
// Ignore unsubscribe below if theres any more existing listeners | ||
if (!matchedQuery || matchedQuery.existingListeners.length > 0) return; | ||
// Unsubscribe from changes with fallback | ||
const currentMatchMedia = matchedQuery.matchMedia; | ||
if (!currentMatchMedia.removeEventListener) { | ||
currentMatchMedia.removeListener(matchedQuery.eventHandler); | ||
} else { | ||
currentMatchMedia.removeEventListener("change", matchedQuery.eventHandler); | ||
} | ||
queries.delete(query); | ||
}; | ||
/** | ||
@@ -79,7 +12,10 @@ * Stateful hook that uses the matchMedia API. | ||
* @param query - The query to match for. | ||
* @param config - _Optional_ configuration object. | ||
* @param config.defaultValue - _Optional_ fallback value that is returned in | ||
* SSR and first match of any unique query. Defaults to `false`. | ||
* @param config.isEnabled - _Optional_ whether or not to listen to events. | ||
* Defaults to `true`. | ||
* @param config.layoutEffect - _Optional_ whether or not to use | ||
* `useLayoutEffect` instead of `useEffect` on client. Defaults to `false`. | ||
* | ||
* @param config - Optional configuration object. | ||
* @param config.defaultValue - Fallback value that is returned in SSR and first match of any unique query. Defaults to `false`. | ||
* @param config.isEnabled - Lets you specify whether to listen for events or not. Defaults to `true`. | ||
* | ||
* @example | ||
@@ -95,23 +31,118 @@ * useMatchMedia("(pointer: coarse)") -> Checks if the browser matches coarse; | ||
*/ | ||
const useMatchMedia = (query: string, config?: Config) => { | ||
const [matches, setMatches] = useState( | ||
getExistingMatch(query, config?.defaultValue) | ||
); | ||
const useMatchMedia = < | ||
TConfig extends Config = Config, | ||
TDefaultValue extends | ||
| TConfig["defaultValue"] | ||
| never = TConfig["defaultValue"] extends undefined | ||
? undefined | ||
: TConfig["defaultValue"] extends null | ||
? null | ||
: never, | ||
>( | ||
query: string, | ||
config?: TConfig, | ||
) => { | ||
const getExistingMatch = useCallback(() => { | ||
const matchedQuery = QUERIES.get(query); | ||
useIsomorphicEffect(() => { | ||
if (config?.isEnabled ?? true) { | ||
const listener = (event: MediaQueryListEvent) => { | ||
setMatches(event.matches); | ||
}; | ||
// If query already exists, return its matched value | ||
if (matchedQuery) return matchedQuery.matchMedia.matches; | ||
const matches = addListener(query, listener); | ||
setMatches(matches); | ||
// Else, return config default value if it exists or fallback (false) | ||
return config && "defaultValue" in config | ||
? (config.defaultValue as TDefaultValue) | ||
: false; | ||
}, [config, query]); | ||
return () => { | ||
removeListener(query, listener); | ||
}; | ||
} | ||
}, [query]); | ||
const createEventHandler = | ||
(query: string) => (event: MediaQueryListEvent) => { | ||
const matchedQuery = QUERIES.get(query); | ||
return matches; | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners.forEach((listener) => listener(event)); | ||
} | ||
}; | ||
const addListener = useCallback((query: string, listener: EventHandler) => { | ||
const matchedQuery = QUERIES.get(query); | ||
// If query already exists, add this new listener to existing array | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners.push(listener); | ||
return matchedQuery.matchMedia.matches; | ||
} | ||
// Else, first query, so create it... | ||
const newQuery = { | ||
matchMedia: window.matchMedia(query), | ||
existingListeners: [listener], | ||
eventHandler: createEventHandler(query), | ||
}; | ||
QUERIES.set(query, newQuery); | ||
// Listen to changes with fallback | ||
const currentMatchMedia = newQuery.matchMedia; | ||
if (!currentMatchMedia.addEventListener) { | ||
currentMatchMedia.addListener(newQuery.eventHandler); | ||
} else { | ||
currentMatchMedia.addEventListener("change", newQuery.eventHandler); | ||
} | ||
return newQuery.matchMedia.matches; | ||
}, []); | ||
const removeListener = useCallback( | ||
(query: string, listener: EventHandler) => { | ||
const matchedQuery = QUERIES.get(query); | ||
// If this matches, filter out this listener from existing array | ||
if (matchedQuery) { | ||
matchedQuery.existingListeners = matchedQuery.existingListeners.filter( | ||
(l) => l !== listener, | ||
); | ||
} | ||
// Ignore unsubscribe below if theres any more existing listeners | ||
if (!matchedQuery || matchedQuery.existingListeners.length > 0) return; | ||
// Unsubscribe from changes with fallback | ||
const currentMatchMedia = matchedQuery.matchMedia; | ||
if (!currentMatchMedia.removeEventListener) { | ||
currentMatchMedia.removeListener(matchedQuery.eventHandler); | ||
} else { | ||
currentMatchMedia.removeEventListener( | ||
"change", | ||
matchedQuery.eventHandler, | ||
); | ||
} | ||
QUERIES.delete(query); | ||
}, | ||
[], | ||
); | ||
const [matches, setMatches] = useState<boolean | TDefaultValue>( | ||
getExistingMatch, | ||
); | ||
const useEffect = config?.layoutEffect | ||
? useIsomorphicLayoutEffect | ||
: React.useEffect; | ||
useEffect(() => { | ||
if (config?.isEnabled ?? true) { | ||
const listener = (event: MediaQueryListEvent) => { | ||
setMatches(event.matches); | ||
}; | ||
// On mount we only checked if there was an existing match. So here we | ||
// set matches again in case this is the first of this query | ||
const matches = addListener(query, listener); | ||
setMatches(matches); | ||
return () => removeListener(query, listener); | ||
} | ||
}, [config?.isEnabled, query, addListener, removeListener]); | ||
return matches; | ||
}; | ||
@@ -121,2 +152,2 @@ | ||
export type { EventHandler, Query }; | ||
export type { Config, EventHandler, Query }; |
@@ -1,21 +0,24 @@ | ||
export type EventHandler = (event: MediaQueryListEvent) => void; | ||
export type Config = { | ||
/** | ||
* Fallback value that is returned in SSR and first match of any unique query. | ||
* Defaults to `false`. | ||
*/ | ||
defaultValue?: boolean | null; | ||
export interface Query { | ||
matchMedia: MediaQueryList; | ||
existingListeners: EventHandler[]; | ||
eventHandler: EventHandler; | ||
} | ||
/** Whether or not to listen to events. Defaults to `true`. */ | ||
isEnabled?: boolean; | ||
export interface Config { | ||
/** | ||
* Fallback value that is returned in SSR and first match of any unique query. | ||
* Defaults to `false`. | ||
*/ | ||
defaultValue?: boolean; | ||
/** | ||
* Whether or not to use `useLayoutEffect` instead of `useEffect` on client. | ||
* Defaults to `false`. | ||
*/ | ||
layoutEffect?: boolean; | ||
}; | ||
/** | ||
* This can be used to conditionally enable / disable the event listener. | ||
* Defaults to `true`. | ||
*/ | ||
isEnabled?: boolean; | ||
} | ||
export type EventHandler = (event: MediaQueryListEvent) => void; | ||
export type Query = { | ||
matchMedia: MediaQueryList; | ||
existingListeners: EventHandler[]; | ||
eventHandler: EventHandler; | ||
}; |
@@ -9,54 +9,96 @@ import { renderHook } from "@testing-library/react"; | ||
describe("The hook", () => { | ||
beforeEach(() => { | ||
matchMedia = new MatchMediaMock(); | ||
}); | ||
beforeEach(() => { | ||
matchMedia = new MatchMediaMock(); | ||
}); | ||
afterEach(() => { | ||
matchMedia.clear(); | ||
}); | ||
afterEach(() => { | ||
matchMedia.clear(); | ||
}); | ||
it("should return 'false' if query doesn't match", () => { | ||
matchMedia.useMediaQuery("(pointer: fine)"); | ||
it("should return 'false' if query doesn't match", () => { | ||
matchMedia.useMediaQuery("(pointer: fine)"); | ||
const { result } = renderHook(() => useMatchMedia("(pointer: coarse)")); | ||
const { result } = renderHook(() => useMatchMedia("(pointer: coarse)")); | ||
expect(result.current).toEqual(false); | ||
}); | ||
expect(result.current).toEqual(false); | ||
}); | ||
it("should return 'true' if query matches", () => { | ||
matchMedia.useMediaQuery("(pointer: coarse)"); | ||
it("should return default value 'true' if query doesn't match", () => { | ||
const { result } = renderHook(() => | ||
useMatchMedia("(pointer: coarse)", { | ||
defaultValue: true, | ||
const { result } = renderHook(() => useMatchMedia("(pointer: coarse)")); | ||
// Under the hood, the hook instantly runs 'useEffect' in tests so we | ||
// need to disable it to be able to test the default value | ||
isEnabled: false, | ||
}), | ||
); | ||
expect(result.current).toEqual(true); | ||
}); | ||
expect(result.current).toEqual(true); | ||
}); | ||
it("should only create one listener for the same query", async () => { | ||
const query = "(pointer: coarse)"; | ||
it("should return default value 'null' if query doesn't match", () => { | ||
const { result } = renderHook(() => | ||
useMatchMedia("(pointer: coarse)", { | ||
defaultValue: null, | ||
renderHook(() => useMatchMedia(query)); | ||
renderHook(() => useMatchMedia(query)); | ||
// Under the hood, the hook instantly runs 'useEffect' in tests so we | ||
// need to disable it to be able to test the default value | ||
isEnabled: false, | ||
}), | ||
); | ||
expect(matchMedia.getListeners(query).length).toEqual(1); | ||
}); | ||
expect(result.current).toEqual(null); | ||
}); | ||
it("should cleanup after unmounting", () => { | ||
const query = "(pointer: coarse)"; | ||
it("should return default value 'undefined' if query doesn't match", () => { | ||
const { result } = renderHook(() => | ||
useMatchMedia("(pointer: coarse)", { | ||
defaultValue: undefined, | ||
const { unmount } = renderHook(() => useMatchMedia("(pointer: coarse)")); | ||
// Under the hood, the hook instantly runs 'useEffect' in tests so we | ||
// need to disable it to be able to test the default value | ||
isEnabled: false, | ||
}), | ||
); | ||
expect(matchMedia.getListeners(query).length).toEqual(1); | ||
expect(result.current).toEqual(undefined); | ||
}); | ||
unmount(); | ||
it("should return 'true' if query matches", () => { | ||
matchMedia.useMediaQuery("(pointer: coarse)"); | ||
expect(matchMedia.getListeners(query).length).toEqual(0); | ||
}); | ||
const { result } = renderHook(() => useMatchMedia("(pointer: coarse)")); | ||
it("doesn't add event listener if listen is set to 'false' on mount", () => { | ||
const query = "(pointer: coarse)"; | ||
expect(result.current).toEqual(true); | ||
}); | ||
renderHook(() => useMatchMedia(query, { isEnabled: false })); | ||
it("should only create one listener for the same query", async () => { | ||
const query = "(pointer: coarse)"; | ||
expect(matchMedia.getListeners(query).length).toEqual(0); | ||
}); | ||
renderHook(() => useMatchMedia(query)); | ||
renderHook(() => useMatchMedia(query)); | ||
expect(matchMedia.getListeners(query).length).toEqual(1); | ||
}); | ||
it("should cleanup after unmounting", () => { | ||
const query = "(pointer: coarse)"; | ||
const { unmount } = renderHook(() => useMatchMedia("(pointer: coarse)")); | ||
expect(matchMedia.getListeners(query).length).toEqual(1); | ||
unmount(); | ||
expect(matchMedia.getListeners(query).length).toEqual(0); | ||
}); | ||
it("doesn't add event listener if listen is set to 'false' on mount", () => { | ||
const query = "(pointer: coarse)"; | ||
renderHook(() => useMatchMedia(query, { isEnabled: false })); | ||
expect(matchMedia.getListeners(query).length).toEqual(0); | ||
}); | ||
}); |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
110
28811
28
459
1