From 824bc9de99a1a0d6b8aa83cf4617516f00a40c80 Mon Sep 17 00:00:00 2001 From: Sylvain Hamann Date: Thu, 9 Apr 2020 14:08:55 -0400 Subject: [PATCH] React hooks - add useMedia --- packages/react-hooks/CHANGELOG.md | 4 +- packages/react-hooks/README.md | 15 +++ packages/react-hooks/src/hooks/index.ts | 1 + packages/react-hooks/src/hooks/media.ts | 23 ++++ .../src/hooks/tests/media.test.tsx | 104 ++++++++++++++++++ 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 packages/react-hooks/src/hooks/media.ts create mode 100644 packages/react-hooks/src/hooks/tests/media.test.tsx diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index 13661e5a35..ddf871f754 100644 --- a/packages/react-hooks/CHANGELOG.md +++ b/packages/react-hooks/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). - +## [Unreleased] + +- Added a `useMedia` hook ([#1364](https://github.com/Shopify/quilt/pull/1364)) ## [1.7.0] - 2020-04-08 diff --git a/packages/react-hooks/README.md b/packages/react-hooks/README.md index 4ce9a75bd5..f564ce5e0c 100644 --- a/packages/react-hooks/README.md +++ b/packages/react-hooks/README.md @@ -114,6 +114,21 @@ function MyComponent() { } ``` +### `useMedia()` + +This hook will listen to a [MediaQueryList](https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList) created via [matchMedia](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) and return true or false if it matches the media query string. + +```tsx +function MyComponent() { + const isSmallScreen = useMedia('(max-width: 640px)'); + return ( +

+ {isSmallScreen ? 'This is a small screen' : 'This is not a small screen'} +

+ ); +} +``` + ### `useMountedRef()` This hook keeps track of a component's mounted / un-mounted status and returns a ref object like React’s [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) with a boolean value representing said status. This is often used when a component contains an async task that sets state after the task has resolved. diff --git a/packages/react-hooks/src/hooks/index.ts b/packages/react-hooks/src/hooks/index.ts index d9ca7ea648..5e5776a55c 100644 --- a/packages/react-hooks/src/hooks/index.ts +++ b/packages/react-hooks/src/hooks/index.ts @@ -1,6 +1,7 @@ export {useDebouncedValue} from './debounced'; export {useInterval} from './interval'; export {useLazyRef} from './lazy-ref'; +export {useMedia} from './media'; export {useMountedRef} from './mounted-ref'; export {useOnValueChange} from './on-value-change'; export {usePrevious} from './previous'; diff --git a/packages/react-hooks/src/hooks/media.ts b/packages/react-hooks/src/hooks/media.ts new file mode 100644 index 0000000000..d55460a384 --- /dev/null +++ b/packages/react-hooks/src/hooks/media.ts @@ -0,0 +1,23 @@ +import {useState, useEffect} from 'react'; + +export function useMedia(query: string) { + const [match, setMatch] = useState(false); + + useEffect(() => { + if (!window || !window.matchMedia) { + return; + } + + const matchMedia = window.matchMedia(query); + const updateMatch = (event: MediaQueryListEvent) => setMatch(event.matches); + + setMatch(matchMedia.matches); + + matchMedia.addListener(updateMatch); + return () => { + matchMedia.removeListener(updateMatch); + }; + }, [query]); + + return match; +} diff --git a/packages/react-hooks/src/hooks/tests/media.test.tsx b/packages/react-hooks/src/hooks/tests/media.test.tsx new file mode 100644 index 0000000000..27c9fa82ac --- /dev/null +++ b/packages/react-hooks/src/hooks/tests/media.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import {matchMedia, mediaQueryList} from '@shopify/jest-dom-mocks'; +import {mount} from '@shopify/react-testing'; + +import {useMedia} from '../media'; + +function MockComponent({mediaQuery}: {mediaQuery: string}) { + const matchedQuery = useMedia(mediaQuery); + const message = matchedQuery ? 'matched' : 'did not match'; + return
{message}
; +} + +describe('useMedia', () => { + beforeEach(() => { + matchMedia.mock(); + }); + + afterEach(() => { + matchMedia.restore(); + }); + + it('installs/uninstalls listeners', () => { + const media = mediaQueryList({ + matches: true, + }); + const mediaAddSpy = jest.spyOn(media, 'addListener'); + const mediaRemoveSpy = jest.spyOn(media, 'removeListener'); + + matchMedia.setMedia(() => media); + + const mockComponent = mount(); + expect(mediaAddSpy).toHaveBeenCalled(); + + mockComponent.unmount(); + expect(mediaRemoveSpy).toHaveBeenCalled(); + }); + + it('installs new listeners when mediaQuery used by hook changes', () => { + const media = mediaQueryList({ + matches: true, + }); + const mediaAddSpy = jest.spyOn(media, 'addListener'); + const mediaRemoveSpy = jest.spyOn(media, 'removeListener'); + + matchMedia.setMedia(() => media); + + const mockComponent = mount(); + expect(mediaAddSpy).toHaveBeenCalled(); + expect(mediaRemoveSpy).not.toHaveBeenCalled(); + + mockComponent.setProps({mediaQuery: 'screen'}); + + expect(mediaRemoveSpy).toHaveBeenCalled(); + expect(mediaAddSpy).toHaveBeenCalledTimes(2); + }); + + it('initial render when matches', () => { + matchMedia.setMedia(() => + mediaQueryList({ + matches: true, + }), + ); + + const mockComponent = mount(); + expect(mockComponent.text()).toContain('matched'); + }); + + it('initial render when does not match', () => { + matchMedia.setMedia(() => + mediaQueryList({ + matches: false, + }), + ); + + const mockComponent = mount(); + expect(mockComponent.text()).toContain('did not match'); + }); + + it('rerenders when the media changes from !match=>match', () => { + const media = mediaQueryList({ + matches: false, + }); + const addListenerSpy = jest.spyOn(media, 'addListener'); + matchMedia.setMedia(() => media); + + const mockComponent = mount(); + expect(mockComponent.text()).toContain('did not match'); + + expect(addListenerSpy).toHaveBeenCalled(); + mockComponent.act(() => { + matchMedia.setMedia(() => + mediaQueryList({ + matches: true, + }), + ); + + // setMedia API does not actually invoke the listeners registered by the hook, so we must invoke manually + const [listener] = addListenerSpy.mock.calls[0]; + (listener as any)({...media, matches: true}); + }); + + expect(mockComponent.text()).toContain('matched'); + }); +});