Skip to content

Commit 1215151

Browse files
committed
viewport: refactor matchMedia mock
1 parent f64b209 commit 1215151

File tree

8 files changed

+437
-25
lines changed

8 files changed

+437
-25
lines changed

example/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import GlobalObserver from './intersection-observer/global-observer/GlobalObserv
77
import MeasureParent from './resize-observer/measure-parent/MeasureParent';
88
import PrintMySize from './resize-observer/print-my-size/PrintMySize';
99
import CustomUseMedia from './viewport/custom-use-media/CustomUseMedia';
10+
import DeprecatedCustomUseMedia from './viewport/deprecated-use-media/DeprecatedUseMedia';
1011

1112
export default function App() {
1213
return (
@@ -16,6 +17,10 @@ export default function App() {
1617
<Switch>
1718
<Route path="/intersection-observer" component={GlobalObserver} />
1819
<Route path="/viewport" component={CustomUseMedia} />
20+
<Route
21+
path="/viewport-deprecated"
22+
component={DeprecatedCustomUseMedia}
23+
/>
1924
<Route path="/resize-observer/do-i-fit" component={MeasureParent} />
2025
<Route
2126
path="/resize-observer/print-my-size"

example/Nav.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ const Nav = (): React.ReactElement => (
2121
Resize Observer: print my size
2222
</Link>
2323
</li>
24-
<li>
24+
<li style={{ marginRight: '2em' }}>
2525
<Link to="/viewport">Viewport</Link>
2626
</li>
27+
<li>
28+
<Link to="/viewport-deprecated">Viewport (old)</Link>
29+
</li>
2730
</ul>
2831
</nav>
2932
);

example/viewport/custom-use-media/CustomUseMedia.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,27 @@ import * as React from 'react';
22

33
import useMedia from './useMedia';
44

5-
const CustomUseMedia = () => {
6-
const isDesktop = useMedia('(min-width: 640px)');
5+
const CustomUseMedia = ({
6+
query = '(min-width: 640px)',
7+
callback,
8+
asObject = false,
9+
messages: { ok = 'desktop', ko = 'not desktop' } = {
10+
ok: 'desktop',
11+
ko: 'not desktop',
12+
},
13+
}: {
14+
query?: string;
15+
callback?: () => void;
16+
asObject?: boolean;
17+
messages?: { ok: string; ko: string };
18+
}) => {
19+
const doesMatch = useMedia(query, null, { callback, asObject });
720

8-
if (isDesktop === null) {
21+
if (doesMatch === null) {
922
return <div>server</div>;
1023
}
1124

12-
return <div>{isDesktop ? 'desktop' : 'not desktop'}</div>;
25+
return <div>{doesMatch ? ok : ko}</div>;
1326
};
1427

1528
export default CustomUseMedia;

example/viewport/custom-use-media/useMedia.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import { useState, useEffect } from 'react';
22

3-
function useMedia(query: string, defaultValue: any | null = null) {
3+
function useMedia(
4+
query: string,
5+
defaultValue: any | null = null,
6+
options?:
7+
| {
8+
callback?: (this: MediaQueryList, ev: MediaQueryListEvent) => any;
9+
asObject: false;
10+
}
11+
| {
12+
callback?: (ev: MediaQueryListEvent) => any;
13+
asObject: true;
14+
}
15+
) {
416
const isInBrowser = typeof window !== 'undefined' && window.matchMedia;
517

618
const mq = isInBrowser ? window.matchMedia(query) : null;
@@ -14,7 +26,25 @@ function useMedia(query: string, defaultValue: any | null = null) {
1426
return;
1527
}
1628

17-
const handler = () => setValue(getValue);
29+
if (options?.asObject) {
30+
const handler = {
31+
handleEvent: (ev: MediaQueryListEvent) => {
32+
setValue(getValue);
33+
34+
options?.callback?.(ev);
35+
},
36+
};
37+
38+
mq.addEventListener('change', handler);
39+
40+
return () => mq.removeEventListener('change', handler);
41+
}
42+
43+
function handler(this: MediaQueryList, ev: MediaQueryListEvent) {
44+
setValue(getValue);
45+
46+
options?.callback?.call(this, ev);
47+
}
1848

1949
mq.addEventListener('change', handler);
2050

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as React from 'react';
2+
import { useState, useEffect } from 'react';
3+
4+
function useMedia(
5+
query: string,
6+
defaultValue: any | null = null,
7+
callback?: (this: MediaQueryList, ev: MediaQueryListEvent) => any
8+
) {
9+
const isInBrowser = typeof window !== 'undefined' && window.matchMedia;
10+
11+
const mq = isInBrowser ? window.matchMedia(query) : null;
12+
13+
const getValue = () => mq?.matches;
14+
15+
const [value, setValue] = useState(isInBrowser ? getValue : defaultValue);
16+
17+
useEffect(() => {
18+
if (mq === null) {
19+
return;
20+
}
21+
22+
function handler(this: MediaQueryList, ev: MediaQueryListEvent) {
23+
setValue(getValue);
24+
25+
callback?.call(this, ev);
26+
}
27+
28+
mq.addListener(handler);
29+
30+
return () => mq.removeListener(handler);
31+
}, []);
32+
33+
return value;
34+
}
35+
36+
const DeprecatedCustomUseMedia = ({
37+
query = '(min-width: 640px)',
38+
callback,
39+
messages: { ok = 'desktop', ko = 'not desktop' } = {
40+
ok: 'desktop',
41+
ko: 'not desktop',
42+
},
43+
}: {
44+
query?: string;
45+
callback?: () => void;
46+
messages?: { ok: string; ko: string };
47+
}) => {
48+
const doesMatch = useMedia(query, null, callback);
49+
50+
if (doesMatch === null) {
51+
return <div>server</div>;
52+
}
53+
54+
return <div>{doesMatch ? ok : ko}</div>;
55+
};
56+
57+
export default DeprecatedCustomUseMedia;

src/mocks/MediaQueryListEvent.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export class MockedMediaQueryListEvent extends Event {
2+
readonly matches: boolean;
3+
readonly media: string;
4+
5+
constructor(type: 'change', eventInitDict: MediaQueryListEventInit = {}) {
6+
super(type);
7+
8+
this.media = eventInitDict.media ?? '';
9+
this.matches = eventInitDict.matches ?? false;
10+
}
11+
}
12+
13+
if (typeof MediaQueryListEvent === 'undefined') {
14+
Object.defineProperty(window, 'MediaQueryListEvent', {
15+
writable: true,
16+
configurable: true,
17+
value: MockedMediaQueryListEvent,
18+
});
19+
}

src/mocks/viewport.ts

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import mediaQuery, { MediaValues } from 'css-mediaquery';
2+
import './MediaQueryListEvent';
3+
import { MockedMediaQueryListEvent } from './MediaQueryListEvent';
24

35
/**
46
* A tool that allows testing components that use js media queries (matchMedia)
@@ -29,51 +31,111 @@ export type MockViewport = {
2931
set: (newDesc: ViewportDescription) => void;
3032
};
3133

32-
type Handler = () => void;
34+
type Listener = (this: MediaQueryList, ev: MockedMediaQueryListEvent) => void;
35+
type ListenerObject = {
36+
handleEvent: (ev: MockedMediaQueryListEvent) => void;
37+
};
38+
type ListenerOrListenerObject = Listener | ListenerObject;
39+
40+
function isEventListenerObject(
41+
obj: ListenerOrListenerObject
42+
): obj is ListenerObject {
43+
return (obj as any).handleEvent !== undefined;
44+
}
3345

3446
function mockViewport(desc: ViewportDescription): MockViewport {
3547
const state: {
3648
currentDesc: ViewportDescription;
37-
listenerHandlers: Handler[];
49+
oldListeners: {
50+
listener: Listener;
51+
list: MediaQueryList;
52+
matches: boolean;
53+
}[];
54+
listeners: {
55+
listener: ListenerOrListenerObject;
56+
list: MediaQueryList;
57+
matches: boolean;
58+
}[];
3859
} = {
3960
currentDesc: desc,
40-
listenerHandlers: [],
61+
oldListeners: [],
62+
listeners: [],
4163
};
4264

4365
const savedImplementation = window.matchMedia;
4466

45-
const addListener = (handler: Handler) => {
46-
state.listenerHandlers.push(handler);
67+
const addOldListener = (
68+
list: MediaQueryList,
69+
matches: boolean,
70+
listener: Listener
71+
) => {
72+
state.oldListeners.push({ listener, matches, list });
73+
};
74+
75+
const removeOldListener = (listenerToRemove: Listener) => {
76+
const index = state.oldListeners.findIndex(
77+
({ listener }) => listener === listenerToRemove
78+
);
79+
80+
state.oldListeners.splice(index, 1);
81+
};
82+
83+
const addListener = (
84+
list: MediaQueryList,
85+
matches: boolean,
86+
listener: ListenerOrListenerObject
87+
) => {
88+
state.listeners.push({ listener, matches, list });
4789
};
4890

49-
const removeListener = (handler: Handler) => {
50-
const index = state.listenerHandlers.findIndex(value => value === handler);
91+
const removeListener = (listenerToRemove: ListenerOrListenerObject) => {
92+
const index = state.listeners.findIndex(
93+
({ listener }) => listener === listenerToRemove
94+
);
5195

52-
state.listenerHandlers.splice(index, 1);
96+
state.listeners.splice(index, 1);
5397
};
5498

5599
Object.defineProperty(window, 'matchMedia', {
56100
writable: true,
57-
value: jest.fn().mockImplementation(query => ({
101+
value: (query: string): MediaQueryList => ({
58102
get matches() {
59103
return mediaQuery.match(query, state.currentDesc);
60104
},
61105
media: query,
62106
onchange: null,
63-
addListener, // deprecated
64-
removeListener, // deprecated
65-
addEventListener: (eventType: string, handler: Handler) => {
107+
addListener: function(listener) {
108+
if (listener) {
109+
addOldListener(this, this.matches, listener);
110+
}
111+
}, // deprecated
112+
removeListener: listener => {
113+
if (listener) {
114+
removeOldListener(listener);
115+
}
116+
}, // deprecated
117+
addEventListener: function(
118+
eventType: Parameters<MediaQueryList['addEventListener']>[0],
119+
listener: Parameters<MediaQueryList['addEventListener']>[1]
120+
) {
66121
if (eventType === 'change') {
67-
addListener(handler);
122+
addListener(this, this.matches, listener);
68123
}
69124
},
70-
removeEventListener: (eventType: string, handler: Handler) => {
125+
removeEventListener: (
126+
eventType: Parameters<MediaQueryList['removeEventListener']>[0],
127+
listener: Parameters<MediaQueryList['removeEventListener']>[1]
128+
) => {
71129
if (eventType === 'change') {
72-
removeListener(handler);
130+
if (isEventListenerObject(listener)) {
131+
removeListener(listener.handleEvent);
132+
} else {
133+
removeListener(listener);
134+
}
73135
}
74136
},
75137
dispatchEvent: jest.fn(),
76-
})),
138+
}),
77139
});
78140

79141
return {
@@ -82,7 +144,41 @@ function mockViewport(desc: ViewportDescription): MockViewport {
82144
},
83145
set: (newDesc: ViewportDescription) => {
84146
state.currentDesc = newDesc;
85-
state.listenerHandlers.forEach(handler => handler());
147+
state.listeners.forEach(({ listener, matches, list }, listenerIndex) => {
148+
const newMatches = list.matches;
149+
150+
if (newMatches !== matches) {
151+
const changeEvent = new MediaQueryListEvent('change', {
152+
matches: newMatches,
153+
media: list.media,
154+
});
155+
156+
if (isEventListenerObject(listener)) {
157+
listener.handleEvent(changeEvent);
158+
} else {
159+
listener.call(list, changeEvent);
160+
}
161+
162+
state.listeners[listenerIndex].matches = newMatches;
163+
}
164+
});
165+
166+
state.oldListeners.forEach(
167+
({ listener, matches, list }, listenerIndex) => {
168+
const newMatches = list.matches;
169+
170+
if (newMatches !== matches) {
171+
const changeEvent = new MediaQueryListEvent('change', {
172+
matches: newMatches,
173+
media: list.media,
174+
});
175+
176+
listener.call(list, changeEvent);
177+
178+
state.oldListeners[listenerIndex].matches = newMatches;
179+
}
180+
}
181+
);
86182
},
87183
};
88184
}

0 commit comments

Comments
 (0)