Skip to content

Commit 09f95ed

Browse files
authored
feat: useStableArray, useBreakpoints, useMediaQueries (#253)
1 parent 5adc7b8 commit 09f95ed

18 files changed

+726
-181
lines changed

packages/fuselage-hooks/README.md

+45-6
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ yarn test
2929

3030
- [useAutoFocus](#useautofocus)
3131
- [Parameters](#parameters)
32+
- [useBreakpoints](#usebreakpoints)
3233
- [useDebouncedCallback](#usedebouncedcallback)
3334
- [Parameters](#parameters-1)
3435
- [useDebouncedReducer](#usedebouncedreducer)
@@ -41,18 +42,23 @@ yarn test
4142
- [Parameters](#parameters-5)
4243
- [useLazyRef](#uselazyref)
4344
- [Parameters](#parameters-6)
44-
- [useMediaQuery](#usemediaquery)
45+
- [useMediaQueries](#usemediaqueries)
4546
- [Parameters](#parameters-7)
46-
- [useMergedRefs](#usemergedrefs)
47+
- [useMediaQuery](#usemediaquery)
4748
- [Parameters](#parameters-8)
48-
- [useMutableCallback](#usemutablecallback)
49+
- [useMergedRefs](#usemergedrefs)
4950
- [Parameters](#parameters-9)
50-
- [useResizeObserver](#useresizeobserver)
51+
- [useMutableCallback](#usemutablecallback)
5152
- [Parameters](#parameters-10)
52-
- [useSafely](#usesafely)
53+
- [useResizeObserver](#useresizeobserver)
5354
- [Parameters](#parameters-11)
54-
- [useToggle](#usetoggle)
55+
- [useSafely](#usesafely)
5556
- [Parameters](#parameters-12)
57+
- [Comparator](#comparator)
58+
- [useStableArray](#usestablearray)
59+
- [Parameters](#parameters-13)
60+
- [useToggle](#usetoggle)
61+
- [Parameters](#parameters-14)
5662
- [useUniqueId](#useuniqueid)
5763

5864
### useAutoFocus
@@ -66,6 +72,12 @@ Hook to automatically request focus for an DOM element.
6672

6773
Returns **Ref<{focus: function (options: Options): void}>** the ref which holds the element
6874

75+
### useBreakpoints
76+
77+
Hook to catch which responsive design' breakpoints are active.
78+
79+
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.
80+
6981
### useDebouncedCallback
7082

7183
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.
137149

138150
Returns **any** the ref
139151

152+
### useMediaQueries
153+
154+
Hook to listen to a set of media queries.
155+
156+
#### Parameters
157+
158+
- `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
159+
160+
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
161+
140162
### useMediaQuery
141163

142164
Hook to listen to a media query.
@@ -192,6 +214,23 @@ which can be safe and asynchronically called even after the component unmounted.
192214

193215
Returns **\[S, D]** a state value and safe dispatcher pair
194216

217+
### Comparator
218+
219+
Type: function (a: T, b: T): [boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)
220+
221+
### useStableArray
222+
223+
Hook to create an array with stable identity if its elements are equal.
224+
225+
#### Parameters
226+
227+
- `array` **T** the array
228+
- `compare` **[Comparator](#comparator)** the equality function that checks if two array elements are
229+
equal (optional, default `Object.is`)
230+
231+
Returns **T** the passed array if the elements are NOT equals; the previously
232+
stored array otherwise
233+
195234
### useToggle
196235

197236
Hook to create a toggleable boolean state.

packages/fuselage-hooks/jest.config.js

+8
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,12 @@ module.exports = {
44
testMatch: [
55
'**/src/**/*.spec.[jt]s?(x)',
66
],
7+
globals: {
8+
'ts-jest': {
9+
tsConfig: {
10+
noUnusedLocals: false,
11+
noUnusedParameters: false,
12+
},
13+
},
14+
},
715
};

packages/fuselage-hooks/package.json

+5
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,10 @@
6565
},
6666
"publishConfig": {
6767
"access": "public"
68+
},
69+
"dependencies": {
70+
"@rocket.chat/fuselage-tokens": "^0.10.0",
71+
"@types/use-subscription": "^1.0.0",
72+
"use-subscription": "^1.4.1"
6873
}
6974
}

packages/fuselage-hooks/src/__mocks__/matchMedia.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,13 @@ import { act } from 'react-dom/test-utils';
33
export const mediaQueryLists = new Set<MediaQueryList>();
44

55
class MediaQueryListMock implements MediaQueryList {
6-
_matches: boolean
7-
86
_media: string
97

108
_onchange: (ev: MediaQueryListEvent) => void | null
119

1210
changeEventListeners: Set<EventListener>
1311

1412
constructor(media: string) {
15-
this._matches = window.innerWidth <= 968;
1613
this._media = media;
1714
this._onchange = null;
1815
this.changeEventListeners = new Set([
@@ -23,7 +20,16 @@ class MediaQueryListMock implements MediaQueryList {
2320
}
2421

2522
get matches(): boolean {
26-
return this._matches;
23+
const regex = /^\((min-width|max-width): (\d+)(px|em)\)$/;
24+
if (regex.test(this._media)) {
25+
const [, condition, width, unit] = regex.exec(this._media);
26+
const widthPx = (unit === 'em' && parseInt(width, 10) * 16)
27+
|| (unit === 'px' && parseInt(width, 10));
28+
return (condition === 'min-width' && window.innerWidth >= widthPx)
29+
|| (condition === 'max-width' && window.innerWidth <= widthPx);
30+
}
31+
32+
return false;
2733
}
2834

2935
get media(): string {
@@ -66,7 +72,6 @@ class MediaQueryListMock implements MediaQueryList {
6672

6773
dispatchEvent(ev: MediaQueryListEvent): boolean {
6874
act(() => {
69-
this._matches = ev.matches;
7075
this._media = ev.media;
7176
this.changeEventListeners.forEach((changeEventListener) => {
7277
changeEventListener(ev);

packages/fuselage-hooks/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
export * from './useAutoFocus';
2+
export * from './useBreakpoints';
23
export * from './useDebouncedCallback';
34
export * from './useDebouncedReducer';
45
export * from './useDebouncedState';
56
export * from './useDebouncedUpdates';
67
export * from './useDebouncedValue';
78
export * from './useIsomorphicLayoutEffect';
89
export * from './useLazyRef';
10+
export * from './useMediaQueries';
911
export * from './useMediaQuery';
1012
export * from './useMergedRefs';
1113
export * from './useMutableCallback';
1214
export * from './useResizeObserver';
1315
export * from './useSafely';
16+
export * from './useStableArray';
1417
export * from './useToggle';
1518
export * from './useUniqueId';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import breakpointsDefinitions from '@rocket.chat/fuselage-tokens/breakpoints.json';
2+
import { FunctionComponent, createElement, StrictMode } from 'react';
3+
import { render } from 'react-dom';
4+
import { act } from 'react-dom/test-utils';
5+
6+
import resizeToMock from './__mocks__/resizeTo';
7+
import matchMediaMock from './__mocks__/matchMedia';
8+
import { useBreakpoints } from '.';
9+
10+
beforeAll(() => {
11+
window.resizeTo = resizeToMock;
12+
window.matchMedia = jest.fn(matchMediaMock);
13+
});
14+
15+
beforeEach(() => {
16+
window.resizeTo(1024, 768);
17+
});
18+
19+
it('returns at least the smallest breakpoint name', () => {
20+
let breakpoints: string[];
21+
const TestComponent: FunctionComponent = () => {
22+
breakpoints = useBreakpoints();
23+
return null;
24+
};
25+
26+
act(() => {
27+
render(
28+
createElement(StrictMode, {}, createElement(TestComponent)),
29+
document.createElement('div'),
30+
);
31+
});
32+
33+
expect(breakpoints[0]).toBe(breakpointsDefinitions[0].name);
34+
});
35+
36+
it('returns matching breakpoint names', () => {
37+
const initialBreakpoints = breakpointsDefinitions.slice(0, -1);
38+
const finalBreakpoints = breakpointsDefinitions.slice(0, -2);
39+
40+
let breakpoints: string[];
41+
const TestComponent: FunctionComponent = () => {
42+
breakpoints = useBreakpoints();
43+
return null;
44+
};
45+
46+
act(() => {
47+
window.resizeTo(initialBreakpoints[initialBreakpoints.length - 1].minViewportWidth, 768);
48+
49+
render(
50+
createElement(StrictMode, {}, createElement(TestComponent)),
51+
document.createElement('div'),
52+
);
53+
});
54+
55+
expect(breakpoints).toStrictEqual(initialBreakpoints.map((breakpoint) => breakpoint.name));
56+
57+
act(() => {
58+
window.resizeTo(finalBreakpoints[finalBreakpoints.length - 1].minViewportWidth, 768);
59+
60+
render(
61+
createElement(StrictMode, {}, createElement(TestComponent)),
62+
document.createElement('div'),
63+
);
64+
});
65+
66+
expect(breakpoints).toStrictEqual(finalBreakpoints.map((breakpoint) => breakpoint.name));
67+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import breakpointsDefinitions from '@rocket.chat/fuselage-tokens/breakpoints.json';
2+
import { useMemo } from 'react';
3+
4+
import { useMediaQueries } from './useMediaQueries';
5+
6+
const mediaQueries = breakpointsDefinitions
7+
.slice(1)
8+
.map((breakpoint) => `(min-width: ${ breakpoint.minViewportWidth }px)`);
9+
10+
/**
11+
* Hook to catch which responsive design' breakpoints are active.
12+
*
13+
* @returns an array of the active breakpoint names.
14+
*/
15+
export const useBreakpoints = (): string[] => {
16+
const matches = useMediaQueries(...mediaQueries);
17+
18+
return useMemo(() => matches.reduce<string[]>((names, matches, i) => {
19+
if (matches) {
20+
return [...names, breakpointsDefinitions[i + 1].name];
21+
}
22+
23+
return names;
24+
}, [breakpointsDefinitions[0].name]), [matches]);
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
import { FunctionComponent, createElement, StrictMode } from 'react';
6+
import { renderToString } from 'react-dom/server';
7+
8+
import { useMediaQueries } from '.';
9+
10+
it('returns empty array for undefined media query', () => {
11+
let matches: boolean[];
12+
const TestComponent: FunctionComponent = () => {
13+
matches = useMediaQueries();
14+
return null;
15+
};
16+
17+
renderToString(
18+
createElement(StrictMode, {}, createElement(TestComponent)),
19+
);
20+
21+
expect(matches).toStrictEqual([]);
22+
});
23+
24+
it('returns false for defined media query', () => {
25+
let matches: boolean[];
26+
const TestComponent: FunctionComponent = () => {
27+
matches = useMediaQueries('(max-width: 1024)');
28+
return null;
29+
};
30+
31+
renderToString(
32+
createElement(StrictMode, {}, createElement(TestComponent)),
33+
);
34+
35+
expect(matches).toStrictEqual([false]);
36+
});

0 commit comments

Comments
 (0)