Unverified Commit 09f95edd authored by Tasso Evangelista's avatar Tasso Evangelista Committed by GitHub
Browse files

feat: useStableArray, useBreakpoints, useMediaQueries (#253)

parent 5adc7b8a
......@@ -29,6 +29,7 @@ yarn test
- [useAutoFocus](#useautofocus)
- [Parameters](#parameters)
- [useBreakpoints](#usebreakpoints)
- [useDebouncedCallback](#usedebouncedcallback)
- [Parameters](#parameters-1)
- [useDebouncedReducer](#usedebouncedreducer)
......@@ -41,18 +42,23 @@ yarn test
- [Parameters](#parameters-5)
- [useLazyRef](#uselazyref)
- [Parameters](#parameters-6)
- [useMediaQuery](#usemediaquery)
- [useMediaQueries](#usemediaqueries)
- [Parameters](#parameters-7)
- [useMergedRefs](#usemergedrefs)
- [useMediaQuery](#usemediaquery)
- [Parameters](#parameters-8)
- [useMutableCallback](#usemutablecallback)
- [useMergedRefs](#usemergedrefs)
- [Parameters](#parameters-9)
- [useResizeObserver](#useresizeobserver)
- [useMutableCallback](#usemutablecallback)
- [Parameters](#parameters-10)
- [useSafely](#usesafely)
- [useResizeObserver](#useresizeobserver)
- [Parameters](#parameters-11)
- [useToggle](#usetoggle)
- [useSafely](#usesafely)
- [Parameters](#parameters-12)
- [Comparator](#comparator)
- [useStableArray](#usestablearray)
- [Parameters](#parameters-13)
- [useToggle](#usetoggle)
- [Parameters](#parameters-14)
- [useUniqueId](#useuniqueid)
### useAutoFocus
......@@ -66,6 +72,12 @@ Hook to automatically request focus for an DOM element.
Returns **Ref<{focus: function (options: Options): void}>** the ref which holds the element
### useBreakpoints
Hook to catch which responsive design' breakpoints are active.
Returns **[Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** an array of the active breakpoint names.
### useDebouncedCallback
Hook to memoize a debounced version of a callback.
......@@ -137,6 +149,16 @@ Hook equivalent to useRef, but with a lazy initialization for computed value.
Returns **any** the ref
### useMediaQueries
Hook to listen to a set of media queries.
#### Parameters
- `queries` **...[Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** the CSS3 expressions of media queries
Returns **[Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)>** a set of booleans expressing if the media queries match or not
### useMediaQuery
Hook to listen to a media query.
......@@ -192,6 +214,23 @@ which can be safe and asynchronically called even after the component unmounted.
Returns **\[S, D]** a state value and safe dispatcher pair
### Comparator
Type: function (a: T, b: T): [boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)
### useStableArray
Hook to create an array with stable identity if its elements are equal.
#### Parameters
- `array` **T** the array
- `compare` **[Comparator](#comparator)** the equality function that checks if two array elements are
equal (optional, default `Object.is`)
Returns **T** the passed array if the elements are NOT equals; the previously
stored array otherwise
### useToggle
Hook to create a toggleable boolean state.
......
......@@ -4,4 +4,12 @@ module.exports = {
testMatch: [
'**/src/**/*.spec.[jt]s?(x)',
],
globals: {
'ts-jest': {
tsConfig: {
noUnusedLocals: false,
noUnusedParameters: false,
},
},
},
};
......@@ -65,5 +65,10 @@
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@rocket.chat/fuselage-tokens": "^0.10.0",
"@types/use-subscription": "^1.0.0",
"use-subscription": "^1.4.1"
}
}
......@@ -3,8 +3,6 @@ import { act } from 'react-dom/test-utils';
export const mediaQueryLists = new Set<MediaQueryList>();
class MediaQueryListMock implements MediaQueryList {
_matches: boolean
_media: string
_onchange: (ev: MediaQueryListEvent) => void | null
......@@ -12,7 +10,6 @@ class MediaQueryListMock implements MediaQueryList {
changeEventListeners: Set<EventListener>
constructor(media: string) {
this._matches = window.innerWidth <= 968;
this._media = media;
this._onchange = null;
this.changeEventListeners = new Set([
......@@ -23,7 +20,16 @@ class MediaQueryListMock implements MediaQueryList {
}
get matches(): boolean {
return this._matches;
const regex = /^\((min-width|max-width): (\d+)(px|em)\)$/;
if (regex.test(this._media)) {
const [, condition, width, unit] = regex.exec(this._media);
const widthPx = (unit === 'em' && parseInt(width, 10) * 16)
|| (unit === 'px' && parseInt(width, 10));
return (condition === 'min-width' && window.innerWidth >= widthPx)
|| (condition === 'max-width' && window.innerWidth <= widthPx);
}
return false;
}
get media(): string {
......@@ -66,7 +72,6 @@ class MediaQueryListMock implements MediaQueryList {
dispatchEvent(ev: MediaQueryListEvent): boolean {
act(() => {
this._matches = ev.matches;
this._media = ev.media;
this.changeEventListeners.forEach((changeEventListener) => {
changeEventListener(ev);
......
export * from './useAutoFocus';
export * from './useBreakpoints';
export * from './useDebouncedCallback';
export * from './useDebouncedReducer';
export * from './useDebouncedState';
......@@ -6,10 +7,12 @@ export * from './useDebouncedUpdates';
export * from './useDebouncedValue';
export * from './useIsomorphicLayoutEffect';
export * from './useLazyRef';
export * from './useMediaQueries';
export * from './useMediaQuery';
export * from './useMergedRefs';
export * from './useMutableCallback';
export * from './useResizeObserver';
export * from './useSafely';
export * from './useStableArray';
export * from './useToggle';
export * from './useUniqueId';
import breakpointsDefinitions from '@rocket.chat/fuselage-tokens/breakpoints.json';
import { FunctionComponent, createElement, StrictMode } from 'react';
import { render } from 'react-dom';
import { act } from 'react-dom/test-utils';
import resizeToMock from './__mocks__/resizeTo';
import matchMediaMock from './__mocks__/matchMedia';
import { useBreakpoints } from '.';
beforeAll(() => {
window.resizeTo = resizeToMock;
window.matchMedia = jest.fn(matchMediaMock);
});
beforeEach(() => {
window.resizeTo(1024, 768);
});
it('returns at least the smallest breakpoint name', () => {
let breakpoints: string[];
const TestComponent: FunctionComponent = () => {
breakpoints = useBreakpoints();
return null;
};
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
expect(breakpoints[0]).toBe(breakpointsDefinitions[0].name);
});
it('returns matching breakpoint names', () => {
const initialBreakpoints = breakpointsDefinitions.slice(0, -1);
const finalBreakpoints = breakpointsDefinitions.slice(0, -2);
let breakpoints: string[];
const TestComponent: FunctionComponent = () => {
breakpoints = useBreakpoints();
return null;
};
act(() => {
window.resizeTo(initialBreakpoints[initialBreakpoints.length - 1].minViewportWidth, 768);
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
expect(breakpoints).toStrictEqual(initialBreakpoints.map((breakpoint) => breakpoint.name));
act(() => {
window.resizeTo(finalBreakpoints[finalBreakpoints.length - 1].minViewportWidth, 768);
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
expect(breakpoints).toStrictEqual(finalBreakpoints.map((breakpoint) => breakpoint.name));
});
import breakpointsDefinitions from '@rocket.chat/fuselage-tokens/breakpoints.json';
import { useMemo } from 'react';
import { useMediaQueries } from './useMediaQueries';
const mediaQueries = breakpointsDefinitions
.slice(1)
.map((breakpoint) => `(min-width: ${ breakpoint.minViewportWidth }px)`);
/**
* Hook to catch which responsive design' breakpoints are active.
*
* @returns an array of the active breakpoint names.
*/
export const useBreakpoints = (): string[] => {
const matches = useMediaQueries(...mediaQueries);
return useMemo(() => matches.reduce<string[]>((names, matches, i) => {
if (matches) {
return [...names, breakpointsDefinitions[i + 1].name];
}
return names;
}, [breakpointsDefinitions[0].name]), [matches]);
};
/**
* @jest-environment node
*/
import { FunctionComponent, createElement, StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import { useMediaQueries } from '.';
it('returns empty array for undefined media query', () => {
let matches: boolean[];
const TestComponent: FunctionComponent = () => {
matches = useMediaQueries();
return null;
};
renderToString(
createElement(StrictMode, {}, createElement(TestComponent)),
);
expect(matches).toStrictEqual([]);
});
it('returns false for defined media query', () => {
let matches: boolean[];
const TestComponent: FunctionComponent = () => {
matches = useMediaQueries('(max-width: 1024)');
return null;
};
renderToString(
createElement(StrictMode, {}, createElement(TestComponent)),
);
expect(matches).toStrictEqual([false]);
});
import { FunctionComponent, createElement, StrictMode } from 'react';
import { render } from 'react-dom';
import { act } from 'react-dom/test-utils';
import resizeToMock from './__mocks__/resizeTo';
import matchMediaMock from './__mocks__/matchMedia';
import { useMediaQueries } from '.';
beforeAll(() => {
window.resizeTo = resizeToMock;
window.matchMedia = jest.fn(matchMediaMock);
});
beforeEach(() => {
window.resizeTo(1024, 768);
});
it('returns empty array if no query is given', () => {
let matches: boolean[];
const TestComponent: FunctionComponent = () => {
matches = useMediaQueries();
return null;
};
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
expect(matches).toStrictEqual([]);
});
it('returns false values if the media queries don\'t match', () => {
let matches: boolean[];
const TestComponent: FunctionComponent = () => {
matches = useMediaQueries('(max-width: 1024px)', '(max-width: 968px)');
return null;
};
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
expect(matches).toStrictEqual([true, false]);
});
it('returns true if the media query does match', () => {
window.resizeTo(968, 768);
let matches: boolean[];
const TestComponent: FunctionComponent = () => {
matches = useMediaQueries('(max-width: 1024px)', '(max-width: 968px)');
return null;
};
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
expect(matches).toStrictEqual([true, true]);
});
it('mutates its value to true if the media query matches', () => {
let matches: boolean[];
const TestComponent: FunctionComponent = () => {
matches = useMediaQueries('(max-width: 1024px)', '(max-width: 968px)');
return null;
};
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
const matchesA = matches;
act(() => {
window.resizeTo(968, 768);
});
const matchesB = matches;
act(() => {
window.resizeTo(1024, 768);
});
const matchesC = matches;
expect(matchesA).toStrictEqual([true, false]);
expect(matchesB).toStrictEqual([true, true]);
expect(matchesC).toStrictEqual([true, false]);
});
import { useMemo } from 'react';
import { useSubscription, Subscription } from 'use-subscription';
import { useStableArray } from './useStableArray';
/**
* Hook to listen to a set of media queries.
*
* @param queries - the CSS3 expressions of media queries
* @returns a set of booleans expressing if the media queries match or not
*/
export const useMediaQueries = (...queries: string[]): boolean[] => {
const stableQueries = useStableArray(queries);
const subscription = useMemo<Subscription<boolean[]>>(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return {
getCurrentValue: () => Array.from({ length: stableQueries.length }, () => false),
subscribe: () => () => undefined,
};
}
const mediaQueryLists = stableQueries.map((query) => window.matchMedia(query));
return {
getCurrentValue: () => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return Array.from({ length: stableQueries.length }, () => false);
}
return mediaQueryLists.map((mediaQueryList) => mediaQueryList.matches);
},
subscribe: (cb: () => void) => {
mediaQueryLists.forEach((mediaQueryList) => {
mediaQueryList.addEventListener('change', cb);
});
return () => {
mediaQueryLists.forEach((mediaQueryList) => {
mediaQueryList.removeEventListener('change', cb);
});
};
},
};
}, [stableQueries]);
return useSubscription(subscription);
};
......@@ -7,32 +7,30 @@ import { renderToString } from 'react-dom/server';
import { useMediaQuery } from '.';
describe('useMediaQuery hook on server', () => {
it('returns false for undefined media query', () => {
let matches: boolean;
const TestComponent: FunctionComponent = () => {
matches = useMediaQuery();
return null;
};
renderToString(
createElement(StrictMode, {}, createElement(TestComponent)),
);
expect(matches).toBe(false);
});
it('returns false for defined media query', () => {
let matches: boolean;
const TestComponent: FunctionComponent = () => {
matches = useMediaQuery('(max-width: 1024)');
return null;
};
renderToString(
createElement(StrictMode, {}, createElement(TestComponent)),
);
expect(matches).toBe(false);
});
it('returns false for undefined media query', () => {
let matches: boolean;
const TestComponent: FunctionComponent = () => {
matches = useMediaQuery();
return null;
};
renderToString(
createElement(StrictMode, {}, createElement(TestComponent)),
);
expect(matches).toBe(false);
});
it('returns false for defined media query', () => {
let matches: boolean;
const TestComponent: FunctionComponent = () => {
matches = useMediaQuery('(max-width: 1024)');
return null;
};
renderToString(
createElement(StrictMode, {}, createElement(TestComponent)),
);
expect(matches).toBe(false);
});
......@@ -6,131 +6,129 @@ import resizeToMock from './__mocks__/resizeTo';
import matchMediaMock from './__mocks__/matchMedia';
import { useMediaQuery } from '.';
describe('useMediaQuery hook', () => {
beforeAll(() => {
window.resizeTo = resizeToMock;
window.matchMedia = jest.fn(matchMediaMock);
});
beforeAll(() => {
window.resizeTo = resizeToMock;
window.matchMedia = jest.fn(matchMediaMock);
});
beforeEach(() => {
window.resizeTo(1024, 768);
});
beforeEach(() => {
window.resizeTo(1024, 768);
});
it('does not register a undefined media query', () => {
const TestComponent: FunctionComponent = () => {
useMediaQuery();
return null;
};
it('does not register a undefined media query', () => {
const TestComponent: FunctionComponent = () => {
useMediaQuery();
return null;
};
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
expect(window.matchMedia).not.toHaveBeenCalled();
});
expect(window.matchMedia).not.toHaveBeenCalled();
it('does register a defined media query', () => {
const TestComponent: FunctionComponent = () => {
useMediaQuery('(max-width: 1024px)');
return null;
};
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
it('does register a defined media query', () => {
const TestComponent: FunctionComponent = () => {
useMediaQuery('(max-width: 1024px)');
return null;
};
act(() => {
render(
createElement(StrictMode, {}, createElement(TestComponent)),
document.createElement('div'),
);
});
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 1024px)');
});
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 1024px)');